Files
ShiErFeng/shierfeng-fastapi-frontend/src/components/MasterExcelActions/index.vue
T
yujiangandClaude Opus 5.5 167df4adb8 Feat: 前端改版 Web 端界面与业务作业流程
按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。

- 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、
  测试模式提示条、深色模式配色、窄屏适配
- 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、
  缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择
  MasterRefSelect,以及编号段解析等工具
- 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案
- 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览;
  分配用户对话框标注增减;7 个新增/编辑对话框统一布局
- 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为
  列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变
- 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查
- 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试,
  同步更新业务与工作台契约脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:22 -07:00

982 lines
30 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-col v-if="allowImport && resource === 'strain'" :span="1.5">
<el-button icon="Upload" @click="openImport" v-hasPermi="['master:strain:import']">导入</el-button>
</el-col>
<el-col v-if="resource === 'strain'" :span="1.5">
<el-button icon="Download" @click="handleExport" v-hasPermi="['master:strain:export']">导出</el-button>
</el-col>
<el-col v-if="allowImport && resource === 'customer'" :span="1.5">
<el-button icon="Upload" @click="openImport" v-hasPermi="['master:customer:import']">导入</el-button>
</el-col>
<el-col v-if="resource === 'customer'" :span="1.5">
<el-button icon="Download" @click="handleExport" v-hasPermi="['master:customer:export']">导出</el-button>
</el-col>
<el-col v-if="allowImport && resource === 'site'" :span="1.5">
<el-button icon="Upload" @click="openImport" v-hasPermi="['master:site:import']">导入</el-button>
</el-col>
<el-col v-if="resource === 'site'" :span="1.5">
<el-button icon="Download" @click="handleExport" v-hasPermi="['master:site:export']">导出</el-button>
</el-col>
<el-col v-if="allowImport && resource === 'line'" :span="1.5">
<el-button icon="Upload" @click="openImport" v-hasPermi="['master:line:import']">导入</el-button>
</el-col>
<el-col v-if="resource === 'line'" :span="1.5">
<el-button icon="Download" @click="handleExport" v-hasPermi="['master:line:export']">导出</el-button>
</el-col>
<el-col v-if="allowImport && resource === 'warehouse'" :span="1.5">
<el-button icon="Upload" @click="openImport" v-hasPermi="['master:warehouse:import']">导入</el-button>
</el-col>
<el-col v-if="resource === 'warehouse'" :span="1.5">
<el-button icon="Download" @click="handleExport" v-hasPermi="['master:warehouse:export']">导出</el-button>
</el-col>
<el-col v-if="allowImport && resource === 'pallet'" :span="1.5">
<el-button icon="Upload" @click="openImport" v-hasPermi="['master:pallet:import']">导入</el-button>
</el-col>
<el-col v-if="resource === 'pallet'" :span="1.5">
<el-button icon="Download" @click="handleExport" v-hasPermi="['master:pallet:export']">导出</el-button>
</el-col>
<el-col v-if="allowImport && resource === 'tunnel'" :span="1.5">
<el-button icon="Upload" @click="openImport" v-hasPermi="['master:tunnel:import']">导入</el-button>
</el-col>
<el-col v-if="resource === 'tunnel'" :span="1.5">
<el-button icon="Download" @click="handleExport" v-hasPermi="['master:tunnel:export']">导出</el-button>
</el-col>
<el-dialog
v-model="upload.open"
:title="upload.title"
width="min(1040px, 94vw)"
top="5vh"
append-to-body
class="sf-import-dialog"
:close-on-click-modal="!busy"
:close-on-press-escape="!busy"
:show-close="!busy"
>
<ol class="sf-import-steps" aria-label="导入步骤">
<li v-for="(label, index) in STEP_LABELS" :key="label" :class="stepClass(index)">
<span class="sf-import-steps__mark">{{ index < stepIndex ? '✓' : index + 1 }}</span>{{ label }}
</li>
</ol>
<!-- 第 1 步:选择文件 -->
<div v-if="upload.step === 'upload'" class="sf-import-upload">
<section class="sf-import-upload__main">
<el-upload
ref="uploadRef"
:limit="1"
accept=".xlsx, .xls"
:auto-upload="false"
:show-file-list="false"
:disabled="busy"
:on-change="handleFileChange"
:on-exceed="handleFileExceed"
drag
>
<span class="sf-import-drop__icon" aria-hidden="true"><el-icon><upload-filled /></el-icon></span>
<strong class="sf-import-drop__title">{{ upload.selectedFile ? upload.selectedFile.name : '拖拽 Excel 文件到这里,或点击选择' }}</strong>
<span class="sf-import-drop__hint">{{ upload.selectedFile ? `${formatSize(upload.selectedFile.size)} · 点击可重新选择` : '支持 .xlsx / .xls · 不超过 5 MB · 最多 5,000 行 · 表头需与模板一致' }}</span>
</el-upload>
<div class="sf-import-modes" role="radiogroup" aria-label="编码已存在时">
<span class="sf-import-section-title">编码已存在时</span>
<div class="sf-import-modes__grid">
<button
v-for="mode in modes"
:key="mode.value"
type="button"
role="radio"
class="sf-import-mode"
:class="{ 'is-active': upload.updateSupport === mode.value }"
:aria-checked="upload.updateSupport === mode.value"
@click="upload.updateSupport = mode.value"
>
<span class="sf-import-mode__radio" aria-hidden="true"></span>
<span><strong>{{ mode.title }}</strong><small>{{ mode.desc }}</small></span>
</button>
</div>
</div>
</section>
<div class="sf-import-upload__side">
<section class="sf-import-columns">
<span class="sf-import-columns__head">
<span class="sf-import-section-title">模板列</span>
<el-button size="small" icon="Download" @click="downloadTemplate">下载模板</el-button>
</span>
<span v-for="column in templateColumns" :key="column.name" class="sf-import-columns__row">
<strong>{{ column.name }}<em v-if="column.required">*</em></strong>
<small>{{ column.desc }}</small>
</span>
</section>
<p class="sf-import-note">上传后先校验、不写入;确认后才导入。文件内编码重复会整批退回,单行问题只跳过该行。</p>
</div>
</div>
<!-- 第 2 步:校验预览 -->
<div v-else-if="upload.step === 'review' && preview" class="sf-import-review">
<div class="sf-import-file">
<span class="sf-import-file__badge">{{ fileExtension }}</span>
<span class="sf-import-file__text">
<strong>{{ upload.selectedFile?.name }}</strong>
<small>{{ formatCount(preview.totalRows) }} 行数据 · {{ formatSize(upload.selectedFile?.size) }} · 校验于 {{ upload.checkedAt }} · 尚未写入</small>
</span>
<span class="sf-import-file__mode">已存在编码:<strong>{{ preview.updateSupport ? '更新已有资料' : '跳过已有资料' }}</strong></span>
</div>
<section class="sf-import-tiles" aria-label="校验结果">
<div v-for="tile in tiles" :key="tile.label">
<span><i :style="{ background: tile.dot }"></i>{{ tile.label }}</span>
<strong :class="{ 'is-danger': tile.danger }">{{ formatCount(tile.value) }}</strong>
<small>{{ tile.hint }}</small>
</div>
</section>
<div class="sf-import-tabs" role="tablist" aria-label="结果筛选">
<button
v-for="tab in reviewTabs"
:key="tab.key"
type="button"
role="tab"
:class="{ 'is-active': upload.tab === tab.key }"
:aria-selected="upload.tab === tab.key"
@click="upload.tab = tab.key"
>
{{ tab.label }}<span :class="{ 'is-danger': tab.key === 'error' && tab.count }">{{ formatCount(tab.count) }}</span>
</button>
</div>
<el-table :data="visibleRows" row-key="excelRow" max-height="380" empty-text="没有符合条件的行" :row-class-name="previewRowClass" class="sf-import-table">
<el-table-column label="行号" prop="excelRow" width="64" />
<el-table-column v-for="(header, index) in preview.headers" :key="header" :label="header" min-width="110" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.values[index] || '—' }}</template>
</el-table-column>
<el-table-column label="校验结果" min-width="260">
<template #default="scope">
<span class="sf-import-result">
<span class="sf-import-pill" :class="`is-${scope.row.outcome}`">{{ OUTCOME_LABELS[scope.row.outcome] }}</span>
<span class="sf-import-result__msg">{{ scope.row.message }}</span>
</span>
</template>
</el-table-column>
</el-table>
<p v-if="hiddenRowCount" class="sf-import-more">另有 {{ formatCount(hiddenRowCount) }} 行未显示,切换上方筛选查看。</p>
</div>
<!-- 第 3 步:完成 -->
<div v-else-if="upload.step === 'done'" class="sf-import-done">
<span class="sf-import-done__icon" aria-hidden="true">✓</span>
<h2>导入完成</h2>
<p>{{ upload.selectedFile?.name }} 已写入。跳过的行没有任何改动,可下载明细修正后再次导入。</p>
<pre class="sf-import-done__msg">{{ upload.resultMessage }}</pre>
</div>
<template #footer>
<div v-if="upload.step === 'upload'" class="sf-import-footer">
<span class="sf-import-footer__text">选择文件后先校验,校验不会写入任何数据。</span>
<el-button :disabled="busy" @click="upload.open = false">取消</el-button>
<el-button type="primary" :loading="upload.checking" :disabled="!upload.selectedFile" @click="runPreview">校验文件</el-button>
</div>
<div v-else-if="upload.step === 'review' && preview" class="sf-import-footer">
<span class="sf-import-footer__text">
<strong class="is-danger">{{ formatCount(preview.errorCount) }} 行</strong>有错误,将被跳过;<strong>{{ formatCount(importableCount) }} 行</strong>可以导入
</span>
<el-button :disabled="busy" @click="backToUpload">重新上传</el-button>
<el-button v-if="preview.errorCount" :disabled="busy" @click="downloadErrorRows">下载错误明细</el-button>
<el-button type="primary" :loading="upload.importing" :disabled="!importableCount" @click="submitFileForm">确认导入 {{ formatCount(importableCount) }} 行</el-button>
</div>
<div v-else class="sf-import-footer">
<el-button @click="backToUpload">继续导入</el-button>
<el-button type="primary" @click="upload.open = false">完成</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { computed, getCurrentInstance, reactive, ref } from "vue";
import request from "@/utils/request";
const props = defineProps({
resource: {
type: String,
required: true
},
allowImport: {
type: Boolean,
default: false
},
queryParams: {
type: Object,
default: () => ({})
},
selectedIds: {
type: Array,
default: () => []
}
});
const emit = defineEmits(["imported"]);
const { proxy } = getCurrentInstance();
const resourceLabels = {
strain: "菌种",
customer: "客户",
site: "客户现场",
line: "产线",
warehouse: "仓库",
pallet: "托盘",
tunnel: "隧道"
};
// 与后端导入模板表头一致;说明只用于提示,校验以服务端为准
const STATUS_COLUMN = { name: "状态", desc: "正常 / 停用,留空视为正常" };
const REMARK_COLUMN = { name: "备注", desc: "选填,最长 500 字" };
const TEMPLATE_COLUMNS = {
strain: [{ name: "菌种编码", required: true, desc: "全局唯一,最长 64 字" }, { name: "菌种名称", required: true, desc: "最长 100 字" }, STATUS_COLUMN, REMARK_COLUMN],
customer: [{ name: "客户编码", required: true, desc: "全局唯一,最长 64 字" }, { name: "客户名称", required: true, desc: "最长 100 字" }, STATUS_COLUMN, REMARK_COLUMN],
site: [{ name: "客户编码", required: true, desc: "须为已存在且正常的客户" }, { name: "现场编码", required: true, desc: "同一客户下唯一,最长 64 字" }, { name: "现场名称", required: true, desc: "最长 100 字" }, { name: "现场地址", desc: "选填,最长 500 字" }, STATUS_COLUMN, REMARK_COLUMN],
line: [{ name: "现场编码", required: true, desc: "须为已存在且正常的现场" }, { name: "产线编码", required: true, desc: "同一现场下唯一,最长 64 字" }, { name: "产线名称", required: true, desc: "最长 100 字" }, STATUS_COLUMN, REMARK_COLUMN],
warehouse: [{ name: "仓库编码", required: true, desc: "全局唯一,最长 64 字" }, { name: "仓库名称", required: true, desc: "最长 100 字" }, { name: "仓库地址", desc: "选填,最长 500 字" }, STATUS_COLUMN, REMARK_COLUMN],
pallet: [{ name: "托盘编号", required: true, desc: "全局唯一;新托盘容量固定 100 包,二维码自动生成" }, STATUS_COLUMN, REMARK_COLUMN],
tunnel: [{ name: "现场编码", required: true, desc: "须为已存在且正常的现场" }, { name: "隧道编号", required: true, desc: "同一现场下唯一,最长 64 字" }, { name: "隧道名称", required: true, desc: "最长 100 字" }, STATUS_COLUMN, REMARK_COLUMN]
};
const STEP_LABELS = ["选择文件", "校验预览", "完成"];
const STEP_ORDER = ["upload", "review", "done"];
const OUTCOME_LABELS = { add: "新增", update: "将更新", skip: "将跳过", error: "错误" };
const PREVIEW_ROW_LIMIT = 500;
const resourceLabel = computed(() => resourceLabels[props.resource] || "基础资料");
const templateColumns = computed(() => TEMPLATE_COLUMNS[props.resource] || []);
const modes = computed(() => [
{ value: true, title: "更新已有资料", desc: `按编码匹配,覆盖名称、状态和备注等${resourceLabel.value}资料` },
{ value: false, title: "跳过已有资料", desc: `只新增文件里的新${resourceLabel.value},已存在的编码保持不变` }
]);
const importPath = computed(() => `/master/${props.resource}/importData`);
const upload = reactive({
open: false,
title: "",
step: "upload",
tab: "all",
checking: false,
importing: false,
updateSupport: false,
selectedFile: null,
checkedAt: "",
resultMessage: ""
});
const preview = ref(null);
const busy = computed(() => upload.checking || upload.importing);
const stepIndex = computed(() => STEP_ORDER.indexOf(upload.step));
const importableCount = computed(() => (preview.value ? preview.value.addCount + preview.value.updateCount : 0));
const fileExtension = computed(() => (upload.selectedFile?.name?.split(".").pop() || "xlsx").toUpperCase());
const tiles = computed(() => {
const data = preview.value;
if (!data) return [];
return [
{ label: "将新增", value: data.addCount, hint: `新的${resourceLabel.value}编码`, dot: "var(--sf-accent, #007787)" },
data.updateSupport
? { label: "将更新", value: data.updateCount, hint: "覆盖已有资料", dot: "var(--sf-transit, #6a7fe8)" }
: { label: "将跳过", value: data.skipCount, hint: "已有资料保持不变", dot: "var(--sf-neutral-dot, #9aa1ac)" },
{ label: "有错误", value: data.errorCount, hint: "这些行不会写入", dot: "var(--sf-danger-dot, #f04438)", danger: data.errorCount > 0 },
{ label: "可导入", value: importableCount.value, hint: `共 ${formatCount(data.totalRows)} 行数据`, dot: "var(--sf-text-1, #0f1115)" }
];
});
const reviewTabs = computed(() => {
const data = preview.value;
if (!data) return [];
return [
{ key: "all", label: "全部", count: data.totalRows },
{ key: "error", label: "有错误", count: data.errorCount },
{ key: "add", label: "新增", count: data.addCount },
data.updateSupport
? { key: "update", label: "将更新", count: data.updateCount }
: { key: "skip", label: "将跳过", count: data.skipCount }
];
});
// 全部视图把有问题的行排在前面,便于先处理错误
const filteredRows = computed(() => {
const rows = preview.value?.rows || [];
if (upload.tab !== "all") return rows.filter((row) => row.outcome === upload.tab);
const rank = { error: 0, update: 1, skip: 2, add: 3 };
return [...rows].sort((a, b) => rank[a.outcome] - rank[b.outcome] || a.excelRow - b.excelRow);
});
const visibleRows = computed(() => filteredRows.value.slice(0, PREVIEW_ROW_LIMIT));
const hiddenRowCount = computed(() => Math.max(0, filteredRows.value.length - PREVIEW_ROW_LIMIT));
function formatCount(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
function formatSize(size) {
const bytes = Number(size) || 0;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function stepClass(index) {
if (index < stepIndex.value) return "is-done";
return index === stepIndex.value ? "is-current" : "";
}
function previewRowClass({ row }) {
return row.outcome === "error" ? "is-error-row" : "";
}
function openImport() {
upload.title = `${resourceLabel.value}批量导入`;
upload.open = true;
backToUpload();
}
function backToUpload() {
upload.step = "upload";
upload.tab = "all";
upload.selectedFile = null;
upload.checking = false;
upload.importing = false;
upload.resultMessage = "";
preview.value = null;
proxy.$refs.uploadRef?.clearFiles();
}
function downloadTemplate() {
proxy.download(
`master/${props.resource}/importTemplate`,
{},
`${props.resource}_template_${new Date().getTime()}.xlsx`
);
}
function handleExport() {
const exportParams = { ...props.queryParams };
if (Array.isArray(props.selectedIds) && props.selectedIds.length > 0) {
exportParams.ids = props.selectedIds.join(",");
}
proxy.download(
`master/${props.resource}/export`,
exportParams,
`${props.resource}_${new Date().getTime()}.xlsx`
);
}
function handleFileChange(file) {
const fileName = file?.name ? file.name.toLowerCase() : "";
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
proxy.$modal.msgError("请选择后缀为 “xls”或“xlsx”的文件。");
proxy.$refs.uploadRef?.clearFiles();
upload.selectedFile = null;
return;
}
upload.selectedFile = file;
}
// 已选过文件时再次选择,直接替换为新文件
function handleFileExceed(files) {
proxy.$refs.uploadRef?.clearFiles();
const [file] = files;
if (!file) return;
proxy.$refs.uploadRef?.handleStart(file);
}
function postImport(previewOnly) {
const formData = new FormData();
formData.append("file", upload.selectedFile.raw || upload.selectedFile);
return request({
url: importPath.value,
method: "post",
params: { updateSupport: upload.updateSupport, preview: previewOnly },
headers: { "Content-Type": "multipart/form-data", repeatSubmit: false },
data: formData
});
}
function runPreview() {
if (!upload.selectedFile || busy.value) return;
upload.checking = true;
postImport(true).then((response) => {
preview.value = response.data || null;
upload.checkedAt = new Date().toTimeString().slice(0, 5);
upload.tab = preview.value?.errorCount ? "error" : "all";
upload.step = "review";
}).catch(() => {
preview.value = null;
}).finally(() => {
upload.checking = false;
});
}
function submitFileForm() {
if (!upload.selectedFile || busy.value || !importableCount.value) return;
upload.importing = true;
postImport(false).then((response) => {
upload.resultMessage = String(response.msg ?? "");
upload.step = "done";
emit("imported");
}).catch(() => {
upload.resultMessage = "";
}).finally(() => {
upload.importing = false;
});
}
// 错误明细在浏览器内生成 CSV,不再经过服务端
function downloadErrorRows() {
const data = preview.value;
if (!data) return;
const escapeCell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`;
const lines = [["行号", ...data.headers, "校验结果"].map(escapeCell).join(",")];
data.rows.filter((row) => row.outcome === "error").forEach((row) => {
lines.push([row.excelRow, ...row.values, row.message].map(escapeCell).join(","));
});
const blob = new Blob(["" + lines.join("\r\n")], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${props.resource}_import_errors_${new Date().getTime()}.csv`;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
</script>
<style lang="scss">
// 对话框挂载在 body 上,样式不能使用 scoped
.sf-import-dialog {
.el-dialog__body {
padding-top: 4px;
}
.sf-import-steps {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 18px;
padding: 0;
list-style: none;
li {
display: inline-flex;
align-items: center;
gap: 8px;
height: 30px;
padding: 0 12px 0 6px;
border-radius: 15px;
color: var(--sf-text-4, #6b717d);
font-size: 13px;
font-weight: 500;
&.is-current {
background: var(--sf-accent-soft, #e6f3f5);
color: var(--sf-accent-strong, #006a78);
font-weight: 600;
}
&.is-done {
color: var(--sf-text-1, #0f1115);
}
}
}
.sf-import-steps__mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--sf-border-strong, #e4e7eb);
color: var(--sf-text-3, #5b616e);
font-size: 11px;
}
.sf-import-steps .is-current .sf-import-steps__mark,
.sf-import-steps .is-done .sf-import-steps__mark {
background: var(--sf-accent, #007787);
color: #ffffff;
}
.sf-import-section-title {
color: var(--sf-text-1, #0f1115);
font-size: 14px;
font-weight: 600;
}
// 第 1 步
.sf-import-upload {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 20px;
}
.sf-import-upload__main {
display: flex;
flex: 999 1 460px;
flex-direction: column;
gap: 16px;
min-width: 0;
.el-upload,
.el-upload-dragger {
width: 100%;
}
.el-upload-dragger {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 40px 24px;
border: 1.5px dashed var(--sf-border-strong, #c3c8cf);
border-radius: 14px;
background: var(--sf-surface, #ffffff);
&:hover,
&.is-dragover {
border-color: var(--sf-accent, #007787);
background: var(--sf-accent-soft, #e6f3f5);
}
}
}
.sf-import-drop__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 14px;
background: var(--sf-accent-soft, #e6f3f5);
color: var(--sf-accent, #007787);
font-size: 26px;
}
.sf-import-drop__title {
color: var(--sf-text-1, #0f1115);
font-size: 15px;
font-weight: 600;
overflow-wrap: anywhere;
}
.sf-import-drop__hint {
color: var(--sf-text-3, #5b616e);
font-size: 13px;
}
.sf-import-modes {
display: flex;
flex-direction: column;
gap: 10px;
}
.sf-import-modes__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.sf-import-mode {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: 10px;
background: var(--sf-surface, #ffffff);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
> span:last-child {
display: flex;
flex-direction: column;
line-height: 1.5;
}
strong {
font-size: 13.5px;
font-weight: 500;
}
small {
color: var(--sf-text-3, #5b616e);
font-size: 12.5px;
}
&.is-active {
border-color: var(--sf-accent, #007787);
background: var(--sf-accent-soft, #e6f3f5);
box-shadow: 0 0 0 1px var(--sf-accent, #007787);
}
}
.sf-import-mode__radio {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
border: 1.5px solid var(--sf-border-strong, #c3c8cf);
border-radius: 50%;
}
.sf-import-mode.is-active .sf-import-mode__radio {
border-color: var(--sf-accent, #007787);
background: radial-gradient(circle, var(--sf-accent, #007787) 0 4px, transparent 4.5px);
}
.sf-import-upload__side {
display: flex;
flex: 1 1 280px;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.sf-import-columns {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 18px;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: 12px;
}
.sf-import-columns__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.sf-import-columns__row {
display: flex;
align-items: baseline;
gap: 10px;
font-size: 13px;
line-height: 1.5;
strong {
flex: none;
min-width: 64px;
font-weight: 500;
}
em {
color: var(--sf-danger-fg, #b42318);
font-style: normal;
}
small {
color: var(--sf-text-3, #5b616e);
font-size: 12.5px;
}
}
.sf-import-note {
margin: 0;
padding: 12px 14px;
border-radius: 10px;
background: var(--sf-surface-3, #eef0f2);
color: var(--sf-text-2, #3d4350);
font-size: 12.5px;
line-height: 1.6;
}
// 第 2 步
.sf-import-review {
display: flex;
flex-direction: column;
gap: 14px;
}
.sf-import-file {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: 12px;
}
.sf-import-file__badge {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 40px;
height: 36px;
border-radius: 9px;
background: #e9f5ee;
color: #1e7a46;
font-size: 11px;
font-weight: 700;
}
.sf-import-file__text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 200px;
line-height: 1.4;
strong {
font-size: 13.5px;
font-weight: 500;
overflow-wrap: anywhere;
}
small {
color: var(--sf-text-4, #6b717d);
font-size: 12.5px;
font-variant-numeric: tabular-nums;
}
}
.sf-import-file__mode {
color: var(--sf-text-2, #4b5260);
font-size: 12.5px;
strong {
font-weight: 600;
}
}
.sf-import-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: 12px;
background: var(--sf-border, #e7e9ec);
> div {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 18px;
background: var(--sf-surface, #ffffff);
}
span {
display: flex;
align-items: center;
gap: 6px;
color: var(--sf-text-3, #5b616e);
font-size: 13px;
}
i {
width: 8px;
height: 8px;
border-radius: 50%;
}
strong {
color: var(--sf-text-1, #0f1115);
font-size: 24px;
font-weight: 600;
line-height: 1.15;
font-variant-numeric: tabular-nums;
&.is-danger {
color: var(--sf-danger-fg, #b42318);
}
}
small {
color: var(--sf-text-4, #6b717d);
font-size: 12px;
}
}
.sf-import-tabs {
display: flex;
gap: 22px;
overflow-x: auto;
border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
button {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
height: 40px;
margin-bottom: -1px;
padding: 0 2px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--sf-text-3, #5b616e);
font: inherit;
font-size: 13.5px;
cursor: pointer;
&.is-active {
border-bottom-color: var(--sf-text-1, #0f1115);
color: var(--sf-text-1, #0f1115);
font-weight: 600;
}
span {
padding: 0 6px;
border-radius: 9px;
background: var(--sf-surface-3, #f1f2f4);
color: var(--sf-text-3, #5b616e);
font-size: 12px;
font-weight: 500;
&.is-danger {
background: var(--sf-danger-bg, #fef3f2);
color: var(--sf-danger-fg, #b42318);
}
}
}
}
.sf-import-table .is-error-row > td.el-table__cell {
background: var(--sf-danger-bg, #fffbfa);
}
.sf-import-result {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.sf-import-result__msg {
overflow: hidden;
color: var(--sf-text-2, #4b5260);
font-size: 12.5px;
white-space: nowrap;
text-overflow: ellipsis;
}
.sf-import-pill {
display: inline-flex;
flex: none;
align-items: center;
gap: 5px;
height: 22px;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
&.is-add {
background: var(--sf-accent-soft, #e6f3f5);
color: var(--sf-accent-strong, #006a78);
}
&.is-update {
background: var(--sf-transit-bg, #eef1fd);
color: var(--sf-transit-fg, #3549a8);
}
&.is-skip {
background: var(--sf-surface-3, #f1f2f4);
color: var(--sf-text-2, #3d4350);
}
&.is-error {
background: var(--sf-danger-bg, #fef3f2);
color: var(--sf-danger-fg, #b42318);
}
}
.sf-import-more {
margin: 0;
color: var(--sf-text-4, #6b717d);
font-size: 12.5px;
}
// 第 3 步
.sf-import-done {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 24px 16px 8px;
text-align: center;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
p {
max-width: 560px;
margin: 0;
color: var(--sf-text-2, #4b5260);
font-size: 14px;
}
}
.sf-import-done__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--sf-accent, #007787);
color: #ffffff;
font-size: 26px;
font-weight: 700;
}
.sf-import-done__msg {
width: 100%;
max-width: 620px;
max-height: 240px;
margin: 8px 0 0;
overflow: auto;
padding: 12px 14px;
border-radius: 10px;
background: var(--sf-surface-2, #f7f8f9);
color: var(--sf-text-2, #3d4350);
font-family: inherit;
font-size: 13px;
line-height: 1.7;
text-align: left;
white-space: pre-wrap;
}
.sf-import-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
.el-button + .el-button {
margin-left: 0;
}
}
.sf-import-footer__text {
margin-right: auto;
color: var(--sf-text-2, #3d4350);
font-size: 13.5px;
text-align: left;
strong {
color: var(--sf-text-1, #0f1115);
font-weight: 600;
&.is-danger {
color: var(--sf-danger-fg, #b42318);
}
}
}
}
</style>