落地报价模板主账与后台:合同映射、灌种 SQL/脚本、智能体填表,并补测服运维探针与合同文档对齐。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user