feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导
- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定) - 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检 - 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示 - 删除专利汇令牌全链路与密钥保留时长选择器
This commit is contained in:
@@ -17,117 +17,11 @@
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div class="dialog-title">密钥设置</div>
|
||||
<div class="dialog-subtitle">密钥按当前登录用户保存在本机,代理设置保存在当前客户端。</div>
|
||||
<div class="dialog-subtitle">密钥保存在服务端并绑定当前账号,换设备登录后自动同步;代理设置保存在当前客户端。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="secret-settings-body">
|
||||
<section v-for="config in secretConfigs" :key="config.key" class="secret-card">
|
||||
<div class="secret-card-head">
|
||||
<div>
|
||||
<div class="secret-card-title">{{ config.title }}</div>
|
||||
<div class="secret-card-desc">{{ config.description }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="secretStates[config.key].exists"
|
||||
type="button"
|
||||
class="link-danger"
|
||||
:disabled="saving"
|
||||
@click="clearSecret(config.key)"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="secretStates[config.key].value"
|
||||
class="secret-input"
|
||||
type="password"
|
||||
:placeholder="config.placeholder"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="saving"
|
||||
/>
|
||||
|
||||
<div class="retention-block">
|
||||
<div class="retention-label">保留时长</div>
|
||||
<div class="retention-options">
|
||||
<label
|
||||
v-for="option in retentionOptions"
|
||||
:key="option.value"
|
||||
class="retention-option"
|
||||
:class="{ 'retention-option--disabled': saving }"
|
||||
>
|
||||
<input
|
||||
v-model="secretStates[config.key].retention"
|
||||
type="radio"
|
||||
:value="option.value"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-meta">
|
||||
<span v-if="secretStates[config.key].exists">
|
||||
{{ formatRetentionText(secretStates[config.key].retention, secretStates[config.key].expiresAt) }}
|
||||
</span>
|
||||
<span v-else>当前未保存</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="secret-card">
|
||||
<div class="secret-card-head">
|
||||
<div>
|
||||
<div class="secret-card-title">代理设置</div>
|
||||
<div class="secret-card-desc">供客户端任务连接代理服务。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="proxy-field">
|
||||
<label class="retention-label" for="proxy-url">代理地址</label>
|
||||
<input
|
||||
id="proxy-url"
|
||||
v-model="proxyUrl"
|
||||
class="secret-input"
|
||||
type="text"
|
||||
placeholder="请输入代理地址"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="!proxyReady || saving"
|
||||
@input="proxyDirty = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="retention-block">
|
||||
<div class="retention-label">代理模式</div>
|
||||
<div class="retention-options">
|
||||
<label
|
||||
v-for="option in proxyModeOptions"
|
||||
:key="option.value"
|
||||
class="retention-option"
|
||||
:class="{ 'retention-option--disabled': !proxyReady || saving }"
|
||||
>
|
||||
<input
|
||||
v-model="proxyMode"
|
||||
type="radio"
|
||||
:value="option.value"
|
||||
:disabled="!proxyReady || saving"
|
||||
@change="proxyDirty = true"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="proxyLoading || proxyLoadFailed || !proxySupported" class="secret-meta">
|
||||
<span v-if="proxyLoading">正在读取代理配置...</span>
|
||||
<span v-else-if="proxyLoadFailed">代理配置读取失败,请关闭弹窗后重试</span>
|
||||
<span v-else>代理设置仅在桌面客户端中可用</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<ApiSecretSettingsPanel ref="panelRef" />
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
@@ -142,8 +36,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="footer-btn footer-btn-primary"
|
||||
:disabled="saving || proxyLoading"
|
||||
@click="saveAll"
|
||||
:disabled="saving"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
@@ -155,26 +49,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
clearStoredApiSecret,
|
||||
getStoredApiSecretSnapshot,
|
||||
saveStoredApiSecret,
|
||||
type ApiSecretModuleKey,
|
||||
type ApiSecretRetention,
|
||||
} from '@/shared/utils/api-secret-store'
|
||||
import { getPywebviewApi, type ProxyMode, type DesktopConfigUpdate } from '@/shared/bridges/pywebview'
|
||||
import ApiSecretSettingsPanel from '@/shared/components/ApiSecretSettingsPanel.vue'
|
||||
|
||||
type SecretState = {
|
||||
value: string
|
||||
retention: ApiSecretRetention
|
||||
expiresAt: number | null
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
const props = withDefaults(
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** topbar=顶栏纯文字样式(对齐主程序);默认=现有胶囊按钮 */
|
||||
variant?: 'topbar'
|
||||
@@ -183,213 +60,31 @@ const props = withDefaults(
|
||||
variant: undefined,
|
||||
},
|
||||
)
|
||||
const proxyUrl = ref('')
|
||||
const proxyMode = ref<ProxyMode>(1)
|
||||
const proxyLoading = ref(false)
|
||||
const proxyLoadFailed = ref(false)
|
||||
const proxyReady = ref(false)
|
||||
const proxySupported = ref(false)
|
||||
const proxyDirty = ref(false)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
let proxyLoadRequestId = 0
|
||||
const panelRef = ref<InstanceType<typeof ApiSecretSettingsPanel> | null>(null)
|
||||
|
||||
const proxyModeOptions: Array<{ value: ProxyMode; label: string }> = [
|
||||
{ value: 1, label: '白名单' },
|
||||
{ value: 2, label: '账号密码' },
|
||||
]
|
||||
|
||||
const retentionOptions: Array<{ value: ApiSecretRetention; label: string }> = [
|
||||
{ value: 'session', label: '本次打开有效' },
|
||||
{ value: '1d', label: '1 天' },
|
||||
{ value: '7d', label: '7 天' },
|
||||
{ value: '30d', label: '30 天' },
|
||||
{ value: 'forever', label: '长期保留' },
|
||||
]
|
||||
|
||||
const secretConfigs: Array<{ key: ApiSecretModuleKey; title: string; description: string; placeholder: string }> = [
|
||||
{
|
||||
key: 'appearance-patent',
|
||||
title: '外观专利密钥',
|
||||
description: '仅用于外观专利检测。',
|
||||
placeholder: '请输入 LLM 接口密钥',
|
||||
},
|
||||
{
|
||||
key: 'appearance-patent-token',
|
||||
title: '专利汇令牌',
|
||||
description: '仅用于外观专利检测,非必填。',
|
||||
placeholder: '请输入专利汇令牌,可留空',
|
||||
},
|
||||
{
|
||||
key: 'similar-asin',
|
||||
title: '货源查询密钥',
|
||||
description: '仅用于货源查询。',
|
||||
placeholder: '请输入 LLM 接口密钥',
|
||||
},
|
||||
]
|
||||
|
||||
const secretStates = ref<Record<ApiSecretModuleKey, SecretState>>({
|
||||
'appearance-patent': emptySecretState(),
|
||||
'appearance-patent-token': emptySecretState(),
|
||||
'similar-asin': emptySecretState(),
|
||||
// 每次打开弹窗重新拉取服务端密钥状态(组件可能被 el-dialog 复用不会重新挂载)
|
||||
watch(dialogVisible, (visible) => {
|
||||
if (visible) void panelRef.value?.reload()
|
||||
})
|
||||
|
||||
function loadStates() {
|
||||
const nextStates = { ...secretStates.value }
|
||||
for (const config of secretConfigs) {
|
||||
const snapshot = getStoredApiSecretSnapshot(config.key)
|
||||
nextStates[config.key] = {
|
||||
value: snapshot.value,
|
||||
retention: snapshot.retention,
|
||||
expiresAt: snapshot.expiresAt,
|
||||
exists: snapshot.exists,
|
||||
}
|
||||
}
|
||||
secretStates.value = nextStates
|
||||
}
|
||||
|
||||
function emptySecretState(): SecretState {
|
||||
return {
|
||||
value: '',
|
||||
retention: 'session',
|
||||
expiresAt: null,
|
||||
exists: false,
|
||||
}
|
||||
}
|
||||
|
||||
function formatRetentionText(retention: ApiSecretRetention, expiresAt: number | null) {
|
||||
if (retention === 'session') return '关闭软件后自动清空'
|
||||
if (retention === 'forever') return '长期保留,直到手动清空'
|
||||
if (!expiresAt) return '已保存'
|
||||
const date = new Date(expiresAt)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||
return `有效期至 ${year}-${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
function clearSecret(moduleKey: ApiSecretModuleKey) {
|
||||
clearStoredApiSecret(moduleKey)
|
||||
loadStates()
|
||||
ElMessage.success('已清空密钥')
|
||||
}
|
||||
|
||||
function proxyUserId() {
|
||||
if (typeof window === 'undefined') return '0'
|
||||
return window.localStorage.getItem('uid') || '0'
|
||||
}
|
||||
|
||||
/** 代理地址按登录用户隔离:proxy_users[uid],各自计费各自复用;
|
||||
* 未登录(uid=0)回退全局 proxy_url(兼容旧版本已保存的配置)。 */
|
||||
function readUserProxy(config: Record<string, unknown> | null | undefined) {
|
||||
const uid = proxyUserId()
|
||||
if (uid !== '0') {
|
||||
const users = (config?.proxy_users ?? {}) as Record<string, unknown>
|
||||
const own = (users[uid] ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
url: typeof own.proxy_url === 'string' ? own.proxy_url : '',
|
||||
mode: Number(own.proxy_mode) === 2 ? 2 : 1,
|
||||
}
|
||||
}
|
||||
return {
|
||||
url: typeof config?.proxy_url === 'string' ? config.proxy_url : '',
|
||||
mode: Number(config?.proxy_mode) === 2 ? 2 : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function userProxyPatch(nextProxyUrl: string, nextProxyMode: ProxyMode) {
|
||||
const uid = proxyUserId()
|
||||
if (uid === '0') {
|
||||
return { proxy_url: nextProxyUrl, proxy_mode: nextProxyMode }
|
||||
}
|
||||
const users: Record<string, unknown> = {}
|
||||
users[uid] = { proxy_url: nextProxyUrl, proxy_mode: nextProxyMode }
|
||||
return { proxy_users: users }
|
||||
}
|
||||
|
||||
async function loadProxyConfig() {
|
||||
const requestId = ++proxyLoadRequestId
|
||||
proxyLoading.value = true
|
||||
proxyLoadFailed.value = false
|
||||
proxyReady.value = false
|
||||
proxyDirty.value = false
|
||||
|
||||
const api = getPywebviewApi()
|
||||
proxySupported.value = Boolean(api?.read_config && api.save_config)
|
||||
if (!api?.read_config || !api.save_config) {
|
||||
proxyUrl.value = ''
|
||||
proxyMode.value = 1
|
||||
proxyLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await api.read_config()
|
||||
if (requestId !== proxyLoadRequestId || !dialogVisible.value) return
|
||||
const own = readUserProxy(config as Record<string, unknown>)
|
||||
proxyUrl.value = own.url
|
||||
proxyMode.value = (own.mode === 2 ? 2 : 1) as ProxyMode
|
||||
proxyReady.value = true
|
||||
} catch (error) {
|
||||
if (requestId !== proxyLoadRequestId || !dialogVisible.value) return
|
||||
proxyLoadFailed.value = true
|
||||
ElMessage.error(error instanceof Error ? error.message : '代理配置读取失败')
|
||||
} finally {
|
||||
if (requestId === proxyLoadRequestId) proxyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
if (saving.value || proxyLoading.value) return
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
const panel = panelRef.value
|
||||
if (!panel) return
|
||||
saving.value = true
|
||||
const secretSnapshot = secretConfigs.map((config) => ({
|
||||
key: config.key,
|
||||
value: secretStates.value[config.key].value,
|
||||
retention: secretStates.value[config.key].retention,
|
||||
}))
|
||||
const shouldSaveProxy = proxyReady.value && proxyDirty.value
|
||||
const nextProxyUrl = proxyUrl.value.trim()
|
||||
const nextProxyMode = proxyMode.value
|
||||
let secretsSaved = false
|
||||
|
||||
try {
|
||||
for (const secret of secretSnapshot) {
|
||||
saveStoredApiSecret(secret.key, secret.value, secret.retention)
|
||||
const ok = await panel.saveAll()
|
||||
if (ok) {
|
||||
dialogVisible.value = false
|
||||
console.log('[api-secret] 密钥设置已保存')
|
||||
}
|
||||
secretsSaved = true
|
||||
loadStates()
|
||||
|
||||
if (shouldSaveProxy) {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.save_config) throw new Error('当前客户端未提供代理配置保存能力')
|
||||
// 按登录用户保存:proxy_users[uid](未登录回退全局 proxy_url 字段)
|
||||
await api.save_config(userProxyPatch(nextProxyUrl, nextProxyMode) as DesktopConfigUpdate)
|
||||
proxyUrl.value = nextProxyUrl
|
||||
proxyDirty.value = false
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
ElMessage.success(shouldSaveProxy ? '密钥和代理设置已保存' : '密钥设置已保存')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '设置保存失败'
|
||||
ElMessage.error(secretsSaved && shouldSaveProxy ? `密钥已保存,但代理设置保存失败:${message}` : message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(dialogVisible, (visible) => {
|
||||
if (visible) {
|
||||
loadStates()
|
||||
void loadProxyConfig()
|
||||
} else {
|
||||
proxyLoadRequestId += 1
|
||||
proxyLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
loadStates()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -504,127 +199,6 @@ loadStates()
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.secret-settings-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.secret-card {
|
||||
padding: 18px;
|
||||
border: 1px solid #313b46;
|
||||
border-radius: 10px;
|
||||
background: #20252b;
|
||||
}
|
||||
|
||||
.secret-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.secret-card-title {
|
||||
color: #eef4fb;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.secret-card-desc {
|
||||
margin-top: 4px;
|
||||
color: #909ba8;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.secret-input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #3b4652;
|
||||
border-radius: 10px;
|
||||
background: #1b2026;
|
||||
color: #dce6f0;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.secret-input:focus {
|
||||
border-color: #5b96d6;
|
||||
}
|
||||
|
||||
.secret-input:disabled {
|
||||
color: #707b86;
|
||||
cursor: not-allowed;
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.proxy-field .retention-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.retention-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.retention-label {
|
||||
margin-bottom: 8px;
|
||||
color: #a3afbb;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.retention-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.retention-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #1b2026;
|
||||
color: #dce4ec;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.retention-option input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.retention-option--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
.secret-meta {
|
||||
margin-top: 10px;
|
||||
color: #7f8a96;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link-danger {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ff9b9b;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link-danger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -254,10 +254,6 @@ function effectiveLlmApiKey() {
|
||||
return getStoredApiSecret('appearance-patent').trim()
|
||||
}
|
||||
|
||||
function effectivePatentToken() {
|
||||
return getStoredApiSecret('appearance-patent-token').trim()
|
||||
}
|
||||
|
||||
|
||||
function uidForStorage() {
|
||||
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
|
||||
@@ -391,8 +387,8 @@ async function parseFiles() {
|
||||
return
|
||||
}
|
||||
if (!effectiveLlmApiKey()) {
|
||||
ElMessage.warning('请先在左上角设置中填写外观专利密钥')
|
||||
return
|
||||
// 本地无明文不再阻断:密钥已服务端化,任务执行按用户 uid 兜底读取
|
||||
console.log('[appearance-patent] 本地无密钥明文,提交时由服务端按用户密钥兜底')
|
||||
}
|
||||
parsing.value = true
|
||||
try {
|
||||
@@ -401,7 +397,7 @@ async function parseFiles() {
|
||||
originalFilename: f.originalFilename,
|
||||
relativePath: f.relativePath,
|
||||
}))
|
||||
const res = await parseAppearancePatent(files, effectiveAiPrompt(), effectiveLlmApiKey(), effectivePatentToken())
|
||||
const res = await parseAppearancePatent(files, effectiveAiPrompt(), effectiveLlmApiKey())
|
||||
// 本模块按主 ID 分组执行,没有分组就没有可跑的批次,一并拦下
|
||||
const guard = checkParseResult(res, {
|
||||
requireGroups: true,
|
||||
@@ -437,8 +433,8 @@ async function pushToPythonQueue() {
|
||||
return
|
||||
}
|
||||
if (!effectiveLlmApiKey()) {
|
||||
ElMessage.warning('请先在左上角设置中填写外观专利密钥')
|
||||
return
|
||||
// 本地无明文不再阻断:服务端已保存该用户密钥,任务执行时按 uid 兜底读取
|
||||
console.log('[appearance-patent] 本地无密钥明文,启动任务由服务端兜底')
|
||||
}
|
||||
pushing.value = true
|
||||
try {
|
||||
@@ -451,7 +447,6 @@ async function pushToPythonQueue() {
|
||||
taskId,
|
||||
prompt: queueAiPrompt(),
|
||||
api_key: effectiveLlmApiKey(),
|
||||
patent_token: effectivePatentToken(),
|
||||
sourceFileCount: currentParseResult.sourceFileCount || 0,
|
||||
totalRows: currentParseResult.totalRows || 0,
|
||||
acceptedRows: currentParseResult.acceptedRows || 0,
|
||||
|
||||
@@ -455,8 +455,8 @@ async function parseFiles() {
|
||||
return
|
||||
}
|
||||
if (!effectiveLlmApiKey()) {
|
||||
ElMessage.warning('请先在左上角设置中填写货源查询密钥')
|
||||
return
|
||||
// 本地无明文不再阻断:密钥已服务端化,任务执行按用户 uid 兜底读取
|
||||
console.log('[similar-asin] 本地无密钥明文,提交时由服务端按用户密钥兜底')
|
||||
}
|
||||
if (!getRequiredAlipriceCredentials()) return
|
||||
parsing.value = true
|
||||
@@ -502,8 +502,8 @@ async function pushToPythonQueue() {
|
||||
return
|
||||
}
|
||||
if (!effectiveLlmApiKey()) {
|
||||
ElMessage.warning('请先在左上角设置中填写货源查询密钥')
|
||||
return
|
||||
// 本地无明文不再阻断:服务端已保存该用户密钥,任务执行时按 uid 兜底读取
|
||||
console.log('[similar-asin] 本地无密钥明文,启动任务由服务端兜底')
|
||||
}
|
||||
const alipriceCredentials = getRequiredAlipriceCredentials()
|
||||
if (!alipriceCredentials) return
|
||||
|
||||
@@ -99,6 +99,7 @@ import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { loginWithDevice } from '@/shared/api/user'
|
||||
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
|
||||
import { clearApiSecretCache } from '@/shared/utils/api-secret-store'
|
||||
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -414,6 +415,8 @@ onMounted(() => {
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
// 清空密钥缓存:内存 + 本地镜像(旧 v1 记录保留,供下次登录自动迁移)
|
||||
clearApiSecretCache()
|
||||
// 同步清掉客户端的登录用户标记:Python 端回退到全局/默认代理池
|
||||
try {
|
||||
const bridge = (window as unknown as { pywebview?: { api?: { save_config?: (data: Record<string, unknown>) => Promise<unknown> } } }).pywebview
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="setup-root">
|
||||
<header class="setup-header">
|
||||
<span class="setup-title">数富AI</span>
|
||||
<div class="setup-header-right">
|
||||
<span class="username-text">{{ username || '未登录' }}</span>
|
||||
<router-link to="/login?logout=1" class="logout-link">退出</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="setup-main">
|
||||
<div class="setup-card">
|
||||
<div class="setup-card-title">完成密钥配置后即可使用</div>
|
||||
<div class="setup-card-desc">
|
||||
密钥保存在服务端并绑定当前账号,换设备登录后自动同步。
|
||||
请填写以下密钥并保存,检测通过后即可进入工具台。
|
||||
</div>
|
||||
|
||||
<ApiSecretSettingsPanel ref="panelRef" />
|
||||
|
||||
<div class="setup-actions">
|
||||
<button type="button" class="setup-submit" :disabled="submitting" @click="submit">
|
||||
{{ submitting ? '校验中...' : '保存并进入' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="setup-hint">{{ hint }}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ApiSecretSettingsPanel from '@/shared/components/ApiSecretSettingsPanel.vue'
|
||||
import {
|
||||
checkApiSecret,
|
||||
getStoredApiSecretSnapshot,
|
||||
listApiSecretModules,
|
||||
loadApiSecrets,
|
||||
type ApiSecretModuleKey,
|
||||
} from '@/shared/utils/api-secret-store'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const panelRef = ref<InstanceType<typeof ApiSecretSettingsPanel> | null>(null)
|
||||
const submitting = ref(false)
|
||||
const username = ref('')
|
||||
const hint = ref('保存后系统会自动检测密钥连通性;检测未通过的密钥需要修正后重试。')
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
username.value = window.localStorage.getItem('username') || ''
|
||||
} catch {
|
||||
/* 忽略存储异常 */
|
||||
}
|
||||
})
|
||||
|
||||
/** 对已保存但尚未检测出结果的必填模块补一次检测,避免"已配置却因未检测被拦"。 */
|
||||
async function ensureAllChecked() {
|
||||
for (const module of listApiSecretModules()) {
|
||||
const moduleKey = module.moduleKey as ApiSecretModuleKey
|
||||
const snapshot = getStoredApiSecretSnapshot(moduleKey)
|
||||
if (!snapshot.exists) continue
|
||||
if (snapshot.checkStatus === 'passed' || snapshot.checkStatus === 'error') continue
|
||||
try {
|
||||
await checkApiSecret(moduleKey)
|
||||
} catch (error) {
|
||||
console.warn('[api-secret] 补齐检测失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
const panel = panelRef.value
|
||||
if (!panel) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const saved = await panel.saveAll({ requireAll: true })
|
||||
if (!saved) return
|
||||
|
||||
await ensureAllChecked()
|
||||
const state = await loadApiSecrets({ force: true })
|
||||
if (state === 'incomplete') {
|
||||
const failed = listApiSecretModules()
|
||||
.map((module) => getStoredApiSecretSnapshot(module.moduleKey as ApiSecretModuleKey))
|
||||
.filter((snapshot) => !snapshot.exists || (snapshot.checkStatus !== 'passed' && snapshot.checkStatus !== 'error'))
|
||||
hint.value = `以下密钥尚未通过检测:${failed.map((item) => item.masked || '未配置').join('、')},请点击「检测」确认。`
|
||||
ElMessage.warning('密钥尚未全部检测通过,请逐项检测确认')
|
||||
return
|
||||
}
|
||||
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
|
||||
? route.query.redirect
|
||||
: '/home'
|
||||
console.log('[api-secret] 密钥配置完成,进入', redirect)
|
||||
await router.replace(redirect)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.setup-root {
|
||||
min-height: 100vh;
|
||||
background: #12161a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 24px;
|
||||
background: #171b20;
|
||||
border-bottom: 1px solid #262d35;
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
color: #f2f6fa;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setup-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.username-text {
|
||||
color: #9aa6b3;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.logout-link {
|
||||
color: #8dc4ff;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logout-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.setup-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 16px 64px;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
padding: 26px;
|
||||
border: 1px solid #2c3540;
|
||||
border-radius: 16px;
|
||||
background: #171b20;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, .45);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.setup-card-title {
|
||||
color: #f5f7fa;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setup-card-desc {
|
||||
margin: 8px 0 18px;
|
||||
color: #8f9aa7;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.setup-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.setup-submit {
|
||||
min-width: 132px;
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #4f8fda;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setup-submit:hover:not(:disabled) {
|
||||
background: #67a6ed;
|
||||
}
|
||||
|
||||
.setup-submit:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .58;
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
margin-top: 12px;
|
||||
color: #7f8a96;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user