落地报价模板主账与后台:合同映射、灌种 SQL/脚本、智能体填表,并补测服运维探针与合同文档对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-14 14:09:45 +08:00
co-authored by Cursor
parent 8bdf4990a2
commit a70bce3c8f
138 changed files with 7239 additions and 1085 deletions
@@ -0,0 +1,61 @@
/**
* 账号管理 API:走 Jeecg 系统用户,不另起 inquiry 账号表。
* 企微 ID 暂写入 workNo(工号),与现网 Jeecg SysUser 字段对齐;无独立 wecom 列时避免改系统实体。
*/
import {
changePassword,
deleteUser,
getAllRolesListNoByTenant,
getUserRoles,
list as listUsers,
saveOrUpdateUser,
} from '/@/views/system/user/user.api';
export {
changePassword,
deleteUser,
getAllRolesListNoByTenant,
getUserRoles,
listUsers,
saveOrUpdateUser,
};
export type AccountRow = {
id: string;
username: string;
name: string;
wecomUserId: string;
roleId: string;
roleLabel: string;
roleTag: string;
phoneMasked: string;
lastLogin: string;
status: 'active' | 'disabled';
};
const ROLE_TAG_CYCLE = ['tag-violet', 'tag-blue', 'tag-teal', 'tag-amber'];
export function roleTagFor(index: number) {
return ROLE_TAG_CYCLE[index % ROLE_TAG_CYCLE.length];
}
/** Jeecg status:1 正常 → active;其余视为禁用。 */
export function mapUserToAccount(
u: any,
roleId = '',
roleLabel = '未绑定角色',
roleTag = 'tag-blue',
): AccountRow {
return {
id: String(u.id || ''),
username: u.username || '',
name: u.realname || u.username || '',
wecomUserId: u.workNo || u.wecomId || u.thirdId || '',
roleId,
roleLabel,
roleTag,
phoneMasked: u.phone || '未填写',
lastLogin: u.lastLoginTime || u.createTime || '—',
status: Number(u.status) === 1 ? 'active' : 'disabled',
};
}
@@ -1,15 +1,27 @@
<script setup lang="ts">
/**
* 账号管理:列表、新建/编辑、重置密码(Mock)。
* 弹窗对齐原型 admin-accounts.html(原生 overlay,紧凑表单)。
* 账号管理:对接 Jeecg /sys/user(主账登录账号)。
* 与员工岗位页分离——本页管后台登录,员工页管企微业务角色。
*/
import { computed, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { message, Modal } from "ant-design-vue";
import { mockAccounts, type AccountRow } from "/@/views/inquiry/mock/accounts";
import {
changePassword,
deleteUser,
getAllRolesListNoByTenant,
getUserRoles,
listUsers,
mapUserToAccount,
roleTagFor,
saveOrUpdateUser,
type AccountRow,
} from "/@/views/inquiry/account/account.api";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
const rows = ref<AccountRow[]>([...mockAccounts]);
const rows = ref<AccountRow[]>([]);
const loading = ref(false);
const roleOptions = ref<{ value: string; label: string; tag: string }[]>([]);
const filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" });
const modalOpen = ref(false);
const editing = ref<AccountRow | null>(null);
@@ -17,19 +29,12 @@ const form = reactive({
username: "",
name: "",
wecomUserId: "",
roleId: "ops",
roleId: "",
phone: "",
status: "active" as AccountRow["status"],
password: "",
});
const roleOptions = [
{ value: "super", label: "超级管理员", tag: "tag-violet" },
{ value: "ops", label: "运营管理员", tag: "tag-blue" },
{ value: "config", label: "业务配置员", tag: "tag-teal" },
{ value: "audit", label: "只读审计", tag: "tag-amber" },
];
const filtered = computed(() =>
rows.value.filter((r) => {
const kw = filters.keyword.trim().toLowerCase();
@@ -45,13 +50,61 @@ const modalSub = computed(() =>
);
const modalOkText = computed(() => (editing.value ? "保存修改" : "创建账号"));
async function loadRoles() {
const res: any = await getAllRolesListNoByTenant({});
const list = Array.isArray(res) ? res : res?.records || res?.result || [];
roleOptions.value = (list as any[]).map((r, i) => ({
value: String(r.id),
label: r.roleName || r.roleCode || r.id,
tag: roleTagFor(i),
}));
if (!form.roleId && roleOptions.value.length) {
form.roleId = roleOptions.value.find((r) => r.label.includes("运营"))?.value || roleOptions.value[0].value;
}
}
async function loadList() {
loading.value = true;
try {
if (!roleOptions.value.length) await loadRoles();
const res: any = await listUsers({ pageNo: 1, pageSize: 200 });
const records = res?.records || res?.result?.records || [];
const mapped: AccountRow[] = [];
for (const u of records as any[]) {
let roleId = "";
let roleLabel = "未绑定角色";
let roleTag = "tag-blue";
try {
const roles: any = await getUserRoles({ userid: u.id });
const ids = Array.isArray(roles) ? roles : roles?.result || [];
if (ids.length) {
roleId = String(ids[0]);
const hit = roleOptions.value.find((r) => r.value === roleId);
roleLabel = hit?.label || roleLabel;
roleTag = hit?.tag || roleTag;
}
} catch {
/* 无角色仍展示账号 */
}
mapped.push(mapUserToAccount(u, roleId, roleLabel, roleTag));
}
rows.value = mapped;
} catch (e: any) {
message.error(e?.message || "加载账号失败");
} finally {
loading.value = false;
}
}
onMounted(() => loadList());
function openCreate() {
editing.value = null;
Object.assign(form, {
username: "",
name: "",
wecomUserId: "",
roleId: "ops",
roleId: roleOptions.value[0]?.value || "",
phone: "",
status: "active",
password: "",
@@ -65,15 +118,15 @@ function openEdit(row: AccountRow) {
username: row.username,
name: row.name,
wecomUserId: row.wecomUserId || "",
roleId: row.roleId,
phone: row.phoneMasked,
roleId: row.roleId || roleOptions.value[0]?.value || "",
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
status: row.status,
password: "",
});
modalOpen.value = true;
}
function save() {
async function save() {
if (!form.username.trim() || !form.name.trim()) {
message.warning("请填写账号与姓名");
return;
@@ -82,51 +135,37 @@ function save() {
message.warning("请填写企业微信 ID");
return;
}
if (!/^[A-Za-z0-9._@-]+$/.test(form.wecomUserId.trim())) {
message.warning("企业微信 ID 格式不正确");
return;
}
if (!editing.value && !form.password.trim()) {
message.warning("请填写初始密码");
return;
}
const role = roleOptions.find((r) => r.value === form.roleId) || roleOptions[1];
if (editing.value) {
Object.assign(editing.value, {
username: form.username,
name: form.name,
wecomUserId: form.wecomUserId.trim(),
roleId: form.roleId,
roleLabel: role.label,
roleTag: role.tag,
phoneMasked: form.phone,
status: form.status,
});
message.success("账号已更新");
} else {
rows.value.push({
id: `a${Date.now()}`,
username: form.username,
name: form.name,
wecomUserId: form.wecomUserId.trim(),
roleId: form.roleId,
roleLabel: role.label,
roleTag: role.tag,
phoneMasked: form.phone || "未填写",
lastLogin: "—",
status: form.status,
});
message.success("账号已创建");
if (!form.roleId) {
message.warning("请选择角色");
return;
}
const payload: Record<string, any> = {
id: editing.value?.id,
username: form.username.trim(),
realname: form.name.trim(),
workNo: form.wecomUserId.trim(),
phone: form.phone.trim() || undefined,
status: form.status === "active" ? 1 : 2,
selectedroles: form.roleId,
};
if (!editing.value) payload.password = form.password;
try {
await saveOrUpdateUser(payload, Boolean(editing.value));
message.success(editing.value ? "账号已更新" : "账号已创建");
modalOpen.value = false;
await loadList();
} catch (e: any) {
message.error(e?.message || "保存失败");
}
modalOpen.value = false;
}
const resetOpen = ref(false);
const resetTarget = ref<AccountRow | null>(null);
const resetForm = reactive({
password: "",
confirm: "",
});
const resetForm = reactive({ password: "", confirm: "" });
function resetPassword(row: AccountRow) {
resetTarget.value = row;
@@ -140,7 +179,7 @@ function closeReset() {
resetTarget.value = null;
}
function confirmResetPassword() {
async function confirmResetPassword() {
if (!resetForm.password.trim()) {
message.warning("请输入新密码");
return;
@@ -153,8 +192,18 @@ function confirmResetPassword() {
message.warning("两次输入的密码不一致");
return;
}
message.success(`已重置 ${resetTarget.value?.username || ""} 的密码`);
closeReset();
if (!resetTarget.value) return;
try {
await changePassword({
username: resetTarget.value.username,
password: resetForm.password,
confirmpassword: resetForm.confirm,
});
message.success(`已重置 ${resetTarget.value.username} 的密码`);
closeReset();
} catch (e: any) {
message.error(e?.message || "重置密码失败");
}
}
function removeAccount(row: AccountRow) {
@@ -165,18 +214,30 @@ function removeAccount(row: AccountRow) {
Modal.confirm({
title: `删除账号 ${row.username}?`,
okType: "danger",
onOk() {
rows.value = rows.value.filter((r) => r.id !== row.id);
message.success("已删除");
async onOk() {
try {
await deleteUser({ id: row.id }, () => undefined);
message.success("已删除");
await loadList();
} catch (e: any) {
message.error(e?.message || "删除失败");
}
},
});
}
watch(
() => filters.keyword,
() => {
/* 本地过滤即可,账号量通常很小 */
},
);
</script>
<template>
<Teleport defer to="#page-topbar-actions">
<div class="account-topbar-actions">
<button type="button" class="btn btn-secondary">
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
<ProtoIcon name="download" :size="14" />导出
</button>
<button type="button" class="btn btn-primary" @click="openCreate">
@@ -212,6 +273,12 @@ function removeAccount(row: AccountRow) {
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td>
</tr>
<tr v-else-if="!filtered.length">
<td colspan="7" style="text-align: center; color: #94a3b8">暂无账号</td>
</tr>
<tr v-for="row in filtered" :key="row.id">
<td class="account-username">{{ row.username }}</td>
<td>{{ row.name }}</td>
@@ -293,7 +360,7 @@ function removeAccount(row: AccountRow) {
v-model="form.wecomUserId"
class="field field-mono"
type="text"
placeholder="请输入企微通讯录账号"
placeholder="写入工号字段,用于推送关联"
/>
</div>
<div class="form-row">
@@ -309,11 +376,7 @@ function removeAccount(row: AccountRow) {
<div class="form-row">
<label class="lbl">状态</label>
<div class="status-toggle">
<button
type="button"
:class="{ 'is-selected': form.status === 'active' }"
@click="form.status = 'active'"
>
<button type="button" :class="{ 'is-selected': form.status === 'active' }" @click="form.status = 'active'">
启用
</button>
<button
@@ -14,7 +14,7 @@ enum Api {
export const getReplyList = (params) => defHttp.get({ url: Api.list, params });
export const getReplyListAll = () => defHttp.get({ url: Api.listAll });
export const saveReply = (params, isUpdate: boolean) =>
isUpdate ? defHttp.put({ url: Api.edit, params }) : defHttp.post({ url: Api.add, params });
export const saveReply = (data, isUpdate: boolean) =>
isUpdate ? defHttp.put({ url: Api.edit, data }) : defHttp.post({ url: Api.add, data });
export const enableReply = (id: string) => defHttp.post({ url: Api.enable, params: { id } });
export const disableReply = (id: string) => defHttp.post({ url: Api.disable, params: { id } });
@@ -1,14 +1,26 @@
<script setup lang="ts">
/**
* 机器人回复语:九分类卡片 + 开关编辑,对齐原型。
* 机器人回复语:对接主账 /inquiry/botReply。
* 分类壳用本地元数据;条目内容全部来自库。
*/
import { reactive, ref } from "vue";
import { onMounted, reactive, ref } from "vue";
import { message } from "ant-design-vue";
import { mockReplyCategories, type ReplyItem } from "/@/views/inquiry/mock/botReplies";
import {
disableReply,
enableReply,
getReplyListAll,
saveReply,
} from "/@/views/inquiry/botReply/botReply.api";
import {
emptyCategories,
type ReplyCategory,
type ReplyItem,
} from "/@/views/inquiry/botReply/replyCategories";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
const categories = ref(structuredClone(mockReplyCategories));
const categories = ref<ReplyCategory[]>(emptyCategories());
const loading = ref(false);
const modalOpen = ref(false);
const editing = ref<{ categoryId: string; item: ReplyItem | null }>({
categoryId: "",
@@ -20,9 +32,63 @@ const form = reactive({
enabled: true,
});
function toggle(item: ReplyItem) {
item.enabled = !item.enabled;
message.success(item.enabled ? "已启用" : "已停用");
function groupByCategory(list: any[]) {
const cats = emptyCategories();
const byId = Object.fromEntries(cats.map((c) => [c.id, c]));
for (const row of list) {
const code = row.categoryCode || "inquiring";
let cat = byId[code];
if (!cat) {
cat = {
id: code,
title: row.categoryTitle || code,
desc: "",
tagClass: "tag-blue",
items: [],
};
cats.push(cat);
byId[code] = cat;
}
if (row.categoryTitle) cat.title = row.categoryTitle;
cat.items.push({
id: String(row.id),
trigger: row.triggerScene || "",
text: row.replyText || "",
enabled: row.enabled === 1 || row.enabled === true,
});
}
return cats;
}
async function loadList() {
loading.value = true;
try {
const res: any = await getReplyListAll();
const list = Array.isArray(res) ? res : res?.result || res?.records || [];
categories.value = groupByCategory(list as any[]);
} catch (e: any) {
message.error(e?.message || "加载回复语失败");
} finally {
loading.value = false;
}
}
onMounted(() => loadList());
async function toggle(item: ReplyItem) {
try {
if (item.enabled) {
await disableReply(item.id);
item.enabled = false;
message.success("已停用");
} else {
await enableReply(item.id);
item.enabled = true;
message.success("已启用");
}
} catch (e: any) {
message.error(e?.message || "切换失败");
}
}
function openCreate(categoryId?: string) {
@@ -41,28 +107,29 @@ function openEdit(categoryId: string, item: ReplyItem) {
modalOpen.value = true;
}
function save() {
async function save() {
if (!form.trigger.trim() || !form.text.trim()) {
message.warning("请填写触发条件与话术");
return;
}
const cat = categories.value.find((c) => c.id === editing.value.categoryId);
if (!cat) return;
if (editing.value.item) {
editing.value.item.trigger = form.trigger;
editing.value.item.text = form.text;
editing.value.item.enabled = form.enabled;
message.success("话术已更新");
} else {
cat.items.push({
id: `r${Date.now()}`,
trigger: form.trigger,
text: form.text,
enabled: form.enabled,
});
message.success("话术已添加");
const body = {
id: editing.value.item?.id,
categoryCode: cat.id,
categoryTitle: cat.title,
triggerScene: form.trigger.trim(),
replyText: form.text.trim(),
enabled: form.enabled ? 1 : 0,
};
try {
await saveReply(body, Boolean(editing.value.item));
message.success(editing.value.item ? "话术已更新" : "话术已添加");
modalOpen.value = false;
await loadList();
} catch (e: any) {
message.error(e?.message || "保存失败");
}
modalOpen.value = false;
}
</script>
@@ -73,6 +140,8 @@ function save() {
</button>
</Teleport>
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
<article v-for="cat in categories" :key="cat.id" class="reply-category">
<div style="display: flex; align-items: center; justify-content: space-between">
<h2>{{ cat.title }}</h2>
@@ -80,6 +149,8 @@ function save() {
</div>
<p class="reply-category-desc">{{ cat.desc }}</p>
<div v-if="!cat.items.length" style="color: #94a3b8; font-size: 12px; padding: 8px 0">暂无话术</div>
<div v-for="item in cat.items" :key="item.id" class="reply-item">
<div style="min-width: 0">
<div class="reply-meta">触发:{{ item.trigger }}</div>
@@ -93,13 +164,7 @@ function save() {
:aria-label="item.enabled ? '停用' : '启用'"
@click="toggle(item)"
/>
<button
type="button"
class="btn btn-ghost btn-icon-sm"
@click="openEdit(cat.id, item)"
>
<ProtoIcon name="pencil" :size="16" />
</button>
<button type="button" class="link-action" @click="openEdit(cat.id, item)">编辑</button>
</div>
</div>
</article>
@@ -111,37 +176,39 @@ function save() {
ok-text="保存"
@ok="save"
>
<div class="admin-modal-field">
<label class="admin-modal-label">触发场景 / 条件 <span class="req">*</span></label>
<div class="form-row">
<label class="lbl">分类</label>
<select v-model="editing.categoryId" class="field" :disabled="Boolean(editing.item)">
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.title }}</option>
</select>
</div>
<div class="form-row">
<label class="lbl">触发条件 <span class="req">*</span></label>
<input v-model="form.trigger" class="field" type="text" />
</div>
<div class="admin-modal-field">
<label class="admin-modal-label">回复内容 <span class="req">*</span></label>
<textarea
v-model="form.text"
class="field"
rows="5"
placeholder="支持 {{变量}} 占位符"
/>
<div class="form-row">
<label class="lbl">回复正文 <span class="req">*</span></label>
<textarea v-model="form.text" class="field" rows="5" placeholder="支持 {{ticket_no}} 等变量" />
</div>
<div class="admin-modal-field">
<label class="admin-modal-label">启用状态</label>
<div class="status-toggle">
<button
type="button"
:class="{ 'is-selected': form.enabled }"
@click="form.enabled = true"
>
启用
</button>
<button
type="button"
:class="{ 'is-selected': !form.enabled }"
@click="form.enabled = false"
>
停用
</button>
</div>
<div class="form-row">
<label class="lbl">状态</label>
<label class="check-line">
<input v-model="form.enabled" type="checkbox" />
<span>启用</span>
</label>
</div>
</AdminModal>
</template>
<style scoped>
.check-line {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
font-size: 11px;
font-weight: 600;
color: #344054;
cursor: pointer;
}
</style>
@@ -0,0 +1,81 @@
/**
* 机器人回复语分类元数据(版式用,非演示业务数据)。
* 条目内容一律来自主账 /inquiry/botReply。
*/
export type ReplyItem = {
id: string;
trigger: string;
text: string;
enabled: boolean;
};
export type ReplyCategory = {
id: string;
title: string;
desc: string;
tagClass: string;
items: ReplyItem[];
};
/** 九分类壳:标题/说明固定;items 由接口填充。 */
export const REPLY_CATEGORY_META: Omit<ReplyCategory, "items">[] = [
{
id: "inquiring",
title: "询价中",
desc: "工单处于需求识别、TMS 查价或内部协同阶段,尚未形成可向客户出示的正式报价方案。",
tagClass: "tag-blue",
},
{
id: "quoted",
title: "已报价",
desc: "系统已出具结构化报价方案或最终报价单,销售可确认采用、调价或等待成交跟进。",
tagClass: "tag-blue",
},
{
id: "deal",
title: "已成交",
desc: "客户已确认接受报价,工单进入终态,停止智能体跟进。",
tagClass: "tag-green",
},
{
id: "negotiating",
title: "协商中",
desc: "销售或客户对当前方案不满意,正在进行调价、换舱、条款沟通,或成交跟进中选择顺延。",
tagClass: "tag-amber",
},
{
id: "lost",
title: "未成交",
desc: "客户明确拒绝或销售确认未成单,须记录原因并通知负责人,工单进入终态。",
tagClass: "tag-rose",
},
{
id: "closed",
title: "已关闭",
desc: "工单因超时、重复、误建等原因关闭。",
tagClass: "tag-rose",
},
{
id: "event-ex",
title: "事件异常",
desc: "销售/产品/航线响应超时等业务事件异常提示。",
tagClass: "tag-amber",
},
{
id: "sys-ex",
title: "系统异常",
desc: "大模型、TMS、建群等系统失败话术。",
tagClass: "tag-rose",
},
{
id: "transfer",
title: "转人工",
desc: "关键词/无价/解析失败等场景转人工话术。",
tagClass: "tag-violet",
},
];
export function emptyCategories(): ReplyCategory[] {
return REPLY_CATEGORY_META.map((c) => ({ ...c, items: [] }));
}
@@ -1,35 +1,39 @@
<script setup lang="ts">
/**
* 分析看板:图标 / 帮助气泡 / 图表悬停提示对齐原型。
* 顶栏工具条用 Teleport defer 挂到布局 #page-topbar-actions。
* 分析看板:对接主账 /inquiry/dashboard/metrics。
* 指标 / 趋势 / 排名来自聚合接口;成本趋势本期主账未提供,展示空态不造假数。
*/
import { computed, onMounted, onUnmounted, ref } from "vue";
import { message } from "ant-design-vue";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import {
todayMetrics,
inquiryTrend,
rankingPeople,
rankingDepts,
costRoutes,
type RankingPerson,
} from "/@/views/inquiry/mock/dashboard";
import { getDashboardMetrics } from "/@/views/inquiry/dashboard/dashboard.api";
import type { MetricCard, InquiryPoint, RankingPerson, CostRoute } from "/@/views/inquiry/mock/dashboard";
const dateDisplay = ref("2026-07-01——2026-07-31");
const dateFrom = ref("2026-07-01");
const dateTo = ref("2026-07-31");
const dateFromMonth = ref("2026-07");
const dateToMonth = ref("2026-07");
const dateFromYear = ref("2026");
const dateToYear = ref("2026");
const todayMetrics = ref<MetricCard[]>([]);
const inquiryTrend = ref<InquiryPoint[]>([]);
const rankingPeople = ref<RankingPerson[]>([]);
const rankingDepts = ref<string[]>([]);
/** 成本趋势主账暂无接口,保持空数组避免 Mock 假线。 */
const costRoutes = ref<CostRoute[]>([]);
const now = new Date();
const isoDay = now.toISOString().slice(0, 10);
const dateDisplay = ref(`${isoDay}——${isoDay}`);
const dateFrom = ref(isoDay);
const dateTo = ref(isoDay);
const dateFromMonth = ref(isoDay.slice(0, 7));
const dateToMonth = ref(isoDay.slice(0, 7));
const dateFromYear = ref(isoDay.slice(0, 4));
const dateToYear = ref(isoDay.slice(0, 4));
const pickerOpen = ref(false);
const pickerMode = ref<"day" | "month" | "year">("day");
const updatedTime = ref("00:00");
const routeFilterOpen = ref(false);
const selectedRoutes = ref<string[]>(costRoutes.map((r) => r.id));
const selectedRoutes = ref<string[]>([]);
const rankingRole = ref<"all" | "sales" | "route" | "product">("all");
const rankingDept = ref("");
const chartReady = ref(false);
const loading = ref(false);
const chartTip = ref({
visible: false,
left: "0px",
@@ -51,12 +55,56 @@ const activePoint = ref<string | null>(null);
const INQUIRY_PLOT_TOP = 36;
const INQUIRY_PLOT_BOTTOM = 188;
function resolveRange(): string {
if (pickerMode.value === "day") {
const from = new Date(dateFrom.value);
const to = new Date(dateTo.value);
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
if (days <= 0) return "today";
if (days <= 6) return "7d";
return "30d";
}
if (pickerMode.value === "month") return "30d";
return "30d";
}
async function loadMetrics() {
loading.value = true;
try {
const res: any = await getDashboardMetrics(resolveRange());
const data = res?.result || res || {};
todayMetrics.value = (data.metrics || []) as MetricCard[];
inquiryTrend.value = ((data.trend || []) as any[]).map((p) => ({
date: String(p.date || ""),
value: Number(p.value || 0),
}));
rankingPeople.value = ((data.rankings || []) as any[]).map((p) => ({
name: String(p.name || ""),
role: (p.role || "sales") as RankingPerson["role"],
dept: String(p.dept || "-"),
volumeNum: Number(p.volumeNum || 0),
}));
rankingDepts.value = Array.from(
new Set(rankingPeople.value.map((p) => p.dept).filter((d) => d && d !== "-")),
);
const t = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
updatedTime.value = `${pad(t.getHours())}:${pad(t.getMinutes())}`;
} catch (e: any) {
message.error(e?.message || "加载看板失败");
} finally {
loading.value = false;
}
}
const inquirySvg = computed(() => {
const points = inquiryTrend;
const points = inquiryTrend.value.length
? inquiryTrend.value
: [{ date: "—", value: 0 }];
const xs = points.map((_, i) => (points.length === 1 ? 380 : 36 + (654 / (points.length - 1)) * i));
const values = points.map((p) => p.value);
const max = Math.max(...values);
const min = Math.min(...values);
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const range = Math.max(max - min, 1);
const plotH = INQUIRY_PLOT_BOTTOM - INQUIRY_PLOT_TOP;
const yFor = (v: number) => INQUIRY_PLOT_BOTTOM - ((v - min) / range) * plotH;
@@ -71,30 +119,31 @@ const inquirySvg = computed(() => {
return { coords, line, area };
});
const filteredRoutes = computed(() => costRoutes.filter((r) => selectedRoutes.value.includes(r.id)));
const filteredRoutes = computed(() => costRoutes.value.filter((r) => selectedRoutes.value.includes(r.id)));
const rankingRows = computed(() => {
let rows: RankingPerson[] = [...rankingPeople];
let rows: RankingPerson[] = [...rankingPeople.value];
if (rankingRole.value !== "all") rows = rows.filter((p) => p.role === rankingRole.value);
if (rankingDept.value) rows = rows.filter((p) => p.dept === rankingDept.value);
return rows.sort((a, b) => b.volumeNum - a.volumeNum || a.name.localeCompare(b.name, "zh-CN"));
});
const costDates = ["06-12", "06-13", "06-14", "06-15", "06-16", "06-17", "06-18"];
const costXs = [36, 145, 254, 363, 472, 581, 690];
const costDates = computed(() => inquiryTrend.value.map((p) => p.date));
const costXs = computed(() => {
const n = Math.max(costDates.value.length, 1);
return costDates.value.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
});
function applyDateRange() {
if (pickerMode.value === "day") dateDisplay.value = `${dateFrom.value}——${dateTo.value}`;
else if (pickerMode.value === "month") dateDisplay.value = `${dateFromMonth.value}——${dateToMonth.value}`;
else dateDisplay.value = `${dateFromYear.value}——${dateToYear.value}`;
pickerOpen.value = false;
loadMetrics();
}
function refreshData() {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
updatedTime.value = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
message.success("数据已更新");
loadMetrics().then(() => message.success("数据已更新"));
}
function toggleRoute(id: string) {
@@ -105,7 +154,7 @@ function toggleRoute(id: string) {
}
function selectAllRoutes() {
selectedRoutes.value = costRoutes.map((r) => r.id);
selectedRoutes.value = costRoutes.value.map((r) => r.id);
}
function clearRoutes() {
@@ -191,6 +240,7 @@ const costWrap = ref<HTMLElement | null>(null);
onMounted(() => {
chartReady.value = true;
document.addEventListener("click", onDocClick);
loadMetrics();
});
onUnmounted(() => document.removeEventListener("click", onDocClick));
</script>
@@ -407,7 +457,9 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<span v-for="(line, i) in costTip.lines" :key="i" class="value-line">{{ line }}</span>
</div>
</div>
<div class="dash-cost-footnote">成本数据来源于 TMS</div>
<div class="dash-cost-footnote">
{{ costRoutes.length ? "成本数据来源于 TMS" : "成本趋势主账暂未开放,待接口就绪后展示" }}
</div>
</div>
</div>
</div>
@@ -26,6 +26,7 @@ export type RoleRow = {
editable: boolean;
};
/** 仅保留 admin;其余演示账号已清除。 */
export const mockAccounts: AccountRow[] = [
{
id: "a1",
@@ -39,54 +40,6 @@ export const mockAccounts: AccountRow[] = [
lastLogin: "2026-07-03 09:12",
status: "active",
},
{
id: "a2",
username: "ops_li",
name: "李运营",
wecomUserId: "ops_li@yt",
roleId: "ops",
roleLabel: "运营管理员",
roleTag: "tag-blue",
phoneMasked: "139****2001",
lastLogin: "2026-07-03 08:45",
status: "active",
},
{
id: "a3",
username: "config_wang",
name: "王配置",
wecomUserId: "config_wang@yt",
roleId: "config",
roleLabel: "业务配置员",
roleTag: "tag-teal",
phoneMasked: "137****3002",
lastLogin: "2026-07-02 17:20",
status: "active",
},
{
id: "a4",
username: "audit_chen",
name: "陈审计",
wecomUserId: "audit_chen@yt",
roleId: "audit",
roleLabel: "只读审计",
roleTag: "tag-amber",
phoneMasked: "136****4003",
lastLogin: "2026-07-01 11:05",
status: "active",
},
{
id: "a5",
username: "ops_zhang",
name: "张运营",
wecomUserId: "ops_zhang@yt",
roleId: "ops",
roleLabel: "运营管理员",
roleTag: "tag-blue",
phoneMasked: "135****5004",
lastLogin: "2026-06-18 16:30",
status: "disabled",
},
];
export const mockRoles: RoleRow[] = [
@@ -18,89 +18,5 @@ export type StaffRow = {
avatarColor: string;
};
export const mockStaff: StaffRow[] = [
{
id: "s1",
name: "张敏",
phoneMasked: "136****1234",
dept: "华东销售一部",
wecomId: "zhangmin@yt",
role: "sales",
roleLabel: "销售",
routes: "-",
notifyEventException: true,
createdAt: "2023-08-15",
status: "active",
avatarColor: "#e11d48",
},
{
id: "s2",
name: "李华",
phoneMasked: "135****5678",
dept: "华南销售二部",
wecomId: "lihua@yt",
role: "sales",
roleLabel: "销售",
routes: "-",
notifyEventException: false,
createdAt: "2024-02-20",
status: "active",
avatarColor: "#f43f5e",
},
{
id: "s3",
name: "航线张三",
phoneMasked: "138****9001",
dept: "空运航线组",
wecomId: "air_zhang@yt",
role: "air",
roleLabel: "航线·空运",
routes: "CGO-KUL / HKG-KUL / 全线",
notifyEventException: true,
createdAt: "2023-11-02",
status: "active",
avatarColor: "#7c3aed",
},
{
id: "s4",
name: "海运李四",
phoneMasked: "137****2203",
dept: "海运产品部",
wecomId: "sea_li@yt",
role: "sea",
roleLabel: "产品·海运",
routes: "海运全线",
notifyEventException: true,
createdAt: "2024-01-08",
status: "active",
avatarColor: "#0e7490",
},
{
id: "s5",
name: "陆运王五",
phoneMasked: "136****4405",
dept: "陆运产品部",
wecomId: "land_wang@yt",
role: "land",
roleLabel: "产品·陆运",
routes: "中港/中亚/中欧 · 东南亚",
notifyEventException: false,
createdAt: "2024-03-12",
status: "active",
avatarColor: "#b54708",
},
{
id: "s6",
name: "赵停用",
phoneMasked: "135****6606",
dept: "华东销售一部",
wecomId: "zhao_off@yt",
role: "sales",
roleLabel: "销售",
routes: "-",
notifyEventException: false,
createdAt: "2022-05-01",
status: "disabled",
avatarColor: "#64748b",
},
];
/** 员工演示数据已清空;正式环境以主账人员表为准。 */
export const mockStaff: StaffRow[] = [];
@@ -1,6 +1,6 @@
/**
* 演示数据:对齐原型 `询价机器人原型图/后台/admin-quote-templates.html` 列表行。
* 关键词 / 默认模板字段对齐旧仓 quoteTemplate(keywordItems + defaultTemplate)。
* 报价模板列表行类型(对接主账 list 摘要)。
* placeholderCount 语义改为 mappingPointCount(映射点数)。
*/
export type TemplateRow = {
@@ -8,24 +8,27 @@ export type TemplateRow = {
name: string;
bizType: "空运" | "海运" | "陆运";
category: string;
/** 列表「模板文件」列展示名(原型常与模板名一致,不含扩展名) */
fileLabel: string;
/** 预览/上传用完整文件名 */
fileName: string;
format: "excel" | "pdf";
parseStatus: "已解析" | "解析中" | "失败";
placeholderCount: number;
/** 匹配用关键词;每项可含逗号,整体不去拆 */
parseStatus: string;
/** 已映射字段点数(原 mock 占位符数) */
mappingPointCount: number;
keywords: string[];
/** 同业务下默认模板(Mock 保存时互斥) */
isDefault: boolean;
status: "enabled" | "disabled";
useCount: number;
createdAt: string;
operator: string;
mode?: "auto" | "manual";
versionId?: string;
currentVersion?: number;
parseMessage?: string;
unmappedRequired?: string[];
objectKey?: string;
};
/** 业务 → 分类(与原型 CATEGORY_BY_BUSINESS 一致) */
/** 业务 → 分类(与 contracts/quote-template-mapping-v1.json 一致) */
export const CATEGORY_BY_BIZ: Record<TemplateRow["bizType"], string[]> = {
海运: ["海运报价单模板"],
空运: ["空运到港报价单模板", "空运到门报价单模板"],
@@ -53,90 +56,35 @@ export const ALL_CATEGORIES = [
"凭祥保税仓&普仓仓储",
];
export const mockTemplates: TemplateRow[] = [
{
id: "t1",
name: "海运整柜报价单模板",
bizType: "海运",
category: "海运报价单模板",
fileLabel: "海运整柜报价单模板",
fileName: "海运整柜报价单模板.xlsx",
format: "excel",
parseStatus: "已解析",
placeholderCount: 14,
keywords: ["整柜", "FCL"],
isDefault: true,
status: "enabled",
useCount: 62,
createdAt: "2024-06-15",
operator: "王磊",
},
{
id: "t2",
name: "海运拼柜报价单模板",
bizType: "海运",
category: "海运报价单模板",
fileLabel: "海运拼柜报价单模板",
fileName: "海运拼柜报价单模板.xlsx",
format: "excel",
parseStatus: "已解析",
placeholderCount: 12,
keywords: ["拼柜", "LCL"],
isDefault: false,
status: "enabled",
useCount: 34,
createdAt: "2024-08-20",
operator: "林雯",
},
{
id: "t3",
name: "空运到港报价单模板",
bizType: "空运",
category: "空运到港报价单模板",
fileLabel: "空运到港报价单模板",
fileName: "空运到港报价单模板.xlsx",
format: "excel",
parseStatus: "已解析",
placeholderCount: 14,
keywords: ["到港"],
isDefault: true,
status: "enabled",
useCount: 52,
createdAt: "2024-03-08",
operator: "林雯",
},
{
id: "t4",
name: "空运到门报价单模板",
bizType: "空运",
category: "空运到门报价单模板",
fileLabel: "空运到门报价单模板",
fileName: "空运到门报价单模板.xlsx",
format: "excel",
parseStatus: "已解析",
placeholderCount: 14,
keywords: ["到门"],
isDefault: false,
status: "enabled",
useCount: 34,
createdAt: "2024-03-08",
operator: "林雯",
},
{
id: "t5",
name: "陆运干线报价单模板",
bizType: "陆运",
category: "国内整车",
fileLabel: "陆运干线报价单模板",
fileName: "陆运干线报价单模板.pdf",
format: "pdf",
parseStatus: "已解析",
placeholderCount: 9,
keywords: ["干线", "整车"],
isDefault: false,
status: "disabled",
useCount: 18,
createdAt: "2025-11-01",
operator: "张敏",
},
];
/** 将主账 list 记录规范为页面行 */
export function mapApiRow(raw: any): TemplateRow {
const created =
typeof raw.createdAt === "string"
? raw.createdAt.slice(0, 10)
: raw.createTime
? String(raw.createTime).slice(0, 10)
: "";
return {
id: String(raw.id || ""),
name: raw.name || "",
bizType: (raw.bizType || "海运") as TemplateRow["bizType"],
category: raw.category || "",
fileLabel: raw.fileLabel || raw.name || "",
fileName: raw.fileName || "",
format: raw.format === "pdf" ? "pdf" : "excel",
parseStatus: raw.parseStatus || "",
mappingPointCount: Number(raw.mappingPointCount || 0),
keywords: Array.isArray(raw.keywords) ? raw.keywords : [],
isDefault: Boolean(raw.isDefault),
status: raw.status === "enabled" ? "enabled" : "disabled",
useCount: Number(raw.useCount || 0),
createdAt: created,
operator: raw.operator || "",
mode: raw.mode === "manual" ? "manual" : "auto",
versionId: raw.versionId || undefined,
currentVersion: raw.currentVersion,
parseMessage: raw.parseMessage,
unmappedRequired: raw.unmappedRequired,
objectKey: raw.objectKey,
};
}
@@ -21,147 +21,5 @@ export type TicketRow = {
bizType: string;
};
export const mockTickets: TicketRow[] = [
{
id: "WO202606160001",
initiator: "张敏",
demandTitle: "空运 · 上海 → 洛杉矶",
demandDetail: "件数 3 · 毛重 180kg · 体积 2.5CBM\n品名 普货 · 客户 A · 时效 下周三前到",
quotes: [
{ source: "TMS 标准报价", price: "USD 1,360" },
{ source: "航线张三手工报价", price: "USD 1,260", highlight: true },
],
attachments: ["航线张三手工报价_WO202606160001.xlsx", "空运报价单_WO202606160001.xlsx"],
status: "已报价",
costPrice: "-",
salePrice: "USD 1,500",
profit: "16%",
eventException: "-",
systemException: "-",
systemExceptionReason: "-",
createdAt: "2026-06-16 09:12",
bizType: "空运",
},
{
id: "WO202606180002",
initiator: "李华",
demandTitle: "海运 · 深圳 → 洛杉矶",
demandDetail: "40HQ × 2 · 品名 电子配件 · 客户 B",
quotes: [{ source: "TMS 标准报价", price: "USD 4,800" }],
attachments: ["海运需求_WO202606180002.xlsx"],
status: "协商中",
costPrice: "USD 4,200",
salePrice: "USD 5,100",
profit: "18%",
eventException: "产品响应超时",
systemException: "-",
systemExceptionReason: "-",
createdAt: "2026-06-18 11:05",
bizType: "海运",
},
{
id: "WO202606180003",
initiator: "王芳",
demandTitle: "陆运 · 义乌 → 阿拉木图",
demandDetail: "整车 · 普货 · 时效 12 天内",
quotes: [],
attachments: [],
status: "询价中",
costPrice: "-",
salePrice: "-",
profit: "-",
eventException: "航线响应超时",
systemException: "-",
systemExceptionReason: "-",
createdAt: "2026-06-18 14:22",
bizType: "陆运",
},
{
id: "WO202606150005",
initiator: "赵强",
demandTitle: "空运 · 香港 → 吉隆坡",
demandDetail: "件数 8 · 毛重 420kg · 危险品咨询",
quotes: [{ source: "航线手工报价", price: "USD 980", highlight: true }],
attachments: ["空运报价单_WO202606150005.pdf"],
status: "已成交",
costPrice: "USD 820",
salePrice: "USD 1,050",
profit: "22%",
eventException: "-",
systemException: "-",
systemExceptionReason: "-",
createdAt: "2026-06-15 16:40",
bizType: "空运",
},
{
id: "WO202606170006",
initiator: "陈丽",
demandTitle: "多段联运 · 上海 → 鹿特丹",
demandDetail: "海运 + 陆运提货 · 客户 C",
quotes: [],
attachments: ["联运需求扫描.jpg"],
status: "询价中",
costPrice: "-",
salePrice: "-",
profit: "-",
eventException: "-",
systemException: "大模型异常",
systemExceptionReason: "附件识别超时",
createdAt: "2026-06-17 08:55",
bizType: "多段联运",
},
{
id: "WO202606190008",
initiator: "张敏",
demandTitle: "海运 · 宁波 → 汉堡",
demandDetail: "20GP × 1 · 品名 家具配件",
quotes: [],
attachments: [],
status: "已关闭",
costPrice: "-",
salePrice: "-",
profit: "-",
eventException: "-",
systemException: "TMS异常",
systemExceptionReason: "字典投影未命中",
createdAt: "2026-06-19 10:18",
bizType: "海运",
},
{
id: "WO202606160022",
initiator: "李华",
demandTitle: "空运 · 广州 → 纽约",
demandDetail: "件数 2 · 毛重 95kg · 时效加急",
quotes: [
{ source: "TMS 标准报价", price: "USD 2,100" },
{ source: "航线手工报价", price: "USD 1,980", highlight: true },
],
attachments: ["空运报价单_WO202606160022.xlsx"],
status: "已报价",
costPrice: "-",
salePrice: "USD 2,280",
profit: "13%",
eventException: "-",
systemException: "-",
systemExceptionReason: "-",
createdAt: "2026-06-16 15:33",
bizType: "空运",
},
{
id: "WO202606160023",
initiator: "王芳",
demandTitle: "陆运 · 深圳 → 河内",
demandDetail: "零担 · 电子配件 · 客户 D",
quotes: [],
attachments: [],
status: "未成交",
costPrice: "-",
salePrice: "-",
profit: "-",
eventException: "销售响应超时",
systemException: "建群异常",
systemExceptionReason: "企微拉人失败",
createdAt: "2026-06-16 17:02",
bizType: "陆运",
},
];
/** 列表已清空;页面从空数组起步,正式环境以主账为准。 */
export const mockTickets: TicketRow[] = [];
+163 -50
View File
@@ -1,19 +1,32 @@
<script setup lang="ts">
/**
* 角色管理:列表 + 菜单权限树 + 数据权限(Mock)。
* 弹窗统一走 AdminModal(紧凑、底栏固定)。
* 角色管理:对接 Jeecg /sys/role 与菜单权限树。
* 数据权限用线路标签写入角色 description,避免伪造第二套 RBAC。
*/
import { computed, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref } from "vue";
import { message, Modal } from "ant-design-vue";
import { mockRoles, menuPermTree, type RoleRow } from "/@/views/inquiry/mock/accounts";
import {
deleteRole,
listRoles,
mapSysRole,
queryRolePermission,
queryTreeListForRole,
saveOrUpdateRole,
saveRolePermission,
type RoleRow,
} from "/@/views/inquiry/role/role.api";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
const rows = ref<RoleRow[]>([...mockRoles]);
const rows = ref<RoleRow[]>([]);
const loading = ref(false);
const menuTree = ref<any[]>([]);
const permTitleMap = ref<Record<string, string>>({});
const modalOpen = ref(false);
const editing = ref<RoleRow | null>(null);
const form = reactive({
name: "",
roleCode: "",
status: "active" as RoleRow["status"],
checkedKeys: [] as string[],
dataPerms: [] as string[],
@@ -32,75 +45,172 @@ const dataOptions = [
];
const modalTitle = computed(() => (editing.value ? "编辑角色" : "新增角色"));
const modalOkText = computed(() => (editing.value ? "保存" : "保存"));
const modalOkText = computed(() => "保存");
function walkTree(nodes: any[], map: Record<string, string>) {
for (const n of nodes || []) {
const key = String(n.key ?? n.id ?? "");
const title = n.slotTitle || n.title || n.name || key;
if (key) map[key] = title;
if (n.children?.length) walkTree(n.children, map);
}
}
function normalizeTree(nodes: any[]): any[] {
return (nodes || []).map((n) => ({
key: String(n.key ?? n.id ?? ""),
title: n.slotTitle || n.title || n.name || String(n.key ?? n.id ?? ""),
children: n.children?.length ? normalizeTree(n.children) : undefined,
}));
}
function parseDataPerms(desc: string | undefined): string[] {
if (!desc) return [];
const m = desc.match(/\[dataPerms:(.+?)\]/);
if (!m) return [];
return m[1]
.split("|")
.map((s) => s.trim())
.filter(Boolean);
}
function buildDescription(dataPerms: string[]) {
if (!dataPerms.length) return "";
return `[dataPerms:${dataPerms.join("|")}]`;
}
async function loadMenuTree() {
const res: any = await queryTreeListForRole();
const tree = res?.treeList || res?.result?.treeList || res?.result || res || [];
const list = Array.isArray(tree) ? tree : [];
menuTree.value = normalizeTree(list);
const map: Record<string, string> = {};
walkTree(menuTree.value, map);
permTitleMap.value = map;
}
async function enrichMenus(row: RoleRow) {
if (!row.editable && row.roleCode.toLowerCase() === "admin") {
row.menus = ["全部菜单"];
return;
}
try {
const perms: any = await queryRolePermission({ roleId: row.id });
const ids = Array.isArray(perms) ? perms : perms?.result || [];
const titles = (ids as any[])
.map((id) => permTitleMap.value[String(id)])
.filter(Boolean);
row.menus = titles.length ? Array.from(new Set(titles)).slice(0, 12) : ["未授权菜单"];
} catch {
row.menus = ["—"];
}
}
async function loadList() {
loading.value = true;
try {
if (!menuTree.value.length) await loadMenuTree();
const res: any = await listRoles({ pageNo: 1, pageSize: 200 });
const records = res?.records || res?.result?.records || [];
const mapped = (records as any[]).map((r) => {
const row = mapSysRole(r);
row.dataPerms = parseDataPerms(r.description);
return row;
});
await Promise.all(mapped.map((r) => enrichMenus(r)));
// 超级管理员固定置顶,其余按创建时间倒序(接口顺序兜底)
rows.value = mapped.sort((a, b) => {
const aSuper = a.roleCode.toLowerCase() === "admin" || a.name === "超级管理员";
const bSuper = b.roleCode.toLowerCase() === "admin" || b.name === "超级管理员";
if (aSuper && !bSuper) return -1;
if (!aSuper && bSuper) return 1;
return String(b.createdAt).localeCompare(String(a.createdAt));
});
} catch (e: any) {
message.error(e?.message || "加载角色失败");
} finally {
loading.value = false;
}
}
onMounted(() => loadList());
function openCreate() {
editing.value = null;
Object.assign(form, {
name: "",
roleCode: "",
status: "active",
checkedKeys: ["dashboard", "dashboard:view", "tickets", "tickets:view"],
dataPerms: ["空运·全部线路"],
checkedKeys: [],
dataPerms: [],
});
modalOpen.value = true;
}
function openEdit(row: RoleRow) {
async function openEdit(row: RoleRow) {
editing.value = row;
Object.assign(form, {
name: row.name,
roleCode: row.roleCode,
status: row.status,
checkedKeys: row.menus.includes("全部菜单")
? menuPermTree.flatMap((n) => [n.key, ...(n.children?.map((c) => c.key) || [])])
: menuPermTree
.filter((n) => row.menus.includes(n.title))
.flatMap((n) => [n.key, ...(n.children?.map((c) => c.key) || [])]),
checkedKeys: [] as string[],
dataPerms: [...row.dataPerms],
});
try {
const perms: any = await queryRolePermission({ roleId: row.id });
form.checkedKeys = (Array.isArray(perms) ? perms : perms?.result || []).map(String);
} catch {
form.checkedKeys = [];
}
modalOpen.value = true;
}
function summarizeMenus(keys: string[]) {
const titles = menuPermTree.filter((n) => keys.includes(n.key)).map((n) => n.title);
return titles.length ? titles : ["未授权菜单"];
}
function save() {
async function save() {
if (!form.name.trim()) {
message.warning("请填写角色名称");
return;
}
const menus = summarizeMenus(form.checkedKeys);
if (editing.value) {
Object.assign(editing.value, {
name: form.name,
status: form.status,
menus,
dataPerms: [...form.dataPerms],
});
message.success("角色已更新");
} else {
rows.value.push({
id: `role${Date.now()}`,
name: form.name,
menus,
dataPerms: [...form.dataPerms],
createdAt: new Date().toISOString().slice(0, 10),
status: form.status,
editable: true,
});
message.success("角色已创建");
const roleCode =
form.roleCode.trim() ||
`inq_${form.name.trim().replace(/\s+/g, "_").toLowerCase()}_${Date.now().toString().slice(-4)}`;
try {
const saved: any = await saveOrUpdateRole(
{
id: editing.value?.id,
roleName: form.name.trim(),
roleCode,
description: buildDescription(form.dataPerms),
},
Boolean(editing.value),
);
const roleId = editing.value?.id || saved?.id || saved?.result?.id;
if (roleId && form.checkedKeys.length) {
await saveRolePermission({
roleId,
permissionIds: form.checkedKeys.join(","),
lastpermissionIds: "",
});
}
message.success(editing.value ? "角色已更新" : "角色已创建");
modalOpen.value = false;
await loadList();
} catch (e: any) {
message.error(e?.message || "保存失败");
}
modalOpen.value = false;
}
function removeRole(row: RoleRow) {
Modal.confirm({
title: `删除角色 ${row.name}?`,
okType: "danger",
onOk() {
rows.value = rows.value.filter((r) => r.id !== row.id);
message.success("已删除");
async onOk() {
try {
await deleteRole({ id: row.id }, () => undefined);
message.success("已删除");
await loadList();
} catch (e: any) {
message.error(e?.message || "删除失败");
}
},
});
}
@@ -127,6 +237,12 @@ function removeRole(row: RoleRow) {
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="6" style="text-align: center; color: #94a3b8">加载中…</td>
</tr>
<tr v-else-if="!rows.length">
<td colspan="6" style="text-align: center; color: #94a3b8">暂无角色</td>
</tr>
<tr v-for="row in rows" :key="row.id">
<td style="font-weight: 800">{{ row.name }}</td>
<td>
@@ -137,6 +253,7 @@ function removeRole(row: RoleRow) {
<td>
<div class="role-perm-summary">
<span v-for="d in row.dataPerms" :key="d" class="tag">{{ d }}</span>
<span v-if="!row.dataPerms.length" style="color: #94a3b8">—</span>
</div>
</td>
<td>{{ row.createdAt }}</td>
@@ -181,7 +298,7 @@ function removeRole(row: RoleRow) {
v-model:checkedKeys="form.checkedKeys"
checkable
default-expand-all
:tree-data="menuPermTree"
:tree-data="menuTree"
:field-names="{ title: 'title', key: 'key', children: 'children' }"
/>
</div>
@@ -202,11 +319,7 @@ function removeRole(row: RoleRow) {
<div class="admin-modal-field">
<label class="admin-modal-label">状态</label>
<div class="status-toggle">
<button
type="button"
:class="{ 'is-selected': form.status === 'active' }"
@click="form.status = 'active'"
>
<button type="button" :class="{ 'is-selected': form.status === 'active' }" @click="form.status = 'active'">
启用
</button>
<button
@@ -0,0 +1,55 @@
/**
* 角色管理 API:Jeecg /sys/role + 权限树。
* 询价后台角色页只做系统 RBAC,不做第二套权限真相。
*/
import {
deleteRole,
list as listRoles,
queryRolePermission,
queryTreeListForRole,
saveOrUpdateRole,
saveRolePermission,
} from '/@/views/system/role/role.api';
export {
deleteRole,
listRoles,
queryRolePermission,
queryTreeListForRole,
saveOrUpdateRole,
saveRolePermission,
};
export type RoleRow = {
id: string;
name: string;
roleCode: string;
menus: string[];
dataPerms: string[];
createdAt: string;
status: 'active' | 'disabled';
editable: boolean;
};
/**
* 映射 Jeecg sys_role。
* 内置 admin 在库里常叫「管理员」;询价后台统一展示为「超级管理员」,且不可删改。
*/
export function mapSysRole(r: any): RoleRow {
const code = String(r.roleCode || '');
const rawName = String(r.roleName || '');
const isSuper =
code.toLowerCase() === 'admin' ||
rawName === '超级管理员' ||
rawName === '管理员';
return {
id: String(r.id || ''),
name: isSuper ? '超级管理员' : rawName,
roleCode: code,
menus: isSuper ? ['全部菜单'] : [],
dataPerms: [],
createdAt: (r.createTime || '—').toString().slice(0, 19),
status: r.status === 0 || r.status === '0' ? 'disabled' : 'active',
editable: !isSuper,
};
}
@@ -1,20 +1,52 @@
<script setup lang="ts">
/**
* 员工管理:岗位 Tab、筛选、新建/编辑(Mock)。
* 企微 userid 绑定展示对齐原型;不向企微换 ID。
* 顶栏:同步企业微信 / 新建员工(Teleport)。
* 员工管理:对接主账 /inquiry/staffRole。
* 企微 userid 由运营手工绑定,不向企微换号;停用走 disable 接口。
*/
import { computed, reactive, ref } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { message, Modal } from "ant-design-vue";
import { mockStaff, type StaffRow } from "/@/views/inquiry/mock/staff";
import {
disableStaff,
getStaffCounts,
getStaffList,
saveStaff as saveStaffApi,
} from "/@/views/inquiry/staffRole/staffRole.api";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
const rows = ref<StaffRow[]>([...mockStaff]);
const tab = ref<"all" | "sales" | "air" | "sea" | "land">("all");
type StaffRole = "sales" | "air" | "sea" | "land";
type StaffRow = {
id: string;
name: string;
phoneMasked: string;
dept: string;
wecomId: string;
role: StaffRole;
roleLabel: string;
routes: string;
notifyEventException: boolean;
createdAt: string;
status: "active" | "disabled";
avatarColor: string;
};
const AVATAR_COLORS = ["#e11d48", "#f43f5e", "#7c3aed", "#0e7490", "#b54708", "#155eef", "#64748b"];
const roleLabelMap: Record<StaffRole, string> = {
sales: "销售",
air: "航线·空运",
sea: "产品·海运",
land: "产品·陆运",
};
const rows = ref<StaffRow[]>([]);
const loading = ref(false);
const counts = ref({ all: 0, sales: 0, air: 0, sea: 0, land: 0 });
const tab = ref<"all" | StaffRole>("all");
const filters = reactive({
keyword: "",
role: "",
role: "" as "" | StaffRole,
route: "",
status: "" as "" | "active" | "disabled",
});
@@ -26,42 +58,86 @@ const form = reactive({
wecomId: "",
phone: "",
dept: "",
role: "sales" as StaffRow["role"],
role: "sales" as StaffRole,
routes: "",
/** 是否接收事件异常消息提醒;对齐旧仓 notifyEventException */
notifyEventException: false,
status: "active" as StaffRow["status"],
});
const counts = computed(() => ({
all: rows.value.length,
sales: rows.value.filter((r) => r.role === "sales").length,
air: rows.value.filter((r) => r.role === "air").length,
sea: rows.value.filter((r) => r.role === "sea").length,
land: rows.value.filter((r) => r.role === "land").length,
}));
const filtered = computed(() => rows.value);
const filtered = computed(() =>
rows.value.filter((r) => {
if (tab.value !== "all" && r.role !== tab.value) return false;
const kw = filters.keyword.trim().toLowerCase();
if (kw) {
const hay = `${r.name}${r.wecomId}${r.phoneMasked}`.toLowerCase();
if (!hay.includes(kw)) return false;
}
if (filters.role && r.role !== filters.role) return false;
if (filters.route && !r.routes.includes(filters.route)) return false;
if (filters.status && r.status !== filters.status) return false;
return true;
}),
function colorFor(name: string) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h + name.charCodeAt(i) * (i + 1)) % AVATAR_COLORS.length;
return AVATAR_COLORS[h];
}
function mapRow(r: any): StaffRow {
const role = (r.roleCode || "sales") as StaffRole;
return {
id: String(r.id || ""),
name: r.name || "",
phoneMasked: r.mobile || "未填写",
dept: r.departName || "未分配",
wecomId: r.wecomId || "",
role: ["sales", "air", "sea", "land"].includes(role) ? role : "sales",
roleLabel: r.roleLabel || roleLabelMap[role] || role,
routes: r.routes || "-",
notifyEventException: r.notifyEventException === 1 || r.notifyEventException === true,
createdAt: (r.createTime || "").toString().slice(0, 10) || "—",
status: r.status === "disabled" ? "disabled" : "active",
avatarColor: colorFor(r.name || "员"),
};
}
async function loadCounts() {
try {
const res: any = await getStaffCounts();
const data = res?.result || res || {};
counts.value = {
all: Number(data.all || 0),
sales: Number(data.sales || 0),
air: Number(data.air || 0),
sea: Number(data.sea || 0),
land: Number(data.land || 0),
};
} catch {
/* 列表仍可用 */
}
}
async function loadList() {
loading.value = true;
try {
const roleCode = tab.value !== "all" ? tab.value : filters.role || undefined;
const res: any = await getStaffList({
keyword: filters.keyword.trim() || undefined,
roleCode,
routes: filters.route || undefined,
status: filters.status || undefined,
pageNo: 1,
pageSize: 200,
});
const records = res?.records || res?.result?.records || [];
rows.value = (records as any[]).map(mapRow);
await loadCounts();
} catch (e: any) {
message.error(e?.message || "加载员工失败");
} finally {
loading.value = false;
}
}
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => [tab.value, filters.keyword, filters.role, filters.route, filters.status],
() => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadList(), 200);
},
);
const roleLabelMap: Record<StaffRow["role"], string> = {
sales: "销售",
air: "航线·空运",
sea: "产品·海运",
land: "产品·陆运",
};
onMounted(() => loadList());
function openCreate() {
editing.value = null;
@@ -83,8 +159,8 @@ function openEdit(row: StaffRow) {
Object.assign(form, {
name: row.name,
wecomId: row.wecomId,
phone: row.phoneMasked,
dept: row.dept,
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
dept: row.dept === "未分配" ? "" : row.dept,
role: row.role,
routes: row.routes === "-" ? "" : row.routes,
notifyEventException: Boolean(row.notifyEventException),
@@ -93,63 +169,60 @@ function openEdit(row: StaffRow) {
modalOpen.value = true;
}
function saveStaff() {
async function saveStaff() {
if (!form.name.trim() || !form.wecomId.trim()) {
message.warning("请填写姓名与企业微信 ID");
return;
}
if (editing.value) {
Object.assign(editing.value, {
name: form.name,
wecomId: form.wecomId,
phoneMasked: form.phone,
dept: form.dept,
role: form.role,
roleLabel: roleLabelMap[form.role],
routes: form.routes || "-",
notifyEventException: form.notifyEventException,
status: form.status,
});
message.success("员工已更新");
} else {
rows.value.unshift({
id: `s${Date.now()}`,
name: form.name,
phoneMasked: form.phone || "未填写",
dept: form.dept || "未分配",
wecomId: form.wecomId,
role: form.role,
roleLabel: roleLabelMap[form.role],
routes: form.routes || "-",
notifyEventException: form.notifyEventException,
createdAt: new Date().toISOString().slice(0, 10),
status: form.status,
avatarColor: "#155eef",
});
message.success("员工已创建");
const body = {
id: editing.value?.id,
name: form.name.trim(),
wecomId: form.wecomId.trim(),
mobile: form.phone.trim() || undefined,
departName: form.dept.trim() || undefined,
roleCode: form.role,
roleLabel: roleLabelMap[form.role],
routes: form.routes.trim() || undefined,
notifyEventException: form.notifyEventException ? 1 : 0,
status: form.status,
};
try {
await saveStaffApi(body, Boolean(editing.value));
message.success(editing.value ? "员工已更新" : "员工已创建");
modalOpen.value = false;
await loadList();
} catch (e: any) {
message.error(e?.message || "保存失败");
}
modalOpen.value = false;
}
function removeStaff(row: StaffRow) {
Modal.confirm({
title: `删除员工 ${row.name}?`,
content: "删除后不可恢复,确认继续?",
title: `停用员工 ${row.name}?`,
content: "主账无物理删除接口,确认后将状态设为禁用。",
okType: "danger",
onOk() {
rows.value = rows.value.filter((r) => r.id !== row.id);
message.success("已删除");
async onOk() {
try {
await disableStaff(row.id);
message.success("已停用");
await loadList();
} catch (e: any) {
message.error(e?.message || "停用失败");
}
},
});
}
function syncWecom() {
message.success("已同步企业微信");
message.info("企微通讯录同步尚未开放,请手工维护企微 ID");
}
function routeTags(routes: string) {
if (!routes || routes === "-") return [];
return routes.split(/[/·,、]/).map((s) => s.trim()).filter(Boolean);
return routes
.split(/[/·,、]/)
.map((s) => s.trim())
.filter(Boolean);
}
</script>
@@ -231,6 +304,12 @@ function routeTags(routes: string) {
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td>
</tr>
<tr v-else-if="!filtered.length">
<td colspan="7" style="text-align: center; color: #94a3b8">暂无员工</td>
</tr>
<tr v-for="row in filtered" :key="row.id">
<td>
<div class="avatar-chip">
@@ -260,8 +339,13 @@ function routeTags(routes: string) {
<td>
<div style="display: flex; align-items: center; gap: 12px">
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
<button class="link-action link-danger" type="button" @click="removeStaff(row)">
删除
<button
v-if="row.status === 'active'"
class="link-action link-danger"
type="button"
@click="removeStaff(row)"
>
停用
</button>
</div>
</td>
@@ -323,11 +407,7 @@ function routeTags(routes: string) {
<div class="form-row">
<label class="lbl">状态</label>
<div class="status-toggle">
<button
type="button"
:class="{ 'is-selected': form.status === 'active' }"
@click="form.status = 'active'"
>
<button type="button" :class="{ 'is-selected': form.status === 'active' }" @click="form.status = 'active'">
启用
</button>
<button
@@ -15,7 +15,7 @@ enum Api {
export const getStaffList = (params) => defHttp.get({ url: Api.list, params });
export const getStaffCounts = () => defHttp.get({ url: Api.counts });
export const saveStaff = (params, isUpdate: boolean) =>
isUpdate ? defHttp.put({ url: Api.edit, params }) : defHttp.post({ url: Api.add, params });
export const saveStaff = (data, isUpdate: boolean) =>
isUpdate ? defHttp.put({ url: Api.edit, data }) : defHttp.post({ url: Api.add, data });
export const disableStaff = (id: string) => defHttp.post({ url: Api.disable, params: { id } });
export const getStaffById = (id: string) => defHttp.get({ url: Api.queryById, params: { id } });
@@ -1,20 +1,35 @@
<script setup lang="ts">
/**
* 报价模板页:对齐原型 admin-quote-templates.html。
* - 上传按钮只在顶栏一处(禁止双按钮)
* - 弹窗用原生 overlay(不依赖 a-modal,避免遮罩残留导致整页点不了)
* - 操作:预览 / 编辑 / 启用|禁用
* - 本阶段仅 mock,暂不对接主账上传/启用接口(页面与侧栏必须保留)
* 报价模板页:对接主账 /inquiry/quoteTemplate。
* - 上传按钮只在顶栏一处;弹窗用原生 overlay
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
* - 预览:弹窗内按映射回填合同样例事实(接近原型可读性);权威版式仍以下载 Excel 为准
*/
import { computed, onMounted, reactive, ref } from "vue";
import { message } from "ant-design-vue";
import {
ALL_CATEGORIES,
CATEGORY_BY_BIZ,
mockTemplates,
mapApiRow,
type TemplateRow,
} from "/@/views/inquiry/mock/templates";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import {
disableTemplate,
downloadTemplateFile,
editTemplateMeta,
enableTemplate,
getTemplateList,
getTemplateVersion,
uploadTemplate,
} from "/@/views/inquiry/template/template.api";
import {
buildPreviewViewModel,
emptyPreviewViewModel,
type PreviewViewModel,
} from "/@/views/inquiry/template/previewSample";
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
/** 清掉可能残留的 Ant Modal 遮罩(会导致整页点不了) */
function clearOrphanAntMasks() {
@@ -28,11 +43,8 @@ function clearOrphanAntMasks() {
document.body.classList.remove("ant-scrolling-effect");
}
onMounted(() => {
clearOrphanAntMasks();
});
const rows = ref<TemplateRow[]>([...mockTemplates]);
const rows = ref<TemplateRow[]>([]);
const loading = ref(false);
const keyword = ref("");
const bizFilter = ref("");
const categoryFilter = ref("");
@@ -42,12 +54,16 @@ const formOpen = ref(false);
const editing = ref<TemplateRow | null>(null);
const previewOpen = ref(false);
const previewRow = ref<TemplateRow | null>(null);
/** 弹窗内映射回填一览(合同样例事实);下载 Excel 仍走 previewSample */
const previewVm = ref<PreviewViewModel>(emptyPreviewViewModel());
const fileInputRef = ref<HTMLInputElement | null>(null);
const pendingFile = ref<File | null>(null);
const form = reactive({
name: "",
bizType: "海运" as TemplateRow["bizType"],
/** 多行关键词;每行可含逗号,加减行数(对齐旧仓 keywordItems) */
category: "",
mode: "auto" as "auto" | "manual",
keywordItems: [""] as string[],
isDefault: false,
fileName: "",
@@ -64,30 +80,29 @@ const parseState = reactive({
const formTitle = computed(() => (editing.value ? "编辑报价模板" : "上传报价模板"));
const formSub = computed(() =>
editing.value ? "修改基本信息或更换模板文件后重新解析" : "上传 Excel / PDF,系统自动解析占位符",
editing.value
? "可改基本信息;更换文件将追加新版本并重新解析映射"
: "上传 Excel,系统按黄区与标签自动建议单元格映射",
);
const formOkText = computed(() => (editing.value ? "保存修改" : "发布模板"));
const formOkText = computed(() => (editing.value ? "保存" : "上传并解析"));
const previewFootHint =
"用户端生成时将复制模板文件并替换 {{占位符}},不改变原有 Excel / PDF 样式";
"用户端生成时将复制本版 Excel 并按映射写入已冻结事实(黄区不改),不改变原有 Excel / PDF 样式;模板改版请重新上传";
const previewTotalText = computed(() => {
const vm = previewVm.value;
if (!vm.totalAmount) return "—";
const n = Number(vm.totalAmount);
const amt = Number.isFinite(n)
? n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: vm.totalAmount;
return `${vm.currency || "USD"} ${amt}`;
});
/** 表单不再手选分类:按业务取默认分类写入数据(列表筛选仍可用) */
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;
}),
);
const filtered = computed(() => rows.value);
function bizTag(biz: string) {
if (biz === "海运") return "tag-teal";
@@ -95,16 +110,44 @@ function bizTag(biz: string) {
return "tag-blue";
}
async function loadList() {
loading.value = true;
try {
const res: any = await getTemplateList({
keyword: keyword.value.trim() || undefined,
bizType: bizFilter.value || undefined,
category: categoryFilter.value || undefined,
status: statusFilter.value || undefined,
pageNo: 1,
pageSize: 200,
});
const records = res?.records || res?.result?.records || [];
rows.value = (records as any[]).map(mapApiRow);
} catch (e: any) {
message.error(e?.message || "加载模板列表失败");
} finally {
loading.value = false;
}
}
onMounted(() => {
clearOrphanAntMasks();
loadList();
});
function resetForm() {
Object.assign(form, {
name: "",
bizType: "海运" as TemplateRow["bizType"],
category: defaultCategoryForBiz("海运"),
mode: "auto" as const,
keywordItems: [""],
isDefault: false,
fileName: "",
fileMeta: "",
format: "excel" as const,
});
pendingFile.value = null;
parseState.visible = false;
parseState.ok = true;
parseState.title = "";
@@ -122,22 +165,25 @@ function openEdit(row: TemplateRow) {
Object.assign(form, {
name: row.name,
bizType: row.bizType,
category: row.category || defaultCategoryForBiz(row.bizType),
mode: row.mode || "auto",
keywordItems: row.keywords.length ? [...row.keywords] : [""],
isDefault: Boolean(row.isDefault),
fileName: row.fileName,
fileMeta: `${row.format === "pdf" ? "PDF" : "Excel"} · 已上传`,
format: row.format,
});
pendingFile.value = null;
parseState.visible = true;
parseState.ok = row.parseStatus === "已解析";
parseState.title = parseState.ok ? "解析成功" : "解析失败";
parseState.desc = parseState.ok
? `识别 ${row.placeholderCount} 个占位符,已映射至工单字段`
: "请更换模板文件后重试";
parseState.ok = row.parseStatus === "已解析" || row.parseStatus === "映射未完成";
parseState.title = row.parseStatus || "解析状态";
parseState.desc =
row.parseMessage ||
`已识别 ${row.mappingPointCount} 个映射点` +
(row.unmappedRequired?.length ? `;未映射:${row.unmappedRequired.join(",")}` : "");
formOpen.value = true;
}
/** 去空去重,保留框内逗号;至少一条才算有效 */
function normalizeKeywordItems(items: string[]): string[] {
const unique: string[] = [];
const seen = new Set<string>();
@@ -150,32 +196,49 @@ function normalizeKeywordItems(items: string[]): string[] {
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);
}
/**
* 同业务只保留一个默认模板:勾选本条时清掉同业务其它默认。
* 演示环境本地互斥;接主账后由接口保证。
* 打开预览:先展示列表摘要,再拉 version.mappingJson 组装样例回填一览。
* 副作用:HTTP 读版本详情;不写库、不调 LLM。
*/
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) {
async function openPreview(row: TemplateRow) {
previewRow.value = row;
previewOpen.value = true;
previewVm.value = {
...emptyPreviewViewModel(),
loading: true,
mappedCount: row.mappingPointCount || 0,
};
if (!row.versionId) {
previewVm.value = {
...emptyPreviewViewModel(),
loading: false,
error: "无可用版本,请先上传模板文件",
mappedCount: row.mappingPointCount || 0,
};
return;
}
try {
const ver: any = await getTemplateVersion(row.versionId);
const mappingJson = ver?.mappingJson ?? ver?.result?.mappingJson;
previewVm.value = buildPreviewViewModel(mappingJson, row.mappingPointCount || 0);
} catch (e: any) {
previewVm.value = {
...emptyPreviewViewModel(),
loading: false,
error: e?.message || "加载映射失败,仍可下载预览文件",
mappedCount: row.mappingPointCount || 0,
};
}
}
function closeForm() {
@@ -184,11 +247,26 @@ function closeForm() {
function closePreview() {
previewOpen.value = false;
previewVm.value = emptyPreviewViewModel();
}
function toggleStatus(row: TemplateRow) {
row.status = row.status === "enabled" ? "disabled" : "enabled";
message.success(row.status === "enabled" ? "已启用" : "已禁用");
async function toggleStatus(row: TemplateRow) {
try {
if (row.status === "enabled") {
await disableTemplate(row.id);
message.success("已停用");
} else {
if (!row.versionId) {
message.warning("无可用版本,请先上传文件");
return;
}
await enableTemplate(row.id, row.versionId);
message.success("已启用");
}
await loadList();
} catch (e: any) {
message.error(e?.message || "启停失败(自动模板须完成必填映射)");
}
}
function formatFileSize(bytes: number) {
@@ -203,14 +281,14 @@ function applySelectedFile(file: File) {
form.format = isPdf ? "pdf" : "excel";
form.fileName = file.name;
form.fileMeta = `${isPdf ? "PDF" : "Excel"} · ${formatFileSize(file.size)}`;
pendingFile.value = file;
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} 个已自动映射至工单字段`;
parseState.title = "待上传解析";
parseState.desc = "点击保存后由主账解析黄区并自动建议映射点";
}
function onFileChange(e: Event) {
@@ -227,71 +305,76 @@ function onDrop(e: DragEvent) {
}
function triggerFilePick() {
if (!form.fileName) fileInputRef.value?.click();
fileInputRef.value?.click();
}
function submitForm() {
if (!form.fileName) {
message.warning("请先上传模板文件");
return;
}
if (!parseState.ok || !parseState.visible) {
message.warning("模板解析失败,请更换文件后重试");
return;
}
async function submitForm() {
const keywords = normalizeKeywordItems(form.keywordItems);
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("模板已发布");
// 分类/模式不对运营展示:分类按业务默认,模式新建默认 auto,编辑保留原值
const category = defaultCategoryForBiz(form.bizType);
const mode = editing.value?.mode || "auto";
try {
if (pendingFile.value) {
const fd = new FormData();
if (editing.value?.id) fd.append("templateId", editing.value.id);
fd.append("name", form.name.trim());
fd.append("bizType", form.bizType);
fd.append("category", category);
fd.append("keywordsJson", JSON.stringify(keywords));
fd.append("mode", mode);
fd.append("isDefault", String(form.isDefault));
fd.append("file", pendingFile.value);
const row: any = await uploadTemplate(fd);
message.success(row?.parseMessage || "已上传并解析");
} else if (editing.value) {
await editTemplateMeta({
id: editing.value.id,
name: form.name.trim(),
bizType: form.bizType,
category,
keywordsJson: JSON.stringify(keywords),
mode,
isDefault: form.isDefault ? 1 : 0,
});
message.success("模板信息已保存");
} else {
message.warning("请先选择模板文件");
return;
}
formOpen.value = false;
await loadList();
} catch (e: any) {
message.error(e?.message || "保存失败");
}
formOpen.value = false;
}
function downloadDemo() {
message.success("已开始下载");
async function downloadPreviewOrOriginal() {
const row = previewRow.value;
if (!row?.versionId) {
message.warning("无版本可下载");
return;
}
const kind = row.format === "excel" ? "previewSample" : "download";
try {
message.loading({ content: "正在生成预览文件…", key: "tpl-dl", duration: 0 });
const hint =
kind === "previewSample"
? (row.fileName || "preview").replace(/\.xlsx$/i, "-preview.xlsx")
: row.fileName || "template.bin";
await downloadTemplateFile(kind, row.versionId, hint);
message.success({ content: "已开始下载", key: "tpl-dl" });
} catch (e: any) {
message.error({ content: e?.message || "下载预览文件失败", key: "tpl-dl" });
}
}
</script>
@@ -307,19 +390,19 @@ function downloadDemo() {
<div class="toolbar">
<div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" />
<input v-model="keyword" placeholder="搜索模板名称…" />
<input v-model="keyword" placeholder="搜索模板名称…" @keyup.enter="loadList" />
</div>
<select v-model="bizFilter">
<select v-model="bizFilter" @change="loadList">
<option value="">全部业务</option>
<option value="空运">空运</option>
<option value="海运">海运</option>
<option value="陆运">陆运</option>
</select>
<select v-model="categoryFilter">
<select v-model="categoryFilter" @change="loadList">
<option value="">全部分类</option>
<option v-for="c in ALL_CATEGORIES" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="statusFilter">
<select v-model="statusFilter" @change="loadList">
<option value="">全部状态</option>
<option value="enabled">启用</option>
<option value="disabled">禁用</option>
@@ -553,7 +636,9 @@ function downloadDemo() {
<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>
<span class="tag tag-green"
>已解析 · {{ previewVm.mappedCount || previewRow.mappingPointCount }} 个映射点</span
>
</div>
<div class="qt-paper">
<div class="qt-paper-head">
@@ -561,63 +646,61 @@ function downloadDemo() {
<div class="qt-paper-title">
{{ previewRow.name.replace(/模板$/, "") || previewRow.name }}
</div>
<div class="qt-paper-cap">模板版式 · 由上传 Excel 决定</div>
<div class="qt-paper-cap">模板款式 · 由上传 Excel 决定(黄区固定)</div>
</div>
<div class="qt-paper-meta">
<div>QT-202607020001</div>
<div>2026-07-02</div>
<div v-if="previewVm.quoteNo">{{ previewVm.quoteNo }}</div>
<div v-if="previewVm.quoteDate">{{ previewVm.quoteDate }}</div>
<div v-else>版本 {{ previewRow.currentVersion || "—" }}</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 v-if="previewVm.loading" class="qt-paper-loading">正在加载映射回填…</div>
<div v-else-if="previewVm.error" class="qt-paper-loading qt-paper-loading--err">
{{ previewVm.error }}
</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>
<template v-else>
<div class="qt-paper-grid">
<div v-for="f in previewVm.fields" :key="f.code">
<span class="qt-paper-label">{{ f.label }}</span>
<span class="qt-paper-value">{{ f.value }}</span>
</div>
<div v-if="!previewVm.fields.length">
<span class="qt-paper-label">映射</span>
<span class="qt-paper-value">暂无已映射字段,请先确认映射或下载 Excel 查看</span>
</div>
</div>
<div v-if="previewVm.totalAmount" class="qt-paper-total">
<div class="qt-paper-total-label">报价合计(样例)</div>
<div class="qt-paper-amt">{{ previewTotalText }}</div>
<div class="qt-paper-total-hint">
按映射回填合同样例事实
<template v-if="previewVm.validUntil"> · 有效期至 {{ previewVm.validUntil }}</template>
</div>
</div>
<table v-if="previewVm.fees.length" class="qt-paper-fee">
<thead>
<tr>
<th>费用项</th>
<th style="text-align: right">金额</th>
</tr>
</thead>
<tbody>
<tr v-for="(fee, idx) in previewVm.fees" :key="idx">
<td>{{ fee.name }}</td>
<td style="text-align: right">{{ fee.amount }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>合计</td>
<td style="text-align: right">{{ previewVm.totalAmount }}</td>
</tr>
</tfoot>
</table>
</template>
<p class="qt-paper-note">
* 版式、Logo、签章与业务上传模板一致;上述为预览回填数据。
* 版式、Logo、签章与业务上传模板一致;上述为预览回填数据,金额只来自冻结报价事实,模型不发明价格。
</p>
</div>
<p class="qt-preview-foot-hint">{{ previewFootHint }}</p>
@@ -625,8 +708,8 @@ function downloadDemo() {
<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 type="button" class="btn btn-primary" @click="downloadPreviewOrOriginal">
<ProtoIcon name="download" :size="16" />下载预览文件
</button>
</div>
</div>
@@ -675,6 +758,16 @@ function downloadDemo() {
pointer-events: auto;
}
.qt-paper-loading {
padding: 16px 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
.qt-paper-loading--err {
color: #b42318;
}
.qt-create-row--keywords {
/* 与名称/业务同用 center,单行时标签与输入框垂直对齐 */
align-items: center;
@@ -0,0 +1,224 @@
/**
* 报价模板预览用:合同样例事实 + 字段中文名。
* 与 contracts/quote-template-mapping-v1.json 的 samplePreviewFacts / systemFields 对齐。
* 仅用于管理端「用户端预览」弹窗回填一览;金额不由模型发明。
*/
/** 系统字段 code → 中文标签 */
export const FIELD_LABELS: Record<string, string> = {
customer_name: "客户名称",
contact_name: "联系人",
contact_phone: "电话",
quote_no: "报价单编号",
quote_date: "报价日期",
valid_until: "有效期至",
currency: "币别",
sales_name: "业务员",
pol: "起运港",
pod: "目的港",
origin: "始发地",
destination: "目的地",
commodity: "品名",
quantity: "货物数量",
container_type_qty: "箱型箱量",
fcl_or_lcl: "整柜或拼柜",
trade_terms: "贸易条款",
cargo_ready_date: "货好时间",
pieces: "件数",
weight_kg: "重量",
volume_cbm: "体积",
total_amount: "报价合计",
};
/** 弹窗优先展示的字段顺序(接近原型) */
export const PREVIEW_FIELD_ORDER = [
"customer_name",
"container_type_qty",
"pol",
"pod",
"origin",
"destination",
"sales_name",
"commodity",
"fcl_or_lcl",
"trade_terms",
"contact_name",
"contact_phone",
"quantity",
"pieces",
"weight_kg",
"volume_cbm",
"cargo_ready_date",
"currency",
] as const;
/** 与主账 previewSample 同源的合同样例事实 */
export const SAMPLE_PREVIEW_FACTS: Record<string, any> = {
customer_name: "ABC Trading Co.",
contact_name: "张三",
contact_phone: "13800000000",
quote_no: "QT-202607020001",
quote_date: "2026-07-02",
valid_until: "2026-07-05",
currency: "USD",
sales_name: "张敏",
pol: "CNSHA",
pod: "USLAX",
origin: "深圳",
destination: "洛杉矶",
commodity: "通用货物",
quantity: "2",
container_type_qty: "40HQ × 2",
fcl_or_lcl: "FCL",
trade_terms: "FOB",
cargo_ready_date: "2026-07-10",
pieces: "10",
weight_kg: "1200",
volume_cbm: "8.5",
total_amount: "3680.00",
fee_rows: [
{ name: "O/F 海运费", amount: "2900", remark: "" },
{ name: "THC + DOC + SEAL", amount: "780", remark: "" },
],
};
export type PreviewFeeRow = { name: string; amount: string; remark?: string };
export type PreviewFieldRow = {
code: string;
label: string;
value: string;
cell: string;
};
export type PreviewViewModel = {
loading: boolean;
error: string;
quoteNo: string;
quoteDate: string;
currency: string;
totalAmount: string;
validUntil: string;
/** 航线摘要:有 pol/pod 或 origin/destination 时拼出 */
routeText: string;
fields: PreviewFieldRow[];
fees: PreviewFeeRow[];
mappedCount: number;
unmappedRequired: string[];
};
function parseMapping(raw: unknown): { fields: Record<string, string>; unmapped: string[] } {
if (!raw) return { fields: {}, unmapped: [] };
let obj: any = raw;
if (typeof raw === "string") {
try {
obj = JSON.parse(raw);
} catch {
return { fields: {}, unmapped: [] };
}
}
const fields: Record<string, string> = {};
const src = obj?.fields || {};
if (src && typeof src === "object") {
for (const [k, v] of Object.entries(src)) {
if (k === "fee_rows") continue;
const cell = String(v ?? "").trim();
if (cell) fields[k] = cell;
}
}
const unmapped = Array.isArray(obj?.unmappedRequired)
? obj.unmappedRequired.map((x: any) => String(x))
: [];
return { fields, unmapped };
}
/**
* 根据 mappingJson 与样例事实组装弹窗视图。
* 只展示「已映射」字段的样例值;费用行在有 fee_rows 映射或样例费用时展示。
*/
export function buildPreviewViewModel(mappingJson: unknown, pointCountHint = 0): PreviewViewModel {
const { fields: mapped, unmapped } = parseMapping(mappingJson);
const facts = SAMPLE_PREVIEW_FACTS;
const fieldRows: PreviewFieldRow[] = [];
const ordered = [
...PREVIEW_FIELD_ORDER.filter((c) => mapped[c]),
...Object.keys(mapped).filter((c) => !(PREVIEW_FIELD_ORDER as readonly string[]).includes(c)),
];
// 航线合并展示,避免 grid 里重复 pol/pod
const hasRoute = Boolean(mapped.pol || mapped.pod || mapped.origin || mapped.destination);
let routeText = "";
if (hasRoute) {
if (mapped.pol || mapped.pod) {
routeText = `${facts.pol || "—"} → ${facts.pod || "—"}`;
} else {
routeText = `${facts.origin || "—"} → ${facts.destination || "—"}`;
}
}
for (const code of ordered) {
if (code === "pol" || code === "pod" || code === "origin" || code === "destination") {
continue;
}
const rawVal = facts[code];
if (rawVal == null || rawVal === "") continue;
fieldRows.push({
code,
label: FIELD_LABELS[code] || code,
value: String(rawVal),
cell: mapped[code],
});
}
if (routeText) {
fieldRows.splice(Math.min(2, fieldRows.length), 0, {
code: "route",
label: "航线",
value: routeText,
cell: [mapped.pol, mapped.pod, mapped.origin, mapped.destination].filter(Boolean).join(" / "),
});
}
const fees: PreviewFeeRow[] = Array.isArray(facts.fee_rows)
? facts.fee_rows.map((r: any) => ({
name: String(r.name ?? ""),
amount: String(r.amount ?? ""),
remark: String(r.remark ?? ""),
}))
: [];
const currency = String(facts.currency || "USD");
const total = String(facts.total_amount || "");
return {
loading: false,
error: "",
quoteNo: String(facts.quote_no || ""),
quoteDate: String(facts.quote_date || ""),
currency,
totalAmount: total,
validUntil: String(facts.valid_until || ""),
routeText,
fields: fieldRows,
fees,
mappedCount: Object.keys(mapped).length || pointCountHint,
unmappedRequired: unmapped,
};
}
export function emptyPreviewViewModel(): PreviewViewModel {
return {
loading: false,
error: "",
quoteNo: "",
quoteDate: "",
currency: "USD",
totalAmount: "",
validUntil: "",
routeText: "",
fields: [],
fees: [],
mappedCount: 0,
unmappedRequired: [],
};
}
@@ -0,0 +1,103 @@
/**
* 报价模板 API(主账 /inquiry/quoteTemplate)。
* 列表/上传/映射/启停/样例预览;禁止浏览器直连对象存储填表以外的 TMS/LLM。
*/
import { defHttp } from '/@/utils/http/axios';
import { useGlobSetting } from '/@/hooks/setting';
import { getToken } from '/@/utils/auth';
enum Api {
list = '/inquiry/quoteTemplate/list',
upload = '/inquiry/quoteTemplate/upload',
version = '/inquiry/quoteTemplate/version',
versions = '/inquiry/quoteTemplate/versions',
mapping = '/inquiry/quoteTemplate/mapping',
enable = '/inquiry/quoteTemplate/enable',
disable = '/inquiry/quoteTemplate/disable',
setDefault = '/inquiry/quoteTemplate/setDefault',
editMeta = '/inquiry/quoteTemplate/editMeta',
previewSample = '/inquiry/quoteTemplate/previewSample',
download = '/inquiry/quoteTemplate/download',
}
export const getTemplateList = (params) => defHttp.get({ url: Api.list, params });
export const uploadTemplate = (data: FormData) =>
defHttp.post({
url: Api.upload,
data,
headers: { 'Content-Type': 'multipart/form-data' },
});
export const getTemplateVersion = (versionId: string) =>
defHttp.get({ url: Api.version, params: { versionId } });
export const listTemplateVersions = (templateId: string) =>
defHttp.get({ url: Api.versions, params: { templateId } });
export const saveTemplateMapping = (versionId: string, mappingJson: string) =>
defHttp.put({ url: Api.mapping, data: { versionId, mappingJson } });
export const enableTemplate = (templateId: string, versionId: string) =>
defHttp.post({ url: Api.enable, params: { templateId, versionId } });
export const disableTemplate = (templateId: string) =>
defHttp.post({ url: Api.disable, params: { templateId } });
export const setTemplateDefault = (templateId: string, isDefault = true) =>
defHttp.post({ url: Api.setDefault, params: { templateId, isDefault } });
export const editTemplateMeta = (params) => defHttp.put({ url: Api.editMeta, params });
/**
* 带鉴权下载样例预览 / 原文件。
* window.open+query token 遇 500 JSON 时浏览器只显示失败;改为 blob 下载并提示 message。
*/
export async function downloadTemplateFile(kind: 'previewSample' | 'download', versionId: string, fileNameHint?: string) {
const glob = useGlobSetting();
const base = (glob.apiUrl || '').replace(/\/$/, '');
const path = kind === 'previewSample' ? Api.previewSample : Api.download;
const url = `${base}${path}?versionId=${encodeURIComponent(versionId)}`;
const token = getToken() || '';
const res = await fetch(url, {
method: 'GET',
headers: {
'X-Access-Token': String(token),
},
});
const ctype = (res.headers.get('content-type') || '').toLowerCase();
if (!res.ok || ctype.includes('application/json')) {
let msg = `下载失败 HTTP ${res.status}`;
try {
const j = await res.json();
if (j?.message) msg = String(j.message);
} catch {
/* ignore */
}
throw new Error(msg);
}
const blob = await res.blob();
let name = fileNameHint || (kind === 'previewSample' ? 'preview.xlsx' : 'template.bin');
const cd = res.headers.get('content-disposition') || '';
const m = /filename\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i.exec(cd);
if (m) {
name = decodeURIComponent((m[1] || m[2] || name).trim());
}
const href = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = href;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(href);
}
/** 带 token 下载样例预览 / 原文件(新窗口,兼容旧调用) */
export function buildTemplateFileUrl(kind: 'previewSample' | 'download', versionId: string) {
const glob = useGlobSetting();
const base = (glob.apiUrl || '').replace(/\/$/, '');
const path = kind === 'previewSample' ? Api.previewSample : Api.download;
const token = getToken() || '';
return `${base}${path}?versionId=${encodeURIComponent(versionId)}&token=${encodeURIComponent(String(token))}`;
}
@@ -1,22 +1,48 @@
<script setup lang="ts">
/**
* 询价工单列表:筛选、详情、关单(Mock);表头左右列 sticky,中间列横滑。
* 六态只展示;正式 transition 必须调主账,禁止本地改真相。
* 询价工单列表:对接主账 /inquiry/workOrder。
* 六态关单/重试必须调主账;禁止本地改 status 冒充成功。
*/
import { computed, reactive, ref } from "vue";
import { onMounted, reactive, ref, watch } from "vue";
import { message, Modal } from "ant-design-vue";
import { mockTickets, type TicketRow } from "/@/views/inquiry/mock/tickets";
import {
getWorkOrderDetail,
getWorkOrderList,
retryAbnormal,
transitionWorkOrder,
} from "/@/views/inquiry/workOrder/workOrder.api";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
const rows = ref<TicketRow[]>([...mockTickets]);
type TicketRow = {
id: string;
initiator: string;
demandTitle: string;
demandDetail: string;
quotes: { source: string; price: string; highlight?: boolean }[];
attachments: string[];
status: string;
costPrice: string;
salePrice: string;
profit: string;
eventException: string;
systemException: string;
systemExceptionReason: string;
createdAt: string;
quoteDate: string;
bizType: string;
};
const rows = ref<TicketRow[]>([]);
const loading = ref(false);
const total = ref(0);
const filters = reactive({
keyword: "",
status: "",
eventException: "",
systemException: "",
bizType: "",
dateFrom: "2026-06-01",
dateTo: "2026-06-30",
dateFrom: "",
dateTo: "",
});
const detailOpen = ref(false);
@@ -24,28 +50,86 @@ const closeOpen = ref(false);
const current = ref<TicketRow | null>(null);
const closeReason = ref("");
const filtered = computed(() =>
rows.value.filter((t) => {
const kw = filters.keyword.trim().toLowerCase();
if (kw) {
const hay = `${t.id}${t.initiator}${t.demandTitle}`.toLowerCase();
if (!hay.includes(kw)) return false;
}
if (filters.status && t.status !== filters.status) return false;
if (filters.eventException && t.eventException !== filters.eventException) return false;
if (filters.systemException && t.systemException !== filters.systemException) return false;
if (filters.bizType && t.bizType !== filters.bizType) return false;
// 创建时间:选完日期即生效,无需再点查询按钮
const day = (t.createdAt || "").slice(0, 10);
if (filters.dateFrom && day && day < filters.dateFrom) return false;
if (filters.dateTo && day && day > filters.dateTo) return false;
return true;
}),
function dash(v: any) {
const s = (v ?? "").toString().trim();
return s ? s : "-";
}
function mapTicket(t: any, quotes: any[] = [], attachments: any[] = []): TicketRow {
return {
id: t.workOrderNo || t.id || "",
initiator: t.salesName || t.salesWecomId || "—",
demandTitle: t.demandTitle || `${t.origin || ""} → ${t.destination || ""}` || "—",
demandDetail: t.demandDetail || "",
quotes: (quotes || []).map((q) => ({
source: q.sourceLabel || q.source || "报价",
price: q.priceText || "-",
highlight: q.highlighted === 1 || q.highlighted === true,
})),
attachments: (attachments || []).map((a) => a.fileName || a.objectKey || "附件").filter(Boolean),
status: t.status || "询价中",
costPrice: dash(t.costPrice),
salePrice: dash(t.salePrice),
profit: dash(t.profitRate),
eventException: dash(t.eventException),
systemException: dash(t.systemException),
systemExceptionReason: dash(t.systemExceptionReason),
createdAt: (t.createTime || "").toString().slice(0, 19),
quoteDate: dash((t.quoteDate || "").toString().slice(0, 10)),
bizType: t.bizTypeLabel || t.businessLine || "",
};
}
async function loadList() {
loading.value = true;
try {
const res: any = await getWorkOrderList({
keyword: filters.keyword.trim() || undefined,
status: filters.status || undefined,
eventException: filters.eventException || undefined,
systemException: filters.systemException || undefined,
bizType: filters.bizType || undefined,
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
pageNo: 1,
pageSize: 200,
});
const page = res?.records ? res : res?.result || {};
const records = page.records || [];
total.value = Number(page.total || records.length || 0);
rows.value = (records as any[]).map((t) => mapTicket(t));
} catch (e: any) {
message.error(e?.message || "加载工单失败");
} finally {
loading.value = false;
}
}
let timer: ReturnType<typeof setTimeout> | null = null;
watch(
() => ({ ...filters }),
() => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => loadList(), 250);
},
{ deep: true },
);
function openDetail(row: TicketRow) {
onMounted(() => loadList());
async function openDetail(row: TicketRow) {
current.value = row;
detailOpen.value = true;
try {
const res: any = await getWorkOrderDetail(row.id);
const detail = res?.result || res || {};
const ticket = detail.ticket || detail;
current.value = mapTicket(ticket, detail.quotes || [], detail.attachments || []);
const idx = rows.value.findIndex((r) => r.id === row.id);
if (idx >= 0) rows.value[idx] = current.value;
} catch (e: any) {
message.error(e?.message || "加载详情失败");
}
}
function openClose(row: TicketRow) {
@@ -54,32 +138,47 @@ function openClose(row: TicketRow) {
closeOpen.value = true;
}
function confirmClose() {
async function confirmClose() {
if (!current.value) return;
current.value.status = "已关闭";
closeOpen.value = false;
message.success(`工单 ${current.value.id} 已关闭`);
try {
await transitionWorkOrder({
workOrderNo: current.value.id,
toStatus: "已关闭",
remark: closeReason.value.trim() || undefined,
});
closeOpen.value = false;
message.success(`工单 ${current.value.id} 已关闭`);
await loadList();
} catch (e: any) {
message.error(e?.message || "关单失败");
}
}
function retryTicket(row: TicketRow) {
Modal.confirm({
title: "重试系统任务?",
content: `确认对工单 ${row.id} 发起重试?`,
onOk() {
message.success(`已提交重试:${row.id}`);
async onOk() {
try {
await retryAbnormal({ workOrderNo: row.id });
message.success(`已提交重试:${row.id}`);
await loadList();
} catch (e: any) {
message.error(e?.message || "重试失败");
}
},
});
}
function refresh() {
message.success("已刷新");
loadList().then(() => message.success("已刷新"));
}
</script>
<template>
<Teleport defer to="#page-topbar-actions">
<div style="display: flex; align-items: center; gap: 12px">
<button type="button" class="btn btn-secondary">
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
<ProtoIcon name="download" :size="16" />导出工单
</button>
<button type="button" class="btn btn-primary" @click="refresh">
@@ -173,7 +272,13 @@ function refresh() {
</tr>
</thead>
<tbody>
<tr v-for="row in filtered" :key="row.id">
<tr v-if="loading">
<td colspan="14" style="text-align: center; color: #94a3b8">加载中…</td>
</tr>
<tr v-else-if="!rows.length">
<td colspan="14" style="text-align: center; color: #94a3b8">暂无工单</td>
</tr>
<tr v-for="row in rows" :key="row.id">
<td class="align-top ticket-cell col-sticky-left">
<button class="ticket-no" type="button" @click="openDetail(row)">
{{ row.id }}
@@ -205,10 +310,7 @@ function refresh() {
style="margin-bottom: 8px"
>
<div class="quote-source">{{ q.source }}</div>
<div
class="quote-price"
:style="q.highlight ? { color: '#1d4ed8' } : undefined"
>
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
{{ q.price }}
</div>
</div>
@@ -232,15 +334,11 @@ function refresh() {
<span class="ticket-margin-cell">{{ row.profit }}</span>
</td>
<td class="col-center">
<span v-if="row.eventException !== '-'" class="tag tag-rose">{{
row.eventException
}}</span>
<span v-if="row.eventException !== '-'" class="tag tag-rose">{{ row.eventException }}</span>
<span v-else style="color: #94a3b8">-</span>
</td>
<td class="align-top">
<span v-if="row.systemException !== '-'" class="tag tag-rose">{{
row.systemException
}}</span>
<span v-if="row.systemException !== '-'" class="tag tag-rose">{{ row.systemException }}</span>
<span v-else style="color: #94a3b8">-</span>
</td>
<td class="align-top">
@@ -269,15 +367,16 @@ function refresh() {
>
关闭工单
</button>
<button class="link-action" type="button" @click="openDetail(row)">
查看详情
</button>
<button class="link-action" type="button" @click="openDetail(row)">查看详情</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<span>共 <b style="color: #0f172a">{{ total }}</b> 条工单</span>
</div>
</div>
</div>
@@ -288,6 +387,7 @@ function refresh() {
<p><b>需求:</b>{{ current.demandTitle }}</p>
<p style="white-space: pre-line">{{ current.demandDetail }}</p>
<p><b>状态:</b>{{ current.status }}</p>
<p><b>报价日期:</b>{{ current.quoteDate }}</p>
<p><b>创建时间:</b>{{ current.createdAt }}</p>
</template>
</a-modal>
@@ -14,6 +14,7 @@ enum Api {
export const getWorkOrderList = (params) => defHttp.get({ url: Api.list, params });
export const getWorkOrderDetail = (workOrderNo: string) =>
defHttp.get({ url: Api.detail, params: { workOrderNo } });
export const transitionWorkOrder = (params) => defHttp.post({ url: Api.transition, params });
export const handleAbnormal = (params) => defHttp.post({ url: Api.abnormalHandle, params });
export const retryAbnormal = (params) => defHttp.post({ url: Api.abnormalRetry, params });
/** POST body:工单号 + 目标状态 + 可选备注 */
export const transitionWorkOrder = (data) => defHttp.post({ url: Api.transition, data });
export const handleAbnormal = (data) => defHttp.post({ url: Api.abnormalHandle, data });
export const retryAbnormal = (data) => defHttp.post({ url: Api.abnormalRetry, data });