feat: AI 提供商支持多模型增删,并优化接入页布局
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m13s
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m13s
卡片可真正追加模型(JSON 存 apiSecret),默认提供商置顶;同步各助手选模逻辑,并附带查价页与邀新文案调整。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
648ccf2a1a
commit
b43dee5753
@ -364,9 +364,7 @@ import {
|
||||
} from '@/composables/assistantMcpGuide';
|
||||
import { isMobileUserAgent, isWeChatBrowser } from '@/utils/device';
|
||||
import { openWecomKf } from '@/utils/wecomKf';
|
||||
import {
|
||||
providerModelTags,
|
||||
} from '@/views/system/aiAssistant/providerCatalog';
|
||||
import { resolveTextModelId } from '@/views/system/aiAssistant/providerCatalog';
|
||||
import NanaAvatar from './NanaAvatar.vue';
|
||||
import logoImg from '@/assets/logo2.png';
|
||||
|
||||
@ -903,10 +901,10 @@ async function loadProvider() {
|
||||
const def = enabled.find((p: any) => p.isDefault) ?? enabled[0];
|
||||
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 : '') || '';
|
||||
providerModel.value = resolveTextModelId({
|
||||
providerType: String(def.providerType || ''),
|
||||
apiSecret: def.apiSecret,
|
||||
});
|
||||
} else {
|
||||
providerModel.value = '';
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
import CodeMirror from '@/components/CodeMirror/index.vue';
|
||||
import { listAiProviders } from '@/api/ai';
|
||||
import {
|
||||
providerModelTags,
|
||||
resolveProviderModelTags,
|
||||
type AiProviderItem,
|
||||
} from '@/views/system/aiAssistant/providerCatalog';
|
||||
import {
|
||||
@ -64,12 +64,7 @@
|
||||
const modelOptions = computed(() => {
|
||||
const p = selectedProvider.value;
|
||||
if (!p) return [];
|
||||
const tags = providerModelTags(p.providerType);
|
||||
const extra = String(p.apiSecret || '').trim();
|
||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
||||
return [...tags, { modelId: extra, role: '自定义' }];
|
||||
}
|
||||
return tags;
|
||||
return resolveProviderModelTags(p);
|
||||
});
|
||||
|
||||
function loadPrefs() {
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { listAiProviders, type AiSkillItem } from '@/api/ai'
|
||||
import {
|
||||
providerModelTags,
|
||||
resolveProviderModelTags,
|
||||
type AiProviderItem,
|
||||
} from '@/views/system/aiAssistant/providerCatalog'
|
||||
import {
|
||||
@ -62,12 +62,7 @@
|
||||
const modelOptions = computed(() => {
|
||||
const p = selectedProvider.value
|
||||
if (!p) return []
|
||||
const tags = providerModelTags(p.providerType)
|
||||
const extra = String(p.apiSecret || '').trim()
|
||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
||||
return [...tags, { modelId: extra, role: '自定义' }]
|
||||
}
|
||||
return tags
|
||||
return resolveProviderModelTags(p)
|
||||
})
|
||||
|
||||
const previewRows = computed(() => {
|
||||
|
||||
@ -152,7 +152,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { chatAi, listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai'
|
||||
import {
|
||||
providerModelTags,
|
||||
resolveProviderModelTags,
|
||||
type AiProviderItem,
|
||||
} from '@/views/system/aiAssistant/providerCatalog'
|
||||
import {
|
||||
@ -237,12 +237,7 @@ const selectedProvider = computed(
|
||||
const modelOptions = computed(() => {
|
||||
const p = selectedProvider.value
|
||||
if (!p) return []
|
||||
const tags = providerModelTags(p.providerType)
|
||||
const extra = String(p.apiSecret || '').trim()
|
||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
||||
return [...tags, { modelId: extra, role: '自定义' }]
|
||||
}
|
||||
return tags
|
||||
return resolveProviderModelTags(p)
|
||||
})
|
||||
|
||||
const canSend = computed(
|
||||
|
||||
@ -158,7 +158,7 @@ import { useRouter } from 'vue-router'
|
||||
import { chatAi, listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai'
|
||||
import type { SnapshotDiffRow } from '@/utils/publishSnapshotDiff'
|
||||
import {
|
||||
providerModelTags,
|
||||
resolveProviderModelTags,
|
||||
type AiProviderItem,
|
||||
} from '@/views/system/aiAssistant/providerCatalog'
|
||||
import {
|
||||
@ -232,12 +232,7 @@ const selectedProvider = computed(
|
||||
const modelOptions = computed(() => {
|
||||
const p = selectedProvider.value
|
||||
if (!p) return []
|
||||
const tags = providerModelTags(p.providerType)
|
||||
const extra = String(p.apiSecret || '').trim()
|
||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
||||
return [...tags, { modelId: extra, role: '自定义' }]
|
||||
}
|
||||
return tags
|
||||
return resolveProviderModelTags(p)
|
||||
})
|
||||
|
||||
const canSend = computed(
|
||||
|
||||
@ -1262,7 +1262,7 @@ import {
|
||||
type DomesticPickupProductOption,
|
||||
} from '@/utils/domesticPickupProducts';
|
||||
import {
|
||||
providerModelTags,
|
||||
resolveProviderModelTags,
|
||||
type AiProviderItem,
|
||||
} from '@/views/system/aiAssistant/providerCatalog';
|
||||
import H5SelectDeclarationSkuSheet from './H5SelectDeclarationSkuSheet.vue';
|
||||
@ -1362,12 +1362,7 @@ const receiverAiSelectedProvider = computed(
|
||||
const receiverAiModelOptions = computed(() => {
|
||||
const p = receiverAiSelectedProvider.value;
|
||||
if (!p) return [];
|
||||
const tags = providerModelTags(p.providerType);
|
||||
const extra = String(p.apiSecret || '').trim();
|
||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
||||
return [...tags, { modelId: extra, role: '自定义' }];
|
||||
}
|
||||
return tags;
|
||||
return resolveProviderModelTags(p);
|
||||
});
|
||||
/** 申报品「更多」展开状态,按 item._key */
|
||||
const openItemMoreKeys = ref<Set<string>>(new Set());
|
||||
|
||||
@ -919,7 +919,7 @@ export default {
|
||||
'h5.invite.tab.share': 'Share invite',
|
||||
'h5.invite.tab.referees': 'Invited',
|
||||
'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.code': 'Invite code',
|
||||
'h5.invite.link': 'Invite link (open in WeChat)',
|
||||
|
||||
@ -905,7 +905,7 @@ export default {
|
||||
'h5.invite.tab.share': '分享邀请',
|
||||
'h5.invite.tab.referees': '已邀请好友',
|
||||
'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.code': '邀请码',
|
||||
'h5.invite.link': '邀请链接(微信内打开)',
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
v-if="formCollapsed"
|
||||
type="button"
|
||||
class="quote-collapsed"
|
||||
:class="{ 'is-loading': queryLoading }"
|
||||
:disabled="queryLoading"
|
||||
:aria-busy="queryLoading"
|
||||
@click="expandQuoteForm"
|
||||
>
|
||||
<div class="quote-collapsed__main">
|
||||
@ -16,8 +19,14 @@
|
||||
<span class="quote-collapsed__meta">{{ collapsedMetaText }}</span>
|
||||
</div>
|
||||
<span class="quote-collapsed__action">
|
||||
<template v-if="queryLoading">
|
||||
<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>
|
||||
</button>
|
||||
|
||||
@ -205,8 +214,12 @@
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section v-if="queried" class="h5-price-results">
|
||||
<div v-if="sortedResults.length" class="h5-results-bar">
|
||||
<section
|
||||
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">
|
||||
<p class="h5-results-tip" :title="resultsBarTipFull">
|
||||
<span>{{ t('h5.price.resultCount', { n: sortedResults.length }) }}</span>
|
||||
@ -228,8 +241,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :loading="queryLoading" class="h5-price-spin">
|
||||
<div v-if="!sortedResults.length && !queryLoading" class="h5-empty h5-empty--no-quote">
|
||||
<!-- 查价中:骨架占位(飞猪/携程式),避免整页 Spin 生硬切换 -->
|
||||
<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">
|
||||
<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" />
|
||||
@ -321,14 +368,16 @@
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<article
|
||||
v-for="item in sortedResults"
|
||||
:key="item.rowKey"
|
||||
v-for="(item, idx) in sortedResults"
|
||||
:key="`${resultsRevealEpoch}-${item.rowKey}`"
|
||||
:ref="(el) => setQuoteRef(item.rowKey, el)"
|
||||
class="h5-quote"
|
||||
class="h5-quote h5-quote--reveal"
|
||||
:class="{
|
||||
'h5-quote--ai': item.rowKey === recommendedRowKey,
|
||||
}"
|
||||
:style="{ '--i': Math.min(idx, 7) }"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<div class="h5-quote__main">
|
||||
@ -363,7 +412,10 @@
|
||||
>
|
||||
{{ t('h5.price.tag.priceOptimal') }}
|
||||
</span>
|
||||
<div class="h5-quote__price">
|
||||
<div
|
||||
class="h5-quote__price"
|
||||
:class="{ 'is-best': item.isPriceOptimal }"
|
||||
>
|
||||
<small>¥</small>{{ formatMoney(item.totalCost) }}
|
||||
</div>
|
||||
<span class="ref-label">
|
||||
@ -403,10 +455,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<!-- 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
|
||||
v-if="!aiPanelOpen"
|
||||
type="button"
|
||||
@ -769,6 +821,8 @@ const optionsLoading = ref(false);
|
||||
const optionsLoadFailed = ref(false);
|
||||
const queryLoading = ref(false);
|
||||
const queried = ref(false);
|
||||
/** 每次查价递增,用于重播卡片/价格入场(不依赖路由重挂载) */
|
||||
const resultsRevealEpoch = ref(0);
|
||||
const queryFailureText = ref('');
|
||||
const queryFailureItems = ref<PriceCheckFailureItem[]>([]);
|
||||
const failureExpanded = ref(false);
|
||||
@ -1244,12 +1298,20 @@ async function handleQuery() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// 乐观收起条件区 + 骨架占位:视线立刻落到「结果位」,避免等接口再硬切
|
||||
queryLoading.value = true;
|
||||
queried.value = true;
|
||||
formCollapsed.value = false;
|
||||
formCollapsed.value = true;
|
||||
results.value = [];
|
||||
queryHint.value = null;
|
||||
resultsRevealEpoch.value += 1;
|
||||
clearPriceCheckFailures();
|
||||
clearRecommendInsight();
|
||||
aiExpanded.value = false;
|
||||
await nextTick();
|
||||
document
|
||||
.querySelector('.h5-price-results')
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
try {
|
||||
const length = parseOptionalDimension(form.length);
|
||||
const width = parseOptionalDimension(form.width);
|
||||
@ -1275,10 +1337,6 @@ async function handleQuery() {
|
||||
} else {
|
||||
clearPriceCheckFailures();
|
||||
formCollapsed.value = true;
|
||||
await nextTick();
|
||||
document
|
||||
.querySelector('.h5-price-results')
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
sortMode.value = 'recommend';
|
||||
} catch (e: any) {
|
||||
@ -1602,9 +1660,27 @@ onMounted(() => {
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:active {
|
||||
&:active:not(:disabled) {
|
||||
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 {
|
||||
@ -1958,6 +2034,11 @@ onMounted(() => {
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
@ -2393,6 +2474,14 @@ onMounted(() => {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -2447,9 +2536,100 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.h5-price-spin {
|
||||
width: 100%;
|
||||
/* 骨架:与真实卡片同结构,shimmer 代替 Spin */
|
||||
.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;
|
||||
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 {
|
||||
@ -2464,7 +2644,12 @@ onMounted(() => {
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
cursor: pointer;
|
||||
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 {
|
||||
box-shadow:
|
||||
@ -2623,17 +2808,119 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.h5-quote__price {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #f53f3f;
|
||||
/* 非最优:中性色;最优才用红强调(无额外高亮动效) */
|
||||
color: #1d2129;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
will-change: filter, opacity, transform;
|
||||
|
||||
small {
|
||||
font-size: 12px;
|
||||
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 {
|
||||
|
||||
@ -45,7 +45,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
} from '@/api/order';
|
||||
import { listAiProviders, listAiSkills, type AiSkillItem } from '@/api/ai';
|
||||
import {
|
||||
providerModelTags,
|
||||
resolveProviderModelTags,
|
||||
type AiProviderItem,
|
||||
} from '@/views/system/aiAssistant/providerCatalog';
|
||||
import { createSenderAddress, getSenderAddressList } from '@/api/order/senderAddress';
|
||||
@ -171,12 +171,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
|
||||
const receiverAiModelOptions = computed(() => {
|
||||
const p = receiverAiSelectedProvider.value;
|
||||
if (!p) return [];
|
||||
const tags = providerModelTags(p.providerType);
|
||||
const extra = String(p.apiSecret || '').trim();
|
||||
if (extra && extra !== '******' && !tags.some((m) => m.modelId === extra)) {
|
||||
return [...tags, { modelId: extra, role: '自定义' }];
|
||||
}
|
||||
return tags;
|
||||
return resolveProviderModelTags(p);
|
||||
});
|
||||
/** 申报表固定高度(框内滚动,不支持拖拽调高) */
|
||||
const DECLARATION_PANEL_HEIGHT = 280;
|
||||
|
||||
@ -257,8 +257,8 @@ export const HOME_DOORS = [
|
||||
icon: 'coin',
|
||||
intentId: 'invite' as const,
|
||||
primary: false,
|
||||
zh: { title: '邀新分成', hint: '好友派送成功,你拿运费余额 + 20 元券。' },
|
||||
en: { title: 'Invite & earn', hint: 'Friends deliver → freight credit + ¥20 coupon.' },
|
||||
zh: { title: '邀新分成', hint: '好友派送成功,你拿运费余额 + 10 元券。' },
|
||||
en: { title: 'Invite & earn', hint: 'Friends deliver → freight credit + ¥10 coupon.' },
|
||||
},
|
||||
{
|
||||
id: 'newcomer',
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
<template>
|
||||
<div class="ai-provider-card xhs-inner-card">
|
||||
<div class="card-header">
|
||||
<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">
|
||||
<span class="wh-action-btn" title="编辑" @click="emit('edit', provider)">
|
||||
<IconEdit :size="14" />
|
||||
@ -17,6 +25,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-block">
|
||||
<div class="field-row">
|
||||
<span class="field-label">请求地址</span>
|
||||
<span class="field-value" :title="provider.baseUrl">{{ provider.baseUrl }}</span>
|
||||
@ -25,38 +34,92 @@
|
||||
<span class="field-label">API Key</span>
|
||||
<span class="field-value">{{ maskedKey }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="modelTags.length" class="models-row">
|
||||
<span class="field-label">模型</span>
|
||||
<div class="models-section">
|
||||
<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 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-name">{{ tag.modelId }}</span>
|
||||
</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">
|
||||
<span v-if="provider.isDefault" class="status-tag default">默认</span>
|
||||
<span class="status-tag" :class="provider.enabled ? 'on' : 'off'">
|
||||
{{ provider.enabled ? '已启用' : '未启用' }}
|
||||
<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>
|
||||
|
||||
<a-modal
|
||||
v-model:visible="modelModalVisible"
|
||||
:title="modelModalTitle"
|
||||
:width="420"
|
||||
ok-text="保存"
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconDelete, IconEdit } from '@arco-design/web-vue/es/icon';
|
||||
import { computed } from 'vue';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { IconDelete, IconEdit, IconPlus } from '@arco-design/web-vue/es/icon';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { saveAiProvider } from '@/api/ai';
|
||||
import {
|
||||
MODEL_ROLES,
|
||||
defaultModel,
|
||||
maskSecret,
|
||||
providerModelTags,
|
||||
parseProviderModelsConfig,
|
||||
providerLabel,
|
||||
providerTypeLabel,
|
||||
resolveProviderModelTags,
|
||||
serializeProviderModelsConfig,
|
||||
type AiProviderItem,
|
||||
type ModelRole,
|
||||
type ProviderModelTag,
|
||||
} from './providerCatalog';
|
||||
|
||||
const props = defineProps<{
|
||||
@ -66,113 +129,321 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit', provider: AiProviderItem): void;
|
||||
(e: 'disable', provider: AiProviderItem): void;
|
||||
(e: 'saved'): void;
|
||||
}>();
|
||||
|
||||
const title = computed(() => providerTypeLabel(props.provider));
|
||||
const maskedKey = computed(() => maskSecret(props.provider.apiKey));
|
||||
const maskedSecret = computed(() => maskSecret(props.provider.apiSecret));
|
||||
const modelTags = computed(() => providerModelTags(props.provider.providerType));
|
||||
const modelTags = computed(() => resolveProviderModelTags(props.provider));
|
||||
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>
|
||||
|
||||
<style scoped lang="less">
|
||||
@import '@/views/xhs/_shared/xhs-page.less';
|
||||
|
||||
.ai-provider-card {
|
||||
height: 196px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px;
|
||||
transition: border-color 0.15s;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
padding: 16px 18px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
|
||||
&: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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-title-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
line-height: 1.3;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-row,
|
||||
.models-row {
|
||||
.status-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
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 {
|
||||
width: 72px;
|
||||
width: 64px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.models-row {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-items: flex-start;
|
||||
.models-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: var(--color-fill-1);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 8px;
|
||||
max-height: 72px;
|
||||
overflow: hidden;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-chip {
|
||||
position: relative;
|
||||
min-width: 92px;
|
||||
max-width: 120px;
|
||||
height: 54px;
|
||||
padding: 5px 8px 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
background: var(--app-card-bg);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.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));
|
||||
}
|
||||
|
||||
&.is-default {
|
||||
border-color: color-mix(in srgb, var(--app-primary-color) 55%, var(--app-card-bg));
|
||||
}
|
||||
}
|
||||
|
||||
.chip-role {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 8px;
|
||||
font-size: 9px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--app-primary-color);
|
||||
line-height: 1;
|
||||
|
||||
&[data-role='图片'] {
|
||||
color: rgb(var(--green-6));
|
||||
@ -184,29 +455,51 @@ const canDisable = computed(() => Boolean(props.provider.enabled));
|
||||
}
|
||||
|
||||
.chip-name {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
font-size: 11px;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding-top: 4px;
|
||||
.chip-default {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-fill-2);
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.4;
|
||||
|
||||
&.default {
|
||||
background: var(--app-primary-color-light, #e6f7f7);
|
||||
@ -223,4 +516,11 @@ const canDisable = computed(() => Boolean(props.provider.enabled));
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
}
|
||||
|
||||
.model-modal-hint {
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -25,12 +25,11 @@
|
||||
@visibility-change="(v: boolean) => (showKey = v)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="!isZhipu" label="API Secret">
|
||||
<a-input-password
|
||||
<a-form-item label="默认模型(文字)">
|
||||
<a-input
|
||||
v-model="form.apiSecret"
|
||||
placeholder="可选,可填模型名"
|
||||
:visibility="showSecret"
|
||||
@visibility-change="(v: boolean) => (showSecret = v)"
|
||||
:placeholder="`默认 ${defaultModel(form.providerType) || '模型 ID'}`"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
@ -47,11 +46,13 @@ import { Message } from '@arco-design/web-vue';
|
||||
import { saveAiProvider } from '@/api/ai';
|
||||
import {
|
||||
defaultBaseUrl,
|
||||
defaultModel,
|
||||
isMaskedSecret,
|
||||
providerLabel,
|
||||
resolveTextModelId,
|
||||
upsertDefaultTextModel,
|
||||
usesZhipuSdk,
|
||||
ZHIPU_CHAT_MODEL,
|
||||
ZHIPU_IMAGE_MODEL,
|
||||
ZHIPU_VISION_MODEL,
|
||||
type AiProviderItem,
|
||||
} from './providerCatalog';
|
||||
|
||||
@ -67,7 +68,6 @@ const emit = defineEmits<{
|
||||
|
||||
const saving = ref(false);
|
||||
const showKey = ref(false);
|
||||
const showSecret = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
providerKey: '',
|
||||
@ -89,9 +89,9 @@ const isZhipu = computed(() => usesZhipuSdk(form.providerType));
|
||||
|
||||
const hintText = computed(() => {
|
||||
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(
|
||||
@ -103,11 +103,12 @@ watch(
|
||||
form.displayName = p.displayName;
|
||||
form.baseUrl = p.baseUrl;
|
||||
form.apiKey = '';
|
||||
form.apiSecret = p.apiSecret === '******' ? '' : p.apiSecret || '';
|
||||
form.apiSecret = isMaskedSecret(p.apiSecret)
|
||||
? ''
|
||||
: resolveTextModelId(p);
|
||||
form.isDefault = Boolean(p.isDefault);
|
||||
form.enabled = p.enabled !== false;
|
||||
showKey.value = false;
|
||||
showSecret.value = false;
|
||||
},
|
||||
{ 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;
|
||||
try {
|
||||
await saveAiProvider({
|
||||
@ -143,7 +151,7 @@ async function onOk() {
|
||||
displayName: form.displayName || providerLabel(form.providerType),
|
||||
baseUrl,
|
||||
apiKey: apiKey || undefined,
|
||||
apiSecret: isZhipu.value ? undefined : form.apiSecret.trim() || undefined,
|
||||
apiSecret: apiSecret || undefined,
|
||||
isDefault: form.isDefault,
|
||||
enabled: form.enabled,
|
||||
});
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<div>
|
||||
<p class="xhs-panel-title">AI 助手 · 模型接入</p>
|
||||
<p class="xhs-panel-desc">
|
||||
默认提供 ChatGPT、豆包、DeepSeek、智谱;点击卡片可编辑密钥与模型,支持禁用。
|
||||
默认提供 ChatGPT、豆包、DeepSeek、智谱;卡片内可新增/修改文字模型,也可编辑密钥,支持禁用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -23,6 +23,7 @@
|
||||
:provider="p"
|
||||
@edit="(item) => emit('edit', item)"
|
||||
@disable="(item) => emit('disable', item)"
|
||||
@saved="emit('refresh')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="empty-hint">
|
||||
@ -75,8 +76,8 @@ const emit = defineEmits<{
|
||||
|
||||
.providers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
@ -87,13 +88,7 @@ const emit = defineEmits<{
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.providers-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@media (max-width: 900px) {
|
||||
.providers-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@ -61,17 +61,174 @@ export function usesZhipuSdk(typeId: string): boolean {
|
||||
return ZHIPU_SDK_TYPES.has(typeId as ProviderTypeId);
|
||||
}
|
||||
|
||||
export function providerModelTags(typeId: string): Array<{ modelId: string; role: string }> {
|
||||
if (typeId === 'zhipu') {
|
||||
return [
|
||||
{ modelId: ZHIPU_CHAT_MODEL, role: '文字' },
|
||||
{ modelId: ZHIPU_IMAGE_MODEL, role: '图片' },
|
||||
{ modelId: ZHIPU_VISION_MODEL, role: '图文' },
|
||||
];
|
||||
export type ModelRole = '文字' | '图片' | '图文';
|
||||
|
||||
export interface ProviderModelTag {
|
||||
modelId: string;
|
||||
role: ModelRole;
|
||||
/** 是否为对话默认模型 */
|
||||
isChatDefault?: boolean;
|
||||
/** 是否相对目录默认有改动(可删除) */
|
||||
custom?: boolean;
|
||||
editable?: boolean;
|
||||
}
|
||||
const def = PROVIDER_TYPES.find((p) => p.id === typeId);
|
||||
if (!def) return [];
|
||||
return [{ modelId: def.defaultModel, role: '文字' }];
|
||||
|
||||
/** 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: '文字' }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
if (secret.startsWith('{')) {
|
||||
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 {
|
||||
@ -81,12 +238,16 @@ export function maskSecret(value?: string | null, visibleTail = 4): string {
|
||||
return '*'.repeat(text.length - visibleTail) + text.slice(-visibleTail);
|
||||
}
|
||||
|
||||
export function sortProviders<T extends { providerKey: string; providerType: string }>(
|
||||
items: T[],
|
||||
): T[] {
|
||||
export function sortProviders<
|
||||
T extends { providerKey: string; providerType: string; isDefault?: boolean },
|
||||
>(items: T[]): T[] {
|
||||
const order = DEFAULT_PROVIDER_KEYS.map((p) => p.key);
|
||||
const typeOrder = DEFAULT_PROVIDER_KEYS.map((p) => p.type);
|
||||
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 bi = order.indexOf(b.providerKey);
|
||||
if (ai >= 0 || bi >= 0) return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
|
||||
|
||||
@ -143,7 +143,7 @@ import {
|
||||
} from '@/api/mcp';
|
||||
import { listAiProviders } from '@/api/ai';
|
||||
import {
|
||||
providerModelTags,
|
||||
resolveTextModelId,
|
||||
type AiProviderItem,
|
||||
} from '@/views/system/aiAssistant/providerCatalog';
|
||||
|
||||
@ -287,8 +287,7 @@ async function loadProviders() {
|
||||
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() || '';
|
||||
modelId.value = resolveTextModelId(def);
|
||||
}
|
||||
} catch {
|
||||
providers.value = [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user