feat(data): 业务数据与系统数据分离的导入导出

数据迁移不再整体替换 trade.db —— 那条路会把账号、权限组、组织、设置(含大模型密钥)、
登录会话与审计日志一起搬到目标部署。改为按类别搬运业务数据,系统数据永远是目标部署自己那份。

- backend/lib/dataTransfer:表目录(30 张业务表,依赖顺序)、gzip JSONL 数据包(每表 sha256)、导入内核
- 归属统一给导入人;冲突跳过不覆盖;父记录按 __refs 自然键快照补壳
- 流水表(price_history)按整行内容判重:用 (sku, changed_at) 当键实测 643 行丢 186 行
- 预览在库副本上真跑一遍导入,报告与真导同源;导入前 VACUUM INTO 备份,单事务写入
- 新权限点 data.transfer(仅超管组),导出/导入均写审计
- 前端「数据迁移」页:按类别勾选导出、上传后预览逐表明细、确认导入;移除设置里的静态占位「数据交换」
- 测试:单测 7 项(含目录外键顺序、BLOB、重复导入、包被改坏)、API 端到端 1 项(两个真实实例互传)
- 用真实库副本彩排:3854 行 0 失败,29 张业务表行数全部一致,包 1.22 MB
This commit is contained in:
Drsakura
2026-10-23 13:46:26 +08:00
parent 1ffcc1ea07
commit 1a759ef37d
17 changed files with 2516 additions and 19 deletions
+2
View File
@@ -50,6 +50,7 @@ const ProfilePage = React.lazy(() => import('./pages/ProfilePage'))
const UserManagement = React.lazy(() => import('./pages/UserManagement'))
const PermissionGroups = React.lazy(() => import('./pages/PermissionGroups'))
const SystemLogs = React.lazy(() => import('./pages/SystemLogs'))
const DataTransfer = React.lazy(() => import('./pages/DataTransfer'))
/* -------------------------------------------------------------- 启动与兜底 */
@@ -317,6 +318,7 @@ function Shell({ theme, onThemeChange }) {
<Route path="/profile" element={<ProfilePage />} />
<Route path="/users" element={guard('/users', <UserManagement />)} />
<Route path="/permission-groups" element={guard('/permission-groups', <PermissionGroups />)} />
<Route path="/data-transfer" element={guard('/data-transfer', <DataTransfer />)} />
<Route path="/logs" element={guard('/logs', <SystemLogs />)} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+5 -15
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, Suspense } from 'react'
import axios from 'axios'
import { MoonStarsIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon, RefreshIcon, BuildingIcon } from './Icons'
import { MoonStarsIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, SparkIcon, RefreshIcon, BuildingIcon } from './Icons'
import { useAuth, hasPermission } from '../auth'
import { useBranding } from '../branding'
@@ -10,11 +10,12 @@ const AIConfigSettings = React.lazy(() => import('./AIConfigSettings'))
const OrgBrandingSettings = React.lazy(() => import('./OrgBrandingSettings'))
/* 设置分类。带 permission 的项只对有该权限点的人显示 ——
智能配置里有接口地址与密钥,组织品牌是系统管理动作,都不该摆在业务员眼前。 */
智能配置里有接口地址与密钥,组织品牌是系统管理动作,都不该摆在业务员眼前。
数据导入导出已从设置里移出,做成独立页面「数据迁移」(侧栏 · 系统管理),
因为那里要放类别勾选和逐表明细,塞进这个 1000px 的模态框里看不清。 */
const CATEGORIES = [
{ key: 'appearance', label: '外观与主题', icon: TuneIcon },
{ key: 'organization', label: '组织与品牌', icon: BuildingIcon, permission: 'branding.manage' },
{ key: 'import-export', label: '数据交换', icon: ExchangeIcon },
{ key: 'ai-config', label: '智能配置', icon: SparkIcon, permission: 'ai.manage' },
{ key: 'system-update', label: '系统与更新', icon: RefreshIcon }
]
@@ -89,7 +90,6 @@ function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onRedu
<Suspense fallback={<div className="settings-section"><p className="settings-description">正在载入…</p></div>}>
{current === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
{current === 'organization' && <OrgBrandingSettings />}
{current === 'import-export' && <ImportExportSettings />}
{current === 'ai-config' && <AIConfigSettings />}
{current === 'system-update' && <SystemUpdateSettings />}
</Suspense>
@@ -128,17 +128,7 @@ function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMoti
)
}
function ImportExportSettings() {
return (
<div className="settings-section">
<h3>导入 / 导出</h3>
<p className="settings-description">管理业务数据的导入、导出与本地备份。</p>
<div className="settings-group"><h4>导出数据</h4><div className="settings-actions"><button className="btn btn-secondary">导出供应商数据</button><button className="btn btn-secondary">导出产品数据</button><button className="btn btn-secondary">导出客户数据</button><button className="btn btn-secondary">导出订单数据</button></div></div>
<div className="settings-group"><h4>导入数据</h4><div className="settings-actions"><label className="btn btn-secondary file-label">导入供应商数据<input type="file" accept=".csv,.xlsx" /></label><label className="btn btn-secondary file-label">导入产品数据<input type="file" accept=".csv,.xlsx" /></label><label className="btn btn-secondary file-label">导入客户数据<input type="file" accept=".csv,.xlsx" /></label></div><p className="help-text">支持 CSV 和 Excel 格式文件</p></div>
<div className="settings-group"><h4>备份与恢复</h4><div className="settings-actions"><button className="btn btn-primary">创建完整备份</button><label className="btn btn-secondary file-label">恢复备份<input type="file" accept=".db,.backup" /></label></div><p className="help-text">建议定期备份数据库</p></div>
</div>
)
}
/* 数据导入导出已移出设置:见 pages/DataTransfer.jsx(侧栏 · 系统管理 · 数据迁移) */
/* 智能配置已拆到 ./AIConfigSettings(多模型接入 + 用途分配)与 ./OrgBrandingSettings(组织与品牌) */
+1
View File
@@ -5,6 +5,7 @@ import './styles/industrial.css'
import './styles/index.css'
import './styles/luxury.css'
import './styles/stage1.css'
import './styles/data-transfer.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
+5 -1
View File
@@ -11,7 +11,8 @@ import {
CleanupIcon,
GlobeIcon,
ShieldIcon,
ActivityIcon
ActivityIcon,
ExchangeIcon
} from './components/Icons'
/* 工作台独立置顶,不属于任何分组 */
@@ -64,6 +65,8 @@ export const NAV_GROUPS = [
items: [
{ to: '/users', label: '用户管理', icon: UsersIcon, permission: 'user.manage' },
{ to: '/permission-groups', label: '权限组管理', icon: ShieldIcon, permission: 'perm.manage' },
/* 业务数据的导入导出(不含账号/权限/设置)。整库业务数据能被一页带走,故只给超管组。 */
{ to: '/data-transfer', label: '数据迁移', icon: ExchangeIcon, permission: 'data.transfer' },
/* 审计轨迹。Wayne 口径:只有系统管理员能看,故认 audit.view(该点只授予超级管理员组)。 */
{ to: '/logs', label: '系统日志', icon: ActivityIcon, permission: 'audit.view' }
]
@@ -96,5 +99,6 @@ export const ROUTE_PERMISSIONS = {
'/contract-cleanup': 'cleanup.view',
'/users': 'user.manage',
'/permission-groups': 'perm.manage',
'/data-transfer': 'data.transfer',
'/logs': 'audit.view'
}
+510
View File
@@ -0,0 +1,510 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import axios from 'axios'
import {
CheckIcon,
CloseIcon,
DownloadIcon,
ExchangeIcon,
RefreshIcon,
ShieldIcon,
UploadIcon
} from '../components/Icons'
import { setPageTitle } from '../branding'
/* ---------------------------------------------------------------------------
数据迁移:业务数据的导出与导入。
Wayne 2026-10-23 的口径:
· 只搬业务数据(产品、客户、订单…),**系统数据(账号、权限、组织、设置、会话、日志)永不随包走**;
· 可以按类别单独导(只导 SKU / 只导订单),也可以一次全导;
· 冲突一律跳过并写进报告,绝不覆盖线上已有记录;
· 导入的归属统一给执行导入的人,导完再由管理员重新分配。
后端同源实现见 backend/lib/dataTransfer。
--------------------------------------------------------------------------- */
const fmtTime = value => {
if (!value) return ''
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
} catch {
return String(value)
}
}
const fmtSize = bytes => {
const size = Number(bytes || 0)
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(2)} MB`
if (size >= 1024) return `${(size / 1024).toFixed(1)} KB`
return `${size} B`
}
const num = value => Number(value || 0).toLocaleString('zh-CN')
/** axios 用 responseType:'blob' 时,服务端的 JSON 错误也变成了 Blob —— 要读出来才看得见原因 */
async function errorMessage(error, fallback) {
const data = error?.response?.data
if (data instanceof Blob) {
try {
const parsed = JSON.parse(await data.text())
return parsed.error || fallback
} catch {
return fallback
}
}
return data?.error || error?.message || fallback
}
function DataTransfer() {
const [catalog, setCatalog] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
/* 导出 */
const [exportPicks, setExportPicks] = useState([])
const [exporting, setExporting] = useState(false)
/* 导入:idle → checking(上传+校验)→ ready(预览)→ applying → done */
const [phase, setPhase] = useState('idle')
const [jobId, setJobId] = useState('')
const [file, setFile] = useState(null)
const [preview, setPreview] = useState(null)
const [importPicks, setImportPicks] = useState([])
const [result, setResult] = useState(null)
const [percent, setPercent] = useState(0)
const [replanning, setReplanning] = useState(false)
const [dragging, setDragging] = useState(false)
const inputRef = useRef(null)
useEffect(() => { setPageTitle('数据迁移') }, [])
const loadCatalog = useCallback(async () => {
setLoading(true)
setError('')
try {
const { data } = await axios.get('/api/data/catalog')
setCatalog(data)
const keys = (data.categories || []).map(item => item.key)
setExportPicks(current => {
const kept = current.filter(key => keys.includes(key))
return kept.length ? kept : keys
})
} catch (e) {
setError(await errorMessage(e, '读取数据目录失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadCatalog() }, [loadCatalog])
const categories = catalog?.categories || []
const categoryByKey = useMemo(() => {
const map = new Map()
for (const item of categories) map.set(item.key, item)
return map
}, [categories])
const rowsOf = useCallback(category => (category?.tables || []).reduce((sum, item) => sum + item.rows, 0), [])
const allPicked = categories.length > 0 && exportPicks.length === categories.length
const pickedRows = categories.filter(item => exportPicks.includes(item.key)).reduce((sum, item) => sum + rowsOf(item), 0)
/* ------------------------------------------------------------ 导出 */
const toggleExport = key => {
setExportPicks(current => current.includes(key) ? current.filter(item => item !== key) : [...current, key])
}
const doExport = async () => {
if (!exportPicks.length) return
setExporting(true)
setError('')
try {
const response = await axios.get('/api/data/export', {
params: { categories: exportPicks.join(',') },
responseType: 'blob',
timeout: 10 * 60 * 1000
})
const disposition = String(response.headers['content-disposition'] || '')
const match = /filename="?([^";]+)"?/.exec(disposition)
const name = match ? match[1] : `happy-export-${Date.now()}.hdata.gz`
const url = URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = name
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (e) {
setError(await errorMessage(e, '导出失败'))
} finally {
setExporting(false)
}
}
/* ------------------------------------------------------------ 导入 */
const startPreview = async picked => {
if (!picked) return
setFile(picked)
setPreview(null)
setResult(null)
setPercent(0)
setPhase('checking')
setError('')
const form = new FormData()
form.append('file', picked)
try {
const { data } = await axios.post('/api/data/import/preview', form, {
timeout: 10 * 60 * 1000,
onUploadProgress: event => {
if (event.total) setPercent(Math.round((event.loaded / event.total) * 100))
}
})
setJobId(data.id)
setPreview(data)
setImportPicks(data.availableCategories || data.file?.categories || [])
setPhase('ready')
} catch (e) {
setError(await errorMessage(e, '数据包解析失败'))
setPhase('idle')
setFile(null)
}
}
/** 切换类别后重算预览:包已经在服务器上,不用重传 */
const toggleImport = async key => {
if (replanning) return
const next = importPicks.includes(key)
? importPicks.filter(item => item !== key)
: [...importPicks, key]
if (!next.length) return
setImportPicks(next)
setReplanning(true)
setError('')
try {
const { data } = await axios.post('/api/data/import/replan', {
id: jobId,
categories: next,
fileName: file?.name || ''
})
setPreview(data)
} catch (e) {
/* 重算失败就把勾选退回去,别让「界面上的类别」和「报告里的数字」对不上 */
setImportPicks(importPicks)
setError(await errorMessage(e, '重新计算预览失败'))
} finally {
setReplanning(false)
}
}
const doApply = async () => {
setPhase('applying')
setError('')
try {
const { data } = await axios.post('/api/data/import/apply', {
id: jobId,
categories: importPicks
}, { timeout: 10 * 60 * 1000 })
setResult(data)
setPhase('done')
loadCatalog()
} catch (e) {
setError(await errorMessage(e, '导入失败'))
setPhase('ready')
}
}
const reset = () => {
if (jobId && phase !== 'done') {
/* 取消时让服务端把临时包丢掉,不用等 24 小时过期 */
axios.delete(`/api/data/import/${jobId}`).catch(() => {})
}
setPhase('idle')
setJobId('')
setFile(null)
setPreview(null)
setResult(null)
setImportPicks([])
setPercent(0)
setError('')
if (inputRef.current) inputRef.current.value = ''
}
const onDrop = event => {
event.preventDefault()
setDragging(false)
const picked = event.dataTransfer?.files?.[0]
if (picked) startPreview(picked)
}
const busy = phase === 'checking' || phase === 'applying' || replanning
const plan = preview?.plan
const availableKeys = preview?.availableCategories || preview?.file?.categories || []
return (
<div className="page-container transfer-page">
<div className="page-header">
<div>
<h1 className="page-title">数据迁移</h1>
<p className="page-subtitle">把业务数据搬到另一个部署,或从备份包把数据导回来。按类别选择,冲突只跳过不覆盖。</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary" onClick={loadCatalog} disabled={loading || exporting || busy}>
<RefreshIcon size={14} /> {loading ? '读取中…' : '刷新'}
</button>
</div>
</div>
<div className="panel transfer-boundary">
<div className="panel-title"><ShieldIcon size={16} /> 数据边界</div>
<div className="transfer-boundary-grid">
<div>
<h4>会随包迁移</h4>
<p>供应商与产品、客户与联系人、询盘报价、订单与杂费、跟进时间线与待办、新客线索。</p>
</div>
<div>
<h4>永不随包迁移</h4>
<p>用户与密码、权限组、组织信息、系统设置(含大模型密钥)、登录会话、系统日志。目标部署继续用自己的那套。</p>
</div>
</div>
<p className="help-text">导入的记录一律归到执行导入的账号名下,导完可在客户/订单页重新分配。</p>
</div>
{error && (
<div className="update-status is-error transfer-error" role="alert">
<CloseIcon size={15} /> <span>{error}</span>
</div>
)}
<div className="transfer-grid">
{/* ---------------------------------------------------------- 导出 */}
<section className="panel transfer-card">
<div className="panel-title"><DownloadIcon size={16} /> 导出</div>
<p className="settings-description">
勾选要带走的数据类别。全选就是「全部业务数据」;只勾订单也能单独带走,不会连带客户档案。
</p>
<div className="transfer-picks">
{categories.map(category => {
const checked = exportPicks.includes(category.key)
return (
<label key={category.key} className={`transfer-pick${checked ? ' is-on' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleExport(category.key)}
aria-label={`导出${category.label}`}
/>
<span className="transfer-pick-body">
<strong>{category.label}</strong>
<small>{category.description}</small>
</span>
<em>{num(rowsOf(category))} 行</em>
</label>
)
})}
{!categories.length && !loading && <p className="muted-note">没有可导出的类别。</p>}
</div>
<div className="transfer-actions">
<button type="button" className="btn btn-secondary" onClick={() => setExportPicks(categories.map(item => item.key))} disabled={allPicked || !categories.length}>
全选
</button>
<button type="button" className="btn btn-secondary" onClick={() => setExportPicks([])} disabled={!exportPicks.length}>
全不选
</button>
<button type="button" className="btn btn-primary" onClick={doExport} disabled={!exportPicks.length || exporting}>
<DownloadIcon size={14} />
{exporting ? '正在生成…' : allPicked ? '导出全部业务数据' : `导出所选(${exportPicks.length} 类)`}
</button>
</div>
<p className="help-text">
共 {num(pickedRows)} 行;包内每张表带 sha256,导入前会先校验完整性。
</p>
</section>
{/* ---------------------------------------------------------- 导入 */}
<section className="panel transfer-card">
<div className="panel-title"><UploadIcon size={16} /> 导入</div>
{(phase === 'idle' || phase === 'checking') && (
<>
<p className="settings-description">把另一个部署导出的 <code>.hdata.gz</code> 拖进来。导入前会先出一份预览报告,确认后才写入。</p>
<div
className={`transfer-drop${dragging ? ' is-drag' : ''}${phase === 'checking' ? ' is-busy' : ''}`}
onDragOver={event => { event.preventDefault(); if (phase === 'idle') setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={event => { if (phase === 'idle') onDrop(event) }}
>
{phase === 'checking' ? (
<>
<span className="spinner" />
<p><strong>正在上传并校验数据包…</strong></p>
<div className="transfer-progress" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
<i style={{ width: `${percent}%` }} />
</div>
<p className="muted-note">{percent}%{percent >= 100 ? ' · 正在逐表核对校验值' : ''}</p>
</>
) : (
<>
<UploadIcon size={26} />
<p><strong>把数据包拖到这里</strong></p>
<p className="muted-note">或者</p>
<label className="btn btn-secondary file-label">
选择文件
<input
ref={inputRef}
type="file"
accept=".gz,.hdata,application/gzip"
onChange={event => { startPreview(event.target.files?.[0]); event.target.value = '' }}
/>
</label>
<p className="muted-note">单包上限 {fmtSize(catalog?.limits?.maxBytes || 100 * 1024 * 1024)}</p>
</>
)}
</div>
</>
)}
{(phase === 'ready' || phase === 'applying') && plan && (
<>
<div className="transfer-file">
<strong>{preview.file?.name || file?.name}</strong>
<small>
{fmtSize(preview.file?.size)} · 导出于 {fmtTime(preview.file?.exportedAt)}
{preview.file?.exporter?.display_name ? ` · 来自 ${preview.file.exporter.display_name}` : ''}
</small>
</div>
<div className="transfer-cats">
<span className="muted-note">导入类别</span>
{availableKeys.map(key => {
const category = categoryByKey.get(key)
const on = importPicks.includes(key)
return (
<button
key={key}
type="button"
className={`chip-filter${on ? ' is-active' : ''}`}
onClick={() => toggleImport(key)}
disabled={replanning || phase === 'applying'}
aria-pressed={on}
>
{category?.label || key}
</button>
)
})}
{replanning && <span className="spinner" />}
</div>
<div className="table-wrap transfer-report">
<table className="data-table">
<thead>
<tr>
<th>数据表</th>
<th className="num">包内</th>
<th className="num">将新增</th>
<th className="num">跳过</th>
<th className="num">失败</th>
</tr>
</thead>
<tbody>
{plan.tables.map(item => (
<tr key={item.table}>
<td>{item.label}</td>
<td className="num">{num(item.rows)}</td>
<td className="num">{num(item.insert)}</td>
<td className="num">{num(item.skip)}</td>
<td className={`num${item.failed ? ' transfer-failed' : ''}`}>{num(item.failed)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td><strong>合计</strong></td>
<td className="num"><strong>{num(plan.totals.rows)}</strong></td>
<td className="num"><strong>{num(plan.totals.insert)}</strong></td>
<td className="num"><strong>{num(plan.totals.skip)}</strong></td>
<td className="num"><strong>{num(plan.totals.failed)}</strong></td>
</tr>
</tfoot>
</table>
</div>
{plan.stubbed?.length > 0 && (
<p className="help-text transfer-stub-note">
<ExchangeIcon size={14} /> 包里没有父记录,将自动补建:{plan.stubbed.map(item => `${item.label} ${item.count} 条`).join('、')}
(只有名称,导完请补全信息)。
</p>
)}
{plan.tables.some(item => item.warnings?.length) && (
<details className="transfer-details">
<summary>预览告警({plan.tables.reduce((sum, item) => sum + (item.warnings?.length || 0), 0)} 条)</summary>
{plan.tables.filter(item => item.warnings?.length).map(item => (
<div key={item.table}>
<strong>{item.label}</strong>
<ul>{item.warnings.map((text, index) => <li key={index}>{text}</li>)}</ul>
</div>
))}
</details>
)}
<p className="help-text">导入前会自动把当前线上库备份成一份 .db 文件,失败可整体回滚。</p>
<div className="transfer-actions">
<button type="button" className="btn btn-ghost" onClick={reset} disabled={phase === 'applying'}>
重新选择
</button>
<button
type="button"
className="btn btn-primary"
onClick={doApply}
disabled={!importPicks.length || replanning || phase === 'applying' || !plan.totals.insert}
>
{phase === 'applying' ? '正在导入…' : plan.totals.insert ? `确认导入(新增 ${num(plan.totals.insert)} 行)` : '没有可新增的记录'}
</button>
</div>
</>
)}
{phase === 'done' && result && (
<>
<div className="update-status is-ok">
<CheckIcon size={15} />
<span>
导入完成:新增 {num(result.plan?.totals?.insert)} 行,跳过 {num(result.plan?.totals?.skip)} 行
{result.plan?.totals?.failed ? `,失败 ${num(result.plan.totals.failed)} 行` : ''}。
</span>
</div>
<p className="help-text">导入前的备份:{result.backup}</p>
{result.plan?.stubbed?.length > 0 && (
<p className="help-text">
自动补建:{result.plan.stubbed.map(item => `${item.label} ${item.count} 条`).join('、')} —— 这些只有名称,请补全联系人、地址等信息。
</p>
)}
{result.plan?.tables?.some(item => item.failed) && (
<details className="transfer-details" open>
<summary>未导入的行({result.plan.tables.reduce((sum, item) => sum + (item.failed || 0), 0)} 行)</summary>
{result.plan.tables.filter(item => item.failed).map(item => (
<div key={item.table}>
<strong>{item.label}</strong>
<ul>{item.warnings.map((text, index) => <li key={index}>{text}</li>)}</ul>
</div>
))}
</details>
)}
<div className="transfer-actions">
<button type="button" className="btn btn-primary" onClick={reset}>继续导入</button>
</div>
</>
)}
</section>
</div>
</div>
)
}
export default DataTransfer
+259
View File
@@ -0,0 +1,259 @@
/* ---------------------------------------------------------------------------
数据迁移页(/data-transfer)
只做这一页需要的样式,配色全部沿用主题变量,昼夜两套主题自动跟随。
--------------------------------------------------------------------------- */
.transfer-page .settings-description {
margin: 0 0 14px;
color: var(--text-secondary);
font-size: 13.5px;
line-height: 1.7;
}
.transfer-boundary {
margin-bottom: 18px;
}
.transfer-boundary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.transfer-boundary-grid h4 {
margin: 0 0 6px;
color: var(--text-primary);
font-size: 13.5px;
}
.transfer-boundary-grid p {
margin: 0;
color: var(--text-tertiary);
font-size: 13px;
line-height: 1.7;
}
.transfer-error {
margin-bottom: 16px;
}
.transfer-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
align-items: start;
}
.transfer-card {
min-height: 260px;
}
/* ---------------------------------------------------------------- 导出 */
.transfer-picks {
display: flex;
flex-direction: column;
gap: 8px;
}
.transfer-pick {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 11px 13px;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: var(--surface-deep);
cursor: pointer;
transition: border-color var(--transition-fast), background var(--transition-fast);
}
.transfer-pick:hover {
border-color: var(--primary-border);
}
.transfer-pick.is-on {
border-color: var(--primary-border);
background: var(--primary-bg);
}
.transfer-pick input {
margin-top: 3px;
accent-color: var(--primary-light);
}
.transfer-pick-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.transfer-pick-body strong {
color: var(--text-primary);
font-size: 13.5px;
}
.transfer-pick-body small {
color: var(--text-tertiary);
font-size: 12.5px;
line-height: 1.6;
}
.transfer-pick em {
flex: 0 0 auto;
color: var(--text-tertiary);
font: 12px var(--font-mono);
font-style: normal;
white-space: nowrap;
}
.transfer-pick.is-on em {
color: var(--primary-light);
}
/* ---------------------------------------------------------------- 导入 */
.transfer-drop {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 26px 20px;
border: 1px dashed var(--border-strong);
border-radius: var(--radius-lg);
background: var(--surface-deep);
color: var(--text-tertiary);
text-align: center;
transition: border-color var(--transition-fast), background var(--transition-fast);
}
.transfer-drop.is-drag {
border-color: var(--primary-border);
background: var(--primary-bg);
color: var(--primary-light);
}
.transfer-drop p {
margin: 0;
font-size: 13.5px;
}
.transfer-drop strong {
color: var(--text-primary);
font-size: 14px;
}
.transfer-drop .file-label {
position: relative;
overflow: hidden;
}
.transfer-drop .file-label input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.transfer-progress {
width: min(280px, 100%);
height: 4px;
border-radius: 999px;
background: var(--border-light);
overflow: hidden;
}
.transfer-progress i {
display: block;
height: 100%;
background: var(--primary-light);
transition: width 160ms linear;
}
.transfer-file {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 12px;
padding: 11px 13px;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: var(--surface-deep);
}
.transfer-file strong {
color: var(--text-primary);
font-size: 13.5px;
word-break: break-all;
}
.transfer-file small {
color: var(--text-tertiary);
font-size: 12.5px;
}
.transfer-cats {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.transfer-report {
max-height: 320px;
}
.transfer-report tfoot td {
border-top: 1px solid var(--border-strong);
background: var(--surface-raised);
}
.transfer-report .transfer-failed {
color: var(--danger);
font-weight: 700;
}
.transfer-stub-note {
display: flex;
align-items: flex-start;
gap: 6px;
}
.transfer-details {
margin-top: 10px;
padding: 10px 13px;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: var(--surface-deep);
color: var(--text-tertiary);
font-size: 13px;
}
.transfer-details summary {
cursor: pointer;
color: var(--text-secondary);
}
.transfer-details ul {
margin: 6px 0 0;
padding-left: 18px;
line-height: 1.7;
}
.transfer-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 16px;
}
@media (max-width: 1080px) {
.transfer-grid,
.transfer-boundary-grid {
grid-template-columns: minmax(0, 1fr);
}
}