@@ -5,11 +5,19 @@
|
||||
<span>密钥设置</span>
|
||||
</button>
|
||||
|
||||
<el-dialog v-model="dialogVisible" width="620px" class="secret-settings-dialog" :append-to-body="true">
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
width="620px"
|
||||
class="secret-settings-dialog"
|
||||
:append-to-body="true"
|
||||
:close-on-click-modal="!saving"
|
||||
:close-on-press-escape="!saving"
|
||||
:show-close="!saving"
|
||||
>
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div class="dialog-title">密钥设置</div>
|
||||
<div class="dialog-subtitle">按当前登录用户保存在本机,外观专利和货源查询分别使用独立的 Coze 接口密钥。</div>
|
||||
<div class="dialog-subtitle">密钥按当前登录用户保存在本机,代理设置保存在当前客户端。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -24,6 +32,7 @@
|
||||
v-if="secretStates[config.key].exists"
|
||||
type="button"
|
||||
class="link-danger"
|
||||
:disabled="saving"
|
||||
@click="clearSecret(config.key)"
|
||||
>
|
||||
清空
|
||||
@@ -37,13 +46,24 @@
|
||||
: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">
|
||||
<input v-model="secretStates[config.key].retention" type="radio" :value="option.value" />
|
||||
<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>
|
||||
@@ -56,12 +76,77 @@
|
||||
<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>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="footer-btn footer-btn-ghost" @click="dialogVisible = false">取消</button>
|
||||
<button type="button" class="footer-btn footer-btn-primary" @click="saveAll">保存</button>
|
||||
<button
|
||||
type="button"
|
||||
class="footer-btn footer-btn-ghost"
|
||||
:disabled="saving"
|
||||
@click="dialogVisible = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="footer-btn footer-btn-primary"
|
||||
:disabled="saving || proxyLoading"
|
||||
@click="saveAll"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -78,6 +163,7 @@ import {
|
||||
type ApiSecretModuleKey,
|
||||
type ApiSecretRetention,
|
||||
} from '@/shared/utils/api-secret-store'
|
||||
import { getPywebviewApi, type ProxyMode } from '@/shared/bridges/pywebview'
|
||||
|
||||
type SecretState = {
|
||||
value: string
|
||||
@@ -87,6 +173,20 @@ type SecretState = {
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
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 saving = ref(false)
|
||||
let proxyLoadRequestId = 0
|
||||
|
||||
const proxyModeOptions: Array<{ value: ProxyMode; label: string }> = [
|
||||
{ value: 1, label: '白名单' },
|
||||
{ value: 2, label: '账号密码' },
|
||||
]
|
||||
|
||||
const retentionOptions: Array<{ value: ApiSecretRetention; label: string }> = [
|
||||
{ value: 'session', label: '本次打开有效' },
|
||||
@@ -165,19 +265,87 @@ function clearSecret(moduleKey: ApiSecretModuleKey) {
|
||||
ElMessage.success('已清空密钥')
|
||||
}
|
||||
|
||||
function saveAll() {
|
||||
for (const config of secretConfigs) {
|
||||
const state = secretStates.value[config.key]
|
||||
saveStoredApiSecret(config.key, state.value, state.retention)
|
||||
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 nextUrl = typeof config?.proxy_url === 'string' ? config.proxy_url : ''
|
||||
const nextMode: ProxyMode = Number(config?.proxy_mode) === 2 ? 2 : 1
|
||||
proxyUrl.value = nextUrl
|
||||
proxyMode.value = nextMode
|
||||
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
|
||||
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)
|
||||
}
|
||||
secretsSaved = true
|
||||
loadStates()
|
||||
|
||||
if (shouldSaveProxy) {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.save_config) throw new Error('当前客户端未提供代理配置保存能力')
|
||||
await api.save_config({
|
||||
proxy_url: nextProxyUrl,
|
||||
proxy_mode: nextProxyMode,
|
||||
})
|
||||
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
|
||||
}
|
||||
loadStates()
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('密钥设置已保存')
|
||||
}
|
||||
|
||||
watch(dialogVisible, (visible) => {
|
||||
if (visible) {
|
||||
loadStates()
|
||||
void loadProxyConfig()
|
||||
} else {
|
||||
proxyLoadRequestId += 1
|
||||
proxyLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -327,6 +495,16 @@ loadStates()
|
||||
border-color: #5b96d6;
|
||||
}
|
||||
|
||||
.secret-input:disabled {
|
||||
color: #707b86;
|
||||
cursor: not-allowed;
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.proxy-field .retention-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.retention-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
@@ -359,6 +537,11 @@ loadStates()
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.retention-option--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
.secret-meta {
|
||||
margin-top: 10px;
|
||||
color: #7f8a96;
|
||||
@@ -374,6 +557,11 @@ loadStates()
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link-danger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -381,6 +569,7 @@ loadStates()
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
min-width: 76px;
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
border-radius: 10px;
|
||||
@@ -390,6 +579,11 @@ loadStates()
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footer-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .58;
|
||||
}
|
||||
|
||||
.footer-btn-ghost {
|
||||
border-color: #3a4653;
|
||||
background: #232a31;
|
||||
|
||||
@@ -35,6 +35,30 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="aliprice-card">
|
||||
<div class="section-title">货源账号</div>
|
||||
<label class="aliprice-field">
|
||||
<span>账号</span>
|
||||
<input
|
||||
v-model="alipriceUsername"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
placeholder="请输入 Aliprice 账号"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="aliprice-field">
|
||||
<span>密码</span>
|
||||
<input
|
||||
v-model="alipricePassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入 Aliprice 密码"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="run-row">
|
||||
<button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles">
|
||||
{{ parsing ? '解析中...' : '解析并创建任务' }}
|
||||
@@ -182,7 +206,7 @@ import {
|
||||
uploadTempFileToJava,
|
||||
} from '@/shared/api/java-modules'
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getPywebviewApi, type ProxyMode } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { getStoredApiSecret } from '@/shared/utils/api-secret-store'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
@@ -197,6 +221,8 @@ const parsing = ref(false)
|
||||
const pushing = ref(false)
|
||||
const imgSwitch = ref(false)
|
||||
const categorySwitch = ref(false)
|
||||
const alipriceUsername = ref('')
|
||||
const alipricePassword = ref('')
|
||||
const queuePayloadText = ref('')
|
||||
const pollingTaskIds = ref<number[]>([])
|
||||
const pendingFileTaskIds = ref<number[]>([])
|
||||
@@ -206,6 +232,7 @@ const HISTORY_CACHE_TTL_MS = 3000
|
||||
let historyInFlight: Promise<void> | null = null
|
||||
let lastHistoryLoadedAt = 0
|
||||
let disposed = false
|
||||
let pywebviewReadyHandler: (() => void) | null = null
|
||||
const timers = createCategorizedTimers('similar-asin')
|
||||
|
||||
const dashboard = ref<SimilarAsinDashboardVo>({
|
||||
@@ -280,6 +307,16 @@ function effectiveCozeApiKey() {
|
||||
return getStoredApiSecret('similar-asin').trim()
|
||||
}
|
||||
|
||||
function getRequiredAlipriceCredentials() {
|
||||
const username = alipriceUsername.value.trim()
|
||||
const password = alipricePassword.value
|
||||
if (!username || !password.trim()) {
|
||||
ElMessage.warning('请填写 Aliprice 账号和密码')
|
||||
return null
|
||||
}
|
||||
return { username, password }
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
@@ -306,6 +343,7 @@ function payloadForDisplay<T extends { data?: Record<string, unknown> }>(payload
|
||||
? {
|
||||
...payload.data,
|
||||
api_key: maskSecret(String(payload.data.api_key || '')),
|
||||
aliprice_pwd: maskSecret(String(payload.data.aliprice_pwd || '')),
|
||||
}
|
||||
: payload.data,
|
||||
}
|
||||
@@ -422,6 +460,7 @@ async function parseFiles() {
|
||||
ElMessage.warning('请先在左上角设置中填写货源查询密钥')
|
||||
return
|
||||
}
|
||||
if (!getRequiredAlipriceCredentials()) return
|
||||
parsing.value = true
|
||||
try {
|
||||
const files: UploadedFileRef[] = uploadedFiles.value.map((f) => ({
|
||||
@@ -458,8 +497,37 @@ async function pushToPythonQueue() {
|
||||
ElMessage.warning('请先在左上角设置中填写货源查询密钥')
|
||||
return
|
||||
}
|
||||
const alipriceCredentials = getRequiredAlipriceCredentials()
|
||||
if (!alipriceCredentials) return
|
||||
const alipriceUsename = alipriceCredentials.username
|
||||
const alipricePwd = alipriceCredentials.password
|
||||
pushing.value = true
|
||||
try {
|
||||
let proxyData: { proxy_url: string; proxy_mode: ProxyMode } | undefined
|
||||
if (api.read_config) {
|
||||
try {
|
||||
const config = await api.read_config()
|
||||
const proxyUrl = typeof config?.proxy_url === 'string' ? config.proxy_url.trim() : ''
|
||||
if (proxyUrl) {
|
||||
proxyData = {
|
||||
proxy_url: proxyUrl,
|
||||
proxy_mode: Number(config?.proxy_mode) === 2 ? 2 : 1,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep the existing queue behavior when proxy configuration is unavailable.
|
||||
}
|
||||
}
|
||||
if (api.save_config) {
|
||||
try {
|
||||
await api.save_config({
|
||||
aliprice_usename: alipriceUsename,
|
||||
aliprice_pwd: alipricePwd,
|
||||
})
|
||||
} catch {
|
||||
ElMessage.warning('Aliprice 账号配置保存失败,本次任务仍会继续')
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
type: 'similar-asin-run',
|
||||
ts: Date.now(),
|
||||
@@ -471,6 +539,9 @@ async function pushToPythonQueue() {
|
||||
totalRows: currentParseResult.totalRows || 0,
|
||||
acceptedRows: currentParseResult.acceptedRows || 0,
|
||||
groupCount: currentParseResult.groupCount || 0,
|
||||
aliprice_usename: alipriceUsename,
|
||||
aliprice_pwd: alipricePwd,
|
||||
...proxyData,
|
||||
},
|
||||
}
|
||||
queuePayloadText.value = JSON.stringify(payloadForDisplay(payload), null, 2)
|
||||
@@ -491,6 +562,19 @@ async function pushToPythonQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlipriceConfig() {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.read_config) return
|
||||
try {
|
||||
const config = await api.read_config()
|
||||
if (disposed) return
|
||||
alipriceUsername.value = typeof config?.aliprice_usename === 'string' ? config.aliprice_usename : ''
|
||||
alipricePassword.value = typeof config?.aliprice_pwd === 'string' ? config.aliprice_pwd : ''
|
||||
} catch {
|
||||
ElMessage.warning('Aliprice 账号配置读取失败')
|
||||
}
|
||||
}
|
||||
|
||||
function clearParsedTask() {
|
||||
parseResult.value = null
|
||||
queuePayloadText.value = ''
|
||||
@@ -574,9 +658,14 @@ function scheduleNextPoll(immediate = false) {
|
||||
pollTimer.value = null
|
||||
if (disposed) return
|
||||
if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) return
|
||||
await refreshTaskProgress()
|
||||
if (!disposed && pollTimer.value == null && (pollingTaskIds.value.length || pendingFileTaskIds.value.length)) {
|
||||
pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
|
||||
try {
|
||||
await refreshTaskProgress()
|
||||
} catch {
|
||||
// A transient request failure must not stop progress polling permanently.
|
||||
} finally {
|
||||
if (!disposed && pollTimer.value == null && (pollingTaskIds.value.length || pendingFileTaskIds.value.length)) {
|
||||
pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (immediate) void run()
|
||||
@@ -959,9 +1048,16 @@ async function deleteTaskRecord(item: SimilarAsinHistoryItem) {
|
||||
|
||||
onMounted(async () => {
|
||||
loadPollingIds()
|
||||
if (typeof window !== 'undefined') {
|
||||
pywebviewReadyHandler = () => {
|
||||
void loadAlipriceConfig()
|
||||
}
|
||||
window.addEventListener('pywebviewready', pywebviewReadyHandler)
|
||||
}
|
||||
await Promise.all([
|
||||
loadDashboard().catch(() => undefined),
|
||||
loadHistory().catch(() => undefined),
|
||||
loadAlipriceConfig(),
|
||||
])
|
||||
seedPendingFileTasksFromHistory()
|
||||
seedRunningTasksFromHistory()
|
||||
@@ -970,6 +1066,10 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
if (typeof window !== 'undefined' && pywebviewReadyHandler) {
|
||||
window.removeEventListener('pywebviewready', pywebviewReadyHandler)
|
||||
pywebviewReadyHandler = null
|
||||
}
|
||||
stopPolling()
|
||||
timers.clearScope()
|
||||
})
|
||||
@@ -1026,6 +1126,44 @@ onUnmounted(() => {
|
||||
background: linear-gradient(135deg, #222a25, #202020);
|
||||
}
|
||||
|
||||
.aliprice-card {
|
||||
margin-bottom: 18px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #343434;
|
||||
border-radius: 8px;
|
||||
background: #232323;
|
||||
}
|
||||
|
||||
.aliprice-field {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #bbb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.aliprice-field + .aliprice-field {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.aliprice-field input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #3b3b3b;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
background: #1b1b1b;
|
||||
color: #ddd;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.aliprice-field input:focus {
|
||||
border-color: #4f91c7;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user