机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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("车型");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user