后台按查看权限收紧菜单与「只看自己」数据范围;跨境整车报价填表更稳,协商中顺延不再误报销售超时。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-11 13:04:09 +08:00
co-authored by Cursor
parent ae818f26b1
commit b070a9b7a7
23 changed files with 1383 additions and 498 deletions
+6 -4
View File
@@ -24,10 +24,12 @@ export const getMenuList = () => {
};
/**
* 切换成vue3菜单
* 切换成 vue3 菜单 —— 询价库永久禁用。
* <p>
* Jeecg 原逻辑会把 sys_permission rename 成 v2 再切 v3 表;测服 inquiry_robot
* 菜单已是 Vue3 且常无 sys_permission_v3,执行会弄挂侧栏。前端不再发切表请求。
*/
export const switchVue3Menu = () => {
return new Promise((resolve) => {
defHttp.get({ url: Api.SwitchVue3Menu });
});
console.warn('[ytd] switchVue3Menu disabled: inquiry_robot menus are already Vue3');
return Promise.resolve({ success: false, message: 'switchVue3Menu disabled' });
};
+23 -3
View File
@@ -8,8 +8,10 @@
import { useRoute, useRouter, RouterView } from 'vue-router';
import { message, ConfigProvider } from 'ant-design-vue';
import { useUserStore } from '/@/store/modules/user';
import { usePermission } from '/@/hooks/web/usePermission';
import ProtoIcon from '/@/views/inquiry/components/ProtoIcon.vue';
import AdminModal from '/@/views/inquiry/components/AdminModal.vue';
import { canSeeInquiryPage } from '/@/views/inquiry/shared/inquiryPagePerms';
import '/@/views/inquiry/styles/admin-prototype.css';
import '/@/views/inquiry/styles/admin-fidelity.css';
import '/@/views/inquiry/styles/dashboard-prototype.css';
@@ -74,12 +76,26 @@
components: { RouterView, ProtoIcon, AdminModal, ConfigProvider },
setup() {
const userStore = useUserStore();
const { hasPermission } = usePermission();
const route = useRoute();
const router = useRouter();
const userMenuOpen = ref(false);
const profileOpen = ref(false);
const passwordOpen = ref(false);
/**
* 侧栏按查看权限码过滤;无权限的分组整组隐藏。
* 不再渲染写死全量 navGroups,避免「没权限仍看见菜单」。
*/
const visibleNavGroups = computed(() =>
navGroups
.map((g) => ({
...g,
items: g.items.filter((item) => canSeeInquiryPage(item.key, hasPermission)),
}))
.filter((g) => g.items.length > 0),
);
const pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台'));
const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : ''));
@@ -167,11 +183,15 @@
if (!t.closest('.admin-user-menu')) closeUserMenu();
}
onMounted(() => document.addEventListener('click', onDocClick));
onUnmounted(() => document.removeEventListener('click', onDocClick));
onMounted(() => {
document.addEventListener('click', onDocClick);
});
onUnmounted(() => {
document.removeEventListener('click', onDocClick);
});
return {
navGroups,
navGroups: visibleNavGroups,
themeToken,
route,
pageTitle,
+130 -16
View File
@@ -21,11 +21,12 @@ import { getMenuList } from '/@/api/sys/menu';
import { getPermCode } from '/@/api/sys/user';
import { PageEnum } from '/@/enums/pageEnum';
import { INQUIRY_PAGE_VIEW_PERMS } from '/@/views/inquiry/shared/inquiryPagePerms';
/**
* 询价原型侧栏路径是写死的;Jeecg BACK 模式只注册后台菜单里的路由。
* 菜单种子缺失/组件路径不对时,点侧栏会进 404(本机与线上相同)。
* 这里按侧栏清单补挂手写页,不依赖种子是否已导入。
* 询价手写页:纠正菜单种子里的组件路径/标题。
* 禁止把未授权页强行补进路由——否则 backMenuList 会膨胀成全量侧栏,无权限也看得见。
* 仅当「后台菜单已有该路径」或「用户已有对应查看权限码」时才补挂缺失叶子。
*/
type InquiryLeaf = {
path: string;
@@ -78,16 +79,65 @@ function findParent(list: AppRouteRecordRaw[] = [], parentPath: string): AppRout
return null;
}
function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
const groups: Record<string, { title: string; redirect: string; leaves: InquiryLeaf[] }> = {
/**
* 永久禁止 Jeecg「Vue2 菜单表」切表弹窗。
* <p>
* 检测条件:顶层既没有 component===layouts/default/index,图标也不含冒号。
* 非管理员若只分到缺标记的询价菜单,会被误判;点确认会 rename sys_permission,测服无 v3 表会弄挂侧栏。
* 本函数恒为 false,禁止再恢复弹窗逻辑。
*/
function shouldPromptVue2MenuSwitch(_routeList: AppRouteRecordRaw[]): boolean {
return false;
}
/**
* 给菜单补齐 Vue3 标记,避免未来误恢复 Jeecg 检测时再次弹窗。
* 副作用:就地改 component / meta.icon;不改业务权限。
*/
function normalizeVue3MenuMarkers(routeList: AppRouteRecordRaw[]): AppRouteRecordRaw[] {
for (const item of routeList || []) {
const comp = String((item as any).component || '');
// Jeecg 检测认 layouts/default/index;LAYOUT 也会被 transform 成询价壳
if (!comp || comp.toUpperCase() === 'LAYOUT') {
(item as any).component = 'layouts/default/index';
}
item.meta = item.meta || ({} as any);
const icon = String((item.meta as any).icon || '');
if (!icon) {
(item.meta as any).icon = 'ant-design:menu-outlined';
} else if (!icon.includes(':')) {
(item.meta as any).icon = `ant-design:${icon}`;
}
}
return routeList;
}
function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[], permCodes: Array<string | number> = []) {
const codeSet = new Set((permCodes || []).map(String));
const canAddLeaf = (leaf: InquiryLeaf) => {
// 后台菜单已下发该路径:只纠正组件,不算越权补挂
if (findRouteByPath(routeList, leaf.path)) {
return true;
}
const need = INQUIRY_PAGE_VIEW_PERMS[leaf.path];
// 无权限码表时不补挂(宁可少菜单,也不把未授权页塞进侧栏)
if (!need) {
return false;
}
return codeSet.has(need);
};
const groups: Record<string, { title: string; redirect: string; icon: string; leaves: InquiryLeaf[] }> = {
'/inquiry': {
title: '询价',
redirect: '/inquiry/dashboard',
icon: 'ant-design:robot-outlined',
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry/') && !l.path.startsWith('/inquiry-sys')),
},
'/inquiry-sys': {
title: '询价系统配置',
redirect: '/inquiry-sys/system-setting',
icon: 'ant-design:setting-outlined',
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry-sys/')),
},
};
@@ -105,6 +155,10 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true };
continue;
}
// 角色未授权且无对应查看权限:禁止补挂(根因:曾把 8 个询价页全塞进 backMenuList)
if (!canAddLeaf(leaf)) {
continue;
}
const node: AppRouteRecordRaw = {
path: leaf.path,
name: leaf.name,
@@ -119,12 +173,23 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
parent = {
path: parentPath,
name: parentPath.replace(/\//g, '-').replace(/^-/, '') || 'inquiry-root',
component: 'LAYOUT' as any,
// 必须用 layouts/default/index + 带冒号图标,满足 Jeecg Vue3 菜单检测(实际仍映射到询价壳)
component: 'layouts/default/index' as any,
redirect: group.redirect,
meta: { title: group.title },
meta: { title: group.title, icon: group.icon },
children: [],
};
routeList.push(parent);
} else {
// 已有父级也补标记,防止非管理员菜单缺图标被误判 Vue2
const pComp = String((parent as any).component || '');
if (!pComp || pComp.toUpperCase() === 'LAYOUT') {
(parent as any).component = 'layouts/default/index';
}
parent.meta = {
...(parent.meta || {}),
icon: (parent.meta as any)?.icon || group.icon,
};
}
parent.children = parent.children || [];
if (leaf.path === group.redirect) {
@@ -137,6 +202,49 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
return routeList;
}
/**
* 去掉无查看权限的询价叶子(角色树勾了菜单但没勾按钮、或历史脏数据)。
* 父节点若子级删空则一并去掉,避免侧栏空分组。
*/
function pruneInquiryLeavesWithoutViewPerm(
routeList: AppRouteRecordRaw[],
permCodes: Array<string | number> = [],
): AppRouteRecordRaw[] {
const codeSet = new Set((permCodes || []).map(String));
// 权限码尚未就绪时禁止裁剪:否则会把 getMenuList 结果清空 → 重登 404
if (codeSet.size === 0) {
return routeList;
}
const norm = (p: string) => {
const s = String(p || '');
return s.startsWith('/') ? s : `/${s}`;
};
const walk = (list: AppRouteRecordRaw[] = []): AppRouteRecordRaw[] => {
const out: AppRouteRecordRaw[] = [];
for (const item of list) {
const path = norm(item.path || '');
const need = INQUIRY_PAGE_VIEW_PERMS[path];
if (need && !codeSet.has(need)) {
continue;
}
const next = { ...item };
if (next.children?.length) {
next.children = walk(next.children as AppRouteRecordRaw[]);
// 询价分组下无子页则不保留空壳
if (
(path === '/inquiry' || path === '/inquiry-sys') &&
(!next.children || next.children.length === 0)
) {
continue;
}
}
out.push(next);
}
return out;
};
return walk(routeList);
}
// 系统权限
interface AuthItem {
@@ -323,21 +431,27 @@ export const usePermissionStore = defineStore({
// 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置
let routeList: AppRouteRecordRaw[] = [];
try {
this.changePermissionCode();
// 必须 await:logout 会清空 permCodeList;若此处不候等,后面 prune 会在空权限码下
// 把询价叶子全删掉 → 侧栏仍能靠稍后到的权限码显示,点进去却是 404。
await this.changePermissionCode();
routeList = (await getMenuList()) as AppRouteRecordRaw[];
// Jeecg 原逻辑:顶层没有 layouts/default/index 且图标无冒号 → 弹窗并调用 /sys/switchVue3Menu
// (把 sys_permission rename 成 v2,再把 sys_permission_v3 rename 成正式表)。
// 询价库 inquiry_robot 菜单种子已是 Vue3,且测服没有 sys_permission_v3;点确认会弄挂侧栏。
// 因此禁用自动切表弹窗;后面仍会 ensureInquiryHandwrittenRoutes 补齐询价页。
// update-begin----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
// update-end----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
// 永久禁止 Vue2→Vue3 切表弹窗(shouldPromptVue2MenuSwitch 恒 false)
if (shouldPromptVue2MenuSwitch(routeList)) {
// 不可达;保留分支防止以后误合并 Jeecg 原弹窗代码
}
// 非管理员菜单常缺 Vue3 标记 → 误弹「菜单表是 Vue2」;先规范化再往下走
routeList = normalizeVue3MenuMarkers(routeList || []);
} catch (error) {
console.error(error);
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
routeList = Array.isArray(routeList) ? routeList : [];
}
// 无论菜单接口成功与否,都按侧栏清单补齐手写路由
routeList = ensureInquiryHandwrittenRoutes(routeList || []);
// 只纠正/补挂「已有菜单或已有查看权限」的手写页,禁止灌入未授权路由
const permCodes = this.getPermCodeList || [];
routeList = ensureInquiryHandwrittenRoutes(routeList || [], permCodes);
routeList = pruneInquiryLeavesWithoutViewPerm(routeList, permCodes);
// 补挂后再标一次(ensure 新建的父级也要带 Vue3 标记)
routeList = normalizeVue3MenuMarkers(routeList);
// 组件地址前加斜杠处理 author: lsq date:2021-09-08
routeList = addSlashToRouteComponent(routeList);
// 动态引入组件
@@ -19,6 +19,12 @@ import {
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
import { usePermission } from "/@/hooks/web/usePermission";
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
const { hasPermission } = usePermission();
/** 账号新建/编辑/删除/重置密码均属 edit 权限 */
const canEditAccount = () => hasPermission(InquiryBtnPerm.accountEdit);
const rows = ref<AccountRow[]>([]);
const loading = ref(false);
@@ -299,7 +305,7 @@ function removeAccount(row: AccountRow) {
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
<ProtoIcon name="download" :size="14" />导出
</button>
<button type="button" class="btn btn-primary" @click="openCreate">
<button v-if="canEditAccount()" type="button" class="btn btn-primary" @click="openCreate">
<ProtoIcon name="plus" :size="14" />新建账号
</button>
</div>
@@ -369,7 +375,7 @@ function removeAccount(row: AccountRow) {
<td>
<div class="account-row-actions">
<button
v-if="!row.isSuperAdmin"
v-if="canEditAccount() && !row.isSuperAdmin"
class="link-action"
type="button"
@click="openEdit(row)"
@@ -378,6 +384,7 @@ function removeAccount(row: AccountRow) {
</button>
<!-- 超管只隐藏编辑/删除;重置密码仍可用。不展示「不可编辑」占位文案 -->
<button
v-if="canEditAccount()"
class="link-action link-muted"
type="button"
@click="resetPassword(row)"
@@ -385,7 +392,7 @@ function removeAccount(row: AccountRow) {
重置密码
</button>
<button
v-if="!row.isSuperAdmin"
v-if="canEditAccount() && !row.isSuperAdmin"
class="link-action link-danger"
type="button"
@click="removeAccount(row)"
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
/**
* 机器人回复语:对接主账 /inquiry/botReply。
* 分类壳用本地元数据;条目内容全部来自库。
@@ -19,6 +19,13 @@ import {
} from "/@/views/inquiry/botReply/replyCategories";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import { usePermission } from "/@/hooks/web/usePermission";
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
const { hasPermission } = usePermission();
const canAdd = () => hasPermission(InquiryBtnPerm.replyAdd);
const canEdit = () => hasPermission(InquiryBtnPerm.replyEdit);
const canDisable = () => hasPermission(InquiryBtnPerm.replyDisable);
const categories = ref<ReplyCategory[]>(emptyCategories());
const loading = ref(false);
@@ -152,7 +159,7 @@ async function save() {
<template>
<Teleport defer to="#page-topbar-actions">
<button type="button" class="btn btn-primary" @click="openCreate()">
<button v-if="canAdd()" type="button" class="btn btn-primary" @click="openCreate()">
<ProtoIcon name="plus" :size="16" />添加话术
</button>
</Teleport>
@@ -177,13 +184,21 @@ async function save() {
</div>
<div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0">
<button
v-if="canDisable()"
type="button"
class="switch"
:class="{ 'is-on': item.enabled }"
:aria-label="item.enabled ? '停用' : '启用'"
@click="toggle(item)"
/>
<button type="button" class="link-action" @click="openEdit(cat.id, item)">编辑</button>
<button
v-if="canEdit()"
type="button"
class="link-action"
@click="openEdit(cat.id, item)"
>
编辑
</button>
</div>
</div>
</article>
@@ -26,6 +26,11 @@ import {
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import { joinPermissionIds } from "/@/views/inquiry/role/rolePermission";
import { usePermission } from "/@/hooks/web/usePermission";
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
const { hasPermission } = usePermission();
const canEditRole = () => hasPermission(InquiryBtnPerm.roleEdit);
const rows = ref<RoleRow[]>([]);
const loading = ref(false);
@@ -250,7 +255,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
<template>
<Teleport defer to="#page-topbar-actions">
<button type="button" class="btn btn-primary" @click="openCreate">
<button v-if="canEditRole()" type="button" class="btn btn-primary" @click="openCreate">
<ProtoIcon name="plus" :size="14" />新增角色
</button>
</Teleport>
@@ -295,7 +300,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
</span>
</td>
<td>
<template v-if="row.editable">
<template v-if="canEditRole() && row.editable">
<div class="role-row-actions">
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
<button class="link-action link-danger" type="button" @click="removeRole(row)">
@@ -0,0 +1,60 @@
/**
* 询价后台页面级权限码:侧栏显隐与「查看」按钮同源。
* <p>
* 为何:侧栏曾写死全量菜单,且 ensureInquiryHandwrittenRoutes 会把未授权页补进路由,
* 导致无 reply:list 等权限的人仍能看到「机器人回复语」。以 Jeecg 权限码为准过滤。
*/
/** 侧栏路径 → 进入该页至少需要的查看类权限码 */
export const INQUIRY_PAGE_VIEW_PERMS: Record<string, string> = {
'/inquiry/dashboard': 'inquiry:dashboard:view',
'/inquiry/work-order': 'inquiry:workorder:list',
'/inquiry/staff-role': 'inquiry:staff:list',
'/inquiry/template': 'inquiry:template:view',
'/inquiry/bot-reply': 'inquiry:reply:list',
'/inquiry-sys/system-setting': 'inquiry:timeout:list',
'/inquiry-sys/user': 'inquiry:account:view',
'/inquiry-sys/role': 'inquiry:role:view',
};
/** 按钮细粒度权限码(与 sys_permission / @RequiresPermissions 对齐) */
export const InquiryBtnPerm = {
workorderList: 'inquiry:workorder:list',
workorderDetail: 'inquiry:workorder:detail',
workorderTransition: 'inquiry:workorder:transition',
workorderExport: 'inquiry:workorder:export',
replyList: 'inquiry:reply:list',
replyAdd: 'inquiry:reply:add',
replyEdit: 'inquiry:reply:edit',
replyDisable: 'inquiry:reply:disable',
staffList: 'inquiry:staff:list',
staffAdd: 'inquiry:staff:add',
staffEdit: 'inquiry:staff:edit',
staffDisable: 'inquiry:staff:disable',
templateView: 'inquiry:template:view',
templateUpload: 'inquiry:template:upload',
templateEdit: 'inquiry:template:edit',
templateToggle: 'inquiry:template:toggle',
accountView: 'inquiry:account:view',
accountEdit: 'inquiry:account:edit',
roleView: 'inquiry:role:view',
roleEdit: 'inquiry:role:edit',
timeoutList: 'inquiry:timeout:list',
timeoutEdit: 'inquiry:timeout:edit',
} as const;
/**
* 当前权限码是否允许进入某侧栏路径。
* @param path 侧栏 key,如 /inquiry/bot-reply
* @param hasPerm hasPermission 回调
*/
export function canSeeInquiryPage(
path: string,
hasPerm: (code: string) => boolean,
): boolean {
const code = INQUIRY_PAGE_VIEW_PERMS[path];
if (!code) {
return true;
}
return hasPerm(code);
}
@@ -25,6 +25,13 @@ import {
dataPermTitle,
normalizeStaffRouteKeys,
} from "/@/views/inquiry/shared/dataPermCatalog";
import { usePermission } from "/@/hooks/web/usePermission";
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
const { hasPermission } = usePermission();
const canAddStaff = () => hasPermission(InquiryBtnPerm.staffAdd);
const canEditStaff = () => hasPermission(InquiryBtnPerm.staffEdit);
const canDisableStaff = () => hasPermission(InquiryBtnPerm.staffDisable);
type StaffRole = "sales" | "air" | "sea" | "land";
@@ -485,10 +492,16 @@ function filterRouteOption(input: string, option: any) {
<template>
<Teleport defer to="#page-topbar-actions">
<div class="staff-topbar-actions">
<button type="button" class="btn btn-secondary" :disabled="syncing" @click="syncWecom">
<button
v-if="canEditStaff()"
type="button"
class="btn btn-secondary"
:disabled="syncing"
@click="syncWecom"
>
<ProtoIcon name="refresh" :size="16" />{{ syncing ? "同步中…" : "同步企业微信" }}
</button>
<button type="button" class="btn btn-primary" @click="openCreate">
<button v-if="canAddStaff()" type="button" class="btn btn-primary" @click="openCreate">
<ProtoIcon name="userPlus" :size="16" />新增员工
</button>
</div>
@@ -586,9 +599,16 @@ function filterRouteOption(input: string, option: any) {
</td>
<td>
<div style="display: flex; align-items: center; gap: 12px">
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
<button
v-if="row.status === 'active'"
v-if="canEditStaff()"
class="link-action"
type="button"
@click="openEdit(row)"
>
编辑
</button>
<button
v-if="canDisableStaff() && row.status === 'active'"
class="link-action link-danger"
type="button"
@click="removeStaff(row)"
@@ -13,6 +13,11 @@ import {
getTimeoutListAll,
saveTimeoutHours,
} from '/@/views/inquiry/systemSetting/systemSetting.api';
import { usePermission } from '/@/hooks/web/usePermission';
import { InquiryBtnPerm } from '/@/views/inquiry/shared/inquiryPagePerms';
const { hasPermission } = usePermission();
const canEditTimeout = () => hasPermission(InquiryBtnPerm.timeoutEdit);
/** 分块顺序:海运 → 空运 → 陆运(产品确认同页分块) */
const MODE_BLOCKS = [
@@ -153,7 +158,7 @@ async function commitHours(row) {
min="0"
step="0.01"
:value="draftHours[row.id]"
:disabled="savingId === row.id"
:disabled="!canEditTimeout() || savingId === row.id"
@input="onHoursInput(row.id, $event.target.value)"
@blur="commitHours(row)"
@keydown.enter.prevent="commitHours(row)"
@@ -27,6 +27,13 @@ import {
summarizeMappingParse,
type MappingParseSummary,
} from "/@/views/inquiry/template/previewSample";
import { usePermission } from "/@/hooks/web/usePermission";
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
const { hasPermission } = usePermission();
const canUploadTemplate = () => hasPermission(InquiryBtnPerm.templateUpload);
const canEditTemplate = () => hasPermission(InquiryBtnPerm.templateEdit);
const canToggleTemplate = () => hasPermission(InquiryBtnPerm.templateToggle);
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
@@ -683,7 +690,12 @@ async function downloadPreviewOrOriginal() {
<template>
<!-- 唯一上传入口:顶栏 -->
<Teleport defer to="#page-topbar-actions">
<button type="button" class="btn btn-primary" @click.stop.prevent="openCreate">
<button
v-if="canUploadTemplate()"
type="button"
class="btn btn-primary"
@click.stop.prevent="openCreate"
>
<ProtoIcon name="upload" :size="16" />上传模板
</button>
</Teleport>
@@ -773,8 +785,16 @@ async function downloadPreviewOrOriginal() {
<button type="button" class="link-action link-muted" @click.stop="openPreview(row)">
预览
</button>
<button type="button" class="link-action" @click.stop="openEdit(row)">编辑</button>
<button
v-if="canEditTemplate()"
type="button"
class="link-action"
@click.stop="openEdit(row)"
>
编辑
</button>
<button
v-if="canEditTemplate()"
type="button"
class="link-action"
:disabled="row.parseStatus === '解析中'"
@@ -788,6 +808,7 @@ async function downloadPreviewOrOriginal() {
重新解析
</button>
<button
v-if="canToggleTemplate()"
type="button"
class="link-action"
:class="row.status === 'enabled' ? 'link-danger' : ''"
@@ -795,7 +816,12 @@ async function downloadPreviewOrOriginal() {
>
{{ row.status === "enabled" ? "禁用" : "启用" }}
</button>
<button type="button" class="link-action link-danger" @click.stop="removeTemplate(row)">
<button
v-if="canEditTemplate()"
type="button"
class="link-action link-danger"
@click.stop="removeTemplate(row)"
>
删除
</button>
</div>
@@ -22,6 +22,23 @@ import {
quantitySummary,
type DemandField,
} from "/@/views/inquiry/workOrder/demandFields";
import { usePermission } from "/@/hooks/web/usePermission";
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
const { hasPermission } = usePermission();
const canCloseTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
const canRetryTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
const canViewDetail = () =>
hasPermission(InquiryBtnPerm.workorderDetail) || hasPermission(InquiryBtnPerm.workorderList);
/**
* 与主账 TicketStatuses 一致:仅询价中/已报价/协商中/转人工可关到「已关闭」。
* 已成交/未成交/已关闭为终态,禁止再显示「关闭工单」(否则点确认会报不允许流转)。
*/
const CLOSEABLE_STATUSES = new Set(["询价中", "已报价", "协商中", "转人工"]);
function canShowCloseForStatus(status: string) {
return canCloseTicket() && CLOSEABLE_STATUSES.has(String(status || "").trim());
}
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
type TicketQuoteFee = { name: string; amount: string };
@@ -615,6 +632,10 @@ async function openDetail(row: TicketRow) {
}
function openClose(row: TicketRow) {
if (!canShowCloseForStatus(row.status)) {
message.warning(`「${row.status}」为终态,不能关闭工单`);
return;
}
current.value = row;
closeReason.value = "";
closeOpen.value = true;
@@ -903,7 +924,7 @@ function refresh() {
<td class="align-top col-actions col-sticky-right">
<div class="ticket-row-actions">
<button
v-if="row.systemException !== '-' && row.status !== '已关闭'"
v-if="canRetryTicket() && row.systemException !== '-' && row.status !== '已关闭'"
class="link-action"
type="button"
@click="retryTicket(row)"
@@ -911,14 +932,21 @@ function refresh() {
重试
</button>
<button
v-if="row.status !== '已关闭'"
v-if="canShowCloseForStatus(row.status)"
class="link-action"
type="button"
@click="openClose(row)"
>
关闭工单
</button>
<button class="link-action" type="button" @click="openDetail(row)">查看详情</button>
<button
v-if="canViewDetail()"
class="link-action"
type="button"
@click="openDetail(row)"
>
查看详情
</button>
</div>
</td>
</tr>
@@ -946,7 +974,12 @@ function refresh() {
<template #titleExtra>
<span v-if="current" class="ticket-detail-no">{{ current.id }}</span>
<button
v-if="current && current.systemException !== '-' && current.status !== '已关闭'"
v-if="
canRetryTicket() &&
current &&
current.systemException !== '-' &&
current.status !== '已关闭'
"
class="link-action"
type="button"
@click="retryTicket(current)"