完善报价单填表与币种回填,并补齐账号末次登录、角色按钮权限与陆运全线匹配。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-19 22:03:45 +08:00
co-authored by Cursor
parent 1a2e1d6cb2
commit e4743541f7
25 changed files with 2262 additions and 163 deletions
@@ -43,6 +43,19 @@ export function roleTagFor(index: number) {
return ROLE_TAG_CYCLE[index % ROLE_TAG_CYCLE.length];
}
/**
* 最后登录展示为 `yyyy-MM-dd HH:mm:ss`;无记录显示 —。
* 兼容接口偶发 ISO(带 T)或已格式化字符串。
*/
export function formatLastLogin(raw: unknown): string {
if (raw == null || raw === '') return '—';
const s = String(raw).trim();
if (!s) return '—';
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2}:\d{2})/);
if (m) return `${m[1]} ${m[2]}`;
return s;
}
/** username=admin 或角色码/名含超级管理员 → 内置超管 */
export function isSuperAdminUser(u: { username?: string; roleLabel?: string; roleCode?: string }) {
const name = String(u.username || '').trim().toLowerCase();
@@ -71,7 +84,8 @@ export function mapUserToAccount(
roleLabel: superAdmin ? '超级管理员' : roleLabel,
roleTag: superAdmin ? 'tag-violet' : roleTag,
phoneMasked: u.phone || '未填写',
lastLogin: u.lastLoginTime || u.createTime || '—',
// 只用真实最后登录;禁止回退 createTime(Jeecg 种子常为 2019,会误导成「最后登录」)
lastLogin: formatLastLogin(u.lastLoginTime),
status: Number(u.status) === 1 ? 'active' : 'disabled',
dashboardSelfOnly: Boolean(u.dashboardSelfOnly === true || u.dashboardSelfOnly === 1),
isSuperAdmin: superAdmin,
@@ -361,9 +361,8 @@ function removeAccount(row: AccountRow) {
>
编辑
</button>
<span v-else class="account-locked" title="超级管理员不可在后台编辑">不可编辑</span>
<!-- 超管只隐藏编辑/删除;重置密码仍可用。不展示「不可编辑」占位文案 -->
<button
v-if="!row.isSuperAdmin"
class="link-action link-muted"
type="button"
@click="resetPassword(row)"
@@ -520,11 +519,6 @@ function removeAccount(row: AccountRow) {
gap: 10px;
}
.account-locked {
color: #94a3b8;
font-size: 12px;
}
.account-self-only {
display: flex;
flex-wrap: wrap;
@@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* 角色管理:对接 Jeecg /sys/role 与菜单权限树。
* 数据权限用线路标签写入角色 description,避免伪造第二套 RBAC。
* 数据权限按 Excel 线路目录树勾选,写入角色 description 的 [dataPerms:…],不另造 RBAC。
*/
import { computed, onMounted, reactive, ref } from "vue";
import { message, Modal } from "ant-design-vue";
@@ -15,6 +15,14 @@ import {
saveRolePermission,
type RoleRow,
} from "/@/views/inquiry/role/role.api";
import {
DATA_PERM_TREE,
SYSTEM_EXCEPTION_NOTIFY_KEY,
SYSTEM_EXCEPTION_NOTIFY_TITLE,
dataPermTitle,
leafDataPermKeys,
normalizeDataPermKeys,
} from "/@/views/inquiry/shared/dataPermCatalog";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
@@ -30,19 +38,12 @@ const form = reactive({
status: "active" as RoleRow["status"],
checkedKeys: [] as string[],
dataPerms: [] as string[],
/** 旧后台独立项:系统异常消息通知 */
systemExceptionNotify: false,
});
const dataOptions = [
"空运·全部线路",
"空运·CGO-KUL",
"空运·HKG-KUL",
"海运·全部线路",
"海运·海运全线",
"陆运·全部线路",
"陆运·中港/中亚/中欧",
"陆运·东南亚",
"系统异常·消息通知",
];
const dataPermTree = DATA_PERM_TREE;
const systemExceptionNotifyTitle = SYSTEM_EXCEPTION_NOTIFY_TITLE;
const modalTitle = computed(() => (editing.value ? "编辑角色" : "新增角色"));
const modalOkText = computed(() => "保存");
@@ -68,15 +69,29 @@ 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);
return normalizeDataPermKeys(
m[1]
.split("|")
.map((s) => s.trim())
.filter(Boolean),
);
}
function buildDescription(dataPerms: string[]) {
if (!dataPerms.length) return "";
return `[dataPerms:${dataPerms.join("|")}]`;
function buildDescription(dataPerms: string[], systemExceptionNotify: boolean) {
const keys = leafDataPermKeys(dataPerms);
if (systemExceptionNotify && !keys.includes(SYSTEM_EXCEPTION_NOTIFY_KEY)) {
keys.push(SYSTEM_EXCEPTION_NOTIFY_KEY);
}
if (!keys.length) return "";
return `[dataPerms:${keys.join("|")}]`;
}
function displayDataPerms(keys: string[]): string[] {
return keys.map((k) => dataPermTitle(k));
}
function routeDataPermsOnly(keys: string[]): string[] {
return keys.filter((k) => k !== SYSTEM_EXCEPTION_NOTIFY_KEY);
}
async function loadMenuTree() {
@@ -143,18 +158,21 @@ function openCreate() {
status: "active",
checkedKeys: [],
dataPerms: [],
systemExceptionNotify: false,
});
modalOpen.value = true;
}
async function openEdit(row: RoleRow) {
editing.value = row;
const allPerms = [...row.dataPerms];
Object.assign(form, {
name: row.name,
roleCode: row.roleCode,
status: row.status,
checkedKeys: [] as string[],
dataPerms: [...row.dataPerms],
dataPerms: routeDataPermsOnly(allPerms),
systemExceptionNotify: allPerms.includes(SYSTEM_EXCEPTION_NOTIFY_KEY),
});
try {
const perms: any = await queryRolePermission({ roleId: row.id });
@@ -179,7 +197,7 @@ async function save() {
id: editing.value?.id,
roleName: form.name.trim(),
roleCode,
description: buildDescription(form.dataPerms),
description: buildDescription(form.dataPerms, form.systemExceptionNotify),
},
Boolean(editing.value),
);
@@ -214,6 +232,12 @@ function removeRole(row: RoleRow) {
},
});
}
/** a-tree 可能回传对象或数组;统一成叶子 key 列表 */
function onDataPermCheck(checked: string[] | { checked: string[] }) {
const keys = Array.isArray(checked) ? checked : checked?.checked || [];
form.dataPerms = leafDataPermKeys(keys.map(String));
}
</script>
<template>
@@ -252,7 +276,7 @@ function removeRole(row: RoleRow) {
</td>
<td>
<div class="role-perm-summary">
<span v-for="d in row.dataPerms" :key="d" class="tag">{{ d }}</span>
<span v-for="d in displayDataPerms(row.dataPerms)" :key="d" class="tag">{{ d }}</span>
<span v-if="!row.dataPerms.length" style="color: #94a3b8">—</span>
</div>
</td>
@@ -306,14 +330,20 @@ function removeRole(row: RoleRow) {
<div class="admin-modal-field">
<label class="admin-modal-label">数据权限</label>
<a-select
v-model:value="form.dataPerms"
mode="multiple"
style="width: 100%"
:options="dataOptions.map((d) => ({ value: d, label: d }))"
placeholder="选择线路 / 通知范围"
:max-tag-count="3"
/>
<div class="perm-tree-box">
<a-tree
:checkedKeys="form.dataPerms"
checkable
default-expand-all
:tree-data="dataPermTree"
:field-names="{ title: 'title', key: 'key', children: 'children' }"
@check="onDataPermCheck"
/>
</div>
<label class="notify-check">
<input v-model="form.systemExceptionNotify" type="checkbox" />
{{ systemExceptionNotifyTitle }}
</label>
</div>
<div class="admin-modal-field">
@@ -340,4 +370,14 @@ function removeRole(row: RoleRow) {
align-items: center;
gap: 10px;
}
.notify-check {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
font-size: 13px;
color: #0f172a;
cursor: pointer;
}
</style>
@@ -0,0 +1,178 @@
/**
* 运营后台数据权限 / 员工负责线路 单一目录。
*
* 来源:《询价智能体运营后台数据权限下拉选项.xlsx》(2026-09)。
* key = 写入 sys_role.description / inq_staff_role.routes 的稳定码;
* title = 界面展示文案。
*/
export type DataPermNode = {
key: string;
title: string;
children?: DataPermNode[];
};
/** 空运具体线路(不含全线) */
export const AIR_ROUTE_ITEMS: { key: string; title: string }[] = [
{ key: "CGO-KUL", title: "郑州-吉隆坡(CGO-KUL)" },
{ key: "SZX-KUL", title: "深圳-吉隆坡(SZX-KUL)" },
{ key: "HKG-KUL", title: "香港-吉隆坡(HKG-KUL)" },
{ key: "NKG-KUL", title: "南京-吉隆坡(NKG-KUL)" },
{ key: "HKG-DEL", title: "香港-新德里(HKG-DEL)" },
{ key: "CGO-DEL", title: "郑州-新德里(CGO-DEL)" },
{ key: "CGO-BLR", title: "郑州-班加罗尔(CGO-BLR)" },
{ key: "CGO-MAA", title: "郑州-金奈(CGO-MAA)" },
{ key: "CTU-MAA", title: "成都-金奈(CTU-MAA)" },
{ key: "CKG-BOM", title: "重庆-孟买(CKG-BOM)" },
{ key: "ZUH-CRK", title: "珠海-克拉克(ZUH-CRK)" },
{ key: "SZX-CRK", title: "深圳-克拉克(SZX-CRK)" },
{ key: "EHU-ORD", title: "鄂州-芝加哥(EHU-ORD)" },
{ key: "WNZ-CGK", title: "温州-雅加达(WNZ-CGK)" },
{ key: "国内-曼谷(BKK 外采)", title: "国内-曼谷(BKK 外采)" },
{ key: "国外-国外(外采-空运)", title: "国外-国外(外采-空运)" },
{ key: "其他线路和快递双清包税(外采-空运)", title: "其他线路和快递双清包税(外采-空运)" },
];
export const LAND_ROUTE_ITEMS: { key: string; title: string }[] = [
{ key: "中港/中亚/中欧", title: "中港/中亚/中欧" },
{ key: "国内长途/零担", title: "国内长途/零担" },
{ key: "国内城配/拖车/打包", title: "国内城配/拖车/打包" },
{ key: "东南亚", title: "东南亚" },
];
/** 角色弹窗数据权限树(父节点仅作分组,不写入 description;文案对齐旧后台「空运数据」) */
export const DATA_PERM_TREE: DataPermNode[] = [
{
key: "group:air",
title: "空运数据",
children: [{ key: "空运全线", title: "空运全线" }, ...AIR_ROUTE_ITEMS],
},
{
key: "group:sea",
title: "海运数据",
children: [{ key: "海运全线", title: "海运全线" }],
},
{
key: "group:land",
title: "陆运数据",
children: [{ key: "陆运全线", title: "陆运全线" }, ...LAND_ROUTE_ITEMS],
},
];
/** 旧后台独立勾选项:系统异常消息通知(企微) */
export const SYSTEM_EXCEPTION_NOTIFY_KEY = "系统异常·消息通知";
export const SYSTEM_EXCEPTION_NOTIFY_TITLE = "系统异常消息通知(企微)";
/** 员工岗位 → 可选负责线路 key(与角色数据权限同源) */
export const ROUTES_BY_ROLE: Record<"air" | "sea" | "land", string[]> = {
air: ["空运全线", ...AIR_ROUTE_ITEMS.map((x) => x.key)],
sea: ["海运全线"],
land: ["陆运全线", ...LAND_ROUTE_ITEMS.map((x) => x.key)],
};
const TITLE_BY_KEY: Record<string, string> = (() => {
const m: Record<string, string> = {
空运全线: "空运全线",
海运全线: "海运全线",
陆运全线: "陆运全线",
[SYSTEM_EXCEPTION_NOTIFY_KEY]: SYSTEM_EXCEPTION_NOTIFY_TITLE,
};
for (const x of AIR_ROUTE_ITEMS) m[x.key] = x.title;
for (const x of LAND_ROUTE_ITEMS) m[x.key] = x.title;
return m;
})();
/** 已知稳定码集合(不含分组父节点) */
export const KNOWN_DATA_PERM_KEYS = new Set(Object.keys(TITLE_BY_KEY));
/**
* 旧写法 → 新稳定码。
* 无法识别时返回 trim 后原文(可能仍展示,但不进勾选树)。
*/
const ALIAS_TO_KEY: Record<string, string> = {
空运全线: "空运全线",
空运全部线路: "空运全线",
"空运·全部线路": "空运全线",
"空运·全线": "空运全线",
全线: "空运全线",
海运全线: "海运全线",
"海运·全部线路": "海运全线",
"海运·海运全线": "海运全线",
陆运全线: "陆运全线",
"陆运·全部线路": "陆运全线",
"陆运·中港/中亚/中欧": "中港/中亚/中欧",
"陆运·国内长途/零担": "国内长途/零担",
"陆运·国内城配/拖车/打包": "国内城配/拖车/打包",
"陆运·东南亚": "东南亚",
BKK: "国内-曼谷(BKK 外采)",
"外采-空运": "国外-国外(外采-空运)",
[SYSTEM_EXCEPTION_NOTIFY_KEY]: SYSTEM_EXCEPTION_NOTIFY_KEY,
系统异常消息通知: SYSTEM_EXCEPTION_NOTIFY_KEY,
"系统异常消息通知(企微)": SYSTEM_EXCEPTION_NOTIFY_KEY,
};
for (const item of AIR_ROUTE_ITEMS) {
ALIAS_TO_KEY[item.key] = item.key;
ALIAS_TO_KEY[item.title] = item.key;
ALIAS_TO_KEY[`空运·${item.key}`] = item.key;
}
for (const item of LAND_ROUTE_ITEMS) {
ALIAS_TO_KEY[item.key] = item.key;
ALIAS_TO_KEY[`陆运·${item.key}`] = item.key;
}
export function normalizeDataPermKey(raw: string): string {
const s = String(raw || "").trim();
if (!s) return "";
if (ALIAS_TO_KEY[s]) return ALIAS_TO_KEY[s];
// 空运·XXX / 陆运·XXX 兜底剥前缀
const m = s.match(/^(?:空运|海运|陆运)[·•](.+)$/);
if (m) {
const inner = m[1].trim();
if (ALIAS_TO_KEY[inner]) return ALIAS_TO_KEY[inner];
if (inner === "全部线路" || inner === "全线") {
if (s.startsWith("空运")) return "空运全线";
if (s.startsWith("海运")) return "海运全线";
if (s.startsWith("陆运")) return "陆运全线";
}
if (KNOWN_DATA_PERM_KEYS.has(inner)) return inner;
}
return s;
}
/** 批量归一;去重保序;只保留已知码(角色勾选回显用) */
export function normalizeDataPermKeys(rawList: string[]): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of rawList || []) {
const k = normalizeDataPermKey(raw);
if (!k || !KNOWN_DATA_PERM_KEYS.has(k) || seen.has(k)) continue;
seen.add(k);
out.push(k);
}
return out;
}
/** 员工 routes:归一后保留已知码;未知旧值也保留以免丢数据 */
export function normalizeStaffRouteKeys(rawList: string[]): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of rawList || []) {
const k = normalizeDataPermKey(raw);
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(k);
}
return out;
}
export function dataPermTitle(key: string): string {
const k = normalizeDataPermKey(key);
return TITLE_BY_KEY[k] || key;
}
/** Ant Design Tree 用:过滤掉分组父 key,只交叶子 */
export function leafDataPermKeys(checked: string[]): string[] {
return (checked || []).filter((k) => KNOWN_DATA_PERM_KEYS.has(k));
}
@@ -20,6 +20,11 @@ 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 { joinRoutes, parseRouteList } from "/@/views/inquiry/staffRole/staffRoutes";
import {
ROUTES_BY_ROLE,
dataPermTitle,
normalizeStaffRouteKeys,
} from "/@/views/inquiry/shared/dataPermCatalog";
type StaffRole = "sales" | "air" | "sea" | "land";
@@ -52,26 +57,7 @@ const roleLabelMap: Record<StaffRole, string> = {
land: "产品·陆运",
};
/** 按岗位的负责线路选项(与原型 admin-staff.html ROUTES_BY_ROLE 一致) */
const ROUTES_BY_ROLE: Record<Exclude<StaffRole, "sales">, string[]> = {
air: [
"CGO-KUL",
"SZX-KUL",
"HKG-KUL",
"NKG-KUL",
"HKG-DEL",
"CGO-MAA",
"CGO-BLR",
"ZUH-CRK",
"WNZ-CGK",
"BKK",
"外采-空运",
"全线",
],
sea: ["海运全线"],
land: ["中港/中亚/中欧", "国内长途/零担", "国内城配/拖车/打包", "东南亚"],
};
/** 负责线路与角色数据权限同源(Excel《数据权限下拉选项》) */
const ALL_ROUTE_FILTER_OPTIONS = [
...ROUTES_BY_ROLE.air,
...ROUTES_BY_ROLE.sea,
@@ -120,8 +106,11 @@ const form = reactive({
/** 当前岗位可选线路;销售为空 */
const routeOptions = computed(() => {
if (!isProductOrAir(form.role)) return [] as string[];
return ROUTES_BY_ROLE[form.role];
if (!isProductOrAir(form.role)) return [] as { label: string; value: string }[];
return ROUTES_BY_ROLE[form.role].map((key) => ({
value: key,
label: dataPermTitle(key),
}));
});
const showLeaderAndRoutes = computed(() => isProductOrAir(form.role));
@@ -367,7 +356,7 @@ watch(
return;
}
const valid = new Set(ROUTES_BY_ROLE[role]);
form.routeList = form.routeList.filter((r) => valid.has(r));
form.routeList = normalizeStaffRouteKeys(form.routeList).filter((r) => valid.has(r));
},
);
@@ -392,7 +381,10 @@ function openCreate() {
function openEdit(row: StaffRow) {
editing.value = row;
const valid = isProductOrAir(row.role) ? new Set(ROUTES_BY_ROLE[row.role]) : null;
const routes = parseRouteList(row.routes).filter((r) => (valid ? valid.has(r) : true));
// 旧 routes(全线/BKK/外采-空运 等)归一到 Excel 码后再过滤本岗选项
const routes = normalizeStaffRouteKeys(parseRouteList(row.routes)).filter((r) =>
valid ? valid.has(r) : true,
);
Object.assign(form, {
name: row.name,
wecomId: row.wecomId,
@@ -432,7 +424,9 @@ async function saveStaff() {
departName: form.dept.trim() || undefined,
roleCode: form.role,
roleLabel: roleLabelMap[form.role],
routes: isProductOrAir(form.role) ? joinRoutes(form.routeList) : undefined,
routes: isProductOrAir(form.role)
? joinRoutes(normalizeStaffRouteKeys(form.routeList))
: undefined,
isLeader: isProductOrAir(form.role) ? (form.isLeader === "true" ? 1 : 0) : null,
notifyEventException: form.notifyEventException ? 1 : 0,
status: form.status,
@@ -480,7 +474,7 @@ async function syncWecom() {
}
function routeTags(routes: string) {
return parseRouteList(routes);
return normalizeStaffRouteKeys(parseRouteList(routes)).map((k) => dataPermTitle(k));
}
function filterRouteOption(input: string, option: any) {
@@ -535,7 +529,7 @@ function filterRouteOption(input: string, option: any) {
</select>
<select v-model="filters.route">
<option value="">负责线路</option>
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ r }}</option>
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ dataPermTitle(r) }}</option>
</select>
<select v-model="filters.status">
<option value="">状态</option>
@@ -713,7 +707,7 @@ function filterRouteOption(input: string, option: any) {
show-search
class="staff-route-select"
placeholder="请选择负责线路,可多选"
:options="routeOptions.map((r) => ({ label: r, value: r }))"
:options="routeOptions"
:filter-option="filterRouteOption"
/>
</div>
@@ -2,7 +2,7 @@
* 员工管理「负责线路」拆合。
*
* 只按逗号/中文逗号/顿号拆整词,禁止把「中港/中亚/中欧」「国内长途/零担」按斜杠切开。
* 陆运没有「陆运全线」快捷项。
* 选项与角色数据权限同源(含空运全线 / 海运全线 / 陆运全线)。
*/
/** 把库里的逗号串还原成多选项。空或占位「-」当没有。 */
@@ -434,7 +434,7 @@ async function toggleStatus(row: TemplateRow) {
/**
* 重新提交 AI 映射解析(已解析也可点;解析中禁用)。
* 未换文件:主账带 mergeExisting,回写时保留已有字段只补缺。
* 未换文件:对齐标签的旧坐标保留,对不上的改用新坐标;缺的字段仍补上。
* 副作用:主账入队;列表进入解析中并轮询。
*/
async function reparseRow(row: TemplateRow) {
@@ -691,7 +691,7 @@ async function downloadPreviewOrOriginal() {
:title="
row.parseStatus === '解析中'
? '解析中,请稍候'
: '重新解析:保留已有映射,只补缺;换文件请用编辑上传'
: '重新解析:对齐的保留,对不上的改坐标;换文件请用编辑上传'
"
@click.stop="reparseRow(row)"
>