机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-19 09:41:59 +08:00
co-authored by Cursor
parent 277cdc1c28
commit 2b528758b4
119 changed files with 6760 additions and 3516 deletions
+7 -3
View File
@@ -6,6 +6,10 @@ import { useRouter } from 'vue-router';
import { useLocaleStore } from '/@/store/modules/locale';
import { REDIRECT_NAME } from '/@/router/constant';
import {
DEFAULT_ADMIN_SITE_TITLE,
adminSiteTitle,
} from '/@/views/inquiry/shared/adminSiteTitle';
/**
* Listening to page changes and dynamically changing site titles
@@ -19,7 +23,7 @@ export function useTitle() {
const pageTitle = usePageTitle();
watch(
[() => currentRoute.value.path, () => localeStore.getLocale],
[() => currentRoute.value.path, () => localeStore.getLocale, adminSiteTitle],
() => {
const route = unref(currentRoute);
@@ -28,8 +32,8 @@ export function useTitle() {
}
const tTitle = t(route?.meta?.title as string);
// 生产 _app.config.js 若漏打 VITE_GLOB_APP_TITLE,title 会是 undefined → 标签出现「报价模板 undefined」
const appTitle = title || '友通达智能体运营后台';
// 优先可配置后台名;否则 env;再否则产品默认名
const appTitle = adminSiteTitle.value || title || DEFAULT_ADMIN_SITE_TITLE;
pageTitle.value = tTitle ? `${tTitle} - ${appTitle}` : `${appTitle}`;
},
{ immediate: true }
+11 -2
View File
@@ -12,6 +12,10 @@
import ProtoIcon from '/@/views/inquiry/components/ProtoIcon.vue';
import AdminModal from '/@/views/inquiry/components/AdminModal.vue';
import { canSeeInquiryPage } from '/@/views/inquiry/shared/inquiryPagePerms';
import {
adminSiteTitle,
refreshAdminSiteTitle,
} from '/@/views/inquiry/shared/adminSiteTitle';
import '/@/views/inquiry/styles/admin-prototype.css';
import '/@/views/inquiry/styles/admin-fidelity.css';
import '/@/views/inquiry/styles/dashboard-prototype.css';
@@ -51,6 +55,7 @@
{
title: '系统配置',
items: [
{ key: '/inquiry-sys/admin-site-title', label: '显示名称', icon: 'botReply' },
{ key: '/inquiry-sys/system-setting', label: '超时响应', icon: 'clock' },
{ key: '/inquiry-sys/user', label: '账号管理', icon: 'account' },
{ key: '/inquiry-sys/role', label: '角色管理', icon: 'role' },
@@ -96,8 +101,9 @@
.filter((g) => g.items.length > 0),
);
const pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台'));
const pageTitle = computed(() => String(route.meta?.title || adminSiteTitle.value));
const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : ''));
const brandTitle = adminSiteTitle;
const username = computed(() => userStore.getUserInfo?.username || '');
const displayName = computed(
@@ -185,6 +191,8 @@
onMounted(() => {
document.addEventListener('click', onDocClick);
// 登录后拉一次可配置后台名;失败则保持默认「友通达智价通AIQ管理后台」
void refreshAdminSiteTitle();
});
onUnmounted(() => {
document.removeEventListener('click', onDocClick);
@@ -195,6 +203,7 @@
themeToken,
route,
pageTitle,
brandTitle,
crumb,
username,
displayName,
@@ -227,7 +236,7 @@
<div class="admin-brand-icon">
<ProtoIcon name="bot" :size="16" />
</div>
<div class="admin-brand-title">友通达智能体运营后台</div>
<div class="admin-brand-title">{{ brandTitle }}</div>
</div>
<nav class="admin-nav">
<template v-for="group in navGroups" :key="group.title">
@@ -129,6 +129,31 @@ export function createPermissionGuard(router: Router) {
}
if (permissionStore.getIsDynamicAddedRoute) {
// #region agent log
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
body: JSON.stringify({
sessionId: 'a0b47d',
runId: 'pre-fix',
hypothesisId: 'H4-H5',
location: 'permissionGuard.ts:alreadyAdded',
message: '动态路由已添加,直接放行',
data: {
toPath: to.path,
toName: String(to.name || ''),
matched: (to.matched || []).map((m) => ({ path: m.path, name: String(m.name || '') })),
hasDashRoute: router.getRoutes().some((r) => String(r.path).includes('inquiry/dashboard') || String(r.name) === 'inquiry-dashboard'),
routeNamesSample: router
.getRoutes()
.map((r) => String(r.name || ''))
.filter((n) => n.includes('inquiry'))
.slice(0, 20),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
next();
return;
}
@@ -143,6 +168,32 @@ export function createPermissionGuard(router: Router) {
permissionStore.setDynamicAddedRoute(true);
// #region agent log
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
body: JSON.stringify({
sessionId: 'a0b47d',
runId: 'pre-fix',
hypothesisId: 'H3-H5',
location: 'permissionGuard.ts:afterAddRoute',
message: '刚动态加路由后的匹配情况',
data: {
toPath: to.path,
toName: String(to.name || ''),
toIs404: to.name === PAGE_NOT_FOUND_NAME,
hasDashRoute: router.getRoutes().some((r) => String(r.path).includes('inquiry/dashboard') || String(r.name) === 'inquiry-dashboard'),
inquiryRoutes: router
.getRoutes()
.filter((r) => String(r.path).includes('inquiry') || String(r.name || '').includes('inquiry'))
.map((r) => ({ path: r.path, name: String(r.name || '') }))
.slice(0, 30),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
if (to.name === PAGE_NOT_FOUND_NAME) {
// 动态添加路由后,此处应当重定向到fullPath,否则会加载404页面内容
next({ path: to.fullPath, replace: true, query: to.query });
@@ -41,12 +41,25 @@ const INQUIRY_LEAVES: InquiryLeaf[] = [
{ path: '/inquiry/staff-role', name: 'inquiry-staff-role', component: 'inquiry/staffRole/index', title: '员工管理' },
{ path: '/inquiry/template', name: 'inquiry-template', component: 'inquiry/template/index', title: '报价模板' },
{ path: '/inquiry/bot-reply', name: 'inquiry-bot-reply', component: 'inquiry/botReply/index', title: '机器人回复语' },
{
path: '/inquiry-sys/admin-site-title',
name: 'inquiry-admin-site-title',
component: 'inquiry/adminSiteTitle/index',
title: '显示名称',
},
{
path: '/inquiry-sys/system-setting',
name: 'inquiry-system-setting',
component: 'inquiry/systemSetting/index',
title: '超时响应',
},
// 旧入口保留:跳到合一页,避免书签/旧菜单失效
{
path: '/inquiry-sys/bot-display-name',
name: 'inquiry-bot-display-name',
component: 'inquiry/adminSiteTitle/index',
title: '显示名称',
},
{ path: '/inquiry-sys/user', name: 'inquiry-sys-user', component: 'inquiry/account/index', title: '账号管理' },
{ path: '/inquiry-sys/role', name: 'inquiry-sys-role', component: 'inquiry/role/index', title: '角色管理' },
];
@@ -448,8 +461,79 @@ export const usePermissionStore = defineStore({
}
// 只纠正/补挂「已有菜单或已有查看权限」的手写页,禁止灌入未授权路由
const permCodes = this.getPermCodeList || [];
// #region agent log
const _dbgCollectPaths = (list: AppRouteRecordRaw[] = [], out: string[] = []): string[] => {
for (const item of list || []) {
out.push(String(item.path || ''));
if (item.children?.length) _dbgCollectPaths(item.children as AppRouteRecordRaw[], out);
}
return out;
};
const _dbgPathsBefore = _dbgCollectPaths(routeList || []);
const _dbgHasDashPerm = (permCodes as any[]).map(String).includes('inquiry:dashboard:view');
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
body: JSON.stringify({
sessionId: 'a0b47d',
runId: 'pre-fix',
hypothesisId: 'H1-H2',
location: 'permission.ts:buildRoutesAction:beforeEnsure',
message: '菜单拉取后、ensure/prune 前',
data: {
menuCount: (routeList || []).length,
pathCount: _dbgPathsBefore.length,
hasDashboardPath: _dbgPathsBefore.some((p) => p.includes('inquiry/dashboard')),
hasDashPerm: _dbgHasDashPerm,
permCount: (permCodes as any[]).length,
samplePerms: (permCodes as any[]).map(String).filter((c) => c.includes('inquiry')).slice(0, 20),
samplePaths: _dbgPathsBefore.filter((p) => p.includes('inquiry')).slice(0, 20),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
routeList = ensureInquiryHandwrittenRoutes(routeList || [], permCodes);
// #region agent log
const _dbgAfterEnsure = _dbgCollectPaths(routeList || []);
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
body: JSON.stringify({
sessionId: 'a0b47d',
runId: 'pre-fix',
hypothesisId: 'H2',
location: 'permission.ts:buildRoutesAction:afterEnsure',
message: 'ensureInquiryHandwrittenRoutes 后',
data: {
hasDashboardPath: _dbgAfterEnsure.some((p) => p.includes('inquiry/dashboard')),
inquiryPaths: _dbgAfterEnsure.filter((p) => p.includes('inquiry')).slice(0, 30),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
routeList = pruneInquiryLeavesWithoutViewPerm(routeList, permCodes);
// #region agent log
const _dbgAfterPrune = _dbgCollectPaths(routeList || []);
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
body: JSON.stringify({
sessionId: 'a0b47d',
runId: 'pre-fix',
hypothesisId: 'H1',
location: 'permission.ts:buildRoutesAction:afterPrune',
message: 'pruneInquiryLeavesWithoutViewPerm 后',
data: {
hasDashboardPath: _dbgAfterPrune.some((p) => p.includes('inquiry/dashboard')),
inquiryPaths: _dbgAfterPrune.filter((p) => p.includes('inquiry')).slice(0, 30),
prunedAway: _dbgAfterEnsure.includes('/inquiry/dashboard') && !_dbgAfterPrune.some((p) => p.includes('inquiry/dashboard')),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
// 补挂后再标一次(ensure 新建的父级也要带 Vue3 标记)
routeList = normalizeVue3MenuMarkers(routeList);
// 组件地址前加斜杠处理 author: lsq date:2021-09-08
@@ -466,6 +550,33 @@ export const usePermissionStore = defineStore({
routeList = routeList.filter(routeRemoveIgnoreFilter);
routeList = flatMultiLevelRoutes(routeList);
// #region agent log
const _dbgFinal = _dbgCollectPaths(routeList || []);
const _dbgTopComp = (routeList || []).slice(0, 5).map((r) => ({
path: r.path,
name: String(r.name || ''),
compType: typeof r.component,
childN: r.children?.length || 0,
childPaths: (r.children || []).slice(0, 8).map((c) => c.path),
}));
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
body: JSON.stringify({
sessionId: 'a0b47d',
runId: 'pre-fix',
hypothesisId: 'H3',
location: 'permission.ts:buildRoutesAction:afterFlat',
message: 'transform+flat 后最终路由树',
data: {
hasDashboardPath: _dbgFinal.some((p) => p.includes('inquiry/dashboard')),
inquiryPaths: _dbgFinal.filter((p) => p.includes('inquiry')).slice(0, 40),
top: _dbgTopComp,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
routes = [PAGE_NOT_FOUND_ROUTE, ...routeList];
break;
}
@@ -0,0 +1,295 @@
<script setup lang="ts">
/**
* 显示名称合一页:后台品牌名 + 企微机器人 @ 名,同属系统配置、同表不同 reply_code。
* 侧栏只留一个入口,避免两个菜单来回点。
* 后台名改完立即刷侧栏;机器人名约 1 分钟后智能体出站生效。
*/
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { getReplyListAll, saveReply } from '/@/views/inquiry/botReply/botReply.api';
import { usePermission } from '/@/hooks/web/usePermission';
import { InquiryBtnPerm } from '/@/views/inquiry/shared/inquiryPagePerms';
import {
ADMIN_SITE_TITLE_REPLY_CODE,
DEFAULT_ADMIN_SITE_TITLE,
adminSiteTitle,
refreshAdminSiteTitle,
} from '/@/views/inquiry/shared/adminSiteTitle';
const BOT_DISPLAY_NAME_CODE = 'system.bot_display_name';
const DEFAULT_BOT_NAME = '智报通AIQ';
const { hasPermission } = usePermission();
const canEdit = () => hasPermission(InquiryBtnPerm.replyEdit);
const loading = ref(false);
const savingSite = ref(false);
const savingBot = ref(false);
const siteRowId = ref('');
const siteTitle = ref(DEFAULT_ADMIN_SITE_TITLE);
const siteEnabled = ref(true);
const botRowId = ref('');
const botName = ref(DEFAULT_BOT_NAME);
const botEnabled = ref(true);
function asList(res: any): any[] {
if (Array.isArray(res)) return res;
if (Array.isArray(res?.result)) return res.result;
if (Array.isArray(res?.records)) return res.records;
return [];
}
async function load() {
loading.value = true;
try {
const list = asList(await getReplyListAll());
const siteHit = list.find((r) => String(r.replyCode || '') === ADMIN_SITE_TITLE_REPLY_CODE);
if (siteHit) {
siteRowId.value = String(siteHit.id || '');
siteTitle.value = String(siteHit.replyText || '').trim() || DEFAULT_ADMIN_SITE_TITLE;
siteEnabled.value = siteHit.enabled === 1 || siteHit.enabled === true;
} else {
siteRowId.value = '';
siteTitle.value = DEFAULT_ADMIN_SITE_TITLE;
siteEnabled.value = true;
}
const botHit = list.find((r) => String(r.replyCode || '') === BOT_DISPLAY_NAME_CODE);
if (botHit) {
botRowId.value = String(botHit.id || '');
botName.value = String(botHit.replyText || '').trim() || DEFAULT_BOT_NAME;
botEnabled.value = botHit.enabled === 1 || botHit.enabled === true;
} else {
botRowId.value = '';
botName.value = DEFAULT_BOT_NAME;
botEnabled.value = true;
}
} catch (e: any) {
message.error(e?.message || '加载显示名称失败');
} finally {
loading.value = false;
}
}
async function saveSite() {
const name = siteTitle.value.trim();
if (!name) {
message.warning('请填写后台名称');
return;
}
if (name.length > 40) {
message.warning('后台名称建议不超过 40 字');
return;
}
savingSite.value = true;
try {
const body: Record<string, any> = {
categoryCode: 'system',
categoryTitle: '系统配置',
replyCode: ADMIN_SITE_TITLE_REPLY_CODE,
triggerScene: '管理后台品牌名称(侧栏/登录页/浏览器标签)',
replyText: name,
enabled: siteEnabled.value ? 1 : 0,
};
if (siteRowId.value) body.id = siteRowId.value;
await saveReply(body, Boolean(siteRowId.value));
await refreshAdminSiteTitle();
adminSiteTitle.value = siteEnabled.value ? name : DEFAULT_ADMIN_SITE_TITLE;
message.success('后台名称已保存');
await load();
} catch (e: any) {
message.error(e?.message || '保存后台名称失败');
} finally {
savingSite.value = false;
}
}
async function saveBot() {
const name = botName.value.trim();
if (!name) {
message.warning('请填写机器人显示名');
return;
}
if (name.includes('@')) {
message.warning('只需填名字本身,不要带 @');
return;
}
savingBot.value = true;
try {
const body: Record<string, any> = {
categoryCode: 'system',
categoryTitle: '系统配置',
replyCode: BOT_DISPLAY_NAME_CODE,
triggerScene: '企微机器人显示名(@ 名)',
replyText: name,
enabled: botEnabled.value ? 1 : 0,
};
if (botRowId.value) body.id = botRowId.value;
await saveReply(body, Boolean(botRowId.value));
message.success('机器人显示名已保存。智能体约 1 分钟内按新名出站;入站仍兼容旧名「询价小助手」。');
await load();
} catch (e: any) {
message.error(e?.message || '保存机器人显示名失败');
} finally {
savingBot.value = false;
}
}
onMounted(() => load());
</script>
<template>
<div class="display-name-page">
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
<template v-else>
<!-- 后台品牌 -->
<section class="block">
<h3 class="block-title">后台名称</h3>
<p class="hint">
侧栏左上角、登录页标题、浏览器标签。未配置时默认「{{ DEFAULT_ADMIN_SITE_TITLE }}」。
</p>
<div class="card">
<div class="form-row">
<label class="lbl">后台名称</label>
<input
v-model="siteTitle"
class="field"
type="text"
maxlength="40"
:placeholder="DEFAULT_ADMIN_SITE_TITLE"
:disabled="!canEdit()"
/>
</div>
<div class="form-row">
<label class="lbl">调用编码</label>
<input class="field" type="text" :value="ADMIN_SITE_TITLE_REPLY_CODE" disabled />
</div>
<div class="form-row">
<label class="lbl">状态</label>
<label class="check-line">
<input v-model="siteEnabled" type="checkbox" :disabled="!canEdit()" />
<span>启用(关闭后回退默认名)</span>
</label>
</div>
<div class="actions">
<button
v-if="canEdit()"
type="button"
class="btn btn-primary"
:disabled="savingSite"
@click="saveSite"
>
{{ savingSite ? '保存中…' : '保存后台名称' }}
</button>
</div>
</div>
</section>
<!-- 企微机器人 -->
<section class="block">
<h3 class="block-title">机器人显示名</h3>
<p class="hint">
销售在企微里 @ 的名字。改完后请同步在企微管理后台把应用显示名改成一致。
</p>
<div class="card">
<div class="form-row">
<label class="lbl">当前显示名</label>
<input
v-model="botName"
class="field"
type="text"
maxlength="32"
:placeholder="DEFAULT_BOT_NAME"
:disabled="!canEdit()"
/>
</div>
<div class="form-row">
<label class="lbl">调用编码</label>
<input class="field" type="text" :value="BOT_DISPLAY_NAME_CODE" disabled />
</div>
<div class="form-row">
<label class="lbl">状态</label>
<label class="check-line">
<input v-model="botEnabled" type="checkbox" :disabled="!canEdit()" />
<span>启用</span>
</label>
</div>
<div class="actions">
<button
v-if="canEdit()"
type="button"
class="btn btn-primary"
:disabled="savingBot"
@click="saveBot"
>
{{ savingBot ? '保存中…' : '保存机器人显示名' }}
</button>
</div>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.display-name-page {
max-width: 640px;
}
.block {
margin-bottom: 28px;
}
.block-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 800;
color: #0f172a;
}
.hint {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.6;
color: #667085;
}
.card {
background: #fff;
border: 1px solid #e4e7ec;
border-radius: 12px;
padding: 20px;
}
.form-row {
display: grid;
grid-template-columns: 100px 1fr;
gap: 12px;
align-items: center;
margin-bottom: 14px;
}
.lbl {
font-size: 11px;
font-weight: 700;
color: #344054;
}
.field {
height: 34px;
border: 1px solid #d0d5dd;
border-radius: 8px;
padding: 0 10px;
font-size: 12px;
}
.field:disabled {
background: #f2f4f7;
color: #98a2b3;
}
.check-line {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: #344054;
}
.actions {
margin-top: 8px;
padding-left: 112px;
}
</style>
@@ -0,0 +1,186 @@
<script setup lang="ts">
/**
* 企微机器人显示名:挂在侧栏「系统配置」下,方便运营直接改 @ 名。
* 数据仍落 inq_bot_reply.reply_code=system.bot_display_name;智能体短缓存约 60 秒生效。
* 过渡期智能体仍认旧名「询价小助手」,本页只改对外当前名。
*/
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import { getReplyListAll, saveReply } from '/@/views/inquiry/botReply/botReply.api';
import { usePermission } from '/@/hooks/web/usePermission';
import { InquiryBtnPerm } from '/@/views/inquiry/shared/inquiryPagePerms';
const REPLY_CODE = 'system.bot_display_name';
const DEFAULT_NAME = '智报通AIQ';
const { hasPermission } = usePermission();
const canEdit = () => hasPermission(InquiryBtnPerm.replyEdit);
const loading = ref(false);
const saving = ref(false);
const rowId = ref('');
const displayName = ref(DEFAULT_NAME);
const enabled = ref(true);
async function load() {
loading.value = true;
try {
const res: any = await getReplyListAll();
const list = Array.isArray(res)
? res
: Array.isArray(res?.result)
? res.result
: Array.isArray(res?.records)
? res.records
: [];
const hit = list.find((r: any) => String(r.replyCode || '') === REPLY_CODE);
if (hit) {
rowId.value = String(hit.id || '');
displayName.value = String(hit.replyText || '').trim() || DEFAULT_NAME;
enabled.value = hit.enabled === 1 || hit.enabled === true;
} else {
rowId.value = '';
displayName.value = DEFAULT_NAME;
enabled.value = true;
}
} catch (e: any) {
message.error(e?.message || '加载显示名失败');
} finally {
loading.value = false;
}
}
async function save() {
const name = displayName.value.trim();
if (!name) {
message.warning('请填写机器人显示名');
return;
}
if (name.includes('@')) {
message.warning('只需填名字本身,不要带 @');
return;
}
saving.value = true;
try {
const body: Record<string, any> = {
categoryCode: 'system',
categoryTitle: '系统配置',
replyCode: REPLY_CODE,
triggerScene: '企微机器人显示名(@ 名)',
replyText: name,
enabled: enabled.value ? 1 : 0,
};
if (rowId.value) {
body.id = rowId.value;
}
await saveReply(body, Boolean(rowId.value));
message.success('已保存。智能体约 1 分钟内按新名出站;入站仍兼容旧名「询价小助手」。');
await load();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
saving.value = false;
}
}
onMounted(() => load());
</script>
<template>
<div class="bot-name-page">
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
<template v-else>
<p class="hint">
销售在企微里 @ 的名字。改完后请同步在企微管理后台把应用显示名改成一致,否则人和系统会对不上。
</p>
<div class="card">
<div class="form-row">
<label class="lbl">当前显示名</label>
<input
v-model="displayName"
class="field"
type="text"
maxlength="32"
placeholder="如 智报通AIQ"
:disabled="!canEdit()"
/>
</div>
<div class="form-row">
<label class="lbl">调用编码</label>
<input class="field" type="text" :value="REPLY_CODE" disabled />
</div>
<div class="form-row">
<label class="lbl">状态</label>
<label class="check-line">
<input v-model="enabled" type="checkbox" :disabled="!canEdit()" />
<span>启用</span>
</label>
</div>
<div class="actions">
<button
v-if="canEdit()"
type="button"
class="btn btn-primary"
:disabled="saving"
@click="save"
>
{{ saving ? '保存中…' : '保存' }}
</button>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.bot-name-page {
max-width: 560px;
}
.hint {
margin: 0 0 16px;
font-size: 12px;
line-height: 1.6;
color: #667085;
}
.card {
background: #fff;
border: 1px solid #e4e7ec;
border-radius: 12px;
padding: 20px;
}
.form-row {
display: grid;
grid-template-columns: 100px 1fr;
gap: 12px;
align-items: center;
margin-bottom: 14px;
}
.lbl {
font-size: 11px;
font-weight: 700;
color: #344054;
}
.field {
height: 34px;
border: 1px solid #d0d5dd;
border-radius: 8px;
padding: 0 10px;
font-size: 12px;
}
.field:disabled {
background: #f2f4f7;
color: #98a2b3;
}
.check-line {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: #344054;
}
.actions {
margin-top: 8px;
padding-left: 112px;
}
</style>
@@ -46,6 +46,10 @@ function groupByCategory(list: any[]) {
const cats = emptyCategories();
const byId = Object.fromEntries(cats.map((c) => [c.id, c]));
for (const row of list) {
// system.* 走独立配置页(后台名称 / 机器人显示名),不进回复语列表
if (String(row.replyCode || "").startsWith("system.")) {
continue;
}
const code = row.categoryCode || "inquiring";
let cat = byId[code];
if (!cat) {
@@ -76,6 +76,12 @@ export const REPLY_CATEGORY_META: Omit<ReplyCategory, "items">[] = [
desc: "AI 无法独立完成询价时,引导销售确认是否拉内部航线/产品进群协同。",
tagClass: "tag-violet",
},
{
id: "system",
title: "系统配置",
desc: "短配置项(非整句话术)。企微机器人显示名供智能体出站 @ 与入站识别共用。",
tagClass: "tag-gray",
},
];
export function emptyCategories(): ReplyCategory[] {
@@ -3,7 +3,7 @@
* 分析看板:对接主账 /inquiry/dashboard/metrics。
* 指标 / 询价趋势 / 人员排名 / 成本趋势均来自主账聚合;禁止用 Mock 假线冒充生产。
*/
import { computed, onMounted, onUnmounted, ref } from "vue";
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { message } from "ant-design-vue";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import { getDashboardMetrics } from "/@/views/inquiry/dashboard/dashboard.api";
@@ -100,6 +100,28 @@ async function loadMetrics() {
const res: any = await getDashboardMetrics(req);
const data = res?.result || res || {};
todayMetrics.value = (data.metrics || []) as MetricCard[];
// #region agent log
fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" },
body: JSON.stringify({
sessionId: "7b2ee5",
runId: "post-fix",
hypothesisId: "THOUSAND_SEP",
location: "dashboard/index.vue:loadMetrics",
message: "指标千分位展示抽样",
data: {
samples: todayMetrics.value.slice(0, 9).map((m) => ({
key: m.key,
raw: m.value,
display: formatMetricDisplay(m),
isTime: !!m.isTime,
})),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
inquiryTrend.value = ((data.trend || []) as any[]).map((p) => ({
date: String(p.date || ""),
value: Number(p.value || 0),
@@ -202,6 +224,36 @@ function formatCost(v: number | null | undefined) {
return v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
}
/**
* 看板统计数字千分位:百分比、时长原样;件数/金额加分组符。
* 主账已格式化时再跑一遍也安全(先去逗号再 format)。
*/
function formatGroupedNumberText(numStr: string): string {
const cleaned = String(numStr || "").replace(/,/g, "").trim();
if (!/^\d+(\.\d+)?$/.test(cleaned)) return numStr;
const n = Number(cleaned);
if (Number.isNaN(n)) return numStr;
if (cleaned.includes(".")) {
const frac = cleaned.split(".")[1].length;
return n.toLocaleString("zh-CN", { minimumFractionDigits: frac, maximumFractionDigits: frac });
}
return n.toLocaleString("zh-CN");
}
function formatMetricDisplay(m: MetricCard): string {
if (m.isTime) return m.value;
const raw = String(m.value ?? "");
if (!raw || raw === "-" || raw.includes("%")) return raw;
const match = raw.match(/^([\d,]+(?:\.\d+)?)(.*)$/);
if (!match) return raw;
return formatGroupedNumberText(match[1]) + match[2];
}
function formatCountDisplay(n: number): string {
if (n == null || Number.isNaN(n)) return "-";
return Number(n).toLocaleString("zh-CN");
}
const inquirySvg = computed(() => {
const points = inquiryTrend.value.length
? inquiryTrend.value
@@ -237,26 +289,6 @@ const costDates = computed(() =>
costDateLabels.value.length ? costDateLabels.value : inquiryTrend.value.map((p) => p.date),
);
const costSampleDays = computed(() => {
const set = new Set<string>();
for (const r of costRoutes.value) {
r.values.forEach((v, i) => {
if (v != null && !Number.isNaN(v) && costDates.value[i]) set.add(costDates.value[i]);
});
}
return [...set];
});
const costFootnote = computed(() => {
if (!costRoutes.value.length) {
return "近 7 日暂无带金额的工单,有报价后将显示线路成本趋势";
}
const days = costSampleDays.value;
if (!days.length) {
return "已选线路在近 7 日无金额样本";
}
return `成本来自主账工单(成本价优先,缺省销售价);有金额日期:${days.join("、")},其余日悬停为 —`;
});
const costXs = computed(() => {
const n = Math.max(costDates.value.length, 1);
return costDates.value.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
@@ -290,7 +322,7 @@ function showInquiryTip(date: string, value: number, x: number, y: number, wrapE
left: `${(x / 760) * box.width}px`,
top: `${(y / 240) * box.height}px`,
date,
value: `${value} 单/日`,
value: `${formatCountDisplay(value)} 单/日`,
lines: [],
};
}
@@ -331,7 +363,7 @@ function exportRanking() {
row.name,
row.dept,
roleLabels[row.role] || row.role,
`${row.volumeNum}单`,
`${formatCountDisplay(row.volumeNum)}单`,
]);
const csv = [header, ...body]
.map((cols) => cols.map((col) => `"${String(col).replace(/"/g, '""')}"`).join(","))
@@ -351,6 +383,92 @@ function onDocClick(e: MouseEvent) {
if (!t.closest(".ref-route-filter")) routeFilterOpen.value = false;
}
/**
* 调试:打开线路筛选时采集弹层/chips/祖先裁切与可滚尺寸。
*/
function probeRouteFilterMenu(runId: string) {
// #region agent log
const menu = document.querySelector(".ref-route-filter__menu") as HTMLElement | null;
const chips = document.querySelector(".ref-route-filter__chips") as HTMLElement | null;
const filter = document.querySelector(".ref-route-filter") as HTMLElement | null;
if (!menu || !chips) {
fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" },
body: JSON.stringify({
sessionId: "7b2ee5",
runId,
hypothesisId: "H2",
location: "dashboard/index.vue:probeRouteFilterMenu",
message: "弹层或 chips 未找到",
data: { hasMenu: !!menu, hasChips: !!chips },
timestamp: Date.now(),
}),
}).catch(() => {});
return;
}
const menuCs = getComputedStyle(menu);
const chipsCs = getComputedStyle(chips);
const menuRect = menu.getBoundingClientRect();
const chipsRect = chips.getBoundingClientRect();
const clipAncestors: Array<{ tag: string; cls: string; overflow: string; overflowY: string; rectBottom: number }> = [];
let node: HTMLElement | null = filter;
while (node && node !== document.body) {
const cs = getComputedStyle(node);
if (cs.overflow !== "visible" || cs.overflowY !== "visible" || cs.overflowX !== "visible") {
clipAncestors.push({
tag: node.tagName.toLowerCase(),
cls: (node.className || "").toString().slice(0, 80),
overflow: cs.overflow,
overflowY: cs.overflowY,
rectBottom: Math.round(node.getBoundingClientRect().bottom),
});
}
node = node.parentElement;
}
const spaceBelow = Math.round(window.innerHeight - menuRect.top);
fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" },
body: JSON.stringify({
sessionId: "7b2ee5",
runId,
hypothesisId: "H1-H5",
location: "dashboard/index.vue:probeRouteFilterMenu",
message: "线路筛选弹层尺寸与溢出",
data: {
routeCount: costRoutes.value.length,
menu: {
h: Math.round(menuRect.height),
bottom: Math.round(menuRect.bottom),
overflow: menuCs.overflow,
overflowY: menuCs.overflowY,
maxHeight: menuCs.maxHeight,
},
chips: {
h: Math.round(chipsRect.height),
clientH: chips.clientHeight,
scrollH: chips.scrollHeight,
canScroll: chips.scrollHeight > chips.clientHeight + 1,
overflow: chipsCs.overflow,
overflowY: chipsCs.overflowY,
maxHeight: chipsCs.maxHeight,
},
viewport: { innerH: window.innerHeight, spaceBelow, menuClippedByViewport: menuRect.bottom > window.innerHeight + 1 },
clipAncestors,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
}
watch(routeFilterOpen, async (open) => {
if (!open) return;
await nextTick();
requestAnimationFrame(() => probeRouteFilterMenu("post-fix"));
});
const inquiryWrap = ref<HTMLElement | null>(null);
const costWrap = ref<HTMLElement | null>(null);
@@ -409,7 +527,7 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<span class="ref-metric-tip">{{ m.tip }}</span>
</button>
</div>
<div class="ref-metric-card__value" :class="{ 'is-time': m.isTime }">{{ m.value }}</div>
<div class="ref-metric-card__value" :class="{ 'is-time': m.isTime }">{{ formatMetricDisplay(m) }}</div>
</div>
</div>
</div>
@@ -447,7 +565,7 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<text v-for="c in inquirySvg.coords" :key="'d-' + c.date" :x="c.x" y="218">{{ c.date }}</text>
</g>
<g fill="#0f172a" font-size="8" font-weight="800" text-anchor="middle">
<text v-for="c in inquirySvg.coords" :key="'v-' + c.date" :x="c.x" :y="c.y - 11">{{ c.value }}</text>
<text v-for="c in inquirySvg.coords" :key="'v-' + c.date" :x="c.x" :y="c.y - 11">{{ formatCountDisplay(c.value) }}</text>
</g>
<g
v-for="c in inquirySvg.coords"
@@ -575,9 +693,6 @@ 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">
{{ costFootnote }}
</div>
</div>
</div>
</div>
@@ -625,7 +740,7 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<span class="col-name">{{ row.name }}</span>
<span class="col-dept">{{ row.dept }}</span>
</div>
<span class="col-volume">{{ row.volumeNum }} 单</span>
<span class="col-volume">{{ formatCountDisplay(row.volumeNum) }} 单</span>
</div>
</div>
</div>
@@ -682,13 +797,6 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
z-index: 120;
}
.dash-cost-footnote {
margin-top: 12px;
font-size: 10px;
font-weight: 700;
color: #94a3b8;
}
.ref-surface--metrics,
.ref-surface--metrics .ref-metrics-grid,
.ref-surface--metrics .ref-metric-card {
@@ -10,6 +10,7 @@
import { ConfigProvider, message } from 'ant-design-vue';
import { useUserStore } from '/@/store/modules/user';
import ProtoIcon from '/@/views/inquiry/components/ProtoIcon.vue';
import { DEFAULT_ADMIN_SITE_TITLE } from '/@/views/inquiry/shared/adminSiteTitle';
import '/@/views/inquiry/styles/admin-prototype.css';
import '/@/views/inquiry/styles/admin-fidelity.css';
@@ -91,7 +92,7 @@
<div class="login-brand-mark">
<ProtoIcon name="bot" :size="22" />
</div>
<h1>友通达智能体运营后台</h1>
<h1>{{ DEFAULT_ADMIN_SITE_TITLE }}</h1>
<p>管理询价工单、人员配置与系统权限</p>
</div>
@@ -0,0 +1,40 @@
/**
* 管理后台品牌标题:默认文案 + 可配置项(落 inq_bot_reply)。
* 登录前用默认;登录后 refresh 一次,侧栏/浏览器标签同步。
* 配置页:系统配置 → 后台名称。
*/
import { ref } from 'vue';
import { getReplyListAll } from '/@/views/inquiry/botReply/botReply.api';
/** 产品默认名;库中无配置时全站用此文案 */
export const DEFAULT_ADMIN_SITE_TITLE = '友通达智价通AIQ管理后台';
/** 与机器人显示名同表;独立 reply_code,不进回复语业务分类 */
export const ADMIN_SITE_TITLE_REPLY_CODE = 'system.admin_site_title';
/** 当前生效的后台名称(侧栏品牌、登录页、浏览器标签后缀) */
export const adminSiteTitle = ref(DEFAULT_ADMIN_SITE_TITLE);
/**
* 从主账拉取已保存的后台名称。
* 失败或未配置时保持默认,不抛错打断布局。
*/
export async function refreshAdminSiteTitle(): Promise<string> {
try {
const res: any = await getReplyListAll();
const list = Array.isArray(res)
? res
: Array.isArray(res?.result)
? res.result
: Array.isArray(res?.records)
? res.records
: [];
const hit = list.find((r: any) => String(r.replyCode || '') === ADMIN_SITE_TITLE_REPLY_CODE);
const enabled = !hit || hit.enabled === 1 || hit.enabled === true;
const name = enabled ? String(hit?.replyText || '').trim() : '';
adminSiteTitle.value = name || DEFAULT_ADMIN_SITE_TITLE;
} catch {
adminSiteTitle.value = DEFAULT_ADMIN_SITE_TITLE;
}
return adminSiteTitle.value;
}
@@ -13,6 +13,9 @@ export const INQUIRY_PAGE_VIEW_PERMS: Record<string, string> = {
'/inquiry/template': 'inquiry:template:view',
'/inquiry/bot-reply': 'inquiry:reply:list',
'/inquiry-sys/system-setting': 'inquiry:timeout:list',
// 合一页「显示名称」:后台名 + 机器人 @ 名,与回复语同源权限
'/inquiry-sys/admin-site-title': 'inquiry:reply:list',
'/inquiry-sys/bot-display-name': 'inquiry:reply:list',
'/inquiry-sys/user': 'inquiry:account:view',
'/inquiry-sys/role': 'inquiry:role:view',
};
@@ -475,6 +475,31 @@ body {
background: #fff;
}
/*
* 多段子工单:整行统一灰底(色值用可见灰 #e8eef5,避免 #f5f8fc 几乎像白)。
* 全局 td:first-child / :last-child 粘列默认刷白;最终覆盖见文末 .is-list-page 段。
*/
.data-table tbody tr.ticket-multi-child td,
.data-table tbody tr.ticket-multi-child td:first-child,
.data-table tbody tr.ticket-multi-child td:last-child,
.table-wrap .data-table tbody tr.ticket-multi-child td:first-child,
.table-wrap .data-table tbody tr.ticket-multi-child td:last-child {
background: #e8eef5 !important;
position: static !important;
left: auto !important;
right: auto !important;
box-shadow: none !important;
}
.data-table tbody tr.ticket-multi-child:hover td,
.data-table tbody tr.ticket-multi-child:hover td:first-child,
.data-table tbody tr.ticket-multi-child:hover td:last-child,
.table-wrap .data-table tbody tr.ticket-multi-child:hover td:first-child,
.table-wrap .data-table tbody tr.ticket-multi-child:hover td:last-child {
background: #dde5f0 !important;
}
.data-table tbody tr.ticket-multi-parent:hover td {
background: #fafcff;
}
.data-table tr:hover td {
background: #f8fbff;
}
@@ -1065,6 +1090,17 @@ h6 {
padding: 12px 16px 16px !important;
}
/*
* 线路筛选弹层是 absolute,会溢出图表卡片。
* .ref-surface 默认 overflow:hidden + radius,会把弹层底部裁掉;
* chips 自身并未溢出(canScroll=false),所以用户既看不全也滚不动。
* 仅放开含图表/线路筛选的卡片,其它 .ref-surface 仍 hidden 保圆角裁切。
*/
.ref-surface:has(.ref-charts-section),
.ref-surface:has(.ref-route-filter) {
overflow: visible !important;
}
.ref-chart-panel__head {
padding: 10px 14px !important;
}
@@ -1323,6 +1359,8 @@ html {
padding: 14px 18px !important;
gap: 12px !important;
min-height: 0 !important;
min-width: 0 !important;
overflow-x: hidden !important;
overflow-y: auto !important;
flex: 1 1 auto !important;
}
@@ -1371,13 +1409,11 @@ html {
}
/**
* 报价模板预览截图:强制等比例(宽高同比例缩放),覆盖任何全局 img 拉满规则。
* 报价模板预览截图:铺满弹窗宽度、等比例增高,竖版 A4 不再被 max-height 压成半宽。
* 为何写在 fidelity:Teleport 弹窗挂 body,需非 scoped + !important 兜底。
*/
.qt-snapshot-wrap {
display: flex !important;
justify-content: center !important;
align-items: flex-start !important;
display: block !important;
max-height: min(72vh, 760px) !important;
min-height: 0 !important;
overflow: auto !important;
@@ -1389,14 +1425,13 @@ html {
.modal-overlay .qt-snapshot-img,
.qt-preview-dialog .qt-snapshot-img {
display: block !important;
width: auto !important;
width: 100% !important;
height: auto !important;
max-width: 100% !important;
max-height: min(70vh, 720px) !important;
max-height: none !important;
object-fit: contain !important;
object-position: top center !important;
flex: 0 0 auto !important;
align-self: flex-start !important;
margin: 0 auto !important;
}
.qt-lightbox-img {
@@ -1595,6 +1630,49 @@ textarea.field.field-tall {
background: #f8fbff !important;
}
/*
* 多段子工单灰底:必须写在 .is-list-page 粘列刷白之后。
* 子行首列是「询价需求」、尾列是「利润点」,不是工单号/操作粘列;
* 若被上面 td:first-child / :last-child 刷成 #fff,截图就会出现「灰底里嵌白块」。
*/
.is-list-page .data-table tbody tr.ticket-multi-child td,
.is-list-page .data-table tbody tr.ticket-multi-child td:first-child,
.is-list-page .data-table tbody tr.ticket-multi-child td:last-child,
.is-list-page .data-table tbody tr.ticket-multi-child td.col-sticky-left,
.is-list-page .data-table tbody tr.ticket-multi-child td.col-sticky-right,
.is-list-page .ticket-list-table tbody tr.ticket-multi-child td,
.is-list-page .ticket-list-table tbody tr.ticket-multi-child td:first-child,
.is-list-page .ticket-list-table tbody tr.ticket-multi-child td:last-child {
background: #e8eef5 !important;
position: static !important;
left: auto !important;
right: auto !important;
box-shadow: none !important;
z-index: auto !important;
}
.is-list-page .data-table tbody tr.ticket-multi-child:hover td,
.is-list-page .data-table tbody tr.ticket-multi-child:hover td:first-child,
.is-list-page .data-table tbody tr.ticket-multi-child:hover td:last-child,
.is-list-page .ticket-list-table tbody tr.ticket-multi-child:hover td,
.is-list-page .ticket-list-table tbody tr.ticket-multi-child:hover td:first-child,
.is-list-page .ticket-list-table tbody tr.ticket-multi-child:hover td:last-child {
background: #dde5f0 !important;
}
/* 子行内附件:灰底上不要再套浅色 pill,与文字同色系蓝链 */
.is-list-page .data-table tbody tr.ticket-multi-child .tag.tag-link,
.is-list-page .ticket-list-table tbody tr.ticket-multi-child .tag.tag-link {
background: transparent !important;
color: #1d4ed8 !important;
padding-left: 0 !important;
padding-right: 0 !important;
max-width: none !important;
}
.is-list-page .data-table tbody tr.ticket-multi-child .tag.tag-link:hover,
.is-list-page .ticket-list-table tbody tr.ticket-multi-child .tag.tag-link:hover {
background: transparent !important;
text-decoration: underline;
}
/*
* 分析看板比一屏高。整页被 html / 主区锁死时,内容区即使设了 overflow,
* 格子也会跟着内容长高,再被主区裁掉,滚轮没有可滚的对象。
@@ -1539,8 +1539,8 @@ textarea:focus-visible {
}
.modal-dialog.qt-preview-dialog {
max-width: min(96vw, 980px);
width: min(96vw, 980px);
max-width: min(96vw, 1100px);
width: min(96vw, 1100px);
}
.modal-dialog.account-modal-dialog {
@@ -2298,11 +2298,9 @@ textarea:focus-visible {
color: #94a3b8;
}
/* 报价模板原表截图:等比例适配弹窗,禁止拉伸变形 */
/* 报价模板原表截图:铺满宽度等比例,竖版可纵向滚动,禁止拉扁 */
.qt-snapshot-wrap {
display: flex;
justify-content: center;
align-items: flex-start;
display: block;
max-height: min(72vh, 760px);
min-height: 0;
overflow: auto;
@@ -2316,14 +2314,13 @@ textarea:focus-visible {
.qt-snapshot-img {
display: block;
width: auto;
width: 100%;
height: auto;
max-width: 100%;
max-height: min(70vh, 720px);
max-height: none;
object-fit: contain;
object-position: top center;
flex: 0 0 auto;
align-self: flex-start;
margin: 0 auto;
background: #fff;
cursor: zoom-in;
}
@@ -783,6 +783,10 @@
z-index: 120;
width: 360px;
max-width: calc(100vw - 48px);
/* 视口不够时由菜单限高,chips 区内滚,避免再被外层裁成「看不全且不能滚」 */
max-height: min(420px, calc(100vh - 96px));
display: flex;
flex-direction: column;
border: 1px solid #dfe5ee;
border-radius: 12px;
background: #fff;
@@ -822,9 +826,14 @@
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 260px;
overflow: auto;
/* 高度交给菜单 max-height + flex;min-height:0 才能在限高后真正出现滚动条 */
flex: 1 1 auto;
min-height: 0;
max-height: none;
overflow-x: hidden;
overflow-y: auto;
padding: 12px 14px 14px;
-webkit-overflow-scrolling: touch;
}
.ref-route-chip {
position: relative;
@@ -3,7 +3,7 @@
* 报价模板页:对接主账 /inquiry/quoteTemplate。
* - 上传按钮只在顶栏一处;弹窗用原生 overlay
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
* - 预览:原表截图(LibreOffice 转图)
* - 预览:原表截图(previewSnapshot / LibreOffice→PNG),与运营看到的版式一致
* - 解析状态列可点:弹窗看已解析 / 未解析清单
* - 业务下拉和列表按主账返回的 allowedBizTypes 收窄:陆运角色不出现海运、空运
*/
@@ -93,7 +93,7 @@ const emptyMappingSummary = (): MappingParseSummary => ({
pointCount: 0,
});
/** 原表截图预览状态(不含映射清单) */
/** 原表截图预览状态(LibreOffice PNG/PDF blob) */
const previewState = reactive({
loading: false,
error: "",
@@ -101,10 +101,14 @@ const previewState = reactive({
isImage: true,
mappedCount: 0,
fileName: "",
/** 浏览器侧已裁过白边,防止 @load 重复裁切 */
trimmed: false,
});
/** 展示用文件名:去掉重复 .xlsx.xlsx */
function displayFileName(name: string) {
const n = String(name || "").trim();
return n.replace(/(\.xlsx)+\.xlsx$/i, ".xlsx").replace(/(\.xls)+\.xls$/i, ".xls") || n;
}
/** 列表「解析状态」点击弹窗:已解析 / 未解析 */
const parseDetailOpen = ref(false);
const parseDetailRow = ref<TemplateRow | null>(null);
@@ -158,103 +162,16 @@ const formSub = computed(() =>
: "上传 Excel 后 AI 异步解析映射;失败原因会显示在列表",
);
const formOkText = computed(() => (editing.value ? "保存" : "上传并解析"));
const previewFootHint = "按原表打印版式等比例截图(不拉高/压扁);点击图片可放大。";
const previewFootHint = "按上传原表打印版式截图,比例 1:1 不变形;点击图片可放大。";
const previewLightbox = ref(false);
const filtered = computed(() => rows.value);
function revokePreviewUrl() {
if (previewState.objectUrl && previewState.objectUrl.startsWith("blob:")) {
if (previewState.objectUrl) {
URL.revokeObjectURL(previewState.objectUrl);
previewState.objectUrl = "";
}
previewState.objectUrl = "";
previewState.trimmed = false;
}
/**
* 浏览器侧再裁一层近白边:主账若仍带 A4 页底空白,预览也能贴紧内容。
* 副作用:替换 objectUrl(旧 blob 释放);仅图片预览调用一次。
*/
function onSnapshotImgLoad(ev: Event) {
if (previewState.trimmed || !previewState.isImage) return;
const img = ev.target as HTMLImageElement | null;
if (!img?.naturalWidth || !img.naturalHeight) return;
try {
const next = cropNearWhiteObjectUrl(img);
if (!next) {
previewState.trimmed = true;
return;
}
const old = previewState.objectUrl;
previewState.trimmed = true;
previewState.objectUrl = next;
if (old && old.startsWith("blob:")) {
URL.revokeObjectURL(old);
}
} catch {
previewState.trimmed = true;
}
}
/**
* Canvas 裁近白边 → data URL。几乎无内容可裁时返回 null。
*/
function cropNearWhiteObjectUrl(img: HTMLImageElement): string | null {
const w = img.naturalWidth;
const h = img.naturalHeight;
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, w, h).data;
const isWhite = (i: number) => data[i] >= 240 && data[i + 1] >= 240 && data[i + 2] >= 240;
const rowMostlyWhite = (y: number) => {
const allow = Math.max(2, Math.floor(w / 200));
let dirty = 0;
const row = y * w * 4;
for (let x = 0; x < w; x++) {
if (!isWhite(row + x * 4)) {
dirty++;
if (dirty > allow) return false;
}
}
return true;
};
const colMostlyWhite = (x: number, y0: number, y1: number) => {
const allow = Math.max(2, Math.floor((y1 - y0 + 1) / 200));
let dirty = 0;
for (let y = y0; y <= y1; y++) {
if (!isWhite((y * w + x) * 4)) {
dirty++;
if (dirty > allow) return false;
}
}
return true;
};
let top = 0;
let bottom = h - 1;
let left = 0;
let right = w - 1;
while (top < h && rowMostlyWhite(top)) top++;
while (bottom > top && rowMostlyWhite(bottom)) bottom--;
while (left < w && colMostlyWhite(left, top, bottom)) left++;
while (right > left && colMostlyWhite(right, top, bottom)) right--;
if (top >= bottom || left >= right || bottom - top + 1 < h / 20) return null;
const pad = 8;
const x = Math.max(0, left - pad);
const y = Math.max(0, top - pad);
const cw = Math.min(w - 1, right + pad) - x + 1;
const ch = Math.min(h - 1, bottom + pad) - y + 1;
if (cw >= w && ch >= h) return null;
const out = document.createElement("canvas");
out.width = cw;
out.height = ch;
const octx = out.getContext("2d");
if (!octx) return null;
octx.drawImage(canvas, x, y, cw, ch, 0, 0, cw, ch);
return out.toDataURL("image/png");
}
/**
@@ -269,9 +186,8 @@ async function openPreview(row: TemplateRow) {
previewState.loading = true;
previewState.error = "";
previewState.isImage = true;
previewState.trimmed = false;
previewState.mappedCount = row.mappingPointCount || 0;
previewState.fileName = row.fileName || "";
previewState.fileName = displayFileName(row.fileName || "");
if (!row.versionId) {
previewState.loading = false;
previewState.error = "无可用版本,请先上传模板文件";
@@ -287,6 +203,29 @@ async function openPreview(row: TemplateRow) {
previewState.isImage = contentType.includes("image/") || blob.type.includes("image/");
previewState.objectUrl = URL.createObjectURL(blob);
previewState.loading = false;
// #region agent log
const payload = {
sessionId: "b4fa63",
runId: "post-fix",
hypothesisId: "H10",
location: "index.vue:openPreview",
message: "snapshot preview loaded",
data: {
versionId: row.versionId,
name: row.name,
isImage: previewState.isImage,
blobSize: blob.size,
contentType,
mappedCount: previewState.mappedCount,
},
timestamp: Date.now(),
};
fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "b4fa63" },
body: JSON.stringify(payload),
}).catch(() => {});
// #endregion
} catch (e: any) {
previewState.loading = false;
previewState.error = e?.message || "原表截图尚未就绪,请稍后重试或下载原模板";
@@ -441,6 +380,7 @@ onUnmounted(() => {
window.clearTimeout(parsePollTimer);
parsePollTimer = null;
}
revokePreviewUrl();
});
function resetForm() {
@@ -1040,13 +980,11 @@ async function downloadPreviewOrOriginal() {
<div class="qt-preview-modal-body">
<div class="qt-preview-tags">
<span class="tag" :class="bizTag(previewRow.bizType)">{{
previewState.fileName || previewRow.fileName
displayFileName(previewState.fileName || previewRow.fileName || "")
}}</span>
<span class="tag tag-green">已映射 {{ previewState.mappedCount }} 项</span>
</div>
<div v-if="previewState.loading" class="qt-paper-loading">
正在加载原表截图…
</div>
<div v-if="previewState.loading" class="qt-paper-loading">正在加载原表截图…</div>
<div v-else-if="previewState.error" class="qt-paper-loading qt-paper-loading--err">
{{ previewState.error }}
</div>
@@ -1057,7 +995,6 @@ async function downloadPreviewOrOriginal() {
:src="previewState.objectUrl"
alt="原表截图预览"
title="点击放大"
@load="onSnapshotImgLoad"
@click="openLightbox"
/>
<iframe
@@ -1080,6 +1017,20 @@ async function downloadPreviewOrOriginal() {
</div>
</Teleport>
<!-- 截图放大灯箱:1:1 比例,关灯箱不关预览弹窗 -->
<Teleport to="body">
<div
v-if="previewLightbox && previewState.objectUrl"
class="qt-lightbox"
role="dialog"
aria-modal="true"
@click.self="closeLightbox"
>
<button type="button" class="qt-lightbox-close" @click="closeLightbox">关闭</button>
<img class="qt-lightbox-img" :src="previewState.objectUrl" alt="原表截图放大" />
</div>
</Teleport>
<!-- 解析状态弹窗:已解析 / 未解析清单 -->
<Teleport to="body">
<div
@@ -1160,30 +1111,13 @@ async function downloadPreviewOrOriginal() {
</div>
</Teleport>
<!-- 截图放大灯箱:1:1 比例,仅关灯箱不关预览弹窗 -->
<Teleport to="body">
<div
v-if="previewLightbox && previewState.objectUrl"
class="qt-lightbox"
role="dialog"
aria-modal="true"
@click.self="closeLightbox"
>
<button type="button" class="qt-lightbox-close" @click="closeLightbox">关闭</button>
<img
class="qt-lightbox-img"
:src="previewState.objectUrl"
alt="原表截图放大"
/>
</div>
</Teleport>
</template>
<style scoped>
:deep(.qt-preview-dialog),
.qt-preview-dialog {
max-width: min(96vw, 980px) !important;
width: min(96vw, 980px);
max-width: min(96vw, 1100px) !important;
width: min(96vw, 1100px);
}
.qt-parse-detail-dialog {
max-width: min(96vw, 720px) !important;
@@ -1388,13 +1322,12 @@ async function downloadPreviewOrOriginal() {
color: #b42318;
}
/**
* 原表截图:等比例缩放;容器贴合图片,不人为撑出大块空白。
* 原表截图:优先铺满弹窗宽度(A4 竖版不再被 max-height 压成半宽);
* 高度超出时在容器内纵向滚动,保持等比例、不拉扁。
* 弹窗 Teleport 到 body,关键规则在 admin-fidelity 也有 !important 兜底。
*/
.qt-snapshot-wrap {
display: flex;
justify-content: center;
align-items: flex-start;
display: block;
max-height: min(72vh, 760px);
overflow: auto;
border: 1px solid #e2e8f0;
@@ -1403,19 +1336,18 @@ async function downloadPreviewOrOriginal() {
text-align: center;
box-sizing: border-box;
padding: 6px;
/* 不设 min-height,避免图矮时底下留灰白空带 */
min-height: 0;
}
.qt-snapshot-img {
display: block;
width: auto !important;
width: 100% !important;
height: auto !important;
max-width: 100% !important;
max-height: min(70vh, 720px) !important;
/* 禁止再用 max-height 把竖版 A4 压窄;可读性优先,超出 wrap 滚动 */
max-height: none !important;
object-fit: contain !important;
object-position: top center;
flex: 0 0 auto;
align-self: flex-start;
margin: 0 auto;
vertical-align: top;
background: #fff;
cursor: zoom-in;
@@ -1601,11 +1533,13 @@ async function downloadPreviewOrOriginal() {
box-shadow: inset 0 0 0 1px #2e90fa;
}
.qt-excel-wrap {
max-height: min(62vh, 560px);
max-height: min(68vh, 640px);
overflow: auto;
border: 1px solid #d0d5dd;
border-radius: 8px;
background: #fff;
width: 100%;
max-width: 100%;
}
.qt-excel {
border-collapse: collapse;
@@ -222,7 +222,7 @@ export function buildSheetPreviewViewModel(sheetRes: any, mappingJson?: unknown)
loading: false,
error: String(sheetRes?.error || ""),
sheet: String(sheetRes?.sheet || ""),
fileName: String(sheetRes?.fileName || ""),
fileName: String(sheetRes?.fileName || "").replace(/(\.xlsx)+\.xlsx$/i, ".xlsx"),
colHeaders,
rows,
mappedCount: Number(sheetRes?.mappedCount || mappedHit || 0),
@@ -55,13 +55,15 @@ export const getTemplateSheetPreview = (versionId: string) =>
export async function fetchTemplateSnapshotBlob(versionId: string): Promise<{ blob: Blob; contentType: string }> {
const glob = useGlobSetting();
const base = (glob.apiUrl || '').replace(/\/$/, '');
const url = `${base}${Api.previewSnapshot}?versionId=${encodeURIComponent(versionId)}`;
// _snap=v10:拼接改上下排后强制绕过浏览器对旧「左右拼」图的缓存
const url = `${base}${Api.previewSnapshot}?versionId=${encodeURIComponent(versionId)}&_snap=v10`;
const token = getToken() || '';
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 150 * 1000);
try {
const res = await fetch(url, {
method: 'GET',
cache: 'no-store',
headers: {
'X-Access-Token': String(token),
},
@@ -27,6 +27,7 @@ const LAND_LABEL: Record<string, string> = {
/**
* JSON 字符串 / 对象都解开成扁平键值。坏 JSON 当空。
* 协同快照里的车型等也要能展示,调用方自己决定合并顺序。
* segments_json 若已是数组/对象,序列化成字符串留给 parseSegments,禁止直接丢掉。
*/
export function parseFactsMap(raw: unknown): Record<string, string> {
if (raw == null) return {};
@@ -43,13 +44,97 @@ export function parseFactsMap(raw: unknown): Record<string, string> {
for (const key of Object.keys(obj)) {
const val = obj[key];
if (val == null) continue;
if (typeof val === "object") continue;
if (typeof val === "object") {
// 多段 segments_json 常被反序列成数组;保留才能在详情里画出段1/段2。
if (
(key === "segments_json" || key === "segmentsJson") &&
(Array.isArray(val) || typeof val === "object")
) {
try {
out[key] = JSON.stringify(val);
} catch {
/* ignore */
}
}
continue;
}
const text = String(val).trim();
if (text) out[key] = text;
}
return out;
}
/** 列表子工单行:一段的运输方式码 + 展示文案。 */
export type MultiSegmentView = {
index: number;
/** AIR / SEA / LAND */
mode: string;
modeWord: string;
/** 子工单左侧详情:海运 · 上海 → 汉堡 · … */
detailLine: string;
/** 旧列表兼容:段1陆运 北京 → 上海 茶叶 */
summaryLine: string;
};
/**
* 多段各段一行摘要(段1陆运 北京 → 上海)。无段返回空数组。
*/
export function multiSegmentSummaryLines(ticket: any): string[] {
return multiSegmentViews(ticket).map((seg) => seg.summaryLine);
}
/**
* 多段子工单视图:列表父行下灰底子行用。
* 详情串尽量贴近原型「方式 · 路线 · 货量条款」;缺字段就跳过,不编造。
*/
export function multiSegmentViews(ticket: any): MultiSegmentView[] {
const facts = mergeInquiryFacts(ticket);
const segs = parseSegments(facts);
return segs.map((seg) => {
const sf = seg.facts;
const o = pick(sf, "起运港", "起运地", "始发站");
const d = pick(sf, "目的港", "目的地", "目的站");
const g = pick(sf, "品名", "货物品名");
const route = o || d ? `${o || "-"} → ${d || "-"}` : "";
const bits: string[] = [seg.modeWord];
if (route) bits.push(route);
const extras = [
pick(sf, "运输条款", "贸易条款"),
pick(sf, "整柜或拼柜"),
pick(sf, "箱型箱量"),
pick(sf, "包装方式", "包装类型"),
pick(sf, "运输类型"),
pick(sf, "客户"),
volumeWeightText(sf),
pick(sf, "车型/数量", "车型"),
readyDateText(sf),
g ? `品名 ${g}` : "",
].filter(Boolean);
bits.push(...extras);
return {
index: seg.index,
mode: seg.mode,
modeWord: seg.modeWord,
detailLine: bits.join(" · "),
summaryLine: [`段${seg.index}${seg.modeWord}`, route, g].filter(Boolean).join(" "),
};
});
}
function volumeWeightText(sf: Record<string, string>): string {
const vol = pick(sf, "体积");
const w = pick(sf, "毛重", "重量");
if (vol && w) return `${vol} / ${w}`;
if (vol) return vol;
if (w) return w;
return "";
}
function readyDateText(sf: Record<string, string>): string {
const d = pick(sf, "货好时间", "cargo_ready_date");
return d ? `货好 ${d}` : "";
}
/** 询价事实优先,协同只补空键(中港整车车型常在协同里)。 */
export function mergeInquiryFacts(ticket: any): Record<string, string> {
const inquiry = parseFactsMap(ticket?.factsJson ?? ticket?.facts);
@@ -69,6 +154,40 @@ function pick(facts: Record<string, string>, ...keys: string[]): string {
return "";
}
const MODE_WORD: Record<string, string> = { AIR: "空运", SEA: "海运", LAND: "陆运" };
/** 从 facts.segments_json 解出各段;坏 JSON 当没有段。 */
function parseSegments(facts: Record<string, string>): Array<{
index: number;
mode: string;
modeWord: string;
facts: Record<string, string>;
}> {
const raw = (facts.segments_json || facts.segmentsJson || "").trim();
if (!raw) return [];
try {
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
return arr
.filter((x) => x && typeof x === "object")
.map((seg: any, i: number) => {
const mode = String(seg.mode || "").toUpperCase();
const nested =
seg.facts && typeof seg.facts === "object" && !Array.isArray(seg.facts)
? parseFactsMap(seg.facts)
: {};
return {
index: Number(seg.index) > 0 ? Number(seg.index) : i + 1,
mode,
modeWord: MODE_WORD[mode] || mode || "段",
facts: nested,
};
});
} catch {
return [];
}
}
function dash(v: unknown): string {
const s = (v ?? "").toString().trim();
return s ? s : "-";
@@ -183,8 +302,34 @@ export function buildDemandFields(ticket: any): DemandField[] {
push(fields, "增值服务", added);
}
push(fields, "报价日期", quoteDate);
} else if (line === "MULTI") {
// 多段:按 segments_json 逐段展示,并带整票工单号。
push(fields, "工单号", columnText(ticket?.workOrderNo) || columnText(ticket?.work_order_no));
const segs = parseSegments(facts);
if (segs.length) {
for (const seg of segs) {
const sf = seg.facts;
const o = pick(sf, "起运港", "起运地", "始发站");
const d = pick(sf, "目的港", "目的地", "目的站");
const g = pick(sf, "品名", "货物品名");
push(
fields,
`段${seg.index}${seg.modeWord}`,
[o || d ? `${o || "-"} → ${d || "-"}` : "", g, pick(sf, "件数"), pick(sf, "毛重", "重量"), pick(sf, "体积")]
.filter(Boolean)
.join(" · "),
);
}
} else {
push(fields, "起运 / 目的", origin || dest ? `${origin || "-"} → ${dest || "-"}` : "");
push(fields, "品名", goods);
}
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
push(fields, "报价日期", quoteDate);
} else {
// 多段或未识别:至少把路线和货量列出来,避免整块空白。
// 未识别:至少把路线和货量列出来,避免整块空白。
push(fields, "起运 / 目的", origin || dest ? `${origin || "-"} → ${dest || "-"}` : "");
push(fields, "品名", goods);
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@ enum Api {
list = '/inquiry/workOrder/view/list',
detail = '/inquiry/workOrder/view/detail',
exportXls = '/inquiry/workOrder/view/export',
attachDownload = '/inquiry/workOrder/view/attachment/download',
transition = '/inquiry/workOrder/transition',
abnormalHandle = '/inquiry/workOrder/abnormal/handle',
abnormalRetry = '/inquiry/workOrder/abnormal/retry',
@@ -26,3 +27,9 @@ export const retryAbnormal = (data) => defHttp.post({ url: Api.abnormalRetry, da
*/
export const exportWorkOrders = (params: Record<string, any>) =>
downloadAuthGet(Api.exportXls, params, '询价工单.xlsx');
/**
* 下载工单附件:走主账鉴权代理,不直连内网 MinIO。
*/
export const downloadWorkOrderAttachment = (id: string, fileName?: string) =>
downloadAuthGet(Api.attachDownload, { id }, fileName || 'attachment.bin');
@@ -48,6 +48,28 @@
const statusMapRef = ref(new Map<string | number, MapValue>());
const { query } = useRoute();
const route = useRoute();
// #region agent log
fetch('http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'a0b47d' },
body: JSON.stringify({
sessionId: 'a0b47d',
runId: 'pre-fix',
hypothesisId: 'H5',
location: 'Exception.vue:setup',
message: '进入 ErrorPage(前端路由未匹配)',
data: {
fullPath: route.fullPath,
path: route.path,
name: String(route.name || ''),
statusProp: props.status,
queryStatus: query?.status,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
const go = useGo();
const redo = useRedo();
const { t } = useI18n();
@@ -5,7 +5,7 @@
<div class="app-loading-dots">
<span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
</div>
<div class="app-loading-title">友通达智能体运营后台</div>
<div class="app-loading-title">友通达智价通AIQ管理后台</div>
</div>
</div>
</template>
@@ -1,97 +1 @@
import { buildDemandFields, quantitySummary } from "/@/views/inquiry/workOrder/demandFields";
test("land FTL shows vehicle not pieces", () => {
const rows = buildDemandFields({
businessLine: "LAND",
bizTypeLabel: "陆运",
origin: "上海",
destination: "春疆货场",
goodsName: "茶叶",
factsJson: JSON.stringify({
运输类型: "国内运输整车",
线路类别: "国内长途/零担",
运输分类: "普件",
"车型/数量": "53尺(16.5厢车)",
起运港: "上海",
目的港: "春疆货场",
品名: "茶叶",
报价日期: "2026-10-03",
}),
});
const map = Object.fromEntries(rows.map((r) => [r.label, r.value]));
expect(map["运输方式"]).toBe("陆运");
expect(map["运输类型"]).toBe("国内运输整车");
expect(map["车型/数量"]).toBe("53尺(16.5厢车)");
expect(map["始发站"]).toBe("上海");
expect(map["货物品名"]).toBe("茶叶");
expect(map["件数"]).toBeUndefined();
expect(map["增值服务"]).toBeUndefined();
});
test("air shows added services only when filled", () => {
const filled = buildDemandFields({
businessLine: "AIR",
bizTypeLabel: "空运",
factsJson: JSON.stringify({
起运港: "深圳",
目的港: "吉隆坡",
品名: "茶叶",
件数: "10",
毛重: "100",
体积: "2",
包装方式: "纸箱",
报价日期: "2026-10-13",
added_services: "托盘、前置仓、带电",
}),
});
const map = Object.fromEntries(filled.map((r) => [r.label, r.value]));
expect(map["增值服务"]).toBe("托盘、前置仓、带电");
expect(map["包装类型"]).toBe("纸箱");
const empty = buildDemandFields({
businessLine: "AIR",
bizTypeLabel: "空运",
factsJson: JSON.stringify({
目的港: "吉隆坡",
品名: "茶叶",
包装方式: "纸箱",
}),
});
const emptyMap = Object.fromEntries(empty.map((r) => [r.label, r.value]));
expect(emptyMap["增值服务"]).toBeUndefined();
});
test("sea shows container spec", () => {
const rows = buildDemandFields({
businessLine: "SEA",
bizTypeLabel: "海运",
factsJson: JSON.stringify({
起运港: "上海",
目的港: "汉堡",
品名: "配件",
货量: "1x40HQ",
整柜或拼柜: "整柜",
箱型箱量: "40HQ*1",
贸易条款: "FOB",
运输分类: "Port to Port",
报价日期: "2026-10-03",
}),
});
const map = Object.fromEntries(rows.map((r) => [r.label, r.value]));
expect(map["箱型箱量"]).toBe("40HQ*1");
expect(map["整柜或拼柜"]).toBe("整柜");
expect(map["件数"]).toBeUndefined();
});
test("land LTL quantity summary uses volume", () => {
const qty = quantitySummary({
businessLine: "LAND",
factsJson: JSON.stringify({
运输类型: "国内运输拼车",
体积: "10CBM",
毛重: "50kg",
}),
});
expect(qty).toContain("体积(CBM) 10CBM");
expect(qty).not.toContain("车型");
});