feat: MCP/AI Agent 接入顾问与查价问 AI,并补齐 /ai /mcp 反代

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-29 12:47:32 +08:00
parent a2e71a3a6b
commit 30a69b69c7
26 changed files with 1962 additions and 291 deletions

View File

@ -10,8 +10,14 @@
"ExtractPropTypes": true, "ExtractPropTypes": true,
"ExtractPublicPropTypes": true, "ExtractPublicPropTypes": true,
"InjectionKey": true, "InjectionKey": true,
"MCP_CAPABILITY_GUIDES": true,
"MaybeRef": true, "MaybeRef": true,
"MaybeRefOrGetter": true, "MaybeRefOrGetter": true,
"McpCapabilityGuide": true,
"McpCapabilityId": true,
"McpFieldHint": true,
"PriceAskContext": true,
"PriceAskIntent": true,
"PropType": true, "PropType": true,
"Ref": true, "Ref": true,
"ScanFeedbackType": true, "ScanFeedbackType": true,
@ -27,6 +33,7 @@
"asyncComputed": true, "asyncComputed": true,
"autoResetRef": true, "autoResetRef": true,
"buildAskAdvisorQuery": true, "buildAskAdvisorQuery": true,
"buildPriceAskPrefill": true,
"buildWaybillContextFromRow": true, "buildWaybillContextFromRow": true,
"computed": true, "computed": true,
"computedAsync": true, "computedAsync": true,
@ -49,18 +56,23 @@
"defineAsyncComponent": true, "defineAsyncComponent": true,
"defineComponent": true, "defineComponent": true,
"defineStore": true, "defineStore": true,
"detectMissingFields": true,
"eagerComputed": true, "eagerComputed": true,
"effectScope": true, "effectScope": true,
"extendRef": true, "extendRef": true,
"extractOrderNo": true,
"getActivePinia": true, "getActivePinia": true,
"getCapability": true,
"getCurrentInstance": true, "getCurrentInstance": true,
"getCurrentScope": true, "getCurrentScope": true,
"getPriceAskLocation": true,
"getWaybillContextKey": true, "getWaybillContextKey": true,
"getWaybillDisplayNo": true, "getWaybillDisplayNo": true,
"getWaybillPrefillText": true, "getWaybillPrefillText": true,
"getWaybillReturnPath": true, "getWaybillReturnPath": true,
"h": true, "h": true,
"ignorableWatch": true, "ignorableWatch": true,
"inferCapabilityFromText": true,
"inject": true, "inject": true,
"isDefined": true, "isDefined": true,
"isProxy": true, "isProxy": true,
@ -96,6 +108,7 @@
"onUpdated": true, "onUpdated": true,
"onWatcherCleanup": true, "onWatcherCleanup": true,
"openAskAdvisor": true, "openAskAdvisor": true,
"openPriceAskAdvisor": true,
"parseWaybillContextFromQuery": true, "parseWaybillContextFromQuery": true,
"pausableWatch": true, "pausableWatch": true,
"provide": true, "provide": true,

View File

