初始化询价智能体新仓:同步本地工程并排除密钥与 .env。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-13 14:30:44 +08:00
co-authored by Cursor
commit 2a14def882
2621 changed files with 393165 additions and 0 deletions
@@ -0,0 +1,763 @@
<script setup lang="ts">
/**
* 报价模板页:对齐原型 admin-quote-templates.html。
* - 上传按钮只在顶栏一处(禁止双按钮)
* - 弹窗用原生 overlay(不依赖 a-modal,避免遮罩残留导致整页点不了)
* - 操作:预览 / 编辑 / 启用|禁用
* - 本阶段仅 mock,暂不对接主账上传/启用接口(页面与侧栏必须保留)
*/
import { computed, onMounted, reactive, ref } from "vue";
import { message } from "ant-design-vue";
import {
ALL_CATEGORIES,
CATEGORY_BY_BIZ,
mockTemplates,
type TemplateRow,
} from "/@/views/inquiry/mock/templates";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
/** 清掉可能残留的 Ant Modal 遮罩(会导致整页点不了) */
function clearOrphanAntMasks() {
document.querySelectorAll(".ant-modal-mask, .ant-modal-wrap").forEach((el) => {
const node = el as HTMLElement;
if (node.style.display === "none" || node.getAttribute("aria-hidden") === "true") {
node.remove();
}
});
document.body.style.removeProperty("overflow");
document.body.classList.remove("ant-scrolling-effect");
}
onMounted(() => {
clearOrphanAntMasks();
});
const rows = ref<TemplateRow[]>([...mockTemplates]);
const keyword = ref("");
const bizFilter = ref("");
const categoryFilter = ref("");
const statusFilter = ref("");
const formOpen = ref(false);
const editing = ref<TemplateRow | null>(null);
const previewOpen = ref(false);
const previewRow = ref<TemplateRow | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const form = reactive({
name: "",
bizType: "海运" as TemplateRow["bizType"],
/** 多行关键词;每行可含逗号,加减行数(对齐旧仓 keywordItems) */
keywordItems: [""] as string[],
isDefault: false,
fileName: "",
fileMeta: "",
format: "excel" as "excel" | "pdf",
});
const parseState = reactive({
visible: false,
ok: true,
title: "",
desc: "",
});
const formTitle = computed(() => (editing.value ? "编辑报价模板" : "上传报价模板"));
const formSub = computed(() =>
editing.value ? "修改基本信息或更换模板文件后重新解析" : "上传 Excel / PDF,系统自动解析占位符",
);
const formOkText = computed(() => (editing.value ? "保存修改" : "发布模板"));
const previewFootHint =
"用户端生成时将复制模板文件并替换 {{占位符}},不改变原有 Excel / PDF 样式";
/** 表单不再手选分类:按业务取默认分类写入数据(列表筛选仍可用) */
function defaultCategoryForBiz(biz: TemplateRow["bizType"]) {
return (CATEGORY_BY_BIZ[biz] || [])[0] || "";
}
const filtered = computed(() =>
rows.value.filter((r) => {
const kw = keyword.value.trim().toLowerCase();
if (kw && !`${r.name}${r.bizType}${r.fileLabel}${r.category}${r.keywords.join(" ")}`.toLowerCase().includes(kw)) {
return false;
}
if (bizFilter.value && r.bizType !== bizFilter.value) return false;
if (categoryFilter.value && r.category !== categoryFilter.value) return false;
if (statusFilter.value === "enabled" && r.status !== "enabled") return false;
if (statusFilter.value === "disabled" && r.status !== "disabled") return false;
return true;
}),
);
function bizTag(biz: string) {
if (biz === "海运") return "tag-teal";
if (biz === "陆运") return "tag-amber";
return "tag-blue";
}
function resetForm() {
Object.assign(form, {
name: "",
bizType: "海运" as TemplateRow["bizType"],
keywordItems: [""],
isDefault: false,
fileName: "",
fileMeta: "",
format: "excel" as const,
});
parseState.visible = false;
parseState.ok = true;
parseState.title = "";
parseState.desc = "";
}
function openCreate() {
editing.value = null;
resetForm();
formOpen.value = true;
}
function openEdit(row: TemplateRow) {
editing.value = row;
Object.assign(form, {
name: row.name,
bizType: row.bizType,
keywordItems: row.keywords.length ? [...row.keywords] : [""],
isDefault: Boolean(row.isDefault),
fileName: row.fileName,
fileMeta: `${row.format === "pdf" ? "PDF" : "Excel"} · 已上传`,
format: row.format,
});
parseState.visible = true;
parseState.ok = row.parseStatus === "已解析";
parseState.title = parseState.ok ? "解析成功" : "解析失败";
parseState.desc = parseState.ok
? `识别 ${row.placeholderCount} 个占位符,已映射至工单字段`
: "请更换模板文件后重试";
formOpen.value = true;
}
/** 去空去重,保留框内逗号;至少一条才算有效 */
function normalizeKeywordItems(items: string[]): string[] {
const unique: string[] = [];
const seen = new Set<string>();
for (const raw of items) {
const kw = String(raw ?? "").trim();
if (!kw || seen.has(kw)) continue;
seen.add(kw);
unique.push(kw);
}
return unique;
}
/** 加一行关键词,最多 20 行,避免误点刷屏 */
function addKeyword() {
if (form.keywordItems.length >= 20) return;
form.keywordItems.push("");
}
/** 至少保留一个输入框 */
function removeKeyword(index: number) {
if (form.keywordItems.length <= 1) return;
form.keywordItems.splice(index, 1);
}
/**
* 同业务只保留一个默认模板:勾选本条时清掉同业务其它默认。
* 演示环境本地互斥;接主账后由接口保证。
*/
function applyDefaultExclusive(targetId: string, bizType: TemplateRow["bizType"], isDefault: boolean) {
if (!isDefault) return;
for (const row of rows.value) {
if (row.bizType === bizType && row.id !== targetId) row.isDefault = false;
}
}
function openPreview(row: TemplateRow) {
previewRow.value = row;
previewOpen.value = true;
}
function closeForm() {
formOpen.value = false;
}
function closePreview() {
previewOpen.value = false;
}
function toggleStatus(row: TemplateRow) {
row.status = row.status === "enabled" ? "disabled" : "enabled";
message.success(row.status === "enabled" ? "已启用" : "已禁用");
}
function formatFileSize(bytes: number) {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function applySelectedFile(file: File) {
const isPdf = /\.pdf$/i.test(file.name);
form.format = isPdf ? "pdf" : "excel";
form.fileName = file.name;
form.fileMeta = `${isPdf ? "PDF" : "Excel"} · ${formatFileSize(file.size)}`;
if (!form.name.trim()) {
form.name = file.name.replace(/\.(xlsx|xls|pdf)$/i, "");
}
const total = file.name.includes("拼柜") ? 12 : isPdf ? 9 : 14;
parseState.visible = true;
parseState.ok = true;
parseState.title = "解析成功";
parseState.desc = `识别 ${total} 个占位符,${total - 2} 个已自动映射至工单字段`;
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) applySelectedFile(file);
input.value = "";
}
function onDrop(e: DragEvent) {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (file) applySelectedFile(file);
}
function triggerFilePick() {
if (!form.fileName) fileInputRef.value?.click();
}
function submitForm() {
if (!form.fileName) {
message.warning("请先上传模板文件");
return;
}
if (!parseState.ok || !parseState.visible) {
message.warning("模板解析失败,请更换文件后重试");
return;
}
if (!form.name.trim()) {
message.warning("请填写模板名称");
return;
}
const keywords = normalizeKeywordItems(form.keywordItems);
if (!keywords.length) {
message.warning("请至少填写一个关键词");
return;
}
const placeholders = form.fileName.includes("拼柜") ? 12 : form.format === "pdf" ? 9 : 14;
const category = editing.value?.category || defaultCategoryForBiz(form.bizType);
if (editing.value) {
Object.assign(editing.value, {
name: form.name.trim(),
bizType: form.bizType,
category,
keywords,
isDefault: form.isDefault,
fileLabel: form.name.trim(),
fileName: form.fileName,
format: form.format,
parseStatus: "已解析" as const,
placeholderCount: placeholders,
});
applyDefaultExclusive(editing.value.id, form.bizType, form.isDefault);
message.success("模板已更新");
} else {
const id = `t${Date.now()}`;
rows.value.unshift({
id,
name: form.name.trim(),
bizType: form.bizType,
category,
keywords,
isDefault: form.isDefault,
fileLabel: form.name.trim(),
fileName: form.fileName,
format: form.format,
parseStatus: "已解析",
placeholderCount: placeholders,
status: "enabled",
useCount: 0,
createdAt: new Date().toISOString().slice(0, 10),
operator: "当前用户",
});
applyDefaultExclusive(id, form.bizType, form.isDefault);
message.success("模板已发布");
}
formOpen.value = false;
}
function downloadDemo() {
message.success("已开始下载");
}
</script>
<template>
<!-- 唯一上传入口:顶栏 -->
<Teleport defer to="#page-topbar-actions">
<button type="button" class="btn btn-primary" @click.stop.prevent="openCreate">
<ProtoIcon name="upload" :size="16" />上传模板
</button>
</Teleport>
<div class="section-card qt-template-page">
<div class="toolbar">
<div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" />
<input v-model="keyword" placeholder="搜索模板名称…" />
</div>
<select v-model="bizFilter">
<option value="">全部业务</option>
<option value="空运">空运</option>
<option value="海运">海运</option>
<option value="陆运">陆运</option>
</select>
<select v-model="categoryFilter">
<option value="">全部分类</option>
<option v-for="c in ALL_CATEGORIES" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="statusFilter">
<option value="">全部状态</option>
<option value="enabled">启用</option>
<option value="disabled">禁用</option>
</select>
</div>
<div class="table-wrap qt-template-table-wrap">
<table class="data-table qt-template-table">
<thead>
<tr>
<th>模板名称</th>
<th>业务</th>
<th>分类</th>
<th>模板文件</th>
<th>解析状态</th>
<th>状态</th>
<th>总使用次数</th>
<th>创建时间</th>
<th>操作人</th>
<th class="col-actions">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filtered" :key="row.id">
<td class="qt-col-name">
<span>{{ row.name }}</span>
<span v-if="row.isDefault" class="tag tag-blue qt-default-tag">默认</span>
</td>
<td>
<span class="tag" :class="bizTag(row.bizType)">{{ row.bizType }}</span>
</td>
<td class="qt-col-muted">{{ row.category }}</td>
<td class="qt-col-file">{{ row.fileLabel }}</td>
<td class="qt-col-muted">{{ row.parseStatus }}</td>
<td class="qt-col-muted">{{ row.status === "enabled" ? "启用" : "禁用" }}</td>
<td class="qt-col-count">{{ row.useCount }} 次</td>
<td class="qt-col-time">{{ row.createdAt }}</td>
<td class="qt-col-muted">{{ row.operator }}</td>
<td class="col-actions">
<div class="qt-row-actions">
<button type="button" class="link-action link-muted" @click.stop="openPreview(row)">
预览
</button>
<button type="button" class="link-action" @click.stop="openEdit(row)">编辑</button>
<button
type="button"
class="link-action"
:class="row.status === 'enabled' ? 'link-danger' : ''"
@click.stop="toggleStatus(row)"
>
{{ row.status === "enabled" ? "禁用" : "启用" }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<span>
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个模板
</span>
<div class="pages">
<button class="is-active" type="button">1</button>
</div>
</div>
</div>
<!-- 上传 / 编辑:原生遮罩,挂到 body -->
<Teleport to="body">
<div
v-if="formOpen"
class="modal-overlay"
role="dialog"
aria-modal="true"
@click.self="closeForm"
>
<div class="modal-dialog modal-lg" @click.stop>
<div class="qt-modal-head">
<div class="qt-modal-title-row">
<div class="qt-modal-icon qt-modal-icon--blue">
<ProtoIcon :name="editing ? 'pencil' : 'upload'" :size="16" />
</div>
<div>
<div class="qt-modal-title">{{ formTitle }}</div>
<div class="qt-modal-sub">{{ formSub }}</div>
</div>
</div>
<button type="button" class="btn btn-ghost" aria-label="关闭" @click="closeForm">
<ProtoIcon name="x" :size="18" />
</button>
</div>
<div class="qt-create-modal-body">
<div class="qt-create-form">
<div class="qt-create-row">
<label>模板名称</label>
<input v-model="form.name" class="field" type="text" placeholder="上传后自动填充,可修改" />
</div>
<div class="qt-create-row">
<label>业务</label>
<select v-model="form.bizType" class="field">
<option value="海运">海运</option>
<option value="空运">空运</option>
<option value="陆运">陆运</option>
</select>
</div>
<div class="qt-create-row qt-create-row--keywords">
<label>关键词</label>
<div class="qt-keyword-list">
<div v-for="(_, index) in form.keywordItems" :key="index" class="qt-keyword-row">
<input
v-model="form.keywordItems[index]"
class="field"
type="text"
placeholder="请输入关键词,可包含逗号"
/>
<button
type="button"
class="qt-keyword-btn"
title="添加"
:disabled="form.keywordItems.length >= 20"
@click="addKeyword"
>
<ProtoIcon name="plus" :size="14" />
</button>
<button
type="button"
class="qt-keyword-btn is-danger"
title="删除"
:disabled="form.keywordItems.length <= 1"
@click="removeKeyword(index)"
>
<ProtoIcon name="minus" :size="14" />
</button>
</div>
</div>
</div>
</div>
<div v-if="parseState.visible" class="qt-parse-banner" :class="{ 'is-error': !parseState.ok }">
<div class="qt-parse-banner-icon" :class="parseState.ok ? 'is-ok' : 'is-err'">
<ProtoIcon :name="parseState.ok ? 'circleCheck' : 'circleX'" :size="16" />
</div>
<div>
<div class="qt-parse-banner-title">{{ parseState.title }}</div>
<div class="qt-parse-banner-desc">{{ parseState.desc }}</div>
</div>
</div>
<div class="qt-upload-zone">
<input
ref="fileInputRef"
type="file"
class="qt-file-input"
accept=".xlsx,.xls,.pdf"
@change="onFileChange"
/>
<div
class="qt-upload-drop"
:class="{ 'has-file': !!form.fileName }"
@dragover.prevent
@drop="onDrop"
@click="triggerFilePick"
>
<template v-if="!form.fileName">
<div class="qt-upload-empty">
<div class="qt-upload-icon">
<ProtoIcon name="uploadCloud" :size="20" />
</div>
<div class="qt-upload-title">点击或拖拽上传模板文件</div>
<div class="qt-upload-hint">.xlsx · .xls · .pdf,不超过 20 MB</div>
</div>
</template>
<div v-else class="qt-upload-file-card" @click.stop>
<div class="qt-file-type-icon" :class="form.format === 'pdf' ? 'is-pdf' : 'is-excel'">
<ProtoIcon :name="form.format === 'pdf' ? 'fileText' : 'fileSpreadsheet'" :size="16" />
</div>
<div class="qt-file-meta">
<div class="qt-file-name">{{ form.fileName }}</div>
<div class="qt-file-sub">{{ form.fileMeta }}</div>
</div>
<button type="button" class="btn btn-ghost qt-replace-btn" @click="fileInputRef?.click()">
更换
</button>
</div>
</div>
</div>
<label class="qt-default-flag">
<input v-model="form.isDefault" type="checkbox" />
<span>默认模板</span>
</label>
</div>
<div class="qt-create-modal-footer">
<button type="button" class="btn btn-secondary" @click="closeForm">取消</button>
<button type="button" class="btn btn-primary" @click="submitForm">
<ProtoIcon name="check" :size="16" />{{ formOkText }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- 预览 -->
<Teleport to="body">
<div
v-if="previewOpen && previewRow"
class="modal-overlay"
role="dialog"
aria-modal="true"
@click.self="closePreview"
>
<div class="modal-dialog qt-preview-dialog" @click.stop>
<div class="qt-modal-head">
<div class="qt-modal-title-row">
<div class="qt-modal-icon qt-modal-icon--green">
<ProtoIcon name="eye" :size="16" />
</div>
<div>
<div class="qt-modal-sub">基于上传模板渲染 · 用户端预览</div>
<div class="qt-modal-title">{{ previewRow.name }}</div>
</div>
</div>
<button type="button" class="btn btn-ghost" aria-label="关闭" @click="closePreview">
<ProtoIcon name="x" :size="18" />
</button>
</div>
<div class="qt-preview-modal-body">
<div class="qt-preview-tags">
<span class="tag" :class="bizTag(previewRow.bizType)">{{ previewRow.fileName }}</span>
<span class="tag tag-green">已解析 · {{ previewRow.placeholderCount }} 个占位符</span>
</div>
<div class="qt-paper">
<div class="qt-paper-head">
<div>
<div class="qt-paper-title">
{{ previewRow.name.replace(/模板$/, "") || previewRow.name }}
</div>
<div class="qt-paper-cap">模板版式 · 由上传 Excel 决定</div>
</div>
<div class="qt-paper-meta">
<div>QT-202607020001</div>
<div>2026-07-02</div>
</div>
</div>
<hr />
<div class="qt-paper-grid">
<div>
<span class="qt-paper-label">客户</span>
<span class="qt-paper-value">ABC Trading Co.</span>
</div>
<div>
<span class="qt-paper-label">箱型</span>
<span class="qt-paper-value">40HQ × 2</span>
</div>
<div>
<span class="qt-paper-label">起运港 → 目的港</span>
<span class="qt-paper-value">CNSHA → USLAX</span>
</div>
<div>
<span class="qt-paper-label">销售</span>
<span class="qt-paper-value">张敏</span>
</div>
</div>
<div class="qt-paper-total">
<div class="qt-paper-total-label">报价合计</div>
<div class="qt-paper-amt">USD 3,680.00</div>
<div class="qt-paper-total-hint">含海运费 + 本地费 · 有效期至 2026-07-05</div>
</div>
<table class="qt-paper-fee">
<thead>
<tr>
<th>费用项</th>
<th style="text-align: right">金额</th>
</tr>
</thead>
<tbody>
<tr>
<td>O/F 海运费</td>
<td style="text-align: right">2,900</td>
</tr>
<tr>
<td>THC + DOC + SEAL</td>
<td style="text-align: right">780</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>合计</td>
<td style="text-align: right">3,680</td>
</tr>
</tfoot>
</table>
<p class="qt-paper-note">
* 版式、Logo、签章与业务上传模板一致;上述为预览回填数据。
</p>
</div>
<p class="qt-preview-foot-hint">{{ previewFootHint }}</p>
</div>
<div class="qt-preview-modal-footer">
<button type="button" class="btn btn-secondary" @click="closePreview">关闭</button>
<button type="button" class="btn btn-primary" @click="downloadDemo">
<ProtoIcon name="download" :size="16" />下载文件
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.qt-col-name {
font-weight: 800;
color: #0f172a;
white-space: nowrap;
font-size: 11px;
}
.qt-col-name .qt-default-tag {
margin-left: 6px;
vertical-align: middle;
}
.qt-col-muted {
font-size: 11px;
color: #334155;
}
.qt-col-file {
font-size: 11px;
font-weight: 700;
color: #334155;
}
.qt-col-count {
font-size: 11px;
color: #1e293b;
white-space: nowrap;
}
.qt-col-time {
font-size: 11px;
color: #64748b;
white-space: nowrap;
}
.qt-row-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
white-space: nowrap;
position: relative;
z-index: 2;
pointer-events: auto;
}
.qt-create-row--keywords {
/* 与名称/业务同用 center,单行时标签与输入框垂直对齐 */
align-items: center;
}
.qt-create-row--keywords > label {
line-height: 34px;
/* 多行关键词时仍贴齐首行输入框中线,避免整块垂直居中飘在中间 */
align-self: start;
}
.qt-keyword-list {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
min-width: 0;
}
.qt-keyword-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
min-height: 34px;
}
.qt-keyword-row .field {
flex: 1;
min-width: 0;
height: 34px;
min-height: 34px;
}
.qt-keyword-btn {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex-shrink: 0;
border: 1px solid #d0d5dd;
border-radius: 50%;
background: #fff;
color: #475467;
cursor: pointer;
padding: 0;
box-sizing: border-box;
}
.qt-keyword-btn:hover:not(:disabled) {
border-color: #98a2b3;
background: #f9fafb;
color: #101828;
}
.qt-keyword-btn.is-danger:hover:not(:disabled) {
border-color: #fda29b;
background: #fef3f2;
color: #b42318;
}
.qt-keyword-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.qt-default-flag {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: #344054;
cursor: pointer;
user-select: none;
}
.qt-default-flag input {
width: 14px;
height: 14px;
margin: 0;
accent-color: #155eef;
cursor: pointer;
}
</style>