Merge pull request '20261028' (#14) from test into master
All checks were successful
Build and Deploy Front / deploy (push) Successful in 2m13s
All checks were successful
Build and Deploy Front / deploy (push) Successful in 2m13s
Reviewed-on: #14
This commit is contained in:
commit
ffadbcd92f
@ -364,9 +364,7 @@ import {
|
|||||||
} from '@/composables/assistantMcpGuide';
|
} from '@/composables/assistantMcpGuide';
|
||||||
import { isMobileUserAgent, isWeChatBrowser } from '@/utils/device';
|
import { isMobileUserAgent, isWeChatBrowser } from '@/utils/device';
|
||||||
import { openWecomKf } from '@/utils/wecomKf';
|
import { openWecomKf } from '@/utils/wecomKf';
|
||||||
import {
|
import { resolveTextModelId } from '@/views/system/aiAssistant/providerCatalog';
|
||||||
providerModelTags,
|
|
||||||
} from '@/views/system/aiAssistant/providerCatalog';
|
|
||||||
import NanaAvatar from './NanaAvatar.vue';
|
import NanaAvatar from './NanaAvatar.vue';
|
||||||
import logoImg from '@/assets/logo2.png';
|
import logoImg from '@/assets/logo2.png';
|
||||||
|
|
||||||
@ -903,10 +901,10 @@ async function loadProvider() {
|
|||||||
const def = enabled.find((p: any) => p.isDefault) ?? enabled[0];
|
const def = enabled.find((p: any) => p.isDefault) ?? enabled[0];
|
||||||
providerKey.value = def?.providerKey ?? '';
|
providerKey.value = def?.providerKey ?? '';
|
||||||
if (def) {
|
if (def) {
|
||||||
const tags = providerModelTags(String(def.providerType || ''));
|
providerModel.value = resolveTextModelId({
|
||||||
const secret = String(def.apiSecret || '').trim();
|
providerType: String(def.providerType || ''),
|
||||||
providerModel.value =
|
apiSecret: def.apiSecret,
|
||||||
tags[0]?.modelId || (secret && secret !== '******' ? secret : '') || '';
|
});
|
||||||
} else {
|
} else {
|
||||||
providerModel.value = '';
|
providerModel.value = '';
|
||||||
}
|
}
|
||||||
@ -1028,10 +1026,20 @@ function buildAgentMessages(userFacingMessages: ChatMessage[]) {
|
|||||||
|
|
||||||
if (activeCapability.value) {
|
if (activeCapability.value) {
|
||||||
const cap = getCapability(activeCapability.value);
|
const cap = getCapability(activeCapability.value);
|
||||||
list.push({
|
if (cap.id === 'guide') {
|
||||||
role: 'system',
|
list.push({
|
||||||
content: `用户当前意图偏向「${cap.toolKey}」。必填信息已齐时立刻调工具;不要索要起运地;不要空回复。`,
|
role: 'system',
|
||||||
});
|
content:
|
||||||
|
'用户在问品名能不能寄/走什么渠道。必须调用 oms.channel_guide,参数形如 ' +
|
||||||
|
'{"productCode":"","productName":"","cargoName":"<从用户话提取的品名>"}。' +
|
||||||
|
'已知渠道再填 productCode/productName,否则留空。不要空回复。',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
list.push({
|
||||||
|
role: 'system',
|
||||||
|
content: `用户当前意图偏向「${cap.toolKey}」。必填信息已齐时立刻调工具;不要索要起运地;不要空回复。`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 只带最近几轮,避免历史把 token 打爆
|
// 只带最近几轮,避免历史把 token 打爆
|
||||||
const dialog = userFacingMessages
|
const dialog = userFacingMessages
|
||||||
|
|||||||
@ -50,8 +50,8 @@ export const MCP_CAPABILITY_GUIDES: McpCapabilityGuide[] = [
|
|||||||
exampleKey: 'nanaAssistant.mcp.guideExample',
|
exampleKey: 'nanaAssistant.mcp.guideExample',
|
||||||
draftKey: 'nanaAssistant.mcp.guideDraft',
|
draftKey: 'nanaAssistant.mcp.guideDraft',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'product', required: true, labelKey: 'nanaAssistant.mcp.field.product' },
|
|
||||||
{ key: 'cargoName', required: true, labelKey: 'nanaAssistant.mcp.field.cargoName' },
|
{ key: 'cargoName', required: true, labelKey: 'nanaAssistant.mcp.field.cargoName' },
|
||||||
|
{ key: 'product', required: false, labelKey: 'nanaAssistant.mcp.field.product' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -80,7 +80,6 @@ const ORDER_NO_LOOSE = /[A-Za-z][A-Za-z0-9\-_]{5,}/
|
|||||||
const ORDER_NO_STRICT =
|
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
|
/\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 =
|
const CARGO_HINT =
|
||||||
/品名|货物|普货|敏感|衣服|服装|鞋|化妆品|lipstick|手机|电池|粉末|液体|食品|寄|发/i
|
/品名|货物|普货|敏感|衣服|服装|鞋|化妆品|lipstick|手机|电池|粉末|液体|食品|寄|发/i
|
||||||
|
|
||||||
@ -113,8 +112,12 @@ export function detectMissingFields(
|
|||||||
if (!COUNTRY_HINT.test(t)) missing.push('country')
|
if (!COUNTRY_HINT.test(t)) missing.push('country')
|
||||||
if (!WEIGHT_HINT.test(t)) missing.push('weight')
|
if (!WEIGHT_HINT.test(t)) missing.push('weight')
|
||||||
} else if (capability === 'guide') {
|
} 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')
|
const hasCargo =
|
||||||
|
CARGO_HINT.test(t) ||
|
||||||
|
/[A-Za-z]{2,}/.test(t) ||
|
||||||
|
(t.replace(/\s/g, '').length >= 2 && !/^(能不能|可以|吗|么|寄|发|走)+$/.test(t.replace(/\s/g, '')))
|
||||||
|
if (!hasCargo) missing.push('cargoName')
|
||||||
} else if (capability === 'track') {
|
} else if (capability === 'track') {
|
||||||
if (!opts?.hasWaybillContext && !extractOrderNo(t)) missing.push('orderNo')
|
if (!opts?.hasWaybillContext && !extractOrderNo(t)) missing.push('orderNo')
|
||||||
}
|
}
|
||||||
@ -131,7 +134,13 @@ export function inferCapabilityFromText(text: string): McpCapabilityId | null {
|
|||||||
if (extractOrderNo(raw) && !COUNTRY_HINT.test(raw)) return 'track'
|
if (extractOrderNo(raw) && !COUNTRY_HINT.test(raw)) return 'track'
|
||||||
if (ORDER_NO_STRICT.test(raw) && raw.length < 40) return 'track'
|
if (ORDER_NO_STRICT.test(raw) && raw.length < 40) return 'track'
|
||||||
|
|
||||||
if (/品名|能发|禁运|可寄|对客说明|承运|敏感货/.test(lower)) return 'guide'
|
if (
|
||||||
|
/品名|能发|禁运|可寄|对客说明|承运|敏感货|能不能走|可以走|走什么渠道|哪个渠道|什么渠道|能不能寄|可以寄|能寄吗|可发吗/.test(
|
||||||
|
lower,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'guide'
|
||||||
|
}
|
||||||
|
|
||||||
if (/price|cost|quote|报价|价格|多少钱|运费|查价|怎么走|走得动/.test(lower)) return 'price'
|
if (/price|cost|quote|报价|价格|多少钱|运费|查价|怎么走|走得动/.test(lower)) return 'price'
|
||||||
|
|
||||||
|
|||||||
@ -40,9 +40,9 @@ export default {
|
|||||||
'nanaAssistant.mcp.guideTitle': 'Can this cargo ship?',
|
'nanaAssistant.mcp.guideTitle': 'Can this cargo ship?',
|
||||||
'nanaAssistant.mcp.guideDesc': 'See if this item can go on this service',
|
'nanaAssistant.mcp.guideDesc': 'See if this item can go on this service',
|
||||||
'nanaAssistant.mcp.guideCoach':
|
'nanaAssistant.mcp.guideCoach':
|
||||||
'I need the sales product (service) and the cargo name.\nExample: product XX, cargo lipstick — can it ship?',
|
'Tell me the cargo name and I’ll check which channels allow it.\nExample: Can lipstick ship? / Which channel for cosmetics?',
|
||||||
'nanaAssistant.mcp.guideExample': 'Can lipstick ship?',
|
'nanaAssistant.mcp.guideExample': 'Can lipstick ship, which channel?',
|
||||||
'nanaAssistant.mcp.guideDraft': 'Sales product code/name:\nCargo name:',
|
'nanaAssistant.mcp.guideDraft': 'Cargo name:\nSales product code/name (optional):',
|
||||||
'nanaAssistant.mcp.trackTitle': 'Where is my parcel?',
|
'nanaAssistant.mcp.trackTitle': 'Where is my parcel?',
|
||||||
'nanaAssistant.mcp.trackDesc': 'See where the parcel is now',
|
'nanaAssistant.mcp.trackDesc': 'See where the parcel is now',
|
||||||
'nanaAssistant.mcp.trackCoach':
|
'nanaAssistant.mcp.trackCoach':
|
||||||
|
|||||||
@ -39,9 +39,9 @@ export default {
|
|||||||
'nanaAssistant.mcp.guideTitle': '这个货能不能寄',
|
'nanaAssistant.mcp.guideTitle': '这个货能不能寄',
|
||||||
'nanaAssistant.mcp.guideDesc': '看这个品名能不能走这条渠道',
|
'nanaAssistant.mcp.guideDesc': '看这个品名能不能走这条渠道',
|
||||||
'nanaAssistant.mcp.guideCoach':
|
'nanaAssistant.mcp.guideCoach':
|
||||||
'判断能不能寄需要:销售产品(运输方式)和品名。\n例如:产品 XX,品名 lipstick,能发吗?',
|
'告诉我品名即可查能不能寄、走什么渠道。\n例如:lipstick 能寄吗? / 化妆品走什么渠道?',
|
||||||
'nanaAssistant.mcp.guideExample': 'lipstick能寄吗',
|
'nanaAssistant.mcp.guideExample': 'lipstick能寄吗,走什么渠道',
|
||||||
'nanaAssistant.mcp.guideDraft': '销售产品代码/名称:\n客户品名:',
|
'nanaAssistant.mcp.guideDraft': '客户品名:\n销售产品代码/名称(选填):',
|
||||||
'nanaAssistant.mcp.trackTitle': '包裹到哪了',
|
'nanaAssistant.mcp.trackTitle': '包裹到哪了',
|
||||||
'nanaAssistant.mcp.trackDesc': '看包裹现在到哪了',
|
'nanaAssistant.mcp.trackDesc': '看包裹现在到哪了',
|
||||||
'nanaAssistant.mcp.trackCoach':
|
'nanaAssistant.mcp.trackCoach':
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
import CodeMirror from '@/components/CodeMirror/index.vue';
|
import CodeMirror from '@/components/CodeMirror/index.vue';
|
||||||
import { listAiProviders } from '@/api/ai';
|
import { listAiProviders } from '@/api/ai';
|
||||||
import {
|
import {
|
||||||
providerModelTags,
|
resolveProviderModelTags,
|
||||||
type AiProviderItem,
|
type AiProviderItem,
|
||||||
} from '@/views/system/aiAssistant/providerCatalog';
|
} from '@/views/system/aiAssistant/providerCatalog';
|
||||||
import {
|
import {
|
||||||
@ -64,12 +64,7 @@
|
|||||||
const modelOptions = computed(() => {
|
const modelOptions = computed(() => {
|
||||||
const p = selectedProvider.value;
|
const p = selectedProvider.value;
|
||||||
if (!p) return [];
|
if (!p) return [];
|
||||||
const tags = providerModelTags(p.providerType);
|
return resolveProviderModelTags(p);
|
||||||
const extra = String(p.apiSecret || '').trim();
|
|
||||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
|
||||||
return [...tags, { modelId: extra, role: '自定义' }];
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadPrefs() {
|
function loadPrefs() {
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { listAiProviders, type AiSkillItem } from '@/api/ai'
|
import { listAiProviders, type AiSkillItem } from '@/api/ai'
|
||||||
import {
|
import {
|
||||||
providerModelTags,
|
resolveProviderModelTags,
|
||||||
type AiProviderItem,
|
type AiProviderItem,
|
||||||
} from '@/views/system/aiAssistant/providerCatalog'
|
} from '@/views/system/aiAssistant/providerCatalog'
|
||||||
import {
|
import {
|
||||||
@ -62,12 +62,7 @@
|
|||||||
const modelOptions = computed(() => {
|
const modelOptions = computed(() => {
|
||||||
const p = selectedProvider.value
|
const p = selectedProvider.value
|
||||||
if (!p) return []
|
if (!p) return []
|
||||||
const tags = providerModelTags(p.providerType)
|
return resolveProviderModelTags(p)
|
||||||
const extra = String(p.apiSecret || '').trim()
|
|
||||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
|
||||||
return [...tags, { modelId: extra, role: '自定义' }]
|
|
||||||
}
|
|
||||||
return tags
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const previewRows = computed(() => {
|
const previewRows = computed(() => {
|
||||||
|
|||||||
@ -152,7 +152,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { chatAi, listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai'
|
import { chatAi, listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai'
|
||||||
import {
|
import {
|
||||||
providerModelTags,
|
resolveProviderModelTags,
|
||||||
type AiProviderItem,
|
type AiProviderItem,
|
||||||
} from '@/views/system/aiAssistant/providerCatalog'
|
} from '@/views/system/aiAssistant/providerCatalog'
|
||||||
import {
|
import {
|
||||||
@ -237,12 +237,7 @@ const selectedProvider = computed(
|
|||||||
const modelOptions = computed(() => {
|
const modelOptions = computed(() => {
|
||||||
const p = selectedProvider.value
|
const p = selectedProvider.value
|
||||||
if (!p) return []
|
if (!p) return []
|
||||||
const tags = providerModelTags(p.providerType)
|
return resolveProviderModelTags(p)
|
||||||
const extra = String(p.apiSecret || '').trim()
|
|
||||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
|
||||||
return [...tags, { modelId: extra, role: '自定义' }]
|
|
||||||
}
|
|
||||||
return tags
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSend = computed(
|
const canSend = computed(
|
||||||
|
|||||||
@ -158,7 +158,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { chatAi, listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai'
|
import { chatAi, listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai'
|
||||||
import type { SnapshotDiffRow } from '@/utils/publishSnapshotDiff'
|
import type { SnapshotDiffRow } from '@/utils/publishSnapshotDiff'
|
||||||
import {
|
import {
|
||||||
providerModelTags,
|
resolveProviderModelTags,
|
||||||
type AiProviderItem,
|
type AiProviderItem,
|
||||||
} from '@/views/system/aiAssistant/providerCatalog'
|
} from '@/views/system/aiAssistant/providerCatalog'
|
||||||
import {
|
import {
|
||||||
@ -232,12 +232,7 @@ const selectedProvider = computed(
|
|||||||
const modelOptions = computed(() => {
|
const modelOptions = computed(() => {
|
||||||
const p = selectedProvider.value
|
const p = selectedProvider.value
|
||||||
if (!p) return []
|
if (!p) return []
|
||||||
const tags = providerModelTags(p.providerType)
|
return resolveProviderModelTags(p)
|
||||||
const extra = String(p.apiSecret || '').trim()
|
|
||||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
|
||||||
return [...tags, { modelId: extra, role: '自定义' }]
|
|
||||||
}
|
|
||||||
return tags
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const canSend = computed(
|
const canSend = computed(
|
||||||
|
|||||||
@ -1262,7 +1262,7 @@ import {
|
|||||||
type DomesticPickupProductOption,
|
type DomesticPickupProductOption,
|
||||||
} from '@/utils/domesticPickupProducts';
|
} from '@/utils/domesticPickupProducts';
|
||||||
import {
|
import {
|
||||||
providerModelTags,
|
resolveProviderModelTags,
|
||||||
type AiProviderItem,
|
type AiProviderItem,
|
||||||
} from '@/views/system/aiAssistant/providerCatalog';
|
} from '@/views/system/aiAssistant/providerCatalog';
|
||||||
import H5SelectDeclarationSkuSheet from './H5SelectDeclarationSkuSheet.vue';
|
import H5SelectDeclarationSkuSheet from './H5SelectDeclarationSkuSheet.vue';
|
||||||
@ -1362,12 +1362,7 @@ const receiverAiSelectedProvider = computed(
|
|||||||
const receiverAiModelOptions = computed(() => {
|
const receiverAiModelOptions = computed(() => {
|
||||||
const p = receiverAiSelectedProvider.value;
|
const p = receiverAiSelectedProvider.value;
|
||||||
if (!p) return [];
|
if (!p) return [];
|
||||||
const tags = providerModelTags(p.providerType);
|
return resolveProviderModelTags(p);
|
||||||
const extra = String(p.apiSecret || '').trim();
|
|
||||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
|
||||||
return [...tags, { modelId: extra, role: '自定义' }];
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
});
|
});
|
||||||
/** 申报品「更多」展开状态,按 item._key */
|
/** 申报品「更多」展开状态,按 item._key */
|
||||||
const openItemMoreKeys = ref<Set<string>>(new Set());
|
const openItemMoreKeys = ref<Set<string>>(new Set());
|
||||||
|
|||||||
@ -919,7 +919,7 @@ export default {
|
|||||||
'h5.invite.tab.share': 'Share invite',
|
'h5.invite.tab.share': 'Share invite',
|
||||||
'h5.invite.tab.referees': 'Invited',
|
'h5.invite.tab.referees': 'Invited',
|
||||||
'h5.invite.lead':
|
'h5.invite.lead':
|
||||||
'Earn freight credit when friends’ deliveries succeed: 5% on 1st order (max ¥50), 3% on orders 2–6 (max ¥30), plus a ¥20 coupon. Friends get ¥5 on sign-up.',
|
'Earn freight credit when friends’ deliveries succeed: 5% on 1st order (max ¥50), 3% on orders 2–6 (max ¥30), plus a ¥10 coupon. Friends get ¥5 on sign-up.',
|
||||||
'h5.invite.locked': 'Upgrade to V1 to invite friends',
|
'h5.invite.locked': 'Upgrade to V1 to invite friends',
|
||||||
'h5.invite.code': 'Invite code',
|
'h5.invite.code': 'Invite code',
|
||||||
'h5.invite.link': 'Invite link (open in WeChat)',
|
'h5.invite.link': 'Invite link (open in WeChat)',
|
||||||
|
|||||||
@ -905,7 +905,7 @@ export default {
|
|||||||
'h5.invite.tab.share': '分享邀请',
|
'h5.invite.tab.share': '分享邀请',
|
||||||
'h5.invite.tab.referees': '已邀请好友',
|
'h5.invite.tab.referees': '已邀请好友',
|
||||||
'h5.invite.lead':
|
'h5.invite.lead':
|
||||||
'邀请有奖:好友派送成功后,您得运费余额(首单 5% 最高 50 元,第 2–6 单 3% 最高 30 元),另得 20 元券;好友注册即送 5 元券',
|
'邀请有奖:好友派送成功后,您得运费余额(首单 5% 最高 50 元,第 2–6 单 3% 最高 30 元),另得 10 元券;好友注册即送 5 元券',
|
||||||
'h5.invite.locked': '升级到 V1 后可邀请好友',
|
'h5.invite.locked': '升级到 V1 后可邀请好友',
|
||||||
'h5.invite.code': '邀请码',
|
'h5.invite.code': '邀请码',
|
||||||
'h5.invite.link': '邀请链接(微信内打开)',
|
'h5.invite.link': '邀请链接(微信内打开)',
|
||||||
|
|||||||
@ -9,6 +9,9 @@
|
|||||||
v-if="formCollapsed"
|
v-if="formCollapsed"
|
||||||
type="button"
|
type="button"
|
||||||
class="quote-collapsed"
|
class="quote-collapsed"
|
||||||
|
:class="{ 'is-loading': queryLoading }"
|
||||||
|
:disabled="queryLoading"
|
||||||
|
:aria-busy="queryLoading"
|
||||||
@click="expandQuoteForm"
|
@click="expandQuoteForm"
|
||||||
>
|
>
|
||||||
<div class="quote-collapsed__main">
|
<div class="quote-collapsed__main">
|
||||||
@ -16,8 +19,14 @@
|
|||||||
<span class="quote-collapsed__meta">{{ collapsedMetaText }}</span>
|
<span class="quote-collapsed__meta">{{ collapsedMetaText }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="quote-collapsed__action">
|
<span class="quote-collapsed__action">
|
||||||
{{ t('h5.price.editQuery') }}
|
<template v-if="queryLoading">
|
||||||
<icon-down class="quote-collapsed__chevron" />
|
<span class="quote-collapsed__pulse" aria-hidden="true" />
|
||||||
|
{{ t('h5.price.querying') }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ t('h5.price.editQuery') }}
|
||||||
|
<icon-down class="quote-collapsed__chevron" />
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -205,8 +214,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="queried" class="h5-price-results">
|
<section
|
||||||
<div v-if="sortedResults.length" class="h5-results-bar">
|
v-if="queried"
|
||||||
|
class="h5-price-results"
|
||||||
|
:aria-busy="queryLoading"
|
||||||
|
>
|
||||||
|
<div v-if="sortedResults.length && !queryLoading" class="h5-results-bar h5-results-bar--in">
|
||||||
<div class="h5-results-bar__row">
|
<div class="h5-results-bar__row">
|
||||||
<p class="h5-results-tip" :title="resultsBarTipFull">
|
<p class="h5-results-tip" :title="resultsBarTipFull">
|
||||||
<span>{{ t('h5.price.resultCount', { n: sortedResults.length }) }}</span>
|
<span>{{ t('h5.price.resultCount', { n: sortedResults.length }) }}</span>
|
||||||
@ -228,8 +241,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-spin :loading="queryLoading" class="h5-price-spin">
|
<!-- 查价中:骨架占位(飞猪/携程式),避免整页 Spin 生硬切换 -->
|
||||||
<div v-if="!sortedResults.length && !queryLoading" class="h5-empty h5-empty--no-quote">
|
<div
|
||||||
|
v-if="queryLoading"
|
||||||
|
class="h5-quote-skel-list"
|
||||||
|
aria-live="polite"
|
||||||
|
:aria-label="t('h5.price.querying')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="i in 3"
|
||||||
|
:key="`skel-${resultsRevealEpoch}-${i}`"
|
||||||
|
class="h5-quote-skel"
|
||||||
|
:style="{ '--i': i - 1 }"
|
||||||
|
>
|
||||||
|
<div class="h5-quote-skel__head">
|
||||||
|
<div class="h5-quote-skel__col">
|
||||||
|
<span class="h5-quote-skel__bone h5-quote-skel__bone--title" />
|
||||||
|
<span class="h5-quote-skel__bone h5-quote-skel__bone--sub" />
|
||||||
|
</div>
|
||||||
|
<span class="h5-quote-skel__bone h5-quote-skel__bone--eta" />
|
||||||
|
<div class="h5-quote-skel__col h5-quote-skel__col--end">
|
||||||
|
<span class="h5-quote-skel__bone h5-quote-skel__bone--price" />
|
||||||
|
<span class="h5-quote-skel__bone h5-quote-skel__bone--ref" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h5-quote-skel__foot">
|
||||||
|
<span class="h5-quote-skel__bone h5-quote-skel__bone--chip" />
|
||||||
|
<span class="h5-quote-skel__bone h5-quote-skel__bone--chip" />
|
||||||
|
<span class="h5-quote-skel__bone h5-quote-skel__bone--btn" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="!sortedResults.length"
|
||||||
|
class="h5-empty h5-empty--no-quote h5-empty--in"
|
||||||
|
>
|
||||||
<div class="h5-empty__icon" aria-hidden="true">
|
<div class="h5-empty__icon" aria-hidden="true">
|
||||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
<circle cx="28" cy="28" r="14" fill="none" stroke="currentColor" stroke-width="3" />
|
<circle cx="28" cy="28" r="14" fill="none" stroke="currentColor" stroke-width="3" />
|
||||||
@ -319,16 +366,18 @@
|
|||||||
<a-button size="small" class="h5-empty__action" @click="askAdvisorEmpty">
|
<a-button size="small" class="h5-empty__action" @click="askAdvisorEmpty">
|
||||||
{{ t('h5.price.askAdvisor') }}
|
{{ t('h5.price.askAdvisor') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<article
|
<article
|
||||||
v-for="item in sortedResults"
|
v-for="(item, idx) in sortedResults"
|
||||||
:key="item.rowKey"
|
:key="`${resultsRevealEpoch}-${item.rowKey}`"
|
||||||
:ref="(el) => setQuoteRef(item.rowKey, el)"
|
:ref="(el) => setQuoteRef(item.rowKey, el)"
|
||||||
class="h5-quote"
|
class="h5-quote h5-quote--reveal"
|
||||||
:class="{
|
:class="{
|
||||||
'h5-quote--ai': item.rowKey === recommendedRowKey,
|
'h5-quote--ai': item.rowKey === recommendedRowKey,
|
||||||
}"
|
}"
|
||||||
|
:style="{ '--i': Math.min(idx, 7) }"
|
||||||
@click="openDetail(item)"
|
@click="openDetail(item)"
|
||||||
>
|
>
|
||||||
<div class="h5-quote__main">
|
<div class="h5-quote__main">
|
||||||
@ -363,7 +412,10 @@
|
|||||||
>
|
>
|
||||||
{{ t('h5.price.tag.priceOptimal') }}
|
{{ t('h5.price.tag.priceOptimal') }}
|
||||||
</span>
|
</span>
|
||||||
<div class="h5-quote__price">
|
<div
|
||||||
|
class="h5-quote__price"
|
||||||
|
:class="{ 'is-best': item.isPriceOptimal }"
|
||||||
|
>
|
||||||
<small>¥</small>{{ formatMoney(item.totalCost) }}
|
<small>¥</small>{{ formatMoney(item.totalCost) }}
|
||||||
</div>
|
</div>
|
||||||
<span class="ref-label">
|
<span class="ref-label">
|
||||||
@ -403,10 +455,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</a-spin>
|
</template>
|
||||||
|
|
||||||
<!-- AI 荐线降权:列表之后,默认一条轻入口 -->
|
<!-- AI 荐线降权:列表之后,默认一条轻入口 -->
|
||||||
<div v-if="sortedResults.length" class="h5-ai-panel">
|
<div v-if="sortedResults.length && !queryLoading" class="h5-ai-panel h5-ai-panel--in">
|
||||||
<button
|
<button
|
||||||
v-if="!aiPanelOpen"
|
v-if="!aiPanelOpen"
|
||||||
type="button"
|
type="button"
|
||||||
@ -769,6 +821,8 @@ const optionsLoading = ref(false);
|
|||||||
const optionsLoadFailed = ref(false);
|
const optionsLoadFailed = ref(false);
|
||||||
const queryLoading = ref(false);
|
const queryLoading = ref(false);
|
||||||
const queried = ref(false);
|
const queried = ref(false);
|
||||||
|
/** 每次查价递增,用于重播卡片/价格入场(不依赖路由重挂载) */
|
||||||
|
const resultsRevealEpoch = ref(0);
|
||||||
const queryFailureText = ref('');
|
const queryFailureText = ref('');
|
||||||
const queryFailureItems = ref<PriceCheckFailureItem[]>([]);
|
const queryFailureItems = ref<PriceCheckFailureItem[]>([]);
|
||||||
const failureExpanded = ref(false);
|
const failureExpanded = ref(false);
|
||||||
@ -1244,12 +1298,20 @@ async function handleQuery() {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
// 乐观收起条件区 + 骨架占位:视线立刻落到「结果位」,避免等接口再硬切
|
||||||
queryLoading.value = true;
|
queryLoading.value = true;
|
||||||
queried.value = true;
|
queried.value = true;
|
||||||
formCollapsed.value = false;
|
formCollapsed.value = true;
|
||||||
|
results.value = [];
|
||||||
|
queryHint.value = null;
|
||||||
|
resultsRevealEpoch.value += 1;
|
||||||
clearPriceCheckFailures();
|
clearPriceCheckFailures();
|
||||||
clearRecommendInsight();
|
clearRecommendInsight();
|
||||||
aiExpanded.value = false;
|
aiExpanded.value = false;
|
||||||
|
await nextTick();
|
||||||
|
document
|
||||||
|
.querySelector('.h5-price-results')
|
||||||
|
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
try {
|
try {
|
||||||
const length = parseOptionalDimension(form.length);
|
const length = parseOptionalDimension(form.length);
|
||||||
const width = parseOptionalDimension(form.width);
|
const width = parseOptionalDimension(form.width);
|
||||||
@ -1275,10 +1337,6 @@ async function handleQuery() {
|
|||||||
} else {
|
} else {
|
||||||
clearPriceCheckFailures();
|
clearPriceCheckFailures();
|
||||||
formCollapsed.value = true;
|
formCollapsed.value = true;
|
||||||
await nextTick();
|
|
||||||
document
|
|
||||||
.querySelector('.h5-price-results')
|
|
||||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
}
|
||||||
sortMode.value = 'recommend';
|
sortMode.value = 'recommend';
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -1602,9 +1660,27 @@ onMounted(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
&:active {
|
&:active:not(:disabled) {
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-loading .quote-collapsed__action {
|
||||||
|
color: var(--app-primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-collapsed__pulse {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--app-primary-color);
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--app-primary-color) 45%, transparent);
|
||||||
|
animation: h5-quote-pulse 1.25s ease-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote-collapsed__main {
|
.quote-collapsed__main {
|
||||||
@ -1958,6 +2034,11 @@ onMounted(() => {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h5-ai-panel--in {
|
||||||
|
animation: h5-soft-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
animation-delay: 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
.h5-ai-entry {
|
.h5-ai-entry {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@ -2393,6 +2474,14 @@ onMounted(() => {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h5-results-bar--in {
|
||||||
|
animation: h5-soft-in 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-empty--in {
|
||||||
|
animation: h5-soft-in 0.36s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
.h5-results-bar__row {
|
.h5-results-bar__row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -2447,9 +2536,100 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.h5-price-spin {
|
/* 骨架:与真实卡片同结构,shimmer 代替 Spin */
|
||||||
width: 100%;
|
.h5-quote-skel-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-quote-skel {
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
animation: h5-soft-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
animation-delay: calc(var(--i, 0) * 55ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-quote-skel__head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-quote-skel__col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&--end {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-quote-skel__foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba(242, 243, 245, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-quote-skel__bone {
|
||||||
display: block;
|
display: block;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#f2f3f5 0%,
|
||||||
|
#e8eaed 40%,
|
||||||
|
#f2f3f5 80%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: h5-skel-shimmer 1.35s ease-in-out infinite;
|
||||||
|
|
||||||
|
&--title {
|
||||||
|
width: 72%;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sub {
|
||||||
|
width: 42%;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--eta {
|
||||||
|
width: 36px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--price {
|
||||||
|
width: 76px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ref {
|
||||||
|
width: 48px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--chip {
|
||||||
|
width: 52px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--btn {
|
||||||
|
width: 64px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.h5-quote {
|
.h5-quote {
|
||||||
@ -2464,7 +2644,12 @@ onMounted(() => {
|
|||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&--reveal {
|
||||||
|
animation: h5-quote-reveal 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
animation-delay: calc(var(--i, 0) * 48ms);
|
||||||
|
}
|
||||||
|
|
||||||
&--ai {
|
&--ai {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@ -2623,17 +2808,119 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.h5-quote__price {
|
.h5-quote__price {
|
||||||
|
display: inline-block;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f53f3f;
|
/* 非最优:中性色;最优才用红强调(无额外高亮动效) */
|
||||||
|
color: #1d2129;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
|
will-change: filter, opacity, transform;
|
||||||
|
|
||||||
small {
|
small {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-right: 1px;
|
margin-right: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-best {
|
||||||
|
color: #f53f3f;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 价格:模糊 → 清晰(后于卡片半拍) */
|
||||||
|
.h5-quote--reveal .h5-quote__price {
|
||||||
|
animation: h5-price-figure 0.52s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
animation-delay: calc(var(--i, 0) * 48ms + 90ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-quote--reveal .ref-label {
|
||||||
|
animation: h5-soft-in 0.36s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
animation-delay: calc(var(--i, 0) * 48ms + 140ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes h5-soft-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes h5-quote-reveal {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes h5-price-figure {
|
||||||
|
from {
|
||||||
|
opacity: 0.25;
|
||||||
|
filter: blur(6px);
|
||||||
|
transform: translateY(3px) scale(0.99);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(1.2px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes h5-skel-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -100% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes h5-quote-pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 color-mix(in srgb, var(--app-primary-color) 42%, transparent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 8px transparent;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 transparent;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.h5-results-bar--in,
|
||||||
|
.h5-empty--in,
|
||||||
|
.h5-ai-panel--in,
|
||||||
|
.h5-quote-skel,
|
||||||
|
.h5-quote--reveal,
|
||||||
|
.h5-quote--reveal .h5-quote__price,
|
||||||
|
.h5-quote--reveal .ref-label,
|
||||||
|
.quote-collapsed__pulse,
|
||||||
|
.h5-quote-skel__bone {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-quote--reveal .h5-quote__price {
|
||||||
|
filter: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.h5-quote__actions {
|
.h5-quote__actions {
|
||||||
|
|||||||
@ -45,7 +45,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
|||||||
} from '@/api/order';
|
} from '@/api/order';
|
||||||
import { listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai';
|
import { listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai';
|
||||||
import {
|
import {
|
||||||
providerModelTags,
|
resolveProviderModelTags,
|
||||||
type AiProviderItem,
|
type AiProviderItem,
|
||||||
} from '@/views/system/aiAssistant/providerCatalog';
|
} from '@/views/system/aiAssistant/providerCatalog';
|
||||||
import { createSenderAddress, getSenderAddressList } from '@/api/order/senderAddress';
|
import { createSenderAddress, getSenderAddressList } from '@/api/order/senderAddress';
|
||||||
@ -171,12 +171,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
|||||||
const receiverAiModelOptions = computed(() => {
|
const receiverAiModelOptions = computed(() => {
|
||||||
const p = receiverAiSelectedProvider.value;
|
const p = receiverAiSelectedProvider.value;
|
||||||
if (!p) return [];
|
if (!p) return [];
|
||||||
const tags = providerModelTags(p.providerType);
|
return resolveProviderModelTags(p);
|
||||||
const extra = String(p.apiSecret || '').trim();
|
|
||||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
|
||||||
return [...tags, { modelId: extra, role: '自定义' }];
|
|
||||||
}
|
|
||||||
return tags;
|
|
||||||
});
|
});
|
||||||
/** 申报表固定高度(框内滚动,不支持拖拽调高) */
|
/** 申报表固定高度(框内滚动,不支持拖拽调高) */
|
||||||
const DECLARATION_PANEL_HEIGHT = 280;
|
const DECLARATION_PANEL_HEIGHT = 280;
|
||||||
|
|||||||
@ -257,8 +257,8 @@ export const HOME_DOORS = [
|
|||||||
icon: 'coin',
|
icon: 'coin',
|
||||||
intentId: 'invite' as const,
|
intentId: 'invite' as const,
|
||||||
primary: false,
|
primary: false,
|
||||||
zh: { title: '邀新分成', hint: '好友派送成功,你拿运费余额 + 20 元券。' },
|
zh: { title: '邀新分成', hint: '好友派送成功,你拿运费余额 + 10 元券。' },
|
||||||
en: { title: 'Invite & earn', hint: 'Friends deliver → freight credit + ¥20 coupon.' },
|
en: { title: 'Invite & earn', hint: 'Friends deliver → freight credit + ¥10 coupon.' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'newcomer',
|
id: 'newcomer',
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ai-provider-card xhs-inner-card">
|
<div class="ai-provider-card xhs-inner-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title" :title="title">{{ title }}</div>
|
<div class="card-title-wrap">
|
||||||
|
<div class="card-title" :title="title">{{ title }}</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<span v-if="provider.isDefault" class="status-tag default">默认</span>
|
||||||
|
<span class="status-tag" :class="provider.enabled ? 'on' : 'off'">
|
||||||
|
{{ provider.enabled ? '已启用' : '未启用' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="wh-action-group">
|
<div class="wh-action-group">
|
||||||
<span class="wh-action-btn" title="编辑" @click="emit('edit', provider)">
|
<span class="wh-action-btn" title="编辑" @click="emit('edit', provider)">
|
||||||
<IconEdit :size="14" />
|
<IconEdit :size="14" />
|
||||||
@ -17,46 +25,101 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="meta-block">
|
||||||
<span class="field-label">请求地址</span>
|
<div class="field-row">
|
||||||
<span class="field-value" :title="provider.baseUrl">{{ provider.baseUrl }}</span>
|
<span class="field-label">请求地址</span>
|
||||||
</div>
|
<span class="field-value" :title="provider.baseUrl">{{ provider.baseUrl }}</span>
|
||||||
<div class="field-row">
|
</div>
|
||||||
<span class="field-label">API Key</span>
|
<div class="field-row">
|
||||||
<span class="field-value">{{ maskedKey }}</span>
|
<span class="field-label">API Key</span>
|
||||||
|
<span class="field-value">{{ maskedKey }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="modelTags.length" class="models-row">
|
<div class="models-section">
|
||||||
<span class="field-label">模型</span>
|
<div class="models-head">
|
||||||
|
<span class="models-title">模型</span>
|
||||||
|
<button type="button" class="models-add-link" @click="openAddModel">
|
||||||
|
<IconPlus :size="12" />
|
||||||
|
新增
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="models-panel">
|
<div class="models-panel">
|
||||||
<div v-for="tag in modelTags" :key="tag.modelId" class="model-chip">
|
<div
|
||||||
|
v-for="(tag, index) in modelTags"
|
||||||
|
:key="`${tag.role}-${tag.modelId}-${index}`"
|
||||||
|
class="model-chip"
|
||||||
|
:class="{ editable: true, custom: tag.custom, 'is-default': tag.isChatDefault }"
|
||||||
|
:title="chipTitle(tag)"
|
||||||
|
@click="onChipClick(tag, index)"
|
||||||
|
>
|
||||||
<span class="chip-role" :data-role="tag.role">{{ tag.role }}</span>
|
<span class="chip-role" :data-role="tag.role">{{ tag.role }}</span>
|
||||||
<span class="chip-name">{{ tag.modelId }}</span>
|
<span class="chip-name">{{ tag.modelId }}</span>
|
||||||
|
<span v-if="tag.isChatDefault" class="chip-default">默</span>
|
||||||
|
<span
|
||||||
|
v-if="canRemoveChip"
|
||||||
|
class="chip-reset"
|
||||||
|
title="删除该模型"
|
||||||
|
@click.stop="removeModel(index)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="field-row">
|
|
||||||
<span class="field-label">API Secret</span>
|
|
||||||
<span class="field-value">{{ maskedSecret }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-footer">
|
<a-modal
|
||||||
<span v-if="provider.isDefault" class="status-tag default">默认</span>
|
v-model:visible="modelModalVisible"
|
||||||
<span class="status-tag" :class="provider.enabled ? 'on' : 'off'">
|
:title="modelModalTitle"
|
||||||
{{ provider.enabled ? '已启用' : '未启用' }}
|
:width="420"
|
||||||
</span>
|
ok-text="保存"
|
||||||
</div>
|
cancel-text="取消"
|
||||||
|
:ok-loading="modelSaving"
|
||||||
|
unmount-on-close
|
||||||
|
@before-ok="saveModel"
|
||||||
|
@cancel="modelModalVisible = false"
|
||||||
|
>
|
||||||
|
<div class="model-modal-hint">
|
||||||
|
「新增」会追加一条模型;点击已有芯片可修改。对话默认使用标记为「默」的文字模型。
|
||||||
|
</div>
|
||||||
|
<a-form :model="modelForm" layout="vertical">
|
||||||
|
<a-form-item label="用途" required>
|
||||||
|
<a-select v-model="modelForm.role">
|
||||||
|
<a-option v-for="role in MODEL_ROLES" :key="role" :value="role">{{ role }}</a-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="模型 ID" required>
|
||||||
|
<a-input
|
||||||
|
v-model="modelForm.modelId"
|
||||||
|
placeholder="如 gpt-4o-mini / glm-5.1"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="modelForm.role === '文字'">
|
||||||
|
<a-checkbox v-model="modelForm.asChatDefault">设为对话默认模型</a-checkbox>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { IconDelete, IconEdit } from '@arco-design/web-vue/es/icon';
|
import { Message } from '@arco-design/web-vue';
|
||||||
import { computed } from 'vue';
|
import { IconDelete, IconEdit, IconPlus } from '@arco-design/web-vue/es/icon';
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import { saveAiProvider } from '@/api/ai';
|
||||||
import {
|
import {
|
||||||
|
MODEL_ROLES,
|
||||||
|
defaultModel,
|
||||||
maskSecret,
|
maskSecret,
|
||||||
providerModelTags,
|
parseProviderModelsConfig,
|
||||||
|
providerLabel,
|
||||||
providerTypeLabel,
|
providerTypeLabel,
|
||||||
|
resolveProviderModelTags,
|
||||||
|
serializeProviderModelsConfig,
|
||||||
type AiProviderItem,
|
type AiProviderItem,
|
||||||
|
type ModelRole,
|
||||||
|
type ProviderModelTag,
|
||||||
} from './providerCatalog';
|
} from './providerCatalog';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -66,113 +129,321 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'edit', provider: AiProviderItem): void;
|
(e: 'edit', provider: AiProviderItem): void;
|
||||||
(e: 'disable', provider: AiProviderItem): void;
|
(e: 'disable', provider: AiProviderItem): void;
|
||||||
|
(e: 'saved'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const title = computed(() => providerTypeLabel(props.provider));
|
const title = computed(() => providerTypeLabel(props.provider));
|
||||||
const maskedKey = computed(() => maskSecret(props.provider.apiKey));
|
const maskedKey = computed(() => maskSecret(props.provider.apiKey));
|
||||||
const maskedSecret = computed(() => maskSecret(props.provider.apiSecret));
|
const modelTags = computed(() => resolveProviderModelTags(props.provider));
|
||||||
const modelTags = computed(() => providerModelTags(props.provider.providerType));
|
|
||||||
const canDisable = computed(() => Boolean(props.provider.enabled));
|
const canDisable = computed(() => Boolean(props.provider.enabled));
|
||||||
|
const canRemoveChip = computed(() => modelTags.value.length > 1);
|
||||||
|
|
||||||
|
const modelModalVisible = ref(false);
|
||||||
|
const modelSaving = ref(false);
|
||||||
|
const editIndex = ref<number | null>(null);
|
||||||
|
const modelForm = reactive<{
|
||||||
|
role: ModelRole;
|
||||||
|
modelId: string;
|
||||||
|
asChatDefault: boolean;
|
||||||
|
}>({
|
||||||
|
role: '文字',
|
||||||
|
modelId: '',
|
||||||
|
asChatDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelModalTitle = computed(() =>
|
||||||
|
editIndex.value == null ? '新增模型' : '修改模型',
|
||||||
|
);
|
||||||
|
|
||||||
|
function chipTitle(tag: ProviderModelTag): string {
|
||||||
|
return tag.isChatDefault ? '对话默认 · 点击修改' : '点击修改';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChipClick(tag: ProviderModelTag, index: number) {
|
||||||
|
editIndex.value = index;
|
||||||
|
modelForm.role = tag.role;
|
||||||
|
modelForm.modelId = tag.modelId;
|
||||||
|
modelForm.asChatDefault = Boolean(tag.isChatDefault) || tag.role === '文字';
|
||||||
|
modelModalVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddModel() {
|
||||||
|
editIndex.value = null;
|
||||||
|
modelForm.role = '文字';
|
||||||
|
modelForm.modelId = '';
|
||||||
|
modelForm.asChatDefault = false;
|
||||||
|
modelModalVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistConfig(apiSecret: string) {
|
||||||
|
const p = props.provider;
|
||||||
|
await saveAiProvider({
|
||||||
|
providerKey: p.providerKey,
|
||||||
|
providerType: p.providerType,
|
||||||
|
displayName: p.displayName || providerLabel(p.providerType),
|
||||||
|
baseUrl: p.baseUrl,
|
||||||
|
apiSecret,
|
||||||
|
isDefault: Boolean(p.isDefault),
|
||||||
|
enabled: p.enabled !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveModel() {
|
||||||
|
const modelId = modelForm.modelId.trim();
|
||||||
|
if (!modelId) {
|
||||||
|
Message.warning('请填写模型 ID');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = parseProviderModelsConfig(
|
||||||
|
props.provider.apiSecret,
|
||||||
|
props.provider.providerType,
|
||||||
|
);
|
||||||
|
const next = { modelId, role: modelForm.role };
|
||||||
|
|
||||||
|
if (editIndex.value == null) {
|
||||||
|
const dup = config.models.some(
|
||||||
|
(m) => m.modelId === modelId && m.role === modelForm.role,
|
||||||
|
);
|
||||||
|
if (dup) {
|
||||||
|
Message.warning('已存在相同用途的模型');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
config.models.push(next);
|
||||||
|
} else {
|
||||||
|
const prev = config.models[editIndex.value];
|
||||||
|
if (!prev) {
|
||||||
|
Message.warning('模型不存在');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const dup = config.models.some(
|
||||||
|
(m, i) =>
|
||||||
|
i !== editIndex.value && m.modelId === modelId && m.role === modelForm.role,
|
||||||
|
);
|
||||||
|
if (dup) {
|
||||||
|
Message.warning('已存在相同用途的模型');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
config.models[editIndex.value] = next;
|
||||||
|
if (prev.modelId === config.defaultModelId) {
|
||||||
|
config.defaultModelId = modelId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelForm.role === '文字' && modelForm.asChatDefault) {
|
||||||
|
config.defaultModelId = modelId;
|
||||||
|
} else if (!config.models.some((m) => m.modelId === config.defaultModelId)) {
|
||||||
|
config.defaultModelId =
|
||||||
|
config.models.find((m) => m.role === '文字')?.modelId ||
|
||||||
|
config.models[0]?.modelId ||
|
||||||
|
defaultModel(props.provider.providerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
modelSaving.value = true;
|
||||||
|
try {
|
||||||
|
await persistConfig(serializeProviderModelsConfig(config));
|
||||||
|
Message.success(editIndex.value == null ? '已新增模型' : '模型已更新');
|
||||||
|
modelModalVisible.value = false;
|
||||||
|
emit('saved');
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.message || '保存失败');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
modelSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeModel(index: number) {
|
||||||
|
const config = parseProviderModelsConfig(
|
||||||
|
props.provider.apiSecret,
|
||||||
|
props.provider.providerType,
|
||||||
|
);
|
||||||
|
if (config.models.length <= 1) {
|
||||||
|
Message.warning('至少保留一个模型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const removed = config.models[index];
|
||||||
|
config.models.splice(index, 1);
|
||||||
|
if (removed && removed.modelId === config.defaultModelId) {
|
||||||
|
config.defaultModelId =
|
||||||
|
config.models.find((m) => m.role === '文字')?.modelId ||
|
||||||
|
config.models[0]?.modelId ||
|
||||||
|
defaultModel(props.provider.providerType);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await persistConfig(serializeProviderModelsConfig(config));
|
||||||
|
Message.success('已删除模型');
|
||||||
|
emit('saved');
|
||||||
|
} catch (e: any) {
|
||||||
|
Message.error(e?.message || '操作失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
@import '@/views/xhs/_shared/xhs-page.less';
|
@import '@/views/xhs/_shared/xhs-page.less';
|
||||||
|
|
||||||
.ai-provider-card {
|
.ai-provider-card {
|
||||||
height: 196px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 10px 12px;
|
gap: 14px;
|
||||||
transition: border-color 0.15s;
|
min-height: 0;
|
||||||
|
padding: 16px 18px;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: color-mix(in srgb, var(--app-primary-color) 45%, var(--app-card-bg));
|
border-color: color-mix(in srgb, var(--app-primary-color) 40%, var(--app-card-bg));
|
||||||
|
box-shadow: 0 2px 10px color-mix(in srgb, var(--app-primary-color) 8%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 6px;
|
gap: 10px;
|
||||||
margin-bottom: 6px;
|
}
|
||||||
|
|
||||||
|
.card-title-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
flex: 1;
|
font-size: 16px;
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
line-height: 1.3;
|
line-height: 1.35;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-row,
|
.status-row {
|
||||||
.models-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 4px;
|
}
|
||||||
min-height: 0;
|
|
||||||
|
.meta-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 12px;
|
||||||
|
background: var(--color-fill-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-label {
|
.field-label {
|
||||||
width: 72px;
|
width: 64px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-value {
|
.field-value {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 11px;
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
|
line-height: 1.4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.models-row {
|
.models-section {
|
||||||
flex: 1;
|
display: flex;
|
||||||
min-height: 0;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.models-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.models-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.models-add-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-primary-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: color-mix(in srgb, var(--app-primary-color) 8%, transparent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.models-panel {
|
.models-panel {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
padding: 8px;
|
|
||||||
background: var(--color-fill-1);
|
|
||||||
border: 1px solid var(--color-border-2);
|
|
||||||
border-radius: 8px;
|
|
||||||
max-height: 72px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-chip {
|
.model-chip {
|
||||||
position: relative;
|
display: inline-flex;
|
||||||
min-width: 92px;
|
align-items: center;
|
||||||
max-width: 120px;
|
gap: 8px;
|
||||||
height: 54px;
|
max-width: 100%;
|
||||||
padding: 5px 8px 6px;
|
min-height: 32px;
|
||||||
|
padding: 6px 10px;
|
||||||
background: var(--app-card-bg);
|
background: var(--app-card-bg);
|
||||||
border: 1px solid var(--color-border-2);
|
border: 1px solid var(--color-border-2);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:hover {
|
&.editable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--app-primary-color) 45%, var(--app-card-bg));
|
||||||
|
background: color-mix(in srgb, var(--app-primary-color) 4%, var(--app-card-bg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.custom {
|
||||||
border-color: color-mix(in srgb, var(--app-primary-color) 40%, var(--app-card-bg));
|
border-color: color-mix(in srgb, var(--app-primary-color) 40%, var(--app-card-bg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-default {
|
||||||
|
border-color: color-mix(in srgb, var(--app-primary-color) 55%, var(--app-card-bg));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-role {
|
.chip-role {
|
||||||
position: absolute;
|
flex-shrink: 0;
|
||||||
top: 5px;
|
font-size: 11px;
|
||||||
right: 8px;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--app-primary-color);
|
color: var(--app-primary-color);
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
&[data-role='图片'] {
|
&[data-role='图片'] {
|
||||||
color: rgb(var(--green-6));
|
color: rgb(var(--green-6));
|
||||||
@ -184,29 +455,51 @@ const canDisable = computed(() => Boolean(props.provider.enabled));
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip-name {
|
.chip-name {
|
||||||
display: block;
|
min-width: 0;
|
||||||
margin-top: 14px;
|
font-size: 12px;
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
|
line-height: 1.2;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-footer {
|
.chip-default {
|
||||||
margin-top: auto;
|
flex-shrink: 0;
|
||||||
display: flex;
|
font-size: 10px;
|
||||||
gap: 6px;
|
font-weight: 600;
|
||||||
padding-top: 4px;
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--app-primary-color-light, #e6f7f7);
|
||||||
|
color: var(--app-primary-color);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-reset {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 2px;
|
||||||
|
line-height: 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgb(var(--danger-6));
|
||||||
|
background: rgb(var(--danger-1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-tag {
|
.status-tag {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
padding: 1px 6px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--color-fill-2);
|
background: var(--color-fill-2);
|
||||||
color: var(--color-text-3);
|
color: var(--color-text-3);
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
&.default {
|
&.default {
|
||||||
background: var(--app-primary-color-light, #e6f7f7);
|
background: var(--app-primary-color-light, #e6f7f7);
|
||||||
@ -223,4 +516,11 @@ const canDisable = computed(() => Boolean(props.provider.enabled));
|
|||||||
color: rgb(var(--danger-6));
|
color: rgb(var(--danger-6));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.model-modal-hint {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -25,12 +25,11 @@
|
|||||||
@visibility-change="(v: boolean) => (showKey = v)"
|
@visibility-change="(v: boolean) => (showKey = v)"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="!isZhipu" label="API Secret">
|
<a-form-item label="默认模型(文字)">
|
||||||
<a-input-password
|
<a-input
|
||||||
v-model="form.apiSecret"
|
v-model="form.apiSecret"
|
||||||
placeholder="可选,可填模型名"
|
:placeholder="`默认 ${defaultModel(form.providerType) || '模型 ID'}`"
|
||||||
:visibility="showSecret"
|
allow-clear
|
||||||
@visibility-change="(v: boolean) => (showSecret = v)"
|
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
@ -47,11 +46,13 @@ import { Message } from '@arco-design/web-vue';
|
|||||||
import { saveAiProvider } from '@/api/ai';
|
import { saveAiProvider } from '@/api/ai';
|
||||||
import {
|
import {
|
||||||
defaultBaseUrl,
|
defaultBaseUrl,
|
||||||
|
defaultModel,
|
||||||
|
isMaskedSecret,
|
||||||
providerLabel,
|
providerLabel,
|
||||||
|
resolveTextModelId,
|
||||||
|
upsertDefaultTextModel,
|
||||||
usesZhipuSdk,
|
usesZhipuSdk,
|
||||||
ZHIPU_CHAT_MODEL,
|
ZHIPU_CHAT_MODEL,
|
||||||
ZHIPU_IMAGE_MODEL,
|
|
||||||
ZHIPU_VISION_MODEL,
|
|
||||||
type AiProviderItem,
|
type AiProviderItem,
|
||||||
} from './providerCatalog';
|
} from './providerCatalog';
|
||||||
|
|
||||||
@ -67,7 +68,6 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const showKey = ref(false);
|
const showKey = ref(false);
|
||||||
const showSecret = ref(false);
|
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
providerKey: '',
|
providerKey: '',
|
||||||
@ -89,9 +89,9 @@ const isZhipu = computed(() => usesZhipuSdk(form.providerType));
|
|||||||
|
|
||||||
const hintText = computed(() => {
|
const hintText = computed(() => {
|
||||||
if (isZhipu.value) {
|
if (isZhipu.value) {
|
||||||
return `填写 API Key 即可;模型在创作/分析时按场景选择。文字 ${ZHIPU_CHAT_MODEL}、图文 ${ZHIPU_VISION_MODEL}、图片 ${ZHIPU_IMAGE_MODEL}。`;
|
return `填写 API Key 与默认文字模型即可。未填模型时使用 ${ZHIPU_CHAT_MODEL}。`;
|
||||||
}
|
}
|
||||||
return '填写请求地址与 API Key;API Secret 可填自定义模型名。';
|
return '填写请求地址、API Key;此处只改对话默认文字模型,不会清空卡片上已新增的其它模型。';
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -103,11 +103,12 @@ watch(
|
|||||||
form.displayName = p.displayName;
|
form.displayName = p.displayName;
|
||||||
form.baseUrl = p.baseUrl;
|
form.baseUrl = p.baseUrl;
|
||||||
form.apiKey = '';
|
form.apiKey = '';
|
||||||
form.apiSecret = p.apiSecret === '******' ? '' : p.apiSecret || '';
|
form.apiSecret = isMaskedSecret(p.apiSecret)
|
||||||
|
? ''
|
||||||
|
: resolveTextModelId(p);
|
||||||
form.isDefault = Boolean(p.isDefault);
|
form.isDefault = Boolean(p.isDefault);
|
||||||
form.enabled = p.enabled !== false;
|
form.enabled = p.enabled !== false;
|
||||||
showKey.value = false;
|
showKey.value = false;
|
||||||
showSecret.value = false;
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@ -135,6 +136,13 @@ async function onOk() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelId = form.apiSecret.trim() || defaultModel(form.providerType);
|
||||||
|
const apiSecret = upsertDefaultTextModel(
|
||||||
|
props.provider?.apiSecret,
|
||||||
|
form.providerType,
|
||||||
|
modelId,
|
||||||
|
);
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
await saveAiProvider({
|
await saveAiProvider({
|
||||||
@ -143,7 +151,7 @@ async function onOk() {
|
|||||||
displayName: form.displayName || providerLabel(form.providerType),
|
displayName: form.displayName || providerLabel(form.providerType),
|
||||||
baseUrl,
|
baseUrl,
|
||||||
apiKey: apiKey || undefined,
|
apiKey: apiKey || undefined,
|
||||||
apiSecret: isZhipu.value ? undefined : form.apiSecret.trim() || undefined,
|
apiSecret: apiSecret || undefined,
|
||||||
isDefault: form.isDefault,
|
isDefault: form.isDefault,
|
||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="xhs-panel-title">AI 助手 · 模型接入</p>
|
<p class="xhs-panel-title">AI 助手 · 模型接入</p>
|
||||||
<p class="xhs-panel-desc">
|
<p class="xhs-panel-desc">
|
||||||
默认提供 ChatGPT、豆包、DeepSeek、智谱;点击卡片可编辑密钥与模型,支持禁用。
|
默认提供 ChatGPT、豆包、DeepSeek、智谱;卡片内可新增/修改文字模型,也可编辑密钥,支持禁用。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -23,6 +23,7 @@
|
|||||||
:provider="p"
|
:provider="p"
|
||||||
@edit="(item) => emit('edit', item)"
|
@edit="(item) => emit('edit', item)"
|
||||||
@disable="(item) => emit('disable', item)"
|
@disable="(item) => emit('disable', item)"
|
||||||
|
@saved="emit('refresh')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-hint">
|
<div v-else class="empty-hint">
|
||||||
@ -75,8 +76,8 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
.providers-grid {
|
.providers-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-hint {
|
.empty-hint {
|
||||||
@ -87,13 +88,7 @@ const emit = defineEmits<{
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 900px) {
|
||||||
.providers-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.providers-grid {
|
.providers-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,17 +61,174 @@ export function usesZhipuSdk(typeId: string): boolean {
|
|||||||
return ZHIPU_SDK_TYPES.has(typeId as ProviderTypeId);
|
return ZHIPU_SDK_TYPES.has(typeId as ProviderTypeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function providerModelTags(typeId: string): Array<{ modelId: string; role: string }> {
|
export type ModelRole = '文字' | '图片' | '图文';
|
||||||
if (typeId === 'zhipu') {
|
|
||||||
return [
|
export interface ProviderModelTag {
|
||||||
{ modelId: ZHIPU_CHAT_MODEL, role: '文字' },
|
modelId: string;
|
||||||
{ modelId: ZHIPU_IMAGE_MODEL, role: '图片' },
|
role: ModelRole;
|
||||||
{ modelId: ZHIPU_VISION_MODEL, role: '图文' },
|
/** 是否为对话默认模型 */
|
||||||
];
|
isChatDefault?: boolean;
|
||||||
|
/** 是否相对目录默认有改动(可删除) */
|
||||||
|
custom?: boolean;
|
||||||
|
editable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** apiSecret 多模型存储结构(兼容旧版纯模型名字符串) */
|
||||||
|
export interface ProviderModelsConfig {
|
||||||
|
defaultModelId: string;
|
||||||
|
models: Array<{ modelId: string; role: ModelRole }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODEL_ROLES: ModelRole[] = ['文字', '图片', '图文'];
|
||||||
|
|
||||||
|
export function defaultModel(typeId: string): string {
|
||||||
|
return PROVIDER_TYPES.find((p) => p.id === typeId)?.defaultModel ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMaskedSecret(value?: string | null): boolean {
|
||||||
|
const text = (value || '').trim();
|
||||||
|
return !text || text === '******';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelRole(value: unknown): value is ModelRole {
|
||||||
|
return value === '文字' || value === '图片' || value === '图文';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 apiSecret:纯字符串 / JSON 多模型 */
|
||||||
|
export function parseProviderModelsConfig(
|
||||||
|
apiSecret: string | null | undefined,
|
||||||
|
providerType: string,
|
||||||
|
): ProviderModelsConfig {
|
||||||
|
const catalogDefault = defaultModel(providerType);
|
||||||
|
const secret = (apiSecret || '').trim();
|
||||||
|
if (isMaskedSecret(secret)) {
|
||||||
|
return {
|
||||||
|
defaultModelId: catalogDefault,
|
||||||
|
models: catalogDefault ? [{ modelId: catalogDefault, role: '文字' }] : [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const def = PROVIDER_TYPES.find((p) => p.id === typeId);
|
|
||||||
if (!def) return [];
|
if (secret.startsWith('{')) {
|
||||||
return [{ modelId: def.defaultModel, role: '文字' }];
|
try {
|
||||||
|
const raw = JSON.parse(secret) as {
|
||||||
|
defaultModelId?: string;
|
||||||
|
models?: Array<{ modelId?: string; role?: string }>;
|
||||||
|
};
|
||||||
|
const models: Array<{ modelId: string; role: ModelRole }> = (raw.models ?? [])
|
||||||
|
.map((m) => ({
|
||||||
|
modelId: String(m.modelId || '').trim(),
|
||||||
|
role: (isModelRole(m.role) ? m.role : '文字') as ModelRole,
|
||||||
|
}))
|
||||||
|
.filter((m) => m.modelId);
|
||||||
|
let defaultModelId = String(raw.defaultModelId || '').trim();
|
||||||
|
if (!defaultModelId) {
|
||||||
|
defaultModelId =
|
||||||
|
models.find((m) => m.role === '文字')?.modelId || models[0]?.modelId || catalogDefault;
|
||||||
|
}
|
||||||
|
if (!models.length && defaultModelId) {
|
||||||
|
models.push({ modelId: defaultModelId, role: '文字' });
|
||||||
|
}
|
||||||
|
if (defaultModelId && !models.some((m) => m.modelId === defaultModelId)) {
|
||||||
|
models.unshift({ modelId: defaultModelId, role: '文字' });
|
||||||
|
}
|
||||||
|
return { defaultModelId: defaultModelId || catalogDefault, models };
|
||||||
|
} catch {
|
||||||
|
/* fallthrough as plain model id */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultModelId: secret,
|
||||||
|
models: [{ modelId: secret, role: '文字' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 序列化到 apiSecret:单模型仍写纯字符串,多模型写 JSON */
|
||||||
|
export function serializeProviderModelsConfig(config: ProviderModelsConfig): string {
|
||||||
|
const models = config.models
|
||||||
|
.map((m) => ({
|
||||||
|
modelId: m.modelId.trim(),
|
||||||
|
role: isModelRole(m.role) ? m.role : ('文字' as ModelRole),
|
||||||
|
}))
|
||||||
|
.filter((m) => m.modelId);
|
||||||
|
const defaultModelId =
|
||||||
|
(config.defaultModelId || '').trim() ||
|
||||||
|
models.find((m) => m.role === '文字')?.modelId ||
|
||||||
|
models[0]?.modelId ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
if (!models.length) return defaultModelId;
|
||||||
|
|
||||||
|
const unique: Array<{ modelId: string; role: ModelRole }> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const m of models) {
|
||||||
|
const key = `${m.role}::${m.modelId}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
unique.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unique.length === 1 && unique[0].role === '文字' && unique[0].modelId === defaultModelId) {
|
||||||
|
return defaultModelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
defaultModelId: defaultModelId || unique[0].modelId,
|
||||||
|
models: unique,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 目录预设 chips(仅文字主模型;不含用户覆盖) */
|
||||||
|
export function providerModelTags(typeId: string): ProviderModelTag[] {
|
||||||
|
const modelId = defaultModel(typeId);
|
||||||
|
if (!modelId) return [];
|
||||||
|
return [{ modelId, role: '文字', editable: true, isChatDefault: true }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片/选择器:展示已配置的全部模型 chips */
|
||||||
|
export function resolveProviderModelTags(item: {
|
||||||
|
providerType: string;
|
||||||
|
apiSecret?: string | null;
|
||||||
|
}): ProviderModelTag[] {
|
||||||
|
const catalogDefault = defaultModel(item.providerType);
|
||||||
|
const config = parseProviderModelsConfig(item.apiSecret, item.providerType);
|
||||||
|
return config.models.map((m) => ({
|
||||||
|
modelId: m.modelId,
|
||||||
|
role: m.role,
|
||||||
|
editable: true,
|
||||||
|
isChatDefault: m.modelId === config.defaultModelId && m.role === '文字'
|
||||||
|
? true
|
||||||
|
: m.modelId === config.defaultModelId,
|
||||||
|
custom: !(m.role === '文字' && m.modelId === catalogDefault && config.models.length === 1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对话默认文字模型;空则回落目录默认 */
|
||||||
|
export function resolveTextModelId(item: {
|
||||||
|
providerType: string;
|
||||||
|
apiSecret?: string | null;
|
||||||
|
}): string {
|
||||||
|
const config = parseProviderModelsConfig(item.apiSecret, item.providerType);
|
||||||
|
return (
|
||||||
|
config.defaultModelId ||
|
||||||
|
config.models.find((m) => m.role === '文字')?.modelId ||
|
||||||
|
defaultModel(item.providerType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在已有配置上设置/确保对话默认文字模型 */
|
||||||
|
export function upsertDefaultTextModel(
|
||||||
|
apiSecret: string | null | undefined,
|
||||||
|
providerType: string,
|
||||||
|
modelId: string,
|
||||||
|
): string {
|
||||||
|
const config = parseProviderModelsConfig(apiSecret, providerType);
|
||||||
|
const id = modelId.trim();
|
||||||
|
if (!id) return serializeProviderModelsConfig(config);
|
||||||
|
const idx = config.models.findIndex((m) => m.modelId === id && m.role === '文字');
|
||||||
|
if (idx < 0) config.models.unshift({ modelId: id, role: '文字' });
|
||||||
|
config.defaultModelId = id;
|
||||||
|
return serializeProviderModelsConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function maskSecret(value?: string | null, visibleTail = 4): string {
|
export function maskSecret(value?: string | null, visibleTail = 4): string {
|
||||||
@ -81,12 +238,16 @@ export function maskSecret(value?: string | null, visibleTail = 4): string {
|
|||||||
return '*'.repeat(text.length - visibleTail) + text.slice(-visibleTail);
|
return '*'.repeat(text.length - visibleTail) + text.slice(-visibleTail);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortProviders<T extends { providerKey: string; providerType: string }>(
|
export function sortProviders<
|
||||||
items: T[],
|
T extends { providerKey: string; providerType: string; isDefault?: boolean },
|
||||||
): T[] {
|
>(items: T[]): T[] {
|
||||||
const order = DEFAULT_PROVIDER_KEYS.map((p) => p.key);
|
const order = DEFAULT_PROVIDER_KEYS.map((p) => p.key);
|
||||||
const typeOrder = DEFAULT_PROVIDER_KEYS.map((p) => p.type);
|
const typeOrder = DEFAULT_PROVIDER_KEYS.map((p) => p.type);
|
||||||
return [...items].sort((a, b) => {
|
return [...items].sort((a, b) => {
|
||||||
|
const aDef = a.isDefault ? 0 : 1;
|
||||||
|
const bDef = b.isDefault ? 0 : 1;
|
||||||
|
if (aDef !== bDef) return aDef - bDef;
|
||||||
|
|
||||||
const ai = order.indexOf(a.providerKey);
|
const ai = order.indexOf(a.providerKey);
|
||||||
const bi = order.indexOf(b.providerKey);
|
const bi = order.indexOf(b.providerKey);
|
||||||
if (ai >= 0 || bi >= 0) return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
|
if (ai >= 0 || bi >= 0) return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
|
||||||
|
|||||||
@ -143,7 +143,7 @@ import {
|
|||||||
} from '@/api/mcp';
|
} from '@/api/mcp';
|
||||||
import { listAiProviders } from '@/api/ai';
|
import { listAiProviders } from '@/api/ai';
|
||||||
import {
|
import {
|
||||||
providerModelTags,
|
resolveTextModelId,
|
||||||
type AiProviderItem,
|
type AiProviderItem,
|
||||||
} from '@/views/system/aiAssistant/providerCatalog';
|
} from '@/views/system/aiAssistant/providerCatalog';
|
||||||
|
|
||||||
@ -287,8 +287,7 @@ async function loadProviders() {
|
|||||||
const def = providers.value.find((p) => p.isDefault) ?? providers.value[0];
|
const def = providers.value.find((p) => p.isDefault) ?? providers.value[0];
|
||||||
if (def) {
|
if (def) {
|
||||||
providerKey.value = def.providerKey;
|
providerKey.value = def.providerKey;
|
||||||
const tags = providerModelTags(def.providerType);
|
modelId.value = resolveTextModelId(def);
|
||||||
modelId.value = tags[0]?.modelId || String(def.apiSecret || '').trim() || '';
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
providers.value = [];
|
providers.value = [];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user