完善任务存储、权限及货源查询流程
Build Backend JAR / build (push) Has been cancelled

This commit is contained in:
supernijia
2026-08-14 22:49:15 +08:00
parent 5b1ccad40e
commit 7a7f1dfa21
21 changed files with 2584 additions and 566 deletions
@@ -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;
@@ -6,6 +6,23 @@ export interface UploadedJavaFile {
relativePath?: string;
}
export type ProxyMode = 1 | 2;
export interface DesktopConfig {
proxy_url?: string;
proxy_mode?: number | string;
aliprice_usename?: string;
aliprice_pwd?: string;
[key: string]: unknown;
}
export interface DesktopConfigUpdate {
proxy_url?: string;
proxy_mode?: ProxyMode;
aliprice_usename?: string;
aliprice_pwd?: string;
}
export interface PywebviewApi {
close?: () => Promise<void>;
minimize?: () => Promise<void>;
@@ -69,6 +86,8 @@ export interface PywebviewApi {
enqueue_json?: (
data: unknown,
) => Promise<{ success: boolean; queue_size?: number; error?: string }>;
read_config?: () => Promise<DesktopConfig>;
save_config?: (data: DesktopConfigUpdate) => Promise<DesktopConfig>;
}
declare global {