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