@ -193,6 +193,9 @@ server {
location ^~ /system/xhsAiAssistant { location ^~ /system/xhsAiAssistant {
try_files /index.html =200; try_files /index.html =200;
} }
location ^~ /system/mcpTools {
try_files /index.html =200;
}
location ^~ /system/dataPerm/ { location ^~ /system/dataPerm/ {
try_files /index.html =200; try_files /index.html =200;
} }
@ -302,6 +305,32 @@ server {
proxy_read_timeout 300s; proxy_read_timeout 300s;
} }
location ^~ /mcp/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 300s;
}
location ^~ /ai/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 300s;
}
# HTML 不缓存 # HTML 不缓存
location = /index.html { location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";

View File

@ -93,6 +93,7 @@ server {
location ^~ /system/batchEmail { try_files /index.html =200; } location ^~ /system/batchEmail { try_files /index.html =200; }
location ^~ /system/otherConfig { try_files /index.html =200; } location ^~ /system/otherConfig { try_files /index.html =200; }
location ^~ /system/xhsAiAssistant { try_files /index.html =200; } location ^~ /system/xhsAiAssistant { try_files /index.html =200; }
location ^~ /system/mcpTools { try_files /index.html =200; }
location ^~ /system/dataPerm/ { try_files /index.html =200; } location ^~ /system/dataPerm/ { try_files /index.html =200; }
location ^~ /api/apiwechat/ { location ^~ /api/apiwechat/ {
@ -167,6 +168,28 @@ server {
proxy_read_timeout 300s; proxy_read_timeout 300s;
} }
location ^~ /mcp/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
location ^~ /ai/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
location = /index.html { location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; add_header Pragma "no-cache";

129
src/api/mcp/index.ts Normal file
View File

@ -0,0 +1,129 @@
/**
* 轻量 MCP 工具管理 / AI Agent
*/
import { axiosInstance } from '@/api/rbac';
import { resolveAdminApiPath } from '@/utils/adminApi';
function mcpUrl(path: string): string {
return resolveAdminApiPath(path);
}
function assertBusinessOk(body: any) {
if (body == null) throw new Error('空响应');
const code = body.code ?? body.Code;
const ok =
code === undefined ||
code === null ||
code === 0 ||
code === '0' ||
code === 200 ||
code === '200';
if (!ok) {
throw new Error(body.message ?? body.Message ?? body.msg ?? '请求失败');
}
return body.data ?? body.Data ?? body;
}
export interface McpToolItem {
id?: number;
toolKey: string;
displayName: string;
description: string;
enabled: boolean;
sortOrder: number;
inputSchemaJson: string;
builtin?: boolean;
}
export interface McpToolCallResult {
toolKey: string;
success: boolean;
content: string;
elapsedMs: number;
}
export interface AiAgentToolTrace {
toolKey: string;
argumentsJson: string;
resultPreview: string;
success: boolean;
}
export interface AiAgentChatResult {
content: string;
toolTraces: AiAgentToolTrace[];
}
export async function listMcpTools(): Promise<McpToolItem[]> {
const res = await axiosInstance.post(mcpUrl('/mcp/tool/list'), {});
const data = assertBusinessOk(res.data);
const items = data?.items ?? data?.Items ?? [];
return (items as any[]).map((x) => ({
id: x.id ?? x.Id,
toolKey: String(x.toolKey ?? x.ToolKey ?? ''),
displayName: String(x.displayName ?? x.DisplayName ?? ''),
description: String(x.description ?? x.Description ?? ''),
enabled: Boolean(x.enabled ?? x.Enabled ?? true),
sortOrder: Number(x.sortOrder ?? x.SortOrder ?? 0),
inputSchemaJson: String(x.inputSchemaJson ?? x.InputSchemaJson ?? ''),
builtin: Boolean(x.builtin ?? x.Builtin ?? true),
}));
}
export async function saveMcpTool(payload: {
toolKey: string;
displayName: string;
description: string;
enabled: boolean;
sortOrder: number;
}): Promise<McpToolItem> {
const res = await axiosInstance.post(mcpUrl('/mcp/tool/save'), payload);
const x = assertBusinessOk(res.data) as any;
return {
id: x.id ?? x.Id,
toolKey: String(x.toolKey ?? x.ToolKey ?? ''),
displayName: String(x.displayName ?? x.DisplayName ?? ''),
description: String(x.description ?? x.Description ?? ''),
enabled: Boolean(x.enabled ?? x.Enabled ?? true),
sortOrder: Number(x.sortOrder ?? x.SortOrder ?? 0),
inputSchemaJson: String(x.inputSchemaJson ?? x.InputSchemaJson ?? ''),
builtin: true,
};
}
export async function callMcpTool(toolKey: string, argumentsJson: string): Promise<McpToolCallResult> {
const res = await axiosInstance.post(mcpUrl('/mcp/tool/call'), {
toolKey,
argumentsJson,
});
const x = assertBusinessOk(res.data) as any;
return {
toolKey: String(x.toolKey ?? x.ToolKey ?? toolKey),
success: Boolean(x.success ?? x.Success),
content: String(x.content ?? x.Content ?? ''),
elapsedMs: Number(x.elapsedMs ?? x.ElapsedMs ?? 0),
};
}
export async function chatAiAgent(payload: {
providerKey: string;
model?: string;
messages: { role: string; content: string }[];
toolKeys?: string[];
maxToolRounds?: number;
}): Promise<AiAgentChatResult> {
// 走 /ai/agent/chat:线上已有;OMS nginx / Vite 须反代 /ai/(见 nginx-*.conf)
// 后端另注册 /mcp/agent/chat,部署后可再切
const res = await axiosInstance.post(mcpUrl('/ai/agent/chat'), payload);
const x = assertBusinessOk(res.data) as any;
const traces = (x.toolTraces ?? x.ToolTraces ?? []) as any[];
return {
content: String(x.content ?? x.Content ?? ''),
toolTraces: traces.map((t) => ({
toolKey: String(t.toolKey ?? t.ToolKey ?? ''),
argumentsJson: String(t.argumentsJson ?? t.ArgumentsJson ?? ''),
resultPreview: String(t.resultPreview ?? t.ResultPreview ?? ''),
success: Boolean(t.success ?? t.Success),
})),
};
}

22
src/auto-imports.d.ts vendored
View File

@ -8,10 +8,12 @@ export {}
declare global { declare global {
const $baseConfirm: typeof import('./composables/useMessage')['$baseConfirm'] const $baseConfirm: typeof import('./composables/useMessage')['$baseConfirm']
const EffectScope: typeof import('vue')['EffectScope'] const EffectScope: typeof import('vue')['EffectScope']
const MCP_CAPABILITY_GUIDES: typeof import('./composables/assistantMcpGuide')['MCP_CAPABILITY_GUIDES']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed'] const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef'] const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const buildAskAdvisorQuery: typeof import('./composables/useAskAdvisor')['buildAskAdvisorQuery'] const buildAskAdvisorQuery: typeof import('./composables/useAskAdvisor')['buildAskAdvisorQuery']
const buildPriceAskPrefill: typeof import('./composables/priceAskAdvisor')['buildPriceAskPrefill']
const buildWaybillContextFromRow: typeof import('./composables/useAskAdvisor')['buildWaybillContextFromRow'] const buildWaybillContextFromRow: typeof import('./composables/useAskAdvisor')['buildWaybillContextFromRow']
const computed: typeof import('vue')['computed'] const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync'] const computedAsync: typeof import('@vueuse/core')['computedAsync']
@ -34,18 +36,23 @@ declare global {
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent'] const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore'] const defineStore: typeof import('pinia')['defineStore']
const detectMissingFields: typeof import('./composables/assistantMcpGuide')['detectMissingFields']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed'] const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope'] const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef'] const extendRef: typeof import('@vueuse/core')['extendRef']
const extractOrderNo: typeof import('./composables/assistantMcpGuide')['extractOrderNo']
const getActivePinia: typeof import('pinia')['getActivePinia'] const getActivePinia: typeof import('pinia')['getActivePinia']
const getCapability: typeof import('./composables/assistantMcpGuide')['getCapability']
const getCurrentInstance: typeof import('vue')['getCurrentInstance'] const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope'] const getCurrentScope: typeof import('vue')['getCurrentScope']
const getPriceAskLocation: typeof import('./composables/priceAskAdvisor')['getPriceAskLocation']
const getWaybillContextKey: typeof import('./composables/useAskAdvisor')['getWaybillContextKey'] const getWaybillContextKey: typeof import('./composables/useAskAdvisor')['getWaybillContextKey']
const getWaybillDisplayNo: typeof import('./composables/useAskAdvisor')['getWaybillDisplayNo'] const getWaybillDisplayNo: typeof import('./composables/useAskAdvisor')['getWaybillDisplayNo']
const getWaybillPrefillText: typeof import('./composables/useAskAdvisor')['getWaybillPrefillText'] const getWaybillPrefillText: typeof import('./composables/useAskAdvisor')['getWaybillPrefillText']
const getWaybillReturnPath: typeof import('./composables/useAskAdvisor')['getWaybillReturnPath'] const getWaybillReturnPath: typeof import('./composables/useAskAdvisor')['getWaybillReturnPath']
const h: typeof import('vue')['h'] const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch'] const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inferCapabilityFromText: typeof import('./composables/assistantMcpGuide')['inferCapabilityFromText']
const inject: typeof import('vue')['inject'] const inject: typeof import('vue')['inject']
const isDefined: typeof import('@vueuse/core')['isDefined'] const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy'] const isProxy: typeof import('vue')['isProxy']
@ -81,6 +88,7 @@ declare global {
const onUpdated: typeof import('vue')['onUpdated'] const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const openAskAdvisor: typeof import('./composables/useAskAdvisor')['openAskAdvisor'] const openAskAdvisor: typeof import('./composables/useAskAdvisor')['openAskAdvisor']
const openPriceAskAdvisor: typeof import('./composables/priceAskAdvisor')['openPriceAskAdvisor']
const parseWaybillContextFromQuery: typeof import('./composables/useAskAdvisor')['parseWaybillContextFromQuery'] const parseWaybillContextFromQuery: typeof import('./composables/useAskAdvisor')['parseWaybillContextFromQuery']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch'] const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide'] const provide: typeof import('vue')['provide']
@ -323,6 +331,12 @@ declare global {
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue') import('vue')
// @ts-ignore // @ts-ignore
export type { McpCapabilityId, McpFieldHint, McpCapabilityGuide } from './composables/assistantMcpGuide'
import('./composables/assistantMcpGuide')
// @ts-ignore
export type { PriceAskIntent, PriceAskContext } from './composables/priceAskAdvisor'
import('./composables/priceAskAdvisor')
// @ts-ignore
export type { WaybillSource, WaybillContext } from './composables/useAskAdvisor' export type { WaybillSource, WaybillContext } from './composables/useAskAdvisor'
import('./composables/useAskAdvisor') import('./composables/useAskAdvisor')
// @ts-ignore // @ts-ignore
@ -343,10 +357,12 @@ declare module 'vue' {
interface ComponentCustomProperties { interface ComponentCustomProperties {
readonly $baseConfirm: UnwrapRef<typeof import('./composables/useMessage')['$baseConfirm']> readonly $baseConfirm: UnwrapRef<typeof import('./composables/useMessage')['$baseConfirm']>
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']> readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly MCP_CAPABILITY_GUIDES: UnwrapRef<typeof import('./composables/assistantMcpGuide')['MCP_CAPABILITY_GUIDES']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']> readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']> readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']> readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly buildAskAdvisorQuery: UnwrapRef<typeof import('./composables/useAskAdvisor')['buildAskAdvisorQuery']> readonly buildAskAdvisorQuery: UnwrapRef<typeof import('./composables/useAskAdvisor')['buildAskAdvisorQuery']>
readonly buildPriceAskPrefill: UnwrapRef<typeof import('./composables/priceAskAdvisor')['buildPriceAskPrefill']>
readonly buildWaybillContextFromRow: UnwrapRef<typeof import('./composables/useAskAdvisor')['buildWaybillContextFromRow']> readonly buildWaybillContextFromRow: UnwrapRef<typeof import('./composables/useAskAdvisor')['buildWaybillContextFromRow']>
readonly computed: UnwrapRef<typeof import('vue')['computed']> readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']> readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
@ -369,18 +385,23 @@ declare module 'vue' {
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']> readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']> readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']> readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly detectMissingFields: UnwrapRef<typeof import('./composables/assistantMcpGuide')['detectMissingFields']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']> readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']> readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']> readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly extractOrderNo: UnwrapRef<typeof import('./composables/assistantMcpGuide')['extractOrderNo']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']> readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCapability: UnwrapRef<typeof import('./composables/assistantMcpGuide')['getCapability']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']> readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']> readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getPriceAskLocation: UnwrapRef<typeof import('./composables/priceAskAdvisor')['getPriceAskLocation']>
readonly getWaybillContextKey: UnwrapRef<typeof import('./composables/useAskAdvisor')['getWaybillContextKey']> readonly getWaybillContextKey: UnwrapRef<typeof import('./composables/useAskAdvisor')['getWaybillContextKey']>
readonly getWaybillDisplayNo: UnwrapRef<typeof import('./composables/useAskAdvisor')['getWaybillDisplayNo']> readonly getWaybillDisplayNo: UnwrapRef<typeof import('./composables/useAskAdvisor')['getWaybillDisplayNo']>
readonly getWaybillPrefillText: UnwrapRef<typeof import('./composables/useAskAdvisor')['getWaybillPrefillText']> readonly getWaybillPrefillText: UnwrapRef<typeof import('./composables/useAskAdvisor')['getWaybillPrefillText']>
readonly getWaybillReturnPath: UnwrapRef<typeof import('./composables/useAskAdvisor')['getWaybillReturnPath']> readonly getWaybillReturnPath: UnwrapRef<typeof import('./composables/useAskAdvisor')['getWaybillReturnPath']>
readonly h: UnwrapRef<typeof import('vue')['h']> readonly h: UnwrapRef<typeof import('vue')['h']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']> readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inferCapabilityFromText: UnwrapRef<typeof import('./composables/assistantMcpGuide')['inferCapabilityFromText']>
readonly inject: UnwrapRef<typeof import('vue')['inject']> readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']> readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']> readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
@ -416,6 +437,7 @@ declare module 'vue' {
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']> readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']> readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly openAskAdvisor: UnwrapRef<typeof import('./composables/useAskAdvisor')['openAskAdvisor']> readonly openAskAdvisor: UnwrapRef<typeof import('./composables/useAskAdvisor')['openAskAdvisor']>
readonly openPriceAskAdvisor: UnwrapRef<typeof import('./composables/priceAskAdvisor')['openPriceAskAdvisor']>
readonly parseWaybillContextFromQuery: UnwrapRef<typeof import('./composables/useAskAdvisor')['parseWaybillContextFromQuery']> readonly parseWaybillContextFromQuery: UnwrapRef<typeof import('./composables/useAskAdvisor')['parseWaybillContextFromQuery']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']> readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly provide: UnwrapRef<typeof import('vue')['provide']> readonly provide: UnwrapRef<typeof import('vue')['provide']>

View File

@ -127,6 +127,33 @@
<h2>{{ t('nanaAssistant.welcomeTitle') }}</h2> <h2>{{ t('nanaAssistant.welcomeTitle') }}</h2>
<p>{{ t('nanaAssistant.welcomeSub') }}</p> <p>{{ t('nanaAssistant.welcomeSub') }}</p>
</div> </div>
<p class="ai-quick-section-label">{{ t('nanaAssistant.mcp.sectionLabel') }}</p>
<div class="ai-mcp-grid">
<button
v-for="cap in mcpCapabilities"
:key="cap.id"
type="button"
class="ai-mcp-card"
:disabled="chatLoading"
@click="startMcpCapability(cap.id)"
>
<span class="ai-mcp-card__title">{{ t(cap.titleKey) }}</span>
<span class="ai-mcp-card__desc">{{ t(cap.descKey) }}</span>
<ul class="ai-mcp-card__fields">
<li
v-for="f in cap.fields"
:key="f.key"
:class="f.required ? 'is-required' : 'is-optional'"
>
{{ f.required ? t('nanaAssistant.mcp.required') : t('nanaAssistant.mcp.optional') }}
{{ t(f.labelKey) }}
</li>
</ul>
<span class="ai-mcp-card__example">{{ t(cap.exampleKey) }}</span>
</button>
</div>
<p class="ai-quick-section-label">{{ t('nanaAssistant.quickLabel') }}</p> <p class="ai-quick-section-label">{{ t('nanaAssistant.quickLabel') }}</p>
<div class="ai-quick-grid"> <div class="ai-quick-grid">
<button <button
@ -187,7 +214,7 @@
class="ai-composer-input" class="ai-composer-input"
rows="1" rows="1"
maxlength="2000" maxlength="2000"
:placeholder="t('nanaAssistant.inputPlaceholder')" :placeholder="composerPlaceholder"
:disabled="chatLoading" :disabled="chatLoading"
@keydown.enter.exact.prevent="sendMessage" @keydown.enter.exact.prevent="sendMessage"
@input="resizeInput" @input="resizeInput"
@ -282,7 +309,8 @@ import {
IconUser, IconUser,
IconWifi, IconWifi,
} from '@arco-design/web-vue/es/icon'; } from '@arco-design/web-vue/es/icon';
import { chatXhsAi, listXhsAiProviders } from '@/api/xhs'; import { chatAiAgent } from '@/api/mcp';
import { listXhsAiProviders } from '@/api/xhs';
import { getOrderDetail } from '@/api/order'; import { getOrderDetail } from '@/api/order';
import { CustomerService, type CustomerServiceAdvisorDto } from '@/api/customer'; import { CustomerService, type CustomerServiceAdvisorDto } from '@/api/customer';
import { getOrderStatusMeta, resolveOrderStatusCode } from '@/constants/orderStatus'; import { getOrderStatusMeta, resolveOrderStatusCode } from '@/constants/orderStatus';
@ -295,7 +323,18 @@ import {
parseWaybillContextFromQuery, parseWaybillContextFromQuery,
type WaybillContext, type WaybillContext,
} from '@/composables/useAskAdvisor'; } from '@/composables/useAskAdvisor';
import {
MCP_CAPABILITY_GUIDES,
detectMissingFields,
extractOrderNo,
getCapability,
inferCapabilityFromText,
type McpCapabilityId,
} from '@/composables/assistantMcpGuide';
import { isMobileUserAgent, isWeChatBrowser } from '@/utils/device'; import { isMobileUserAgent, isWeChatBrowser } from '@/utils/device';
import {
providerModelTags,
} from '@/views/system/xhsAiAssistant/providerCatalog';
import NanaAvatar from './NanaAvatar.vue'; import NanaAvatar from './NanaAvatar.vue';
import logoImg from '@/assets/logo2.png'; import logoImg from '@/assets/logo2.png';
@ -351,7 +390,12 @@ const inputText = ref('');
const chatLoading = ref(false); const chatLoading = ref(false);
const providerLoading = ref(false); const providerLoading = ref(false);
const providerKey = ref(''); const providerKey = ref('');
const providerModel = ref('');
const activeCapability = ref<McpCapabilityId | null>(null);
const guidedSession = ref(false);
const threads = ref<Thread[]>([]); const threads = ref<Thread[]>([]);
const mcpCapabilities = MCP_CAPABILITY_GUIDES;
const activeThreadId = ref<string | null>(null); const activeThreadId = ref<string | null>(null);
const messages = ref<ChatMessage[]>([]); const messages = ref<ChatMessage[]>([]);
const mainScrollRef = ref<HTMLElement>(); const mainScrollRef = ref<HTMLElement>();
@ -416,12 +460,24 @@ const filteredThreads = computed(() => {
}); });
}); });
const isChatting = computed(() => messages.value.some((m) => m.role === 'user')); const isChatting = computed(
() => messages.value.some((m) => m.role === 'user') || guidedSession.value,
);
const showWelcome = computed(() => !isChatting.value && !waybillContext.value); const showWelcome = computed(() => !isChatting.value && !waybillContext.value);
const canSend = computed(() => Boolean(inputText.value.trim() && !chatLoading.value)); const canSend = computed(() => Boolean(inputText.value.trim() && !chatLoading.value));
const composerPlaceholder = computed(() => {
if (activeCapability.value) {
return t(getCapability(activeCapability.value).exampleKey);
}
if (waybillContext.value) {
return t('nanaAssistant.inputPlaceholderTrack');
}
return t('nanaAssistant.inputPlaceholderLive');
});
const youInitial = computed(() => t('nanaAssistant.you').charAt(0).toUpperCase()); const youInitial = computed(() => t('nanaAssistant.you').charAt(0).toUpperCase());
const waybillDisplayNo = computed(() => const waybillDisplayNo = computed(() =>
@ -580,6 +636,8 @@ function deleteThread(threadId: string) {
function startNewChat() { function startNewChat() {
if (chatLoading.value) return; if (chatLoading.value) return;
syncActiveThreadMessages(); syncActiveThreadMessages();
activeCapability.value = null;
guidedSession.value = false;
const current = getActiveThread(); const current = getActiveThread();
if (current && current.messages.length === 0) { if (current && current.messages.length === 0) {
historyOpen.value = false; historyOpen.value = false;
@ -742,33 +800,31 @@ async function refreshWaybillStatusLabel() {
function resolveTagReply(tagId: string) { function resolveTagReply(tagId: string) {
const def = quickTagDefs.find((tag) => tag.id === tagId); const def = quickTagDefs.find((tag) => tag.id === tagId);
if (!def) return t('nanaAssistant.fallback'); if (!def) return t('nanaAssistant.emptyReply');
if (def.track && waybillContext.value) { if (def.track && waybillContext.value) {
return t('nanaAssistant.trackWaybillDemo', { no: waybillDisplayNo.value }); return t('nanaAssistant.trackWaybillDemo', { no: waybillDisplayNo.value });
} }
if (def.track) return t('nanaAssistant.trackHint'); if (def.track) return t('nanaAssistant.trackHint');
if (def.replyKey) return t(def.replyKey); if (def.replyKey) return t(def.replyKey);
return t('nanaAssistant.fallback'); return t('nanaAssistant.emptyReply');
} }
function isTrackQuestion(text: string) { function isTrackQuestion(text: string) {
const lower = text.toLowerCase(); const lower = text.toLowerCase();
return /track|tracking|物流|单号|追踪|轨迹/.test(lower); return /track|tracking|物流|单号|追踪|轨迹/.test(lower) || Boolean(extractOrderNo(text));
} }
function mockReply(userText: string) { /** 未接 AI 时的诚实提示,不再假装已理解并乱要「起运地」 */
const lower = userText.toLowerCase(); function offlineReply(userText: string) {
if (waybillContext.value && isTrackQuestion(userText)) { if (waybillContext.value && isTrackQuestion(userText)) {
return t('nanaAssistant.trackWaybillDemo', { no: waybillDisplayNo.value }); return t('nanaAssistant.trackWaybillDemo', { no: waybillDisplayNo.value });
} }
if (/track|tracking|物流|单号|追踪/.test(lower)) return t('nanaAssistant.trackHint'); if (providerLoading.value) return t('nanaAssistant.providerLoading');
if (/price|cost|quote|报价|价格|多少钱/.test(lower)) return t('nanaAssistant.replyPrice'); return t('nanaAssistant.aiUnavailable');
if (/small|difficult|疑难|小批量|不接/.test(lower)) return t('nanaAssistant.replySmall');
return t('nanaAssistant.fallback');
} }
function demoReplyDelay() { function demoReplyDelay() {
return 700 + Math.random() * 400; return 400 + Math.random() * 200;
} }
async function appendDemoReply(replyText: string) { async function appendDemoReply(replyText: string) {
@ -781,11 +837,20 @@ async function loadProvider() {
providerLoading.value = true; providerLoading.value = true;
try { try {
const items = await listXhsAiProviders(); const items = await listXhsAiProviders();
const enabled = (items ?? []).filter((p) => p.enabled && p.apiKey); const enabled = (items ?? []).filter((p: any) => p.enabled && p.apiKey);
const def = enabled.find((p) => p.isDefault) ?? enabled[0]; const def = enabled.find((p: any) => p.isDefault) ?? enabled[0];
providerKey.value = def?.providerKey ?? ''; providerKey.value = def?.providerKey ?? '';
if (def) {
const tags = providerModelTags(String(def.providerType || ''));
const secret = String(def.apiSecret || '').trim();
providerModel.value =
tags[0]?.modelId || (secret && secret !== '******' ? secret : '') || '';
} else {
providerModel.value = '';
}
} catch { } catch {
providerKey.value = ''; providerKey.value = '';
providerModel.value = '';
} finally { } finally {
providerLoading.value = false; providerLoading.value = false;
} }
@ -799,10 +864,37 @@ async function sendMessage() {
async function askWaybillTrack() { async function askWaybillTrack() {
if (chatLoading.value || !waybillContext.value) return; if (chatLoading.value || !waybillContext.value) return;
activeCapability.value = 'track';
const text = t('nanaAssistant.waybillAutoAsk', { no: waybillDisplayNo.value }); const text = t('nanaAssistant.waybillAutoAsk', { no: waybillDisplayNo.value });
await sendUserText(text); await sendUserText(text);
} }
/** 点选三大能力:先给引导话术 + 预填草稿,等客户补全再发 */
async function startMcpCapability(id: McpCapabilityId) {
if (chatLoading.value) return;
const cap = getCapability(id);
activeCapability.value = id;
if (id === 'track' && waybillContext.value) {
await askWaybillTrack();
return;
}
if (!isChatting.value && messages.value.length === 0) {
ensureWelcomeMessage();
}
const coach = t(cap.coachKey);
messages.value.push({ role: 'assistant', content: coach });
guidedSession.value = true;
saveThreads();
inputText.value = t(cap.draftKey);
await nextTick();
resizeInput();
scrollToBottom();
focusInput({ force: true });
}
async function sendQuickTag(tag: { id: string; label: string }) { async function sendQuickTag(tag: { id: string; label: string }) {
if (chatLoading.value) return; if (chatLoading.value) return;
const def = quickTagDefs.find((item) => item.id === tag.id); const def = quickTagDefs.find((item) => item.id === tag.id);
@ -810,14 +902,86 @@ async function sendQuickTag(tag: { id: string; label: string }) {
openAdvisorView(); openAdvisorView();
return; return;
} }
if (providerKey.value) {
if (tag.id === 'price' || tag.id === 'quote-speed') {
await startMcpCapability('price');
return;
}
if (tag.id === 'track') {
await startMcpCapability('track');
return;
}
if (tag.id === 'small') {
await startMcpCapability('guide');
return;
}
}
await sendUserText(tag.label, resolveTagReply(tag.id)); await sendUserText(tag.label, resolveTagReply(tag.id));
} }
function buildAgentMessages(userFacingMessages: ChatMessage[]) {
const list: { role: string; content: string }[] = [];
if (waybillContext.value) {
list.push({
role: 'system',
content: `用户当前关注运单号:${waybillDisplayNo.value}。查轨迹时可直接使用该单号调用 oms.track_query。`,
});
}
const recentUserBlob = userFacingMessages
.filter((m) => m.role === 'user')
.slice(-4)
.map((m) => m.content)
.join('\n');
const hintedOrderNo = extractOrderNo(recentUserBlob);
if (hintedOrderNo && !waybillContext.value) {
list.push({
role: 'system',
content: `用户消息中已出现单号「${hintedOrderNo}」。若在查轨迹,请立即用该单号调用 oms.track_query,不要再向用户索要单号。`,
});
}
if (activeCapability.value) {
const cap = getCapability(activeCapability.value);
list.push({
role: 'system',
content: `用户当前意图偏向「${cap.toolKey}」。必填信息已齐时立刻调工具;不要索要起运地;不要空回复。`,
});
}
// 只带最近几轮,避免历史把 token 打爆
const dialog = userFacingMessages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.slice(-8)
.map((m) => ({
role: m.role,
content: m.content.length > 1000 ? `${m.content.slice(0, 1000)}…` : m.content,
}));
list.push(...dialog);
return list;
}
function buildMissingCoach(capability: McpCapabilityId, missingKeys: string[]) {
const cap = getCapability(capability);
const labels = missingKeys
.map((key) => {
const field = cap.fields.find((f) => f.key === key);
return field ? t(field.labelKey) : key;
})
.filter(Boolean);
if (!labels.length) return '';
return t('nanaAssistant.mcp.missingAsk', { fields: labels.join('、') });
}
async function sendUserText(text: string, presetReply?: string) { async function sendUserText(text: string, presetReply?: string) {
if (!isChatting.value && messages.value.length === 0 && !waybillContext.value) { if (!isChatting.value && messages.value.length === 0 && !waybillContext.value) {
ensureWelcomeMessage(); ensureWelcomeMessage();
} }
const inferred = inferCapabilityFromText(text) || activeCapability.value;
if (inferred) activeCapability.value = inferred;
messages.value.push({ role: 'user', content: text }); messages.value.push({ role: 'user', content: text });
saveThreads(); saveThreads();
inputText.value = ''; inputText.value = '';
@ -831,34 +995,57 @@ async function sendUserText(text: string, presetReply?: string) {
return; return;
} }
const preferApi = Boolean(providerKey.value || (waybillContext.value && isTrackQuestion(text))); const priorTexts = messages.value
.filter((m) => m.role === 'user')
.slice(-5)
.map((m) => m.content);
if (preferApi && providerKey.value) { // 仅在「明显缺必填」时本地追问;多轮上下文合并判断,避免反复要已经给过的信息
const apiMessages = messages.value if (providerKey.value && inferred) {
.filter((m) => m.role === 'user' || m.role === 'assistant') const missing = detectMissingFields(inferred, text, {
.map((m) => ({ role: m.role, content: m.content })); hasWaybillContext: Boolean(waybillContext.value),
priorTexts: priorTexts.slice(0, -1),
const res = await chatXhsAi({
providerKey: providerKey.value,
messages: apiMessages,
scene: 'logistics',
}); });
messages.value.push({ role: 'assistant', content: res.content }); if (missing.length) {
const coach = buildMissingCoach(inferred, missing);
await appendDemoReply(coach || t(getCapability(inferred).coachKey));
return;
}
}
if (providerKey.value) {
const res = await chatAiAgent({
providerKey: providerKey.value,
model: providerModel.value || undefined,
messages: buildAgentMessages(messages.value),
maxToolRounds: 2,
});
let content = String(res.content || '').trim();
if (!content && res.toolTraces?.length) {
const ok = res.toolTraces.filter((x) => x.success).map((x) => x.toolKey).join('、');
content = ok
? t('nanaAssistant.toolDoneEmpty', { tools: ok })
: t('nanaAssistant.emptyReply');
}
if (!content) content = t('nanaAssistant.emptyReply');
messages.value.push({ role: 'assistant', content });
saveThreads(); saveThreads();
return; return;
} }
await appendDemoReply(mockReply(text)); await appendDemoReply(offlineReply(text));
} catch (e: any) { } catch (e: any) {
if (presetReply !== undefined) return; if (presetReply !== undefined) return;
if (providerKey.value) { const errMsg = String(e?.message || '').trim();
Message.warning(e?.message || t('nanaAssistant.sendFailed')); Message.warning(errMsg || t('nanaAssistant.sendFailed'));
} messages.value.push({
await appendDemoReply(mockReply(text)); role: 'assistant',
content: errMsg ? t('nanaAssistant.sendFailedDetail', { msg: errMsg }) : t('nanaAssistant.sendFailed'),
});
saveThreads();
} finally { } finally {
chatLoading.value = false; chatLoading.value = false;
scrollToBottom(); scrollToBottom();
// 用户刚发过消息:允许保持输入焦点(字号≥16px,不会触发系统放大)
focusInput({ force: true }); focusInput({ force: true });
} }
} }
@ -956,23 +1143,72 @@ function isAdvisorRoute() {
return route.path === '/assistant' || route.path.startsWith('/h5/assistant'); return route.path === '/assistant' || route.path.startsWith('/h5/assistant');
} }
function applyPrefillFromRoute() { const prefillRouteApplied = ref(false);
if (parseWaybillContextFromQuery(route.query)) return; let prefillApplying = false;
const raw = route.query.prefill;
function takeQueryString(key: string): string {
const raw = route.query[key];
const text = Array.isArray(raw) ? raw[0] : raw; const text = Array.isArray(raw) ? raw[0] : raw;
if (typeof text !== 'string' || !text.trim()) return; return typeof text === 'string' ? text.trim() : '';
if (inputText.value.trim()) return; }
inputText.value = text.trim();
nextTick(() => { async function clearPrefillQueryFlags() {
if (!route.query.prefill && !route.query.auto && !route.query.from) return;
const nextQuery = { ...route.query } as Record<string, unknown>;
delete nextQuery.prefill;
delete nextQuery.auto;
delete nextQuery.from;
try {
await router.replace({ path: route.path, query: nextQuery as any });
} catch {
// ignore
}
}
async function applyPrefillFromRoute() {
if (parseWaybillContextFromQuery(route.query)) return;
const text = takeQueryString('prefill');
if (!text || prefillApplying) return;
if (inputText.value.trim() && prefillRouteApplied.value) return;
prefillApplying = true;
try {
const intent = takeQueryString('intent');
if (intent === 'guide' || intent === 'price' || intent === 'track') {
activeCapability.value = intent;
}
// 从查价跳入:已有对话时开新会话,避免上下文串台
if (takeQueryString('from') === 'priceCheck' && messages.value.some((m) => m.role === 'user')) {
startNewChat();
activeCapability.value =
intent === 'guide' || intent === 'price' || intent === 'track' ? intent : activeCapability.value;
}
if (!inputText.value.trim()) {
inputText.value = text;
}
prefillRouteApplied.value = true;
const shouldAuto = takeQueryString('auto') === '1';
await nextTick();
resizeInput(); resizeInput();
focusInput(); if (!shouldSkipAutoFocus()) focusInput();
});
if (shouldAuto && canSend.value) {
await clearPrefillQueryFlags();
await sendMessage();
}
} finally {
prefillApplying = false;
}
} }
watch( watch(
() => route.query, () => route.query,
() => { () => {
if (!isAdvisorRoute()) return; if (!isAdvisorRoute()) return;
if (takeQueryString('prefill')) prefillRouteApplied.value = false;
void applyWaybillContextFromRoute().then(() => applyPrefillFromRoute()); void applyWaybillContextFromRoute().then(() => applyPrefillFromRoute());
}, },
{ deep: true }, { deep: true },

View File

@ -746,6 +746,94 @@
gap: 10px; gap: 10px;
} }
.ai-mcp-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 8px;
}
.ai-mcp-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 14px;
text-align: left;
border-radius: 14px;
border: 1px solid rgba(45, 140, 140, 0.18);
background: linear-gradient(160deg, #fff 0%, rgba(45, 140, 140, 0.06) 100%);
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.ai-mcp-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(45, 140, 140, 0.12);
}
.ai-mcp-card:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.ai-mcp-card__title {
font-size: 14px;
font-weight: 700;
color: #1d2129;
}
.ai-mcp-card__desc {
font-size: 12px;
line-height: 1.45;
color: #4e5969;
}
.ai-mcp-card__fields {
margin: 4px 0 0;
padding: 0;
list-style: none;
width: 100%;
}
.ai-mcp-card__fields li {
position: relative;
padding-left: 10px;
margin-top: 3px;
font-size: 11px;
line-height: 1.4;
color: #86909c;
}
.ai-mcp-card__fields li::before {
content: '·';
position: absolute;
left: 0;
}
.ai-mcp-card__fields li.is-required {
color: #f53f3f;
font-weight: 600;
}
.ai-mcp-card__fields li.is-optional {
color: #86909c;
}
.ai-mcp-card__example {
margin-top: 4px;
font-size: 11px;
color: var(--app-primary-color, #2d8c8c);
line-height: 1.4;
}
@media (max-width: 900px) {
.ai-mcp-grid {
grid-template-columns: 1fr;
}
}
.ai-quick-card { .ai-quick-card {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;

View File

@ -0,0 +1,147 @@
/** AI 助手 · 三大 MCP 能力所需信息引导 */
export type McpCapabilityId = 'price' | 'guide' | 'track'
export type McpFieldHint = {
key: string
required: boolean
/** i18n key under nanaAssistant.mcp.* */
labelKey: string
}
export type McpCapabilityGuide = {
id: McpCapabilityId
toolKey: string
/** 快捷入口 i18n */
titleKey: string
descKey: string
fields: McpFieldHint[]
/** 引导话术 i18n */
coachKey: string
/** 输入框示例 */
exampleKey: string
/** 预填到输入框的示例文案 i18n(可编辑后再发) */
draftKey: string
}
export const MCP_CAPABILITY_GUIDES: McpCapabilityGuide[] = [
{
id: 'price',
toolKey: 'oms.price_check',
titleKey: 'nanaAssistant.mcp.priceTitle',
descKey: 'nanaAssistant.mcp.priceDesc',
coachKey: 'nanaAssistant.mcp.priceCoach',
exampleKey: 'nanaAssistant.mcp.priceExample',
draftKey: 'nanaAssistant.mcp.priceDraft',
fields: [
{ key: 'country', required: true, labelKey: 'nanaAssistant.mcp.field.country' },
{ key: 'weight', required: true, labelKey: 'nanaAssistant.mcp.field.weight' },
{ key: 'postal', required: false, labelKey: 'nanaAssistant.mcp.field.postal' },
{ key: 'dims', required: false, labelKey: 'nanaAssistant.mcp.field.dims' },
{ key: 'cargoType', required: false, labelKey: 'nanaAssistant.mcp.field.cargoType' },
],
},
{
id: 'guide',
toolKey: 'oms.channel_guide',
titleKey: 'nanaAssistant.mcp.guideTitle',
descKey: 'nanaAssistant.mcp.guideDesc',
coachKey: 'nanaAssistant.mcp.guideCoach',
exampleKey: 'nanaAssistant.mcp.guideExample',
draftKey: 'nanaAssistant.mcp.guideDraft',
fields: [
{ key: 'product', required: true, labelKey: 'nanaAssistant.mcp.field.product' },
{ key: 'cargoName', required: true, labelKey: 'nanaAssistant.mcp.field.cargoName' },
],
},
{
id: 'track',
toolKey: 'oms.track_query',
titleKey: 'nanaAssistant.mcp.trackTitle',
descKey: 'nanaAssistant.mcp.trackDesc',
coachKey: 'nanaAssistant.mcp.trackCoach',
exampleKey: 'nanaAssistant.mcp.trackExample',
draftKey: 'nanaAssistant.mcp.trackDraft',
fields: [
{ key: 'orderNo', required: true, labelKey: 'nanaAssistant.mcp.field.orderNo' },
],
},
]
const COUNTRY_HINT =
/美国|英國|英国|德国|法国|澳洲|澳大利亚|加拿大|日本|韩国|意大利|西班牙|荷兰|墨西哥|巴西|新西兰|\b(US|GB|UK|DE|FR|AU|CA|JP|KR|IT|ES|NL|MX|BR|NZ)\b/i
/** 0.5kg / 0.5 kg / 重量0.5 / 大概0.5公斤 — 避免把运单号里的纯数字当成重量 */
const WEIGHT_HINT =
/(\d+(\.\d+)?)\s*(kg|KG|公斤|千克)|(?:重量|实重)\s*[::]?\s*\d+(\.\d+)?|(?:约|大概)\s*\d+(\.\d+)?\s*(kg|KG|公斤|千克)?/i
/** IDE 运单、跟踪号等 */
const ORDER_NO_LOOSE = /[A-Za-z][A-Za-z0-9\-_]{5,}/
const ORDER_NO_STRICT =
/\b(IDE\d{8,}|(?:YD|SF|YT|NF|LT)[A-Za-z0-9]{6,}|(?:[A-Z]{2,}\d{8,}))\b/i
const PRODUCT_HINT = /产品|渠道|线路|运输方式|[A-Z]{2,}[_-][A-Z0-9_]+/i
const CARGO_HINT =
/品名|货物|普货|敏感|衣服|服装|鞋|化妆品|lipstick|手机|电池|粉末|液体|食品|寄|发/i
export function extractOrderNo(text: string): string | null {
const strict = text.match(ORDER_NO_STRICT)
if (strict?.[1] || strict?.[0]) return (strict[1] || strict[0]).trim()
// 「运单号:XXXX」类
const labeled = text.match(
/(?:运单号|跟踪号|客户单号|物流单号|单号)[::\s]*([A-Za-z0-9][A-Za-z0-9\-_]{5,})/i,
)
if (labeled?.[1]) return labeled[1].trim()
const loose = text.match(ORDER_NO_LOOSE)
return loose?.[0]?.trim() || null
}
function joinedContext(text: string, priorTexts?: string[]) {
const parts = [...(priorTexts ?? []), text].map((s) => String(s || '').trim()).filter(Boolean)
return parts.join('\n')
}
/** 粗判用户这句话还缺哪些必填信息(用于先追问再调工具);会合并多轮用户原文 */
export function detectMissingFields(
capability: McpCapabilityId,
text: string,
opts?: { hasWaybillContext?: boolean; priorTexts?: string[] },
): string[] {
const t = joinedContext(text, opts?.priorTexts)
const missing: string[] = []
if (capability === 'price') {
if (!COUNTRY_HINT.test(t)) missing.push('country')
if (!WEIGHT_HINT.test(t)) missing.push('weight')
} else if (capability === 'guide') {
if (!PRODUCT_HINT.test(t)) missing.push('product')
if (!CARGO_HINT.test(t) && t.replace(/\s/g, '').length < 4) missing.push('cargoName')
} else if (capability === 'track') {
if (!opts?.hasWaybillContext && !extractOrderNo(t)) missing.push('orderNo')
}
return missing
}
export function inferCapabilityFromText(text: string): McpCapabilityId | null {
const raw = text.trim()
if (!raw) return null
const lower = raw.toLowerCase()
// 轨迹优先:关键词或明显单号
if (/track|tracking|轨迹|物流|运单|查单|单号|查下|查一下/.test(lower)) return 'track'
if (extractOrderNo(raw) && !COUNTRY_HINT.test(raw)) return 'track'
if (ORDER_NO_STRICT.test(raw) && raw.length < 40) return 'track'
if (/品名|能发|禁运|可寄|对客说明|承运|敏感货/.test(lower)) return 'guide'
if (/price|cost|quote|报价|价格|多少钱|运费|查价|怎么走|走得动/.test(lower)) return 'price'
// 隐式查价:目的国 + 重量 / 「走|寄|发」+ 国家
if (COUNTRY_HINT.test(raw) && WEIGHT_HINT.test(raw)) return 'price'
if (/(?:走|寄|发往|寄到|发到|到)/.test(raw) && COUNTRY_HINT.test(raw)) return 'price'
return null
}
export function getCapability(id: McpCapabilityId): McpCapabilityGuide {
return MCP_CAPABILITY_GUIDES.find((c) => c.id === id)!
}

View File

@ -0,0 +1,99 @@
import type { Router } from 'vue-router'
export type PriceAskIntent = 'canShip' | 'cheaper' | 'general'
export type PriceAskContext = {
destinationCountry: string
cargoWeight: string | number
postalCode?: string
length?: string | number
width?: string | number
height?: string | number
productCode?: string
shippingMethod?: string
cargoType?: string
cargoTypeName?: string
warehouse?: string
totalCost?: string | number
deliveryTime?: string
quoteLevel?: string
/** 客户品名(可承运场景可先空着让用户补) */
cargoName?: string
}
function fmtMoney(v: unknown) {
const n = Number(v)
if (!Number.isFinite(n)) return String(v ?? '').trim()
return n.toFixed(2)
}
function line(label: string, value: unknown) {
const text = String(value ?? '').trim()
if (!text) return ''
return `${label}:${text}`
}
/** 生成带查价上下文的 AI 提问文案 */
export function buildPriceAskPrefill(intent: PriceAskIntent, ctx: PriceAskContext): string {
const dims =
ctx.length != null && ctx.width != null && ctx.height != null
? `${ctx.length}×${ctx.width}×${ctx.height}cm`
: ''
const contextBlock = [
'【查价上下文】',
line('目的国', ctx.destinationCountry),
line('实重', ctx.cargoWeight != null && String(ctx.cargoWeight) !== '' ? `${ctx.cargoWeight}kg` : ''),
line('邮编', ctx.postalCode),
line('尺寸', dims),
line('销售产品', ctx.productCode),
line('运输方式', ctx.shippingMethod),
line('货物属性', ctx.cargoTypeName || ctx.cargoType),
line('仓库', ctx.warehouse),
line('参考报价', ctx.totalCost != null && String(ctx.totalCost) !== '' ? `¥${fmtMoney(ctx.totalCost)}` : ''),
line('时效', ctx.deliveryTime),
line('报价精度', ctx.quoteLevel === 'estimated' ? '估算(仅供参考)' : ctx.quoteLevel),
]
.filter(Boolean)
.join('\n')
if (intent === 'canShip') {
const name = String(ctx.cargoName || '').trim() || '(请填写品名)'
return `${contextBlock}\n\n请根据该销售产品的对客说明,判断品名「${name}」能否承运。说明未写明时请明确说不确定,并提示我补充或联系客服。`
}
if (intent === 'cheaper') {
return `${contextBlock}\n\n请结合可查报价,说明是否有更便宜或性价比更好的选择(兼顾时效与限制),用简短中文给出结论。`
}
return `${contextBlock}\n\n请基于以上信息给我建议(价格、可否承运、注意事项)。信息不足时先追问。`
}
export function getPriceAskLocation(
intent: PriceAskIntent,
ctx: PriceAskContext,
options?: { h5?: boolean; autoAsk?: boolean },
) {
const prefill = buildPriceAskPrefill(intent, ctx)
const query: Record<string, string> = {
prefill,
intent: intent === 'canShip' ? 'guide' : 'price',
from: 'priceCheck',
}
// 更便宜:上下文已齐,可自动发出;能否发需用户补品名,不自动发
if (options?.autoAsk ?? intent === 'cheaper') query.auto = '1'
return {
path: options?.h5 ? '/h5/assistant' : '/assistant',
query,
}
}
export function openPriceAskAdvisor(
router: Router,
intent: PriceAskIntent,
ctx: PriceAskContext,
options?: { h5?: boolean; autoAsk?: boolean },
) {
return router.push(getPriceAskLocation(intent, ctx, options))
}

View File

@ -250,6 +250,8 @@ export const PAGE_BUTTON_PARENT_ALIASES: Record<string, string> = {
'Rbac.DataPerm.Query': 'Rbac.DataPerm', 'Rbac.DataPerm.Query': 'Rbac.DataPerm',
'Rbac.DataPermUsers': 'Rbac.DataPerm', 'Rbac.DataPermUsers': 'Rbac.DataPerm',
'System.XhsAiAssistant': 'Xhs.Ai', 'System.XhsAiAssistant': 'Xhs.Ai',
'System.McpTools': 'System.McpTools',
'System.McpTools.Use': 'System.McpTools',
'Xhs.Ai.Use': 'Xhs.Ai', 'Xhs.Ai.Use': 'Xhs.Ai',
'Xhs.StartService': 'Xhs.Agent', 'Xhs.StartService': 'Xhs.Agent',
'Xhs.PublishHistory': 'Xhs.Publish', 'Xhs.PublishHistory': 'Xhs.Publish',

View File

@ -721,6 +721,38 @@ export default {
'menu.system.warehouseAddress': 'Warehouse Address', 'menu.system.warehouseAddress': 'Warehouse Address',
'menu.system.batchEmail': 'Batch Email', 'menu.system.batchEmail': 'Batch Email',
'menu.system.xhsAiAssistant': 'AI Assistant', 'menu.system.xhsAiAssistant': 'AI Assistant',
'menu.system.mcpTools': 'MCP Tools',
'system.mcpTools.pageDesc':
'Manage lightweight MCP tools: price check, channel customer guide, and tracking. Enable, test, and try AI Agent.',
'system.mcpTools.refresh': 'Refresh',
'system.mcpTools.displayName': 'Display name',
'system.mcpTools.description': 'Description (for the model)',
'system.mcpTools.sortOrder': 'Sort',
'system.mcpTools.save': 'Save',
'system.mcpTools.saveOk': 'Saved',
'system.mcpTools.saveFail': 'Save failed',
'system.mcpTools.loadFail': 'Load failed',
'system.mcpTools.enabled': 'Enabled',
'system.mcpTools.disabled': 'Disabled',
'system.mcpTools.selectHint': 'Select a tool on the left',
'system.mcpTools.testTitle': 'Test tool',
'system.mcpTools.testHint': 'Call the OMS wrapper with JSON args (no LLM).',
'system.mcpTools.testArgsPlaceholder': '{ "destinationCountry": "US", "cargoWeight": 0.5 }',
'system.mcpTools.runTest': 'Run',
'system.mcpTools.testFail': 'Test failed',
'system.mcpTools.argsInvalid': 'Arguments must be valid JSON',
'system.mcpTools.agentTitle': 'AI Agent',
'system.mcpTools.agentHint': 'Ask in natural language; the model may call enabled MCP tools.',
'system.mcpTools.provider': 'AI provider',
'system.mcpTools.model': 'Model',
'system.mcpTools.agentPlaceholder':
'e.g. quote US 0.5kg / can lipstick ship on channel X / track waybill xxx',
'system.mcpTools.runAgent': 'Send',
'system.mcpTools.agentReply': 'AI reply',
'system.mcpTools.toolTraces': 'Tool traces',
'system.mcpTools.agentNeedPrompt': 'Enter a question',
'system.mcpTools.agentNeedProvider': 'Select an AI provider (configure keys in AI Assistant)',
'system.mcpTools.agentFail': 'Agent failed',
'menu.system.dataPerm': 'Data Permission', 'menu.system.dataPerm': 'Data Permission',
'menu.system.dataPermUsers': 'User Data Permission', 'menu.system.dataPermUsers': 'User Data Permission',
'menu.system.dataPermTemplates': 'Permission Templates', 'menu.system.dataPermTemplates': 'Permission Templates',
@ -945,7 +977,43 @@ export default {
'nanaAssistant.noSearchResults': 'No matching conversations', 'nanaAssistant.noSearchResults': 'No matching conversations',
'nanaAssistant.deleteThread': 'Delete conversation', 'nanaAssistant.deleteThread': 'Delete conversation',
'nanaAssistant.hint': 'Common questions show reference answers. Type in the box to ask more.', 'nanaAssistant.hint': 'Common questions show reference answers. Type in the box to ask more.',
'nanaAssistant.hintLive': 'Smart advisor is connected — type a question or pick one below.', 'nanaAssistant.hintLive':
'Smart advisor is live (price, channel guide, tracking). Type a question or pick one below.',
'nanaAssistant.inputPlaceholder': 'Ask a question…',
'nanaAssistant.inputPlaceholderLive':
'e.g. US 0.5kg quote / can lipstick ship on XX / track waybill…',
'nanaAssistant.inputPlaceholderTrack': 'Ask about tracking or add details…',
'nanaAssistant.mcp.sectionLabel': 'What I can look up',
'nanaAssistant.mcp.required': 'Required',
'nanaAssistant.mcp.optional': 'Optional',
'nanaAssistant.mcp.missingAsk': 'Please also provide: {fields}. Then I can look it up.',
'nanaAssistant.mcp.priceTitle': 'Price check',
'nanaAssistant.mcp.priceDesc': 'Estimate freight by country & weight',
'nanaAssistant.mcp.priceCoach':
'To quote I need:\n1) Destination country (e.g. US)\n2) Actual weight in kg\n\nOptional: postal code, L×W×H (cm), cargo type.\nExample: US 0.5kg zip 10001',
'nanaAssistant.mcp.priceExample': 'e.g. US 0.5kg zip 10001',
'nanaAssistant.mcp.priceDraft':
'Country:\nWeight (kg):\nPostal code (optional):\nDims LWH cm (optional):',
'nanaAssistant.mcp.guideTitle': 'Can this cargo ship?',
'nanaAssistant.mcp.guideDesc': 'Check against the channel customer guide',
'nanaAssistant.mcp.guideCoach':
'I need:\n1) Sales product code or name\n2) Cargo / product name\n\nExample: product EX_ASP_HKDHL, cargo lipstick — can it ship?',
'nanaAssistant.mcp.guideExample': 'e.g. product XX, cargo lipstick OK?',
'nanaAssistant.mcp.guideDraft': 'Sales product code/name:\nCargo name:',
'nanaAssistant.mcp.trackTitle': 'Track shipment',
'nanaAssistant.mcp.trackDesc': 'Timeline by waybill number',
'nanaAssistant.mcp.trackCoach':
'Please provide a waybill / tracking / customer order number.\nIf a waybill is already loaded, tap “Track”.',
'nanaAssistant.mcp.trackExample': 'e.g. Track waybill NFXXXX',
'nanaAssistant.mcp.trackDraft': 'Waybill No.:',
'nanaAssistant.mcp.field.country': 'Destination country',
'nanaAssistant.mcp.field.weight': 'Weight (kg)',
'nanaAssistant.mcp.field.postal': 'Postal code',
'nanaAssistant.mcp.field.dims': 'Dimensions (cm)',
'nanaAssistant.mcp.field.cargoType': 'Cargo type',
'nanaAssistant.mcp.field.product': 'Sales product code/name',
'nanaAssistant.mcp.field.cargoName': 'Cargo name',
'nanaAssistant.mcp.field.orderNo': 'Waybill No.',
'nanaAssistant.human': 'Talk to a human advisor →', 'nanaAssistant.human': 'Talk to a human advisor →',
'nanaAssistant.you': 'You', 'nanaAssistant.you': 'You',
'nanaAssistant.backToChat': 'Back to chat', 'nanaAssistant.backToChat': 'Back to chat',
@ -957,18 +1025,22 @@ export default {
'nanaAssistant.quickAfterSales': 'Something went wrong?', 'nanaAssistant.quickAfterSales': 'Something went wrong?',
'nanaAssistant.quickHuman': 'Talk to human advisor', 'nanaAssistant.quickHuman': 'Talk to human advisor',
'nanaAssistant.replySmall': 'Yes. IDE focuses on SMB sellers and shipments others decline — special routes, complex cargo, and low-volume orders are in scope. Tell me origin, destination, and cargo type to get started.', 'nanaAssistant.replySmall': 'Yes. IDE focuses on SMB sellers and shipments others decline — special routes, complex cargo, and low-volume orders are in scope. Tell me origin, destination, and cargo type to get started.',
'nanaAssistant.replyPrice': 'Pricing depends on origin, weight, dimensions, and channel (air / sea / express). Share those details and we return a route-specific quote with transit options within 24 hours.', 'nanaAssistant.replyPrice': 'For a quote, destination country + actual weight (kg) is enough — e.g. US 0.5kg. Postal code/dims help; origin city is not required.',
'nanaAssistant.replyQuoteSpeed': 'Route-specific quotes are typically returned within 24 hours after you share shipment details. Urgent lanes can be prioritized — mention your target ship date.', 'nanaAssistant.replyQuoteSpeed': 'With country + weight we can return an indicative quote right away; final billing follows inbound weight and product rules.',
'nanaAssistant.trackHint': "Paste your tracking number in the box below. I'll walk you through what each milestone usually means.", 'nanaAssistant.trackHint': 'Just send the waybill number to track, e.g. IDE2607281746002926',
'nanaAssistant.replyVisibility': 'You get milestone updates from pickup to delivery — pickup, export, line-haul, customs, and final delivery. We proactively flag delays before they escalate.', 'nanaAssistant.replyVisibility': 'You get milestone updates from pickup to delivery — pickup, export, line-haul, customs, and final delivery. We proactively flag delays before they escalate.',
'nanaAssistant.replyAfterSales': "Your dedicated advisor handles exceptions, claims, and after-sales follow-up — you won't be left chasing tickets alone. Describe the issue and tracking number if you have one.", 'nanaAssistant.replyAfterSales': "Your dedicated advisor handles exceptions, claims, and after-sales follow-up — you won't be left chasing tickets alone. Describe the issue and tracking number if you have one.",
'nanaAssistant.replyHuman': 'Reach us via the contact page — phone 18123959245 or email bd01@1st56.com. Mention your route and cargo so we assign the right advisor.', 'nanaAssistant.replyHuman': 'Reach us via the contact page — phone 18123959245 or email bd01@1st56.com. Mention your route and cargo so we assign the right advisor.',
'nanaAssistant.fallback': "Got it. Share origin, destination, cargo type, and weight for a route-specific quote — or talk to a human advisor and we'll respond within 24 hours.", 'nanaAssistant.fallback': 'Tell me destination country + weight to quote, or paste a waybill number to track — or talk to a human advisor.',
'nanaAssistant.emptyReply': 'No useful reply came back. Please try again with destination + weight, or a waybill number.',
'nanaAssistant.aiUnavailable': 'AI advisor is not configured yet. Please contact a human advisor or try later.',
'nanaAssistant.providerLoading': 'Connecting to AI advisor… please send again in a moment.',
'nanaAssistant.toolDoneEmpty': 'Called {tools}, but the model returned no summary. Ask “summarize” or contact support.',
'nanaAssistant.sendFailedDetail': 'Request failed: {msg}',
'nanaAssistant.wechatId': 'WeChat ID', 'nanaAssistant.wechatId': 'WeChat ID',
'nanaAssistant.noAdvisor': 'No exclusive advisor assigned yet', 'nanaAssistant.noAdvisor': 'No exclusive advisor assigned yet',
'nanaAssistant.noWechatQr': 'WeChat QR code not uploaded', 'nanaAssistant.noWechatQr': 'WeChat QR code not uploaded',
'nanaAssistant.greeting': "Hi, I'm Nana, your AI assistant.\n\nAsk me about tracking, shipping times, declared values, customs clearance, and more. How can I help you today?", 'nanaAssistant.greeting': "Hi, I'm Nana, your AI assistant.\n\nAsk me about tracking, shipping times, declared values, customs clearance, and more. How can I help you today?",
'nanaAssistant.inputPlaceholder': 'Type your question…',
'nanaAssistant.send': 'Send', 'nanaAssistant.send': 'Send',
'nanaAssistant.close': 'Close', 'nanaAssistant.close': 'Close',
'nanaAssistant.back': 'Back', 'nanaAssistant.back': 'Back',
@ -1335,10 +1407,11 @@ export default {
'order.myOrders.table.cargoAttribute': 'Cargo Attribute', 'order.myOrders.table.cargoAttribute': 'Cargo Attribute',
'order.myOrders.drawer.orderStatus': 'Order Status', 'order.myOrders.drawer.orderStatus': 'Order Status',
'order.myOrders.drawer.predeclareFailReason': 'Predeclare Failure Reason', 'order.myOrders.drawer.predeclareFailReason': 'Predeclare Failure Reason',
'order.myOrders.drawer.failGuideTitle': 'Pre-declaration incomplete — what to change', 'order.myOrders.drawer.failGuideTitle': 'Pre-declaration incomplete',
'order.myOrders.drawer.failGuideRaw': 'Details', 'order.myOrders.drawer.failGuideRaw': 'System response',
'order.myOrders.drawer.failGuideAskSupport': 'Contact support', 'order.myOrders.drawer.failGuideAskSupport': 'Contact support',
'order.myOrders.drawer.failGuideEdit': 'Edit order', 'order.myOrders.drawer.failGuideEdit': 'Edit order',
'order.myOrders.drawer.failGuideTip': 'Tip',
'order.myOrders.drawer.allocationPendingReason': 'Allocation Pending Reason', 'order.myOrders.drawer.allocationPendingReason': 'Allocation Pending Reason',
'order.myOrders.drawer.warehouse': 'Warehouse', 'order.myOrders.drawer.warehouse': 'Warehouse',
'order.myOrders.drawer.receiveTime': 'Receive Time', 'order.myOrders.drawer.receiveTime': 'Receive Time',
@ -1572,6 +1645,9 @@ export default {
'order.priceCheck.table.viewGuide': 'Channel guide', 'order.priceCheck.table.viewGuide': 'Channel guide',
'order.priceCheck.table.viewGuideHint': 'Click to view route guide', 'order.priceCheck.table.viewGuideHint': 'Click to view route guide',
'order.priceCheck.table.orderNow': 'Place order', 'order.priceCheck.table.orderNow': 'Place order',
'order.priceCheck.table.askAi': 'Ask AI',
'order.priceCheck.table.askAiCanShip': 'Can this ship?',
'order.priceCheck.table.askAiCheaper': 'Anything cheaper?',
'order.priceCheck.table.empty': 'No quotes yet. Run a search first.', 'order.priceCheck.table.empty': 'No quotes yet. Run a search first.',
'order.priceCheck.table.emptyHint': 'Try adding postal code, dimensions, or cargo/warehouse filters; contact support if needed.', 'order.priceCheck.table.emptyHint': 'Try adding postal code, dimensions, or cargo/warehouse filters; contact support if needed.',
@ -1731,16 +1807,15 @@ export default {
'order.placeOrder.single.failed': 'Failed to place order', 'order.placeOrder.single.failed': 'Failed to place order',
'order.placeOrder.single.failTitle': 'Order was not placed', 'order.placeOrder.single.failTitle': 'Order was not placed',
'order.placeOrder.single.failTitleDraft': 'Pre-declaration failed (draft saved)', 'order.placeOrder.single.failTitleDraft': 'Pre-declaration failed (draft saved)',
'order.placeOrder.single.failWhatHappened': 'What happened', 'order.placeOrder.single.failWhatHappened': 'Failure reason',
'order.placeOrder.single.failHowToFix': 'What to change', 'order.placeOrder.single.failHowToFix': 'Tip',
'order.placeOrder.single.failRawReason': 'Details', 'order.placeOrder.single.failRawReason': 'System response',
'order.placeOrder.single.failAskSupport': 'Contact support', 'order.placeOrder.single.failAskSupport': 'Contact support',
'order.placeOrder.single.failCopied': 'Waybill No. copied', 'order.placeOrder.single.failCopied': 'Waybill No. copied',
'order.placeOrder.single.failCopyFailed': 'Copy failed. Please select the number manually.', 'order.placeOrder.single.failCopyFailed': 'Copy failed. Please select the number manually.',
'order.placeOrder.single.failDraftSavedNote': 'order.placeOrder.single.failDraftSavedNote':
'The order was saved as a draft, but pre-declaration did not finish. Edit the draft in My Orders and pre-declare again — do not submit again here (that would create a duplicate).', 'Draft saved — edit then pre-declare (do not resubmit here)',
'order.placeOrder.single.failNotSavedNote': 'order.placeOrder.single.failNotSavedNote': 'Order was not saved',
'The order was not saved. Continue editing and retry, or save it as a draft for later.',
'order.placeOrder.single.failServiceNo': 'Waybill No.', 'order.placeOrder.single.failServiceNo': 'Waybill No.',
'order.placeOrder.single.failContinueEdit': 'Continue editing', 'order.placeOrder.single.failContinueEdit': 'Continue editing',
'order.placeOrder.single.failEditDraft': 'Edit draft', 'order.placeOrder.single.failEditDraft': 'Edit draft',

View File

@ -724,6 +724,37 @@ export default {
'menu.system.warehouseAddress': '仓库地址', 'menu.system.warehouseAddress': '仓库地址',
'menu.system.batchEmail': '批量发送邮件', 'menu.system.batchEmail': '批量发送邮件',
'menu.system.xhsAiAssistant': 'AI 助手', 'menu.system.xhsAiAssistant': 'AI 助手',
'menu.system.mcpTools': 'MCP 工具',
'system.mcpTools.pageDesc': '管理轻量 MCP 工具:查价格、查渠道对客说明、查轨迹。可启用/试跑,并用 AI Agent 联调。',
'system.mcpTools.refresh': '刷新',
'system.mcpTools.displayName': '显示名称',
'system.mcpTools.description': '描述(给 AI 看)',
'system.mcpTools.sortOrder': '排序',
'system.mcpTools.save': '保存',
'system.mcpTools.saveOk': '已保存',
'system.mcpTools.saveFail': '保存失败',
'system.mcpTools.loadFail': '加载失败',
'system.mcpTools.enabled': '已启用',
'system.mcpTools.disabled': '已禁用',
'system.mcpTools.selectHint': '请选择左侧工具',
'system.mcpTools.testTitle': '试跑工具',
'system.mcpTools.testHint': '填写 JSON 参数后直接调用 OMS 封装,不经过大模型。',
'system.mcpTools.testArgsPlaceholder': '{ "destinationCountry": "US", "cargoWeight": 0.5 }',
'system.mcpTools.runTest': '试跑',
'system.mcpTools.testFail': '试跑失败',
'system.mcpTools.argsInvalid': '参数必须是合法 JSON',
'system.mcpTools.agentTitle': 'AI Agent 联调',
'system.mcpTools.agentHint': '用自然语言提问,模型可自动调用已启用的 MCP 工具后作答。',
'system.mcpTools.provider': 'AI 提供商',
'system.mcpTools.model': '模型',
'system.mcpTools.agentPlaceholder': '例如:美国 0.5kg 大概多少钱? / 品名 lipstick 能不能走某某渠道? / 查运单 xxx 轨迹',
'system.mcpTools.runAgent': '发送',
'system.mcpTools.agentReply': 'AI 回复',
'system.mcpTools.toolTraces': '工具调用轨迹',
'system.mcpTools.agentNeedPrompt': '请输入问题',
'system.mcpTools.agentNeedProvider': '请选择 AI 提供商(在 AI 助手页配置密钥)',
'system.mcpTools.agentFail': 'Agent 调用失败',
'menu.system.dataPerm': '数据权限', 'menu.system.dataPerm': '数据权限',
'menu.system.dataPermUsers': '用户数据权限', 'menu.system.dataPermUsers': '用户数据权限',
'menu.system.dataPermTemplates': '权限模板', 'menu.system.dataPermTemplates': '权限模板',
@ -752,7 +783,7 @@ export default {
'nanaAssistant.tabAdvisor': '专属顾问', 'nanaAssistant.tabAdvisor': '专属顾问',
'nanaAssistant.badge': '物流顾问', 'nanaAssistant.badge': '物流顾问',
'nanaAssistant.welcomeTitle': '这次发货,需要什么帮助?', 'nanaAssistant.welcomeTitle': '这次发货,需要什么帮助?',
'nanaAssistant.welcomeSub': '报价、线路、查单、疑难单 — 都可以从这里开始。专属顾问从报价跟到签收。', 'nanaAssistant.welcomeSub': '查价格、品名能否发、查轨迹 — 点选能力卡片,按提示补全信息即可。',
'nanaAssistant.quickLabel': '常见问题', 'nanaAssistant.quickLabel': '常见问题',
'nanaAssistant.welcomeMsg': '你好,我是 IDE 物流顾问。可以点选下方问题,或直接输入。', 'nanaAssistant.welcomeMsg': '你好,我是 IDE 物流顾问。可以点选下方问题,或直接输入。',
'nanaAssistant.historyTitle': '历史记录', 'nanaAssistant.historyTitle': '历史记录',
@ -762,7 +793,40 @@ export default {
'nanaAssistant.noSearchResults': '未找到匹配的会话', 'nanaAssistant.noSearchResults': '未找到匹配的会话',
'nanaAssistant.deleteThread': '删除会话', 'nanaAssistant.deleteThread': '删除会话',
'nanaAssistant.hint': '以下为常见问题的参考回复,输入框可继续提问。', 'nanaAssistant.hint': '以下为常见问题的参考回复,输入框可继续提问。',
'nanaAssistant.hintLive': '已接入智能顾问,可直接输入或点选下方问题。', 'nanaAssistant.hintLive': '已接入智能顾问(可查价格、对客说明、轨迹),直接提问或点选下方问题。',
'nanaAssistant.inputPlaceholder': '输入您的问题…',
'nanaAssistant.inputPlaceholderLive': '例如:美国 0.5kg 多少钱 / 品名 lipstick 能否走 XX 渠道 / 查运单号…',
'nanaAssistant.inputPlaceholderTrack': '可直接问轨迹,或补充问题…',
'nanaAssistant.mcp.sectionLabel': '我能帮你查',
'nanaAssistant.mcp.required': '必填',
'nanaAssistant.mcp.optional': '选填',
'nanaAssistant.mcp.missingAsk': '还需要补充:{fields}。补全后我再帮你查。',
'nanaAssistant.mcp.priceTitle': '查价格',
'nanaAssistant.mcp.priceDesc': '按目的国与重量试算运费',
'nanaAssistant.mcp.priceCoach':
'查价格需要你提供:\n① 目的国家(如美国 / US)\n② 实重 kg\n\n选填:邮编、长宽高(cm)、货物属性(普货/敏感)。\n补全后发送即可,例如:美国 0.5kg 邮编10001',
'nanaAssistant.mcp.priceExample': '例:美国 0.5kg 邮编10001',
'nanaAssistant.mcp.priceDraft': '目的国:\n实重(kg):\n邮编(选填):\n尺寸长宽高cm(选填):',
'nanaAssistant.mcp.guideTitle': '品名能否发',
'nanaAssistant.mcp.guideDesc': '对照渠道对客说明判断可否承运',
'nanaAssistant.mcp.guideCoach':
'判断品名能否承运需要:\n① 销售产品代码或名称(运输方式)\n② 客户品名(英文/中文均可)\n\n例如:产品 EX_ASP_HKDHL,品名 lipstick,能不能发?',
'nanaAssistant.mcp.guideExample': '例:产品XX,品名 lipstick 能发吗',
'nanaAssistant.mcp.guideDraft': '销售产品代码/名称:\n客户品名:',
'nanaAssistant.mcp.trackTitle': '查轨迹',
'nanaAssistant.mcp.trackDesc': '按运单号查看物流节点',
'nanaAssistant.mcp.trackCoach':
'查轨迹请提供运单号 / 跟踪号 / 客户单号(任选其一)。\n若已从订单带入运单,可直接点「查轨迹」。',
'nanaAssistant.mcp.trackExample': '例:请查运单 NFXXXX 的轨迹',
'nanaAssistant.mcp.trackDraft': '运单号:',
'nanaAssistant.mcp.field.country': '目的国家',
'nanaAssistant.mcp.field.weight': '实重(kg)',
'nanaAssistant.mcp.field.postal': '邮编',
'nanaAssistant.mcp.field.dims': '长宽高(cm)',
'nanaAssistant.mcp.field.cargoType': '货物属性',
'nanaAssistant.mcp.field.product': '销售产品代码/名称',
'nanaAssistant.mcp.field.cargoName': '客户品名',
'nanaAssistant.mcp.field.orderNo': '运单号',
'nanaAssistant.human': '联系人工顾问 →', 'nanaAssistant.human': '联系人工顾问 →',
'nanaAssistant.you': '你', 'nanaAssistant.you': '你',
'nanaAssistant.backToChat': '返回对话', 'nanaAssistant.backToChat': '返回对话',
@ -774,18 +838,22 @@ export default {
'nanaAssistant.quickAfterSales': '出问题了怎么办?', 'nanaAssistant.quickAfterSales': '出问题了怎么办?',
'nanaAssistant.quickHuman': '联系人工顾问', 'nanaAssistant.quickHuman': '联系人工顾问',
'nanaAssistant.replySmall': '可以。IDE 专注中小卖家和别人不接的单 — 特殊线路、复杂货型、小批量都在服务范围内。告诉我起运地、目的地和货型即可开始。', 'nanaAssistant.replySmall': '可以。IDE 专注中小卖家和别人不接的单 — 特殊线路、复杂货型、小批量都在服务范围内。告诉我起运地、目的地和货型即可开始。',
'nanaAssistant.replyPrice': '价格取决于起运地、重量、尺寸和渠道(空运 / 海运 / 专线)。提供这些信息后,我们会在 24 小时内给出含时效选项的线路报价。', 'nanaAssistant.replyPrice': '查价只需目的国和实重(kg)。例如:美国 0.5kg。有邮编/尺寸会更准;不需要起运地。',
'nanaAssistant.replyQuoteSpeed': '提交货物信息后,通常 24 小时内返回线路报价。若有时效要求,请注明目标发货日期,我们会优先处理。', 'nanaAssistant.replyQuoteSpeed': '提交目的国和重量后可即时试算参考价;正式出账以入仓重与产品规则为准。',
'nanaAssistant.trackHint': '请在下方输入框粘贴单号,我会说明各节点通常代表什么状态。', 'nanaAssistant.trackHint': '直接发送运单号即可查轨迹,例如:IDE2607281746002926',
'nanaAssistant.replyVisibility': '从揽收到签收有关键节点更新 — 揽收、出口、干线、清关、妥投。异常升级前我们会主动预警。', 'nanaAssistant.replyVisibility': '从揽收到签收有关键节点更新 — 揽收、出口、干线、清关、妥投。异常升级前我们会主动预警。',
'nanaAssistant.replyAfterSales': '专属顾问负责异常、理赔与售后跟进,不用自己排队工单。请说明问题,如有单号一并提供。', 'nanaAssistant.replyAfterSales': '专属顾问负责异常、理赔与售后跟进,不用自己排队工单。请说明问题,如有单号一并提供。',
'nanaAssistant.replyHuman': '请前往联系页 — 电话 18123959245 或邮箱 bd01@1st56.com。说明线路和货型,我们会安排对应顾问。', 'nanaAssistant.replyHuman': '请前往联系页 — 电话 18123959245 或邮箱 bd01@1st56.com。说明线路和货型,我们会安排对应顾问。',
'nanaAssistant.fallback': '收到。请补充起运地、目的地、货型与重量以便报价;也可联系人工顾问,24 小时内回复。', 'nanaAssistant.fallback': '可以说目的国和重量让我查价,或直接发运单号查轨迹;也可以联系人工顾问。',
'nanaAssistant.emptyReply': '刚才没有得到有效回复,请再发一次,或换个更具体的说法(目的国+重量 / 运单号)。',
'nanaAssistant.aiUnavailable': '智能顾问暂未配置或未就绪。请联系人工顾问,或稍后再试。',
'nanaAssistant.providerLoading': '正在连接智能顾问,请稍候再发…',
'nanaAssistant.toolDoneEmpty': '已调用 {tools},但模型未返回说明。请再问一句「总结一下」或联系人工顾问。',
'nanaAssistant.sendFailedDetail': '请求失败:{msg}',
'nanaAssistant.wechatId': '微信号', 'nanaAssistant.wechatId': '微信号',
'nanaAssistant.noAdvisor': '暂未分配专属顾问', 'nanaAssistant.noAdvisor': '暂未分配专属顾问',
'nanaAssistant.noWechatQr': '暂未上传微信二维码', 'nanaAssistant.noWechatQr': '暂未上传微信二维码',
'nanaAssistant.greeting': '嗨,我是 Nana~,我是你的AI智能客服\n\n无论是查轨迹、了解渠道时效,还是申报价值和清关问题,都可以问我。今天有什么可以帮你的吗?', 'nanaAssistant.greeting': '嗨,我是 Nana~,我是你的AI智能客服\n\n无论是查轨迹、了解渠道时效,还是申报价值和清关问题,都可以问我。今天有什么可以帮你的吗?',
'nanaAssistant.inputPlaceholder': '输入您的问题…',
'nanaAssistant.send': '发送', 'nanaAssistant.send': '发送',
'nanaAssistant.close': '关闭', 'nanaAssistant.close': '关闭',
'nanaAssistant.back': '返回', 'nanaAssistant.back': '返回',
@ -1223,10 +1291,11 @@ export default {
'order.myOrders.table.cargoAttribute': '货物属性', 'order.myOrders.table.cargoAttribute': '货物属性',
'order.myOrders.drawer.orderStatus': '订单状态', 'order.myOrders.drawer.orderStatus': '订单状态',
'order.myOrders.drawer.predeclareFailReason': '预报失败原因', 'order.myOrders.drawer.predeclareFailReason': '预报失败原因',
'order.myOrders.drawer.failGuideTitle': '预报未完成 — 建议修改', 'order.myOrders.drawer.failGuideTitle': '预报未完成',
'order.myOrders.drawer.failGuideRaw': '详细原因', 'order.myOrders.drawer.failGuideRaw': '查看系统返回',
'order.myOrders.drawer.failGuideAskSupport': '联系客服', 'order.myOrders.drawer.failGuideAskSupport': '联系客服',
'order.myOrders.drawer.failGuideEdit': '去修改', 'order.myOrders.drawer.failGuideEdit': '去修改',
'order.myOrders.drawer.failGuideTip': '建议',
'order.myOrders.drawer.allocationPendingReason': '待排货原因', 'order.myOrders.drawer.allocationPendingReason': '待排货原因',
'order.myOrders.drawer.warehouse': '仓库', 'order.myOrders.drawer.warehouse': '仓库',
'order.myOrders.drawer.receiveTime': '收货时间', 'order.myOrders.drawer.receiveTime': '收货时间',
@ -1460,6 +1529,9 @@ export default {
'order.priceCheck.table.viewGuide': '渠道说明', 'order.priceCheck.table.viewGuide': '渠道说明',
'order.priceCheck.table.viewGuideHint': '点击查看线路说明', 'order.priceCheck.table.viewGuideHint': '点击查看线路说明',
'order.priceCheck.table.orderNow': '立刻下单', 'order.priceCheck.table.orderNow': '立刻下单',
'order.priceCheck.table.askAi': '问 AI',
'order.priceCheck.table.askAiCanShip': '这个能不能发?',
'order.priceCheck.table.askAiCheaper': '有没有更便宜?',
'order.priceCheck.table.empty': '暂无报价,请先查询', 'order.priceCheck.table.empty': '暂无报价,请先查询',
'order.priceCheck.table.emptyHint': '可尝试填写邮编、尺寸或选择货物属性/仓库后重试;也可联系客服协助查价。', 'order.priceCheck.table.emptyHint': '可尝试填写邮编、尺寸或选择货物属性/仓库后重试;也可联系客服协助查价。',
@ -1618,14 +1690,14 @@ export default {
'order.placeOrder.single.failed': '单票下单失败', 'order.placeOrder.single.failed': '单票下单失败',
'order.placeOrder.single.failTitle': '下单未成功', 'order.placeOrder.single.failTitle': '下单未成功',
'order.placeOrder.single.failTitleDraft': '预报未完成(草稿已保存)', 'order.placeOrder.single.failTitleDraft': '预报未完成(草稿已保存)',
'order.placeOrder.single.failWhatHappened': '发生了什么', 'order.placeOrder.single.failWhatHappened': '失败原因',
'order.placeOrder.single.failHowToFix': '建议修改', 'order.placeOrder.single.failHowToFix': '建议',
'order.placeOrder.single.failRawReason': '详细原因', 'order.placeOrder.single.failRawReason': '查看系统返回',
'order.placeOrder.single.failAskSupport': '联系客服', 'order.placeOrder.single.failAskSupport': '联系客服',
'order.placeOrder.single.failCopied': '运单号已复制', 'order.placeOrder.single.failCopied': '运单号已复制',
'order.placeOrder.single.failCopyFailed': '复制失败,请手动选中运单号', 'order.placeOrder.single.failCopyFailed': '复制失败,请手动选中运单号',
'order.placeOrder.single.failDraftSavedNote': '订单已保存为草稿,但预报未完成。请到「我的订单」修改草稿后重新预报,不要在本页再次提交(会重复建单)。', 'order.placeOrder.single.failDraftSavedNote': '草稿已保存,请改完后再预报(勿重复提交)',
'order.placeOrder.single.failNotSavedNote': '订单尚未落库。您可以继续修改后重试,或先保存为草稿稍后再预报。', 'order.placeOrder.single.failNotSavedNote': '订单尚未保存',
'order.placeOrder.single.failServiceNo': '运单号', 'order.placeOrder.single.failServiceNo': '运单号',
'order.placeOrder.single.failContinueEdit': '继续修改', 'order.placeOrder.single.failContinueEdit': '继续修改',
'order.placeOrder.single.failEditDraft': '去修改草稿', 'order.placeOrder.single.failEditDraft': '去修改草稿',

View File

@ -109,6 +109,16 @@ const SYSTEM_MANAGEMENT: AppRouteRecordRaw = {
permission: 'Xhs.Ai.Use', permission: 'Xhs.Ai.Use',
}, },
}, },
{
path: 'mcpTools',
name: 'SystemMcpTools',
component: () => import('@/views/system/mcpTools/index.vue'),
meta: {
locale: 'menu.system.mcpTools',
requiresAuth: true,
permission: 'System.McpTools.Use',
},
},
{ {
path: 'dataPerm/users', path: 'dataPerm/users',
name: 'SystemDataPermUsers', name: 'SystemDataPermUsers',

View File

@ -92,6 +92,7 @@ export const MENU_NAME_TO_LOCALE_KEY: Record<string, string> = {
仓库地址: 'menu.system.warehouseAddress', 仓库地址: 'menu.system.warehouseAddress',
批量发送邮件: 'menu.system.batchEmail', 批量发送邮件: 'menu.system.batchEmail',
'AI 助手': 'menu.system.xhsAiAssistant', 'AI 助手': 'menu.system.xhsAiAssistant',
'MCP 工具': 'menu.system.mcpTools',
数据权限: 'menu.system.dataPerm', 数据权限: 'menu.system.dataPerm',
用户数据权限: 'menu.system.dataPermUsers', 用户数据权限: 'menu.system.dataPermUsers',
权限模板: 'menu.system.dataPermTemplates', 权限模板: 'menu.system.dataPermTemplates',

View File

@ -79,6 +79,7 @@ export const SUB_MENU_DEFAULT_ICONS: Record<string, string> = {
'menu.system.warehouseAddress': 'IconHome', 'menu.system.warehouseAddress': 'IconHome',
'menu.system.otherConfig': 'IconSettings', 'menu.system.otherConfig': 'IconSettings',
'menu.system.xhsAiAssistant': 'IconRobot', 'menu.system.xhsAiAssistant': 'IconRobot',
'menu.system.mcpTools': 'IconApps',
'menu.system.dataPerm': 'IconLock', 'menu.system.dataPerm': 'IconLock',
'menu.system.dataPermUsers': 'IconUser', 'menu.system.dataPermUsers': 'IconUser',
'menu.system.dataPermTemplates': 'IconFile', 'menu.system.dataPermTemplates': 'IconFile',

View File

@ -124,14 +124,14 @@ type GuideRule = {
const GUIDE_RULES: GuideRule[] = [ const GUIDE_RULES: GuideRule[] = [
{ {
test: /黑名单/, test: /黑名单/,
plainReason: '收件地址在当前运输方式的禁运/黑名单范围内', plainReason: '收件地址在禁运/黑名单范围内',
tips: ['请更换收件国家、省市或邮编', '或尝试更换运输方式(销售产品)'], tips: ['请更换收件国家、省市、邮编,或换运输方式'],
section: 'recipient', section: 'recipient',
}, },
{ {
test: /价格分区未匹配/, test: /价格分区未匹配/,
plainReason: '收件邮编/国家未匹配到可用价格分区', plainReason: '收件邮编/国家未匹配到价格分区',
tips: ['请核对收件国家与邮编是否正确', '若仍无法匹配,请更换运输方式或联系客服'], tips: ['请核对国家与邮编;仍不行请换运输方式或联系客服'],
section: 'recipient', section: 'recipient',
suggestSupport: true, suggestSupport: true,
}, },
@ -143,21 +143,21 @@ const GUIDE_RULES: GuideRule[] = [
}, },
{ {
test: /产品卖价未匹配|卖价未匹配|未配置计重|未命中价格|未命中规则|未命中.*快照/, test: /产品卖价未匹配|卖价未匹配|未配置计重|未命中价格|未命中规则|未命中.*快照/,
plainReason: '当前运输方式暂无可用报价或规则未配置完整', plainReason: '当前运输方式暂无可用报价',
tips: ['请更换运输方式,或联系客服确认该线路价格/规则是否已配置'], tips: ['请更换运输方式,或联系客服确认线路报价'],
section: 'basic', section: 'basic',
suggestSupport: true, suggestSupport: true,
}, },
{ {
test: /销售产品不存在|产品代码|产品未启用|仓库不存在|仓库ID|货物属性|出口报关/, test: /销售产品不存在|产品代码|产品未启用|仓库不存在|仓库ID|货物属性|出口报关/,
plainReason: '基础信息选择有误或选项已失效', plainReason: '基础信息选择有误或已失效',
tips: ['请检查运输方式、仓库、货物属性等基础信息是否选择正确'], tips: ['请检查运输方式、仓库、货物属性'],
section: 'basic', section: 'basic',
}, },
{ {
test: /客户单号已存在/, test: /客户单号已存在/,
plainReason: '客户单号与已有订单重复', plainReason: '客户单号与已有订单重复',
tips: ['请修改客户单号后重新提交'], tips: ['请修改客户单号后重试'],
section: 'basic', section: 'basic',
}, },
{ {
@ -169,7 +169,7 @@ const GUIDE_RULES: GuideRule[] = [
{ {
test: /收件人信息不完整/, test: /收件人信息不完整/,
plainReason: '收件人必填信息不完整', plainReason: '收件人必填信息不完整',
tips: ['请补全收件人姓名、国家、地址1、邮编等必填项'], tips: ['请补全姓名、国家、地址、邮编等'],
section: 'recipient', section: 'recipient',
}, },
{ {
@ -181,13 +181,13 @@ const GUIDE_RULES: GuideRule[] = [
{ {
test: /申报明细|申报币种/, test: /申报明细|申报币种/,
plainReason: '申报信息不完整', plainReason: '申报信息不完整',
tips: ['请检查申报明细必填字段与申报币种是否完整'], tips: ['请检查申报明细与申报币种'],
section: 'declaration', section: 'declaration',
}, },
{ {
test: /完善手机号和邮箱|联系方式/, test: /完善手机号和邮箱|联系方式/,
plainReason: '账户联系方式未完善', plainReason: '账户联系方式未完善',
tips: ['请先完善账户手机号与邮箱后再下单'], tips: ['请先完善账户手机号与邮箱'],
section: 'basic', section: 'basic',
}, },
] ]
@ -218,7 +218,7 @@ export function buildParcelCreateFailGuide(error: unknown): ParcelCreateFailGuid
return { return {
rawReason, rawReason,
reason: hit?.plainReason || rawReason, reason: hit?.plainReason || rawReason,
tips: hit?.tips?.length ? hit.tips : ['请根据失败原因核对表单后重试;若仍失败请联系客服'], tips: hit?.tips?.length ? hit.tips.slice(0, 1) : ['请根据失败原因核对表单后重试'],
section: hit?.section ?? 'basic', section: hit?.section ?? 'basic',
draftSaved, draftSaved,
orderId, orderId,

View File

@ -586,27 +586,29 @@
width="92%" width="92%"
> >
<div v-if="createFailInfo" class="h5-po-fail"> <div v-if="createFailInfo" class="h5-po-fail">
<p class="h5-po-fail__label">{{ t('h5.placeOrder.failWhat') }}</p> <div class="h5-po-fail__hero">
<p class="h5-po-fail__reason">{{ createFailInfo.reason }}</p> <p class="h5-po-fail__reason">{{ createFailInfo.reason }}</p>
<p <p v-if="createFailInfo.tips?.[0]" class="h5-po-fail__tip">
<span class="h5-po-fail__tip-label">{{ t('h5.placeOrder.failHow') }}</span>
{{ createFailInfo.tips[0] }}
</p>
</div>
<div v-if="createFailInfo.draftSaved" class="h5-po-fail__status">
<span>{{ t('h5.placeOrder.failDraftNote') }}</span>
<span v-if="createFailInfo.serviceNo" class="h5-po-fail__sno">
{{ createFailInfo.serviceNo }}
</span>
</div>
<details
v-if="createFailInfo.rawReason && createFailInfo.rawReason !== createFailInfo.reason" v-if="createFailInfo.rawReason && createFailInfo.rawReason !== createFailInfo.reason"
class="h5-po-fail__raw" class="h5-po-fail__details"
> >
{{ t('h5.placeOrder.failRaw') }}:{{ createFailInfo.rawReason }} <summary>{{ t('h5.placeOrder.failRaw') }}</summary>
</p> <p class="h5-po-fail__raw">{{ createFailInfo.rawReason }}</p>
<p v-if="createFailInfo.draftSaved" class="h5-po-fail__draft"> </details>
{{ t('h5.placeOrder.failDraftNote') }}
<template v-if="createFailInfo.serviceNo">
({{ createFailInfo.serviceNo }})
</template>
</p>
<p v-else class="h5-po-fail__draft">
{{ t('h5.placeOrder.failNotSavedNote') }}
</p>
<p class="h5-po-fail__label">{{ t('h5.placeOrder.failHow') }}</p>
<ul class="h5-po-fail__tips">
<li v-for="(tip, idx) in createFailInfo.tips" :key="idx">{{ tip }}</li>
</ul>
<div class="h5-po-fail__actions"> <div class="h5-po-fail__actions">
<button <button
type="button" type="button"
@ -1949,51 +1951,80 @@ watch(
.h5-po-fail { .h5-po-fail {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 12px;
} }
.h5-po-fail__label { .h5-po-fail__hero {
margin: 0; padding: 12px 14px;
font-size: 13px; border-radius: 8px;
font-weight: 600; border-left: 3px solid #f53f3f;
color: #1d2129; background: #fff7f7;
} }
.h5-po-fail__reason { .h5-po-fail__reason {
margin: 0; margin: 0;
font-size: 14px; font-size: 16px;
line-height: 1.55; font-weight: 600;
color: #4e5969; line-height: 1.45;
color: #1d2129;
word-break: break-word; word-break: break-word;
} }
.h5-po-fail__tip {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.5;
color: #4e5969;
}
.h5-po-fail__tip-label {
margin-right: 4px;
color: var(--app-primary-color, #2d8c8c);
font-weight: 500;
}
.h5-po-fail__status {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
line-height: 1.4;
color: #0fc6c2;
background: rgba(15, 198, 194, 0.08);
}
.h5-po-fail__sno {
text-decoration: underline;
}
.h5-po-fail__details {
font-size: 12px;
color: #86909c;
summary {
cursor: pointer;
user-select: none;
}
}
.h5-po-fail__raw { .h5-po-fail__raw {
margin: 0; margin: 6px 0 0;
padding: 8px 10px;
border-radius: 6px;
background: #f7f8fa;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
color: #86909c; color: #86909c;
word-break: break-word; word-break: break-word;
} }
.h5-po-fail__draft {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--app-primary-color);
}
.h5-po-fail__tips {
margin: 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.6;
color: #4e5969;
}
.h5-po-fail__actions { .h5-po-fail__actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
margin-top: 8px; margin-top: 4px;
.h5-po__btn { .h5-po__btn {
flex: 1; flex: 1;

View File

@ -46,6 +46,8 @@ export default {
'h5.price.sort.time': 'Fastest', 'h5.price.sort.time': 'Fastest',
'h5.price.recommendTag': 'Best', 'h5.price.recommendTag': 'Best',
'h5.price.askAdvisor': 'Ask AI', 'h5.price.askAdvisor': 'Ask AI',
'h5.price.askAiCanShip': 'Can this ship?',
'h5.price.askAiCheaper': 'Anything cheaper?',
'h5.price.goOrder': 'Place order', 'h5.price.goOrder': 'Place order',
'h5.price.goOrderHint': 'Opening place-order…', 'h5.price.goOrderHint': 'Opening place-order…',
'h5.price.empty': 'Enter country and weight to get quotes', 'h5.price.empty': 'Enter country and weight to get quotes',
@ -286,13 +288,11 @@ export default {
'h5.placeOrder.failed': 'Failed to create order', 'h5.placeOrder.failed': 'Failed to create order',
'h5.placeOrder.failTitle': 'Order was not placed', 'h5.placeOrder.failTitle': 'Order was not placed',
'h5.placeOrder.failTitleDraft': 'Pre-declaration failed (draft saved)', 'h5.placeOrder.failTitleDraft': 'Pre-declaration failed (draft saved)',
'h5.placeOrder.failWhat': 'What happened', 'h5.placeOrder.failWhat': 'Failure reason',
'h5.placeOrder.failHow': 'What to change', 'h5.placeOrder.failHow': 'Tip',
'h5.placeOrder.failRaw': 'Details', 'h5.placeOrder.failRaw': 'System response',
'h5.placeOrder.failDraftNote': 'h5.placeOrder.failDraftNote': 'Draft saved — edit then pre-declare',
'Saved as draft. Open the order, fix details, then pre-declare again. Do not resubmit here.', 'h5.placeOrder.failNotSavedNote': 'Order was not saved',
'h5.placeOrder.failNotSavedNote':
'The order was not saved. Continue editing and retry, or save it as a draft.',
'h5.placeOrder.failContinue': 'Continue editing', 'h5.placeOrder.failContinue': 'Continue editing',
'h5.placeOrder.failSaveDraft': 'Save as draft', 'h5.placeOrder.failSaveDraft': 'Save as draft',
'h5.placeOrder.failSaveDraftOk': 'Saved as draft', 'h5.placeOrder.failSaveDraftOk': 'Saved as draft',

View File

@ -46,6 +46,8 @@ export default {
'h5.price.sort.time': '快时效', 'h5.price.sort.time': '快时效',
'h5.price.recommendTag': '推荐', 'h5.price.recommendTag': '推荐',
'h5.price.askAdvisor': '问顾问', 'h5.price.askAdvisor': '问顾问',
'h5.price.askAiCanShip': '能不能发?',
'h5.price.askAiCheaper': '有没有更便宜?',
'h5.price.goOrder': '去下单', 'h5.price.goOrder': '去下单',
'h5.price.goOrderHint': '正在跳转下单…', 'h5.price.goOrderHint': '正在跳转下单…',
'h5.price.empty': '输入国家与重量后查询报价', 'h5.price.empty': '输入国家与重量后查询报价',
@ -285,11 +287,11 @@ export default {
'h5.placeOrder.failed': '下单失败', 'h5.placeOrder.failed': '下单失败',
'h5.placeOrder.failTitle': '下单未成功', 'h5.placeOrder.failTitle': '下单未成功',
'h5.placeOrder.failTitleDraft': '预报未完成(草稿已保存)', 'h5.placeOrder.failTitleDraft': '预报未完成(草稿已保存)',
'h5.placeOrder.failWhat': '发生了什么', 'h5.placeOrder.failWhat': '失败原因',
'h5.placeOrder.failHow': '建议修改', 'h5.placeOrder.failHow': '建议',
'h5.placeOrder.failRaw': '详细原因', 'h5.placeOrder.failRaw': '查看系统返回',
'h5.placeOrder.failDraftNote': '订单已存为草稿。请打开订单详情修改后重新预报,勿在此页再次提交。', 'h5.placeOrder.failDraftNote': '草稿已保存,请改完后再预报',
'h5.placeOrder.failNotSavedNote': '订单尚未落库。可继续修改后重试,或先保存为草稿。', 'h5.placeOrder.failNotSavedNote': '订单尚未保存',
'h5.placeOrder.failContinue': '继续修改', 'h5.placeOrder.failContinue': '继续修改',
'h5.placeOrder.failSaveDraft': '保存为草稿', 'h5.placeOrder.failSaveDraft': '保存为草稿',
'h5.placeOrder.failSaveDraftOk': '已保存为草稿', 'h5.placeOrder.failSaveDraftOk': '已保存为草稿',

View File

@ -169,9 +169,19 @@
<button type="button" class="link-btn" @click="openDetail(item)"> <button type="button" class="link-btn" @click="openDetail(item)">
{{ t('h5.price.viewDetail') }} {{ t('h5.price.viewDetail') }}
</button> </button>
<button type="button" class="link-btn" @click="askAdvisor(item)"> <a-dropdown trigger="click" position="top">
{{ t('h5.price.askAdvisor') }} <button type="button" class="link-btn">
</button> {{ t('h5.price.askAdvisor') }}
</button>
<template #content>
<a-doption @click="askAdvisor(item, 'canShip')">
{{ t('h5.price.askAiCanShip') }}
</a-doption>
<a-doption @click="askAdvisor(item, 'cheaper')">
{{ t('h5.price.askAiCheaper') }}
</a-doption>
</template>
</a-dropdown>
<button type="button" class="order-btn" @click="goOrder(item)"> <button type="button" class="order-btn" @click="goOrder(item)">
{{ t('h5.price.goOrder') }} {{ t('h5.price.goOrder') }}
</button> </button>
@ -337,7 +347,17 @@
</section> </section>
<div class="h5-detail__footer"> <div class="h5-detail__footer">
<a-button long @click="askAdvisor(detailItem)">{{ t('h5.price.askAdvisor') }}</a-button> <a-dropdown trigger="click" position="top" class="h5-detail__ask-drop">
<a-button long>{{ t('h5.price.askAdvisor') }}</a-button>
<template #content>
<a-doption @click="askAdvisor(detailItem, 'canShip')">
{{ t('h5.price.askAiCanShip') }}
</a-doption>
<a-doption @click="askAdvisor(detailItem, 'cheaper')">
{{ t('h5.price.askAiCheaper') }}
</a-doption>
</template>
</a-dropdown>
<a-button type="primary" long @click="goOrder(detailItem)"> <a-button type="primary" long @click="goOrder(detailItem)">
{{ t('h5.price.goOrder') }} {{ t('h5.price.goOrder') }}
</a-button> </a-button>
@ -373,6 +393,10 @@ import { getDictListAll } from '@/api/system/dict';
import { getSalesProductDetail } from '@/api/channel/salesProduct'; import { getSalesProductDetail } from '@/api/channel/salesProduct';
import { h5Navigate } from '@/utils/device'; import { h5Navigate } from '@/utils/device';
import { checkCustomerContactRequired } from '@/utils/customerContactGate'; import { checkCustomerContactRequired } from '@/utils/customerContactGate';
import {
getPriceAskLocation,
type PriceAskIntent,
} from '@/composables/priceAskAdvisor';
import H5PlaceOrderSheet from '../components/H5PlaceOrderSheet.vue'; import H5PlaceOrderSheet from '../components/H5PlaceOrderSheet.vue';
import H5CompleteContactSheet from '../components/H5CompleteContactSheet.vue'; import H5CompleteContactSheet from '../components/H5CompleteContactSheet.vue';
@ -735,22 +759,22 @@ async function openDetail(item: any) {
} }
} }
function askAdvisor(item: any) { function askAdvisor(item: any, intent: PriceAskIntent = 'general') {
const text = [ const ctx = {
t('h5.price.country') + `: ${destinationDisplay.value || form.destinationCountry}`, destinationCountry: String(form.destinationCountry || '').trim(),
t('h5.price.weight') + `: ${form.cargoWeight}`, cargoWeight: form.cargoWeight,
item.shippingMethod, postalCode: form.postalCode,
item.cargoTypeName || item.cargoType, productCode: String(item?.productCode ?? '').trim(),
item.warehouse, shippingMethod: String(item?.shippingMethod ?? '').trim(),
`¥${formatMoney(item.totalCost)}`, cargoType: String(item?.cargoType ?? form.cargoType ?? '').trim(),
] cargoTypeName: String(item?.cargoTypeName ?? item?.cargoType ?? '').trim(),
.filter(Boolean) warehouse: String(item?.warehouse ?? '').trim(),
.join(' / '); totalCost: item?.totalCost,
deliveryTime: item?.deliveryTime,
quoteLevel: String(item?.quoteLevel ?? item?.QuoteLevel ?? '').trim(),
};
detailVisible.value = false; detailVisible.value = false;
h5Navigate(router, { h5Navigate(router, getPriceAskLocation(intent, ctx, { h5: true }));
path: '/h5/assistant',
query: { prefill: text },
});
} }
function buildOrderDraft(item: any) { function buildOrderDraft(item: any) {

View File

@ -80,19 +80,20 @@
</div> </div>
<div v-if="predeclareFailGuide" class="predeclare-fail-guide"> <div v-if="predeclareFailGuide" class="predeclare-fail-guide">
<div class="predeclare-fail-guide__title">
{{ $t('order.myOrders.drawer.failGuideTitle') }}
</div>
<p class="predeclare-fail-guide__reason">{{ predeclareFailGuide.reason }}</p> <p class="predeclare-fail-guide__reason">{{ predeclareFailGuide.reason }}</p>
<p <p v-if="predeclareFailGuide.tips?.[0]" class="predeclare-fail-guide__tip">
v-if="predeclareFailGuide.rawReason !== predeclareFailGuide.reason" <span class="predeclare-fail-guide__tip-label">
class="predeclare-fail-guide__raw" {{ $t('order.myOrders.drawer.failGuideTip') }}
> </span>
{{ $t('order.myOrders.drawer.failGuideRaw') }}:{{ predeclareFailGuide.rawReason }} {{ predeclareFailGuide.tips[0] }}
</p> </p>
<ul class="predeclare-fail-guide__tips"> <details
<li v-for="(tip, idx) in predeclareFailGuide.tips" :key="idx">{{ tip }}</li> v-if="predeclareFailGuide.rawReason !== predeclareFailGuide.reason"
</ul> class="predeclare-fail-guide__details"
>
<summary>{{ $t('order.myOrders.drawer.failGuideRaw') }}</summary>
<p class="predeclare-fail-guide__raw">{{ predeclareFailGuide.rawReason }}</p>
</details>
<div class="predeclare-fail-guide__actions"> <div class="predeclare-fail-guide__actions">
<a-button size="small" type="outline" @click="handleAskAdvisor"> <a-button size="small" type="outline" @click="handleAskAdvisor">
{{ $t('order.myOrders.drawer.failGuideAskSupport') }} {{ $t('order.myOrders.drawer.failGuideAskSupport') }}
@ -1923,38 +1924,51 @@ defineExpose({
margin-top: 12px; margin-top: 12px;
padding: 12px 14px; padding: 12px 14px;
border-radius: 8px; border-radius: 8px;
background: #fff7e8; background: #fff7f7;
border: 1px solid #ffe4ba; border-left: 3px solid #f53f3f;
border-top: 1px solid #ffd8d2;
&__title { border-right: 1px solid #ffd8d2;
font-size: 13px; border-bottom: 1px solid #ffd8d2;
font-weight: 600;
color: #1d2129;
margin-bottom: 6px;
}
&__reason { &__reason {
margin: 0; margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.45;
color: #1d2129;
}
&__tip {
margin: 8px 0 0;
font-size: 13px; font-size: 13px;
line-height: 1.55; line-height: 1.5;
color: #4e5969; color: #4e5969;
} }
&__tip-label {
margin-right: 4px;
color: var(--app-primary-color, #2d8c8c);
font-weight: 500;
}
&__details {
margin-top: 8px;
font-size: 12px;
color: #86909c;
summary {
cursor: pointer;
user-select: none;
}
}
&__raw { &__raw {
margin: 4px 0 0; margin: 6px 0 0;
font-size: 12px; font-size: 12px;
color: #86909c; color: #86909c;
word-break: break-word; word-break: break-word;
} }
&__tips {
margin: 8px 0 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.6;
color: #4e5969;
}
&__actions { &__actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@ -2904,7 +2904,10 @@
</div> </div>
<div class="single-form-footer liquid-card"> <div class="single-form-footer liquid-card">
<div class="single-submit-hint" :class="{ ready: canSubmitSingle }"> <div
class="single-submit-hint"
:class="canSubmitSingle ? 'is-ready' : 'is-pending'"
>
{{ singleSubmitHint }} {{ singleSubmitHint }}
</div> </div>
<div class="order-submit-actions"> <div class="order-submit-actions">
@ -3249,35 +3252,37 @@
:esc-to-close="!createFailCancelling && !createFailSavingDraft" :esc-to-close="!createFailCancelling && !createFailSavingDraft"
> >
<div v-if="createFailInfo" class="create-fail-wrap"> <div v-if="createFailInfo" class="create-fail-wrap">
<div class="create-fail-section"> <div class="create-fail-hero">
<div class="create-fail-label">{{ t('order.placeOrder.single.failWhatHappened') }}</div>
<p class="create-fail-reason">{{ createFailInfo.reason }}</p> <p class="create-fail-reason">{{ createFailInfo.reason }}</p>
<p <p v-if="createFailInfo.tips?.[0]" class="create-fail-tip">
v-if="createFailInfo.rawReason && createFailInfo.rawReason !== createFailInfo.reason" <span class="create-fail-tip-label">{{ t('order.placeOrder.single.failHowToFix') }}</span>
class="create-fail-raw" {{ createFailInfo.tips[0] }}
</p>
</div>
<div
v-if="createFailInfo.draftSaved"
class="create-fail-status is-draft"
>
<span>{{ t('order.placeOrder.single.failDraftSavedNote') }}</span>
<button
v-if="createFailInfo.serviceNo"
type="button"
class="create-fail-copy"
@click="handleCreateFailCopyServiceNo"
> >
{{ t('order.placeOrder.single.failRawReason') }}:{{ createFailInfo.rawReason }} {{ createFailInfo.serviceNo }}
</p> </button>
<p v-if="createFailInfo.draftSaved" class="create-fail-draft-note">
{{ t('order.placeOrder.single.failDraftSavedNote') }}
<template v-if="createFailInfo.serviceNo">
({{ t('order.placeOrder.single.failServiceNo') }}:
<button type="button" class="create-fail-copy" @click="handleCreateFailCopyServiceNo">
{{ createFailInfo.serviceNo }}
</button>
)
</template>
</p>
<p v-else class="create-fail-draft-note">
{{ t('order.placeOrder.single.failNotSavedNote') }}
</p>
</div>
<div class="create-fail-section">
<div class="create-fail-label">{{ t('order.placeOrder.single.failHowToFix') }}</div>
<ul class="create-fail-tips">
<li v-for="(tip, idx) in createFailInfo.tips" :key="idx">{{ tip }}</li>
</ul>
</div> </div>
<details
v-if="createFailInfo.rawReason && createFailInfo.rawReason !== createFailInfo.reason"
class="create-fail-details"
>
<summary>{{ t('order.placeOrder.single.failRawReason') }}</summary>
<p class="create-fail-raw">{{ createFailInfo.rawReason }}</p>
</details>
<div class="create-fail-actions"> <div class="create-fail-actions">
<a-button <a-button
:disabled="createFailCancelling || createFailSavingDraft" :disabled="createFailCancelling || createFailSavingDraft"
@ -4556,65 +4561,108 @@
.create-fail-wrap { .create-fail-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 14px;
} }
.create-fail-section { .create-fail-hero {
.create-fail-label { padding: 14px 16px;
margin-bottom: 6px; border-radius: 6px;
color: #1d2129; border-left: 3px solid #f53f3f;
font-size: 13px; background: #fff7f7;
font-weight: 600; }
}
.create-fail-reason { .create-fail-reason {
margin: 0; margin: 0;
color: #4e5969; color: #1d2129;
font-size: 14px; font-size: 16px;
line-height: 1.6; font-weight: 600;
word-break: break-word; line-height: 1.45;
} word-break: break-word;
}
.create-fail-raw { .create-fail-tip {
margin: 6px 0 0; margin: 8px 0 0;
color: #86909c; color: #4e5969;
font-size: 12px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
word-break: break-word; }
}
.create-fail-draft-note { .create-fail-tip-label {
margin: 8px 0 0; margin-right: 6px;
color: var(--app-primary-color); color: var(--app-primary-color, #2d8c8c);
font-size: 13px; font-weight: 500;
line-height: 1.5; }
}
.create-fail-copy { .create-fail-status {
border: none; display: flex;
padding: 0; align-items: center;
background: transparent; flex-wrap: wrap;
color: inherit; gap: 8px;
font: inherit; padding: 8px 12px;
text-decoration: underline; border-radius: 4px;
font-size: 12px;
line-height: 1.4;
color: #4e5969;
background: #f7f8fa;
&.is-draft {
color: #0fc6c2;
background: rgba(15, 198, 194, 0.08);
}
}
.create-fail-copy {
border: none;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.create-fail-details {
font-size: 12px;
color: #86909c;
summary {
cursor: pointer; cursor: pointer;
user-select: none;
list-style: none;
&::-webkit-details-marker {
display: none;
}
&::before {
content: '› ';
display: inline-block;
transition: transform 0.15s;
}
} }
.create-fail-tips { &[open] summary::before {
margin: 0; transform: rotate(90deg);
padding-left: 18px;
color: #4e5969;
font-size: 13px;
line-height: 1.7;
} }
} }
.create-fail-raw {
margin: 6px 0 0;
padding: 8px 10px;
border-radius: 4px;
background: #f7f8fa;
color: #86909c;
font-size: 12px;
line-height: 1.5;
word-break: break-word;
}
.create-fail-actions { .create-fail-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
padding-top: 4px; padding-top: 2px;
} }
.single-tab { .single-tab {
@ -4837,12 +4885,21 @@
.single-submit-hint { .single-submit-hint {
margin-right: auto; margin-right: auto;
max-width: min(420px, 52%);
padding: 6px 12px;
border-radius: 4px;
font-size: 12px; font-size: 12px;
color: #86909c; font-weight: 600;
line-height: 1.4; line-height: 1.4;
&.ready { &.is-pending {
color: var(--app-primary-color); color: #f53f3f;
background: #ffece8;
}
&.is-ready {
color: #00b42a;
background: #e8ffea;
} }
} }

View File

@ -15,6 +15,7 @@
import AiRecommendCard from './components/AiRecommendCard.vue'; import AiRecommendCard from './components/AiRecommendCard.vue';
import { recommendWithAi, type QuoteCandidate } from './recommendChannel'; import { recommendWithAi, type QuoteCandidate } from './recommendChannel';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute'; import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import { openPriceAskAdvisor, type PriceAskIntent } from '@/composables/priceAskAdvisor';
defineOptions({ defineOptions({
name: 'PriceCheck', name: 'PriceCheck',
@ -369,7 +370,7 @@
{ {
title: t('order.priceCheck.table.operation'), title: t('order.priceCheck.table.operation'),
slotName: 'operations', slotName: 'operations',
width: 96, width: 200,
fixed: 'right' as const, fixed: 'right' as const,
}, },
]); ]);
@ -547,6 +548,32 @@
priceBreakdown.value = null; priceBreakdown.value = null;
}; };
function buildAskContext(item: any) {
const cargoTypeName =
cargoTypeOptions.value.find((o) => o.value === String(item?.cargoType ?? ''))?.label ||
String(item?.cargoTypeName ?? item?.cargoType ?? '').trim();
return {
destinationCountry: String(formData.destinationCountry || '').trim(),
cargoWeight: formData.cargoWeight,
postalCode: formData.postalCode,
length: formData.length,
width: formData.width,
height: formData.height,
productCode: String(item?.productCode ?? '').trim(),
shippingMethod: String(item?.shippingMethod ?? '').trim(),
cargoType: String(item?.cargoType ?? '').trim(),
cargoTypeName,
warehouse: String(item?.warehouse ?? '').trim(),
totalCost: item?.totalCost,
deliveryTime: item?.deliveryTime,
quoteLevel: String(item?.quoteLevel ?? item?.QuoteLevel ?? queryHint.value?.quoteLevel ?? '').trim(),
};
}
function handleAskAi(item: any, intent: PriceAskIntent) {
openPriceAskAdvisor(router, intent, buildAskContext(item));
}
const handleOrder = (item: any) => { const handleOrder = (item: any) => {
const rowDisclaimers = normalizeStringList(item?.disclaimers ?? item?.Disclaimers); const rowDisclaimers = normalizeStringList(item?.disclaimers ?? item?.Disclaimers);
const hintDisclaimers = normalizeStringList(queryHint.value?.disclaimers); const hintDisclaimers = normalizeStringList(queryHint.value?.disclaimers);
@ -1054,6 +1081,20 @@
<template #operations="{ record }"> <template #operations="{ record }">
<div class="ops-actions" @click.stop> <div class="ops-actions" @click.stop>
<a-dropdown trigger="hover" position="br">
<button type="button" class="ops-btn ops-btn--ghost">
{{ t('order.priceCheck.table.askAi') }}
<icon-down style="margin-left: 2px" />
</button>
<template #content>
<a-doption @click="handleAskAi(record, 'canShip')">
{{ t('order.priceCheck.table.askAiCanShip') }}
</a-doption>
<a-doption @click="handleAskAi(record, 'cheaper')">
{{ t('order.priceCheck.table.askAiCheaper') }}
</a-doption>
</template>
</a-dropdown>
<button <button
type="button" type="button"
class="ops-btn ops-btn--primary" class="ops-btn ops-btn--primary"

View File

@ -114,19 +114,20 @@
</div> </div>
<div v-if="predeclareFailGuide" class="predeclare-fail-guide"> <div v-if="predeclareFailGuide" class="predeclare-fail-guide">
<div class="predeclare-fail-guide__title">
{{ $t('order.myOrders.drawer.failGuideTitle') }}
</div>
<p class="predeclare-fail-guide__reason">{{ predeclareFailGuide.reason }}</p> <p class="predeclare-fail-guide__reason">{{ predeclareFailGuide.reason }}</p>
<p <p v-if="predeclareFailGuide.tips?.[0]" class="predeclare-fail-guide__tip">
v-if="predeclareFailGuide.rawReason !== predeclareFailGuide.reason" <span class="predeclare-fail-guide__tip-label">
class="predeclare-fail-guide__raw" {{ $t('order.myOrders.drawer.failGuideTip') }}
> </span>
{{ $t('order.myOrders.drawer.failGuideRaw') }}:{{ predeclareFailGuide.rawReason }} {{ predeclareFailGuide.tips[0] }}
</p> </p>
<ul class="predeclare-fail-guide__tips"> <details
<li v-for="(tip, idx) in predeclareFailGuide.tips" :key="idx">{{ tip }}</li> v-if="predeclareFailGuide.rawReason !== predeclareFailGuide.reason"
</ul> class="predeclare-fail-guide__details"
>
<summary>{{ $t('order.myOrders.drawer.failGuideRaw') }}</summary>
<p class="predeclare-fail-guide__raw">{{ predeclareFailGuide.rawReason }}</p>
</details>
<div class="predeclare-fail-guide__actions"> <div class="predeclare-fail-guide__actions">
<a-button size="small" type="outline" @click="handleAskAdvisor"> <a-button size="small" type="outline" @click="handleAskAdvisor">
{{ $t('order.myOrders.drawer.failGuideAskSupport') }} {{ $t('order.myOrders.drawer.failGuideAskSupport') }}
@ -2148,38 +2149,51 @@ defineExpose({
margin-top: 12px; margin-top: 12px;
padding: 12px 14px; padding: 12px 14px;
border-radius: 8px; border-radius: 8px;
background: #fff7e8; background: #fff7f7;
border: 1px solid #ffe4ba; border-left: 3px solid #f53f3f;
border-top: 1px solid #ffd8d2;
&__title { border-right: 1px solid #ffd8d2;
font-size: 13px; border-bottom: 1px solid #ffd8d2;
font-weight: 600;
color: #1d2129;
margin-bottom: 6px;
}
&__reason { &__reason {
margin: 0; margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.45;
color: #1d2129;
}
&__tip {
margin: 8px 0 0;
font-size: 13px; font-size: 13px;
line-height: 1.55; line-height: 1.5;
color: #4e5969; color: #4e5969;
} }
&__tip-label {
margin-right: 4px;
color: var(--app-primary-color, #2d8c8c);
font-weight: 500;
}
&__details {
margin-top: 8px;
font-size: 12px;
color: #86909c;
summary {
cursor: pointer;
user-select: none;
}
}
&__raw { &__raw {
margin: 4px 0 0; margin: 6px 0 0;
font-size: 12px; font-size: 12px;
color: #86909c; color: #86909c;
word-break: break-word; word-break: break-word;
} }
&__tips {
margin: 8px 0 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.6;
color: #4e5969;
}
&__actions { &__actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@ -0,0 +1,527 @@
<template>
<div class="mcp-tools-container">
<div class="page-head">
<div>
<h2 class="page-title">{{ t('menu.system.mcpTools') }}</h2>
<p class="page-desc">{{ t('system.mcpTools.pageDesc') }}</p>
</div>
<a-button :loading="loading" @click="loadTools">{{ t('system.mcpTools.refresh') }}</a-button>
</div>
<div class="layout">
<div class="tool-list liquid-card">
<div
v-for="tool in tools"
:key="tool.toolKey"
class="tool-card"
:class="{ active: selectedKey === tool.toolKey, disabled: !tool.enabled }"
@click="selectTool(tool)"
>
<div class="tool-card-top">
<span class="tool-name">{{ tool.displayName }}</span>
<a-switch
:model-value="tool.enabled"
size="small"
@click.stop
@change="(v: boolean | string | number) => toggleEnabled(tool, Boolean(v))"
/>
</div>
<div class="tool-key">{{ tool.toolKey }}</div>
<p class="tool-desc">{{ tool.description }}</p>
</div>
<a-empty v-if="!loading && !tools.length" />
</div>
<div class="tool-detail liquid-card">
<template v-if="selected">
<div class="detail-title">{{ selected.displayName }}</div>
<a-form layout="vertical" class="detail-form">
<a-form-item :label="t('system.mcpTools.displayName')">
<a-input v-model="editForm.displayName" />
</a-form-item>
<a-form-item :label="t('system.mcpTools.description')">
<a-textarea v-model="editForm.description" :auto-size="{ minRows: 3, maxRows: 6 }" />
</a-form-item>
<a-form-item :label="t('system.mcpTools.sortOrder')">
<a-input-number v-model="editForm.sortOrder" :min="0" style="width: 160px" />
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="saving" @click="saveSelected">
{{ t('system.mcpTools.save') }}
</a-button>
</a-form-item>
</a-form>
<a-divider />
<div class="section-title">{{ t('system.mcpTools.testTitle') }}</div>
<p class="section-hint">{{ t('system.mcpTools.testHint') }}</p>
<a-textarea
v-model="testArgs"
:placeholder="t('system.mcpTools.testArgsPlaceholder')"
:auto-size="{ minRows: 4, maxRows: 10 }"
/>
<div class="row-actions">
<a-button type="primary" :loading="testing" :disabled="!selected.enabled" @click="runTest">
{{ t('system.mcpTools.runTest') }}
</a-button>
</div>
<pre v-if="testResult" class="result-pre" :class="{ ok: testOk, bad: testOk === false }">{{
testResult
}}</pre>
<a-divider />
<div class="section-title">{{ t('system.mcpTools.agentTitle') }}</div>
<p class="section-hint">{{ t('system.mcpTools.agentHint') }}</p>
<div class="agent-toolbar">
<a-select
v-model="providerKey"
:placeholder="t('system.mcpTools.provider')"
style="width: 200px"
allow-search
>
<a-option
v-for="p in providers"
:key="p.providerKey"
:value="p.providerKey"
:label="p.displayName || p.providerKey"
/>
</a-select>
<a-input
v-model="modelId"
:placeholder="t('system.mcpTools.model')"
style="width: 200px"
allow-clear
/>
</div>
<a-textarea
v-model="agentPrompt"
:placeholder="t('system.mcpTools.agentPlaceholder')"
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
<div class="row-actions">
<a-button type="primary" :loading="agentLoading" @click="runAgent">
{{ t('system.mcpTools.runAgent') }}
</a-button>
</div>
<div v-if="agentReply" class="agent-reply">
<div class="agent-reply-label">{{ t('system.mcpTools.agentReply') }}</div>
<pre class="result-pre ok">{{ agentReply }}</pre>
</div>
<div v-if="agentTraces.length" class="agent-traces">
<div class="agent-reply-label">{{ t('system.mcpTools.toolTraces') }}</div>
<div v-for="(tr, idx) in agentTraces" :key="idx" class="trace-item">
<div class="trace-head">
<span>{{ tr.toolKey }}</span>
<a-tag :color="tr.success ? 'green' : 'red'" size="small">
{{ tr.success ? 'OK' : 'FAIL' }}
</a-tag>
</div>
<pre class="trace-args">{{ tr.argumentsJson }}</pre>
<pre class="trace-result">{{ tr.resultPreview }}</pre>
</div>
</div>
</template>
<a-empty v-else :description="t('system.mcpTools.selectHint')" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
callMcpTool,
chatAiAgent,
listMcpTools,
saveMcpTool,
type AiAgentToolTrace,
type McpToolItem,
} from '@/api/mcp';
import { listXhsAiProviders } from '@/api/xhs';
import {
providerModelTags,
type AiProviderItem,
} from '@/views/system/xhsAiAssistant/providerCatalog';
const { t } = useI18n();
const loading = ref(false);
const saving = ref(false);
const testing = ref(false);
const agentLoading = ref(false);
const tools = ref<McpToolItem[]>([]);
const selectedKey = ref('');
const testArgs = ref('{}');
const testResult = ref('');
const testOk = ref<boolean | null>(null);
const providers = ref<AiProviderItem[]>([]);
const providerKey = ref('');
const modelId = ref('');
const agentPrompt = ref('');
const agentReply = ref('');
const agentTraces = ref<AiAgentToolTrace[]>([]);
const editForm = reactive({
displayName: '',
description: '',
sortOrder: 0,
enabled: true,
});
const selected = computed(() => tools.value.find((x) => x.toolKey === selectedKey.value) || null);
const SAMPLE_ARGS: Record<string, string> = {
'oms.price_check': JSON.stringify(
{ destinationCountry: 'US', cargoWeight: 0.5, postalCode: '10001' },
null,
2,
),
'oms.channel_guide': JSON.stringify(
{ productCode: '', productName: '', cargoName: 'lipstick' },
null,
2,
),
'oms.track_query': JSON.stringify({ orderNumber: '' }, null, 2),
};
function selectTool(tool: McpToolItem) {
selectedKey.value = tool.toolKey;
editForm.displayName = tool.displayName;
editForm.description = tool.description;
editForm.sortOrder = tool.sortOrder;
editForm.enabled = tool.enabled;
testArgs.value = SAMPLE_ARGS[tool.toolKey] || '{\n \n}';
testResult.value = '';
testOk.value = null;
}
async function loadTools() {
loading.value = true;
try {
tools.value = await listMcpTools();
if (!selectedKey.value && tools.value[0]) {
selectTool(tools.value[0]);
} else if (selectedKey.value) {
const cur = tools.value.find((x) => x.toolKey === selectedKey.value);
if (cur) selectTool(cur);
}
} catch (e: any) {
Message.error(e?.message || t('system.mcpTools.loadFail'));
} finally {
loading.value = false;
}
}
async function toggleEnabled(tool: McpToolItem, enabled: boolean) {
try {
await saveMcpTool({
toolKey: tool.toolKey,
displayName: tool.displayName,
description: tool.description,
enabled,
sortOrder: tool.sortOrder,
});
tool.enabled = enabled;
if (selected.value?.toolKey === tool.toolKey) editForm.enabled = enabled;
Message.success(enabled ? t('system.mcpTools.enabled') : t('system.mcpTools.disabled'));
} catch (e: any) {
Message.error(e?.message || t('system.mcpTools.saveFail'));
}
}
async function saveSelected() {
if (!selected.value) return;
saving.value = true;
try {
const saved = await saveMcpTool({
toolKey: selected.value.toolKey,
displayName: editForm.displayName.trim(),
description: editForm.description.trim(),
enabled: editForm.enabled,
sortOrder: Number(editForm.sortOrder) || 0,
});
const idx = tools.value.findIndex((x) => x.toolKey === saved.toolKey);
if (idx >= 0) {
tools.value[idx] = { ...tools.value[idx], ...saved };
}
Message.success(t('system.mcpTools.saveOk'));
} catch (e: any) {
Message.error(e?.message || t('system.mcpTools.saveFail'));
} finally {
saving.value = false;
}
}
async function runTest() {
if (!selected.value) return;
let args = testArgs.value.trim() || '{}';
try {
JSON.parse(args);
} catch {
Message.warning(t('system.mcpTools.argsInvalid'));
return;
}
testing.value = true;
testResult.value = '';
testOk.value = null;
try {
const res = await callMcpTool(selected.value.toolKey, args);
testOk.value = res.success;
testResult.value = `${res.success ? '✓' : '✗'} ${res.elapsedMs}ms\n\n${res.content}`;
} catch (e: any) {
testOk.value = false;
testResult.value = e?.message || t('system.mcpTools.testFail');
} finally {
testing.value = false;
}
}
async function loadProviders() {
try {
const items = await listXhsAiProviders();
providers.value = ((items ?? []) as AiProviderItem[]).filter((p) => p.enabled && p.apiKey);
const def = providers.value.find((p) => p.isDefault) ?? providers.value[0];
if (def) {
providerKey.value = def.providerKey;
const tags = providerModelTags(def.providerType);
modelId.value = tags[0]?.modelId || String(def.apiSecret || '').trim() || '';
}
} catch {
providers.value = [];
}
}
async function runAgent() {
const text = agentPrompt.value.trim();
if (!text) {
Message.warning(t('system.mcpTools.agentNeedPrompt'));
return;
}
if (!providerKey.value) {
Message.warning(t('system.mcpTools.agentNeedProvider'));
return;
}
agentLoading.value = true;
agentReply.value = '';
agentTraces.value = [];
try {
const res = await chatAiAgent({
providerKey: providerKey.value,
model: modelId.value || undefined,
messages: [{ role: 'user', content: text }],
maxToolRounds: 2,
});
agentReply.value = res.content;
agentTraces.value = res.toolTraces;
} catch (e: any) {
Message.error(e?.message || t('system.mcpTools.agentFail'));
} finally {
agentLoading.value = false;
}
}
onMounted(async () => {
await Promise.all([loadTools(), loadProviders()]);
});
</script>
<style scoped lang="less">
.mcp-tools-container {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
background: #f0f3f4;
min-height: 0;
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1d2129;
}
.page-desc {
margin: 4px 0 0;
font-size: 13px;
color: #86909c;
line-height: 1.5;
}
.layout {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 320px 1fr;
gap: 12px;
}
.liquid-card {
background: #fff;
border-radius: 8px;
border: 1px solid #e5e6eb;
padding: 14px;
overflow: auto;
}
.tool-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.tool-card {
padding: 12px;
border: 1px solid #e5e6eb;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: var(--app-primary-color, #2d8c8c);
}
&.active {
border-color: var(--app-primary-color, #2d8c8c);
background: rgba(45, 140, 140, 0.06);
}
&.disabled {
opacity: 0.65;
}
}
.tool-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.tool-name {
font-size: 14px;
font-weight: 600;
color: #1d2129;
}
.tool-key {
margin-top: 4px;
font-size: 11px;
color: #86909c;
font-family: Consolas, monospace;
}
.tool-desc {
margin: 8px 0 0;
font-size: 12px;
color: #4e5969;
line-height: 1.5;
}
.detail-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #1d2129;
}
.section-hint {
margin: 4px 0 10px;
font-size: 12px;
color: #86909c;
}
.row-actions {
margin-top: 10px;
display: flex;
gap: 8px;
}
.result-pre {
margin-top: 10px;
padding: 10px 12px;
border-radius: 6px;
background: #f7f8fa;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 280px;
overflow: auto;
&.ok {
background: #e8ffea;
}
&.bad {
background: #ffece8;
}
}
.agent-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.agent-reply,
.agent-traces {
margin-top: 12px;
}
.agent-reply-label {
font-size: 12px;
font-weight: 600;
color: #4e5969;
margin-bottom: 6px;
}
.trace-item {
border: 1px solid #e5e6eb;
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 8px;
}
.trace-head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 600;
}
.trace-args,
.trace-result {
margin: 6px 0 0;
font-size: 11px;
white-space: pre-wrap;
word-break: break-word;
color: #86909c;
}
@media (max-width: 960px) {
.layout {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -207,6 +207,7 @@ export default defineConfig(({ mode }) => {
'/system/batchEmail', '/system/batchEmail',
'/system/otherConfig', '/system/otherConfig',
'/system/xhsAiAssistant', '/system/xhsAiAssistant',
'/system/mcpTools',
'/system/dataPerm', '/system/dataPerm',
]; ];
if (systemSpaPrefixes.some((p) => path === p || path.startsWith(`${p}/`))) { if (systemSpaPrefixes.some((p) => path === p || path.startsWith(`${p}/`))) {
@ -220,6 +221,18 @@ export default defineConfig(({ mode }) => {
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },
// MCP 工具 / AI Agent(须与 nginx /mcp/ 反代一致)
'/mcp': {
target: omsAdminTarget,
changeOrigin: true,
secure: false,
},
// 兼容旧 Agent 路径 /ai/agent/chat
'/ai': {
target: omsAdminTarget,
changeOrigin: true,
secure: false,
},
// 微信公众号服务器回调(兼容 WMS 路径 /api/apiwechat/verification) // 微信公众号服务器回调(兼容 WMS 路径 /api/apiwechat/verification)
'/api/apiwechat': { '/api/apiwechat': {
target: omsAdminTarget, target: omsAdminTarget,
@ -419,6 +432,7 @@ export default defineConfig(({ mode }) => {
'/system/organizations', '/system/organizations',
'/system/batchEmail', '/system/batchEmail',
'/system/xhsAiAssistant', '/system/xhsAiAssistant',
'/system/mcpTools',
'/system/otherConfig', '/system/otherConfig',
'/system/dataPerm', '/system/dataPerm',
// User 相关前端路由 // User 相关前端路由