更新外观模块
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>相似ASIN检测</title>
|
||||
<title>货源查询</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div class="secret-settings-entry">
|
||||
<button type="button" class="secret-settings-trigger" @click="dialogVisible = true">
|
||||
<span class="secret-settings-icon" aria-hidden="true">◎</span>
|
||||
<span>密钥设置</span>
|
||||
</button>
|
||||
|
||||
<el-dialog v-model="dialogVisible" width="560px" class="secret-settings-dialog" :append-to-body="true">
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div class="dialog-title">设置</div>
|
||||
<div class="dialog-subtitle">按当前登录用户保存在本机,可分别管理外观专利和货源查询密钥。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="secret-settings-body">
|
||||
<section v-for="item in moduleStates" :key="item.moduleKey" class="secret-card">
|
||||
<div class="secret-card-head">
|
||||
<div>
|
||||
<div class="secret-card-title">{{ item.title }}</div>
|
||||
<div class="secret-card-desc">{{ item.description }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="item.exists"
|
||||
type="button"
|
||||
class="link-danger"
|
||||
@click="clearSecret(item.moduleKey)"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="item.value"
|
||||
class="secret-input"
|
||||
type="password"
|
||||
:placeholder="`请输入${item.title}密钥`"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
|
||||
<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="item.retention" type="radio" :value="option.value" />
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-meta">
|
||||
<span v-if="item.exists">{{ formatRetentionText(item.retention, item.expiresAt) }}</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>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
type ModuleState = {
|
||||
moduleKey: ApiSecretModuleKey
|
||||
title: string
|
||||
description: string
|
||||
value: string
|
||||
retention: ApiSecretRetention
|
||||
expiresAt: number | null
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
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 moduleStates = ref<ModuleState[]>([])
|
||||
|
||||
function buildModuleState(
|
||||
moduleKey: ApiSecretModuleKey,
|
||||
title: string,
|
||||
description: string,
|
||||
): ModuleState {
|
||||
const snapshot = getStoredApiSecretSnapshot(moduleKey)
|
||||
return {
|
||||
moduleKey,
|
||||
title,
|
||||
description,
|
||||
value: snapshot.value,
|
||||
retention: snapshot.retention,
|
||||
expiresAt: snapshot.expiresAt,
|
||||
exists: snapshot.exists,
|
||||
}
|
||||
}
|
||||
|
||||
function loadStates() {
|
||||
moduleStates.value = [
|
||||
buildModuleState('appearance-patent', '外观专利', '用于外观专利检测的 Coze 接口密钥。'),
|
||||
buildModuleState('similar-asin', '货源查询', '用于货源查询的 Coze 接口密钥。'),
|
||||
]
|
||||
}
|
||||
|
||||
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 saveAll() {
|
||||
for (const item of moduleStates.value) {
|
||||
saveStoredApiSecret(item.moduleKey, item.value, item.retention)
|
||||
}
|
||||
loadStates()
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('密钥设置已保存')
|
||||
}
|
||||
|
||||
watch(dialogVisible, (visible) => {
|
||||
if (visible) {
|
||||
loadStates()
|
||||
}
|
||||
})
|
||||
|
||||
loadStates()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.secret-settings-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.secret-settings-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #3c4a58;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, #2b3239 0%, #242a31 100%);
|
||||
color: #e7edf4;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secret-settings-trigger:hover {
|
||||
border-color: #4c647d;
|
||||
}
|
||||
|
||||
.secret-settings-icon {
|
||||
color: #8dc4ff;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: #f5f7fa;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-subtitle {
|
||||
color: #8f9aa7;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.secret-settings-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.secret-card {
|
||||
padding: 16px;
|
||||
border: 1px solid #2f363f;
|
||||
border-radius: 12px;
|
||||
background: #22272d;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footer-btn-ghost {
|
||||
border-color: #3a4653;
|
||||
background: #232a31;
|
||||
color: #d9e2eb;
|
||||
}
|
||||
|
||||
.footer-btn-primary {
|
||||
background: #5aa2ea;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -24,11 +24,6 @@
|
||||
<div class="prompt-preview">{{ defaultAiPrompt }}</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-card">
|
||||
<div class="section-title">密钥</div>
|
||||
<input v-model="cozeApiKey" class="secret-input" type="password" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
|
||||
<div class="run-row">
|
||||
<button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles">
|
||||
{{ parsing ? '解析中...' : '解析并创建任务' }}
|
||||
@@ -170,6 +165,7 @@ import {
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import { getPywebviewApi } 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'
|
||||
|
||||
const selectedFileNames = ref<string[]>([])
|
||||
@@ -185,7 +181,6 @@ const defaultAiPrompt = `请帮我排查以下亚马逊商品在英国及欧洲
|
||||
标题维度: 排查标题中是否包含大牌商标、敏感词或版权保护词汇。
|
||||
结论:XX`
|
||||
const aiPrompt = ref('')
|
||||
const cozeApiKey = ref('')
|
||||
const parsing = ref(false)
|
||||
const pushing = ref(false)
|
||||
const queuePayloadText = ref('')
|
||||
@@ -225,7 +220,7 @@ function effectiveAiPrompt() {
|
||||
}
|
||||
|
||||
function effectiveCozeApiKey() {
|
||||
return cozeApiKey.value.trim()
|
||||
return getStoredApiSecret('appearance-patent').trim()
|
||||
}
|
||||
|
||||
function maskSecret(secret: string) {
|
||||
@@ -352,7 +347,7 @@ async function parseFiles() {
|
||||
return
|
||||
}
|
||||
if (!effectiveCozeApiKey()) {
|
||||
ElMessage.warning('请先填写密钥')
|
||||
ElMessage.warning('请先在左上角设置中填写外观专利密钥')
|
||||
return
|
||||
}
|
||||
parsing.value = true
|
||||
@@ -399,6 +394,10 @@ async function pushToPythonQueue() {
|
||||
ElMessage.error('当前环境未启用 pywebview enqueue_json')
|
||||
return
|
||||
}
|
||||
if (!effectiveCozeApiKey()) {
|
||||
ElMessage.warning('请先在左上角设置中填写外观专利密钥')
|
||||
return
|
||||
}
|
||||
pushing.value = true
|
||||
try {
|
||||
const payload = {
|
||||
@@ -773,6 +772,7 @@ onUnmounted(() => {
|
||||
.selected-files { margin-top: 14px; color: #999; font-size: 12px; word-break: break-all; }
|
||||
.selected-files span { display: block; margin: 4px 0; }
|
||||
.prompt-card, .secret-card { margin-bottom: 18px; }
|
||||
.secret-card { display: none; }
|
||||
.prompt-input { width: 100%; box-sizing: border-box; resize: vertical; min-height: 180px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; line-height: 1.6; outline: none; }
|
||||
.secret-input { width: 100%; box-sizing: border-box; height: 38px; padding: 0 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; outline: none; }
|
||||
.prompt-input:focus, .secret-input:focus { border-color: #3498db; }
|
||||
|
||||
@@ -18,15 +18,30 @@
|
||||
</div>
|
||||
|
||||
<div class="prompt-card">
|
||||
<div class="section-title">AI 提示词</div>
|
||||
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,留空时使用下方默认提示词" />
|
||||
<div class="prompt-default-label">留空时默认使用以下提示词</div>
|
||||
<div class="prompt-preview">{{ defaultAiPrompt }}</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-card">
|
||||
<div class="section-title">密钥</div>
|
||||
<input v-model="cozeApiKey" class="secret-input" type="password" autocomplete="off" spellcheck="false" />
|
||||
<div class="section-title">筛选条件</div>
|
||||
<textarea v-model="filterConditionInput" class="prompt-input" rows="4" placeholder="请输入筛选条件" />
|
||||
<div class="condition-actions">
|
||||
<button type="button" class="opt-btn" :disabled="savingCondition" @click="saveFilterCondition">
|
||||
{{ savingCondition ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="section-title condition-list-title">备选区</div>
|
||||
<div v-if="!filterConditions.length" class="empty-conditions">暂无筛选条件,输入后保存即可加入备选区</div>
|
||||
<ul v-else class="condition-list">
|
||||
<li v-for="condition in filterConditions" :key="condition.id" class="condition-item">
|
||||
<label class="condition-check">
|
||||
<input
|
||||
v-model="selectedFilterConditionId"
|
||||
type="radio"
|
||||
name="similar-asin-filter-condition"
|
||||
:value="condition.id"
|
||||
@change="applySelectedFilterCondition(condition)"
|
||||
/>
|
||||
<span :title="condition.conditionText">{{ condition.conditionText }}</span>
|
||||
</label>
|
||||
<button type="button" class="link-danger" @click="removeFilterCondition(condition.id)">删除</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="run-row">
|
||||
@@ -47,7 +62,7 @@
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">相似ASIN检测</div>
|
||||
<div class="panel-header">货源查询</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
@@ -93,7 +108,7 @@
|
||||
<ul v-else class="task-list clean-result-list">
|
||||
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
|
||||
<div class="left">
|
||||
<span class="id">{{ item.sourceFilename || '相似ASIN检测' }}</span>
|
||||
<span class="id">{{ item.sourceFilename || '货源查询' }}</span>
|
||||
<div class="files">任务 ID:{{ item.taskId }}</div>
|
||||
<div class="files">行数:{{ item.rowCount ?? '-' }}</div>
|
||||
</div>
|
||||
@@ -113,7 +128,7 @@
|
||||
<ul v-else class="task-list clean-result-list">
|
||||
<li v-for="item in historyOnlyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
|
||||
<div class="left">
|
||||
<span class="id">{{ item.sourceFilename || '相似ASIN检测' }}</span>
|
||||
<span class="id">{{ item.sourceFilename || '货源查询' }}</span>
|
||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||||
<div v-if="item.resultFilename" class="files">
|
||||
{{ item.resultFilename || '下载结果' }}
|
||||
@@ -153,13 +168,17 @@ import { ElMessage } from 'element-plus'
|
||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||
import {
|
||||
activateSimilarAsinTask,
|
||||
addSimilarAsinFilterCondition,
|
||||
deleteSimilarAsinFilterCondition,
|
||||
deleteSimilarAsinHistory,
|
||||
deleteSimilarAsinTask,
|
||||
getSimilarAsinDashboard,
|
||||
getSimilarAsinHistory,
|
||||
getSimilarAsinResultDownloadUrl,
|
||||
getSimilarAsinTaskProgressBatch,
|
||||
listSimilarAsinFilterConditions,
|
||||
parseSimilarAsin,
|
||||
type SimilarAsinFilterConditionVo,
|
||||
type SimilarAsinParsedGroup,
|
||||
type SimilarAsinDashboardVo,
|
||||
type SimilarAsinHistoryItem,
|
||||
@@ -171,6 +190,7 @@ import {
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import { getPywebviewApi } 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'
|
||||
|
||||
const selectedFileNames = ref<string[]>([])
|
||||
@@ -179,14 +199,12 @@ const parseResult = ref<SimilarAsinParseVo | null>(null)
|
||||
const parsedGroups = ref<SimilarAsinParsedGroup[]>([])
|
||||
type TaskSummary = Pick<SimilarAsinParseVo, 'taskId' | 'totalRows' | 'acceptedRows' | 'groupCount' | 'droppedRows'>
|
||||
const queuedTaskSummary = ref<TaskSummary | null>(null)
|
||||
const defaultAiPrompt = `1. 图案与形象限制:不含各国国旗及类似图案,无各国领导人画像;不含圣诞老人形象;排除迪士尼、漫威、三丽鸥等所有指定动漫IP的图案与关联物品,且不涉及知名油画作品图案。
|
||||
2. 品类排除:非图书、刀具、服装;不含药品、带容器的粉末及液体;与吸烟行为无关,无医疗标志。
|
||||
3. 功能与配件要求:不可充电且不带电池,无磁吸功能;配件多的情况下需完全匹配。
|
||||
4. 规格与外观限制:不含体积大的家具类物品(如椅子、桌子、大镜子、柜子);不带任何品牌logo;无多种款式可选。`
|
||||
const aiPrompt = ref('')
|
||||
const cozeApiKey = ref('')
|
||||
const filterConditionInput = ref('')
|
||||
const filterConditions = ref<SimilarAsinFilterConditionVo[]>([])
|
||||
const selectedFilterConditionId = ref<number | null>(null)
|
||||
const parsing = ref(false)
|
||||
const pushing = ref(false)
|
||||
const savingCondition = ref(false)
|
||||
const queuePayloadText = ref('')
|
||||
const pollingTaskIds = ref<number[]>([])
|
||||
const pendingFileTaskIds = ref<number[]>([])
|
||||
@@ -206,11 +224,7 @@ const dashboard = ref<SimilarAsinDashboardVo>({
|
||||
})
|
||||
const historyItems = ref<SimilarAsinHistoryItem[]>([])
|
||||
|
||||
const parsedRows = computed<SimilarAsinParsedRow[]>(() =>
|
||||
parseResult.value?.items?.length
|
||||
? parseResult.value.items
|
||||
: parsedGroups.value.flatMap((group) => group.items || []),
|
||||
)
|
||||
const parsedRows = computed<SimilarAsinParsedRow[]>(() => parseResult.value?.items || [])
|
||||
const previewRows = computed(() => parsedRows.value)
|
||||
const visibleTaskSummary = computed(() => parseResult.value || queuedTaskSummary.value)
|
||||
const currentItems = computed(() => historyItems.value.filter((i) => i.taskId != null && pollingTaskIds.value.includes(i.taskId)))
|
||||
@@ -221,12 +235,18 @@ const historyOnlyItems = computed(() =>
|
||||
}),
|
||||
)
|
||||
|
||||
function effectiveAiPrompt() {
|
||||
return aiPrompt.value.trim() || defaultAiPrompt
|
||||
function selectedFilterConditionText() {
|
||||
const selectedId = selectedFilterConditionId.value
|
||||
if (selectedId == null) return ''
|
||||
return filterConditions.value.find((item) => item.id === selectedId)?.conditionText?.trim() || ''
|
||||
}
|
||||
|
||||
function effectiveFilterCondition() {
|
||||
return filterConditionInput.value.trim() || selectedFilterConditionText()
|
||||
}
|
||||
|
||||
function effectiveCozeApiKey() {
|
||||
return cozeApiKey.value.trim()
|
||||
return getStoredApiSecret('similar-asin').trim()
|
||||
}
|
||||
|
||||
function maskSecret(secret: string) {
|
||||
@@ -347,13 +367,64 @@ async function selectFolder() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFilterConditions() {
|
||||
try {
|
||||
filterConditions.value = await listSimilarAsinFilterConditions()
|
||||
const ids = new Set(filterConditions.value.map((item) => item.id))
|
||||
if (selectedFilterConditionId.value != null && !ids.has(selectedFilterConditionId.value)) {
|
||||
selectedFilterConditionId.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '加载筛选条件失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFilterCondition() {
|
||||
const text = filterConditionInput.value.trim()
|
||||
if (!text) {
|
||||
ElMessage.warning('请输入筛选条件')
|
||||
return
|
||||
}
|
||||
savingCondition.value = true
|
||||
try {
|
||||
const saved = await addSimilarAsinFilterCondition(text)
|
||||
await loadFilterConditions()
|
||||
if (saved?.id) selectedFilterConditionId.value = saved.id
|
||||
ElMessage.success('筛选条件已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '保存筛选条件失败')
|
||||
} finally {
|
||||
savingCondition.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applySelectedFilterCondition(condition: SimilarAsinFilterConditionVo) {
|
||||
filterConditionInput.value = condition.conditionText || ''
|
||||
}
|
||||
|
||||
async function removeFilterCondition(id: number) {
|
||||
try {
|
||||
await deleteSimilarAsinFilterCondition(id)
|
||||
if (selectedFilterConditionId.value === id) selectedFilterConditionId.value = null
|
||||
await loadFilterConditions()
|
||||
ElMessage.success('筛选条件已删除')
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '删除筛选条件失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function parseFiles() {
|
||||
if (!uploadedFiles.value.length) {
|
||||
ElMessage.warning('请先选择 Excel')
|
||||
return
|
||||
}
|
||||
const filterCondition = effectiveFilterCondition()
|
||||
if (!filterCondition) {
|
||||
ElMessage.warning('请输入筛选条件,或在备选区选择一个筛选条件')
|
||||
return
|
||||
}
|
||||
if (!effectiveCozeApiKey()) {
|
||||
ElMessage.warning('请先填写密钥')
|
||||
ElMessage.warning('请先在左上角设置中填写货源查询密钥')
|
||||
return
|
||||
}
|
||||
parsing.value = true
|
||||
@@ -363,20 +434,10 @@ async function parseFiles() {
|
||||
originalFilename: f.originalFilename,
|
||||
relativePath: f.relativePath,
|
||||
}))
|
||||
const res = await parseSimilarAsin(files, effectiveAiPrompt(), effectiveCozeApiKey())
|
||||
const res = await parseSimilarAsin(files, filterCondition, effectiveCozeApiKey())
|
||||
parseResult.value = res
|
||||
queuedTaskSummary.value = null
|
||||
parsedGroups.value = res.items?.length
|
||||
? [{
|
||||
sourceFileKey: res.items[0]?.sourceFileKey,
|
||||
sourceFilename: res.items[0]?.sourceFilename,
|
||||
groupKey: `similar-asin:${res.taskId}`,
|
||||
baseId: '',
|
||||
displayId: res.items[0]?.displayId,
|
||||
itemCount: res.items.length,
|
||||
items: res.items,
|
||||
}]
|
||||
: []
|
||||
parsedGroups.value = []
|
||||
queuePayloadText.value = ''
|
||||
ElMessage.success(`解析完成,共 ${res.acceptedRows} 条`)
|
||||
} catch (e) {
|
||||
@@ -390,7 +451,7 @@ async function pushToPythonQueue() {
|
||||
const api = getPywebviewApi()
|
||||
const currentParseResult = parseResult.value
|
||||
const taskId = currentParseResult?.taskId
|
||||
if (!taskId || !parsedRows.value.length) {
|
||||
if (!taskId) {
|
||||
ElMessage.warning('请先解析文件')
|
||||
return
|
||||
}
|
||||
@@ -399,7 +460,7 @@ async function pushToPythonQueue() {
|
||||
return
|
||||
}
|
||||
if (!effectiveCozeApiKey()) {
|
||||
ElMessage.warning('请先填写密钥')
|
||||
ElMessage.warning('请先在左上角设置中填写货源查询密钥')
|
||||
return
|
||||
}
|
||||
pushing.value = true
|
||||
@@ -409,41 +470,12 @@ async function pushToPythonQueue() {
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
taskId,
|
||||
prompt: currentParseResult.aiPrompt || effectiveAiPrompt(),
|
||||
prompt: currentParseResult.aiPrompt || effectiveFilterCondition(),
|
||||
api_key: effectiveCozeApiKey(),
|
||||
groups: parsedGroups.value.map((group) => ({
|
||||
sourceFileKey: group.sourceFileKey || '',
|
||||
sourceFilename: group.sourceFilename || '',
|
||||
groupKey: group.groupKey || '',
|
||||
baseId: group.baseId || '',
|
||||
displayId: group.displayId || '',
|
||||
items: (group.items || []).map((row) => ({
|
||||
sourceFileKey: row.sourceFileKey || '',
|
||||
sourceFilename: row.sourceFilename || '',
|
||||
rowToken: row.rowToken || '',
|
||||
groupKey: row.groupKey || '',
|
||||
id: row.displayId,
|
||||
asin: row.asin,
|
||||
country: row.country,
|
||||
price: row.price || '',
|
||||
url: row.url || '',
|
||||
title: row.title || '',
|
||||
target_urls: [],
|
||||
})),
|
||||
})),
|
||||
rows: parsedRows.value.map((row) => ({
|
||||
sourceFileKey: row.sourceFileKey || '',
|
||||
sourceFilename: row.sourceFilename || '',
|
||||
rowToken: row.rowToken || '',
|
||||
groupKey: row.groupKey || '',
|
||||
id: row.displayId,
|
||||
asin: row.asin,
|
||||
country: row.country,
|
||||
price: row.price || '',
|
||||
url: row.url || '',
|
||||
title: row.title || '',
|
||||
target_urls: [],
|
||||
})),
|
||||
sourceFileCount: currentParseResult.sourceFileCount || 0,
|
||||
totalRows: currentParseResult.totalRows || 0,
|
||||
acceptedRows: currentParseResult.acceptedRows || 0,
|
||||
groupCount: currentParseResult.groupCount || 0,
|
||||
},
|
||||
}
|
||||
queuePayloadText.value = JSON.stringify(payloadForDisplay(payload), null, 2)
|
||||
@@ -747,6 +779,7 @@ async function deleteTaskRecord(item: SimilarAsinHistoryItem) {
|
||||
onMounted(async () => {
|
||||
loadPollingIds()
|
||||
await Promise.all([
|
||||
loadFilterConditions().catch(() => undefined),
|
||||
loadDashboard().catch(() => undefined),
|
||||
loadHistory().catch(() => undefined),
|
||||
])
|
||||
@@ -779,12 +812,20 @@ onUnmounted(() => {
|
||||
.btn-run:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.selected-files { margin-top: 14px; color: #999; font-size: 12px; word-break: break-all; }
|
||||
.selected-files span { display: block; margin: 4px 0; }
|
||||
.prompt-card, .secret-card { margin-bottom: 18px; }
|
||||
.prompt-input { width: 100%; box-sizing: border-box; resize: vertical; min-height: 180px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; line-height: 1.6; outline: none; }
|
||||
.secret-input { width: 100%; box-sizing: border-box; height: 38px; padding: 0 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; outline: none; }
|
||||
.prompt-input:focus, .secret-input:focus { border-color: #3498db; }
|
||||
.prompt-default-label { margin-top: 10px; color: #8d8d8d; font-size: 12px; }
|
||||
.prompt-preview { margin-top: 10px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #9ea7b3; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
|
||||
.prompt-card { margin-bottom: 14px; }
|
||||
.prompt-input { width: 100%; box-sizing: border-box; resize: vertical; min-height: 96px; max-height: 140px; padding: 8px 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; line-height: 1.5; outline: none; }
|
||||
.prompt-input:focus { border-color: #3498db; }
|
||||
.condition-actions { display: flex; justify-content: flex-end; margin-top: 8px; }
|
||||
.condition-actions .opt-btn { padding: 6px 14px; }
|
||||
.condition-list-title { margin-top: 10px; }
|
||||
.empty-conditions { color: #666; font-size: 12px; padding: 10px 4px; }
|
||||
.condition-list { list-style: none; margin: 0; padding: 0; max-height: 96px; overflow: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||
.condition-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px; border-bottom: 1px solid #303030; }
|
||||
.condition-item:last-child { border-bottom: none; }
|
||||
.condition-check { display: flex; align-items: center; gap: 8px; min-width: 0; color: #cfd6df; font-size: 12px; cursor: pointer; }
|
||||
.condition-check input { width: 16px; height: 16px; margin: 0; flex: 0 0 auto; }
|
||||
.condition-check span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.link-danger { border: none; background: transparent; color: #ff8f8f; cursor: pointer; font-size: 12px; padding: 2px 0; white-space: nowrap; }
|
||||
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #b8c1cc; font-size: 12px; }
|
||||
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
|
||||
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
|
||||
|
||||
@@ -34,13 +34,16 @@
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<div class="top-right"></div>
|
||||
<div class="top-right">
|
||||
<BrandApiSecretSettingsButton />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import BrandApiSecretSettingsButton from '@/pages/brand/components/BrandApiSecretSettingsButton.vue'
|
||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||
|
||||
type ActiveNavKey =
|
||||
@@ -85,7 +88,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
||||
{ key: 'variant', label: '变体分析' },
|
||||
{ key: 'brand', label: '品牌检测', href: '/brand' },
|
||||
{ key: 'appearance-patent', label: '外观专利检测', href: '/new_web_source/appearance-patent.html' },
|
||||
{ key: 'similar-asin', label: '相似ASIN检测', href: '/new_web_source/similar-asin.html' },
|
||||
{ key: 'similar-asin', label: '货源查询', href: '/new_web_source/similar-asin.html' },
|
||||
{ key: 'dedupe', label: '数据去重', href: '/new_web_source/dedupe.html' },
|
||||
{ key: 'split', label: '数据拆分', href: '/new_web_source/split.html' },
|
||||
{ key: 'convert', label: '格式转换', href: '/new_web_source/convert.html' },
|
||||
@@ -248,8 +251,11 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.top-right {
|
||||
width: 60px;
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
|
||||
@@ -1604,7 +1604,13 @@ export function getAppearancePatentResultDownloadUrl(resultId: number) {
|
||||
return getJavaDownloadUrl(`/appearance-patent/results/${resultId}/download`);
|
||||
}
|
||||
|
||||
// ========== 相似ASIN检测 ==========
|
||||
// ========== 货源查询 ==========
|
||||
|
||||
export interface SimilarAsinFilterConditionVo {
|
||||
id: number;
|
||||
conditionText: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface SimilarAsinParsedRow {
|
||||
rowIndex: number;
|
||||
@@ -1696,7 +1702,7 @@ export interface SimilarAsinTaskBatchVo {
|
||||
missingTaskIds?: number[];
|
||||
}
|
||||
|
||||
export function parseSimilarAsin(files: UploadedFileRef[], aiPrompt: string, apiKey?: string) {
|
||||
export function parseSimilarAsin(files: UploadedFileRef[], filterCondition: string, apiKey?: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<
|
||||
JavaApiResponse<SimilarAsinParseVo>,
|
||||
@@ -1704,12 +1710,41 @@ export function parseSimilarAsin(files: UploadedFileRef[], aiPrompt: string, api
|
||||
>(`${JAVA_API_PREFIX}/similar-asin/parse`, {
|
||||
user_id: getCurrentUserId(),
|
||||
files,
|
||||
ai_prompt: aiPrompt,
|
||||
ai_prompt: filterCondition,
|
||||
api_key: apiKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function listSimilarAsinFilterConditions() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<SimilarAsinFilterConditionVo[]>>(
|
||||
`${JAVA_API_PREFIX}/similar-asin/filter-conditions`,
|
||||
{ params: { user_id: getCurrentUserId() } },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function addSimilarAsinFilterCondition(conditionText: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<
|
||||
JavaApiResponse<SimilarAsinFilterConditionVo>,
|
||||
{ user_id: number; condition_text: string }
|
||||
>(`${JAVA_API_PREFIX}/similar-asin/filter-conditions`, {
|
||||
user_id: getCurrentUserId(),
|
||||
condition_text: conditionText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteSimilarAsinFilterCondition(id: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/similar-asin/filter-conditions/${id}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getSimilarAsinDashboard() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<SimilarAsinDashboardVo>>(
|
||||
|
||||
156
frontend-vue/src/shared/utils/api-secret-store.ts
Normal file
156
frontend-vue/src/shared/utils/api-secret-store.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
export type ApiSecretModuleKey = 'appearance-patent' | 'similar-asin'
|
||||
|
||||
export type ApiSecretRetention = 'session' | '1d' | '7d' | '30d' | 'forever'
|
||||
|
||||
type ApiSecretRecord = {
|
||||
value: string
|
||||
retention: ApiSecretRetention
|
||||
expiresAt: number | null
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type ApiSecretSnapshot = {
|
||||
value: string
|
||||
retention: ApiSecretRetention
|
||||
expiresAt: number | null
|
||||
updatedAt: number | null
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'brand:api-secret'
|
||||
|
||||
function currentUserStorageId() {
|
||||
if (typeof window === 'undefined') return '0'
|
||||
return window.localStorage.getItem('uid') || '0'
|
||||
}
|
||||
|
||||
function buildStorageKey(moduleKey: ApiSecretModuleKey) {
|
||||
return `${STORAGE_PREFIX}:${currentUserStorageId()}:${moduleKey}`
|
||||
}
|
||||
|
||||
function readStorageRecord(storage: Storage, moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
|
||||
const raw = storage.getItem(buildStorageKey(moduleKey))
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ApiSecretRecord>
|
||||
if (typeof parsed.value !== 'string') return null
|
||||
const retention = normalizeRetention(parsed.retention)
|
||||
const expiresAt = typeof parsed.expiresAt === 'number' ? parsed.expiresAt : null
|
||||
const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now()
|
||||
return {
|
||||
value: parsed.value,
|
||||
retention,
|
||||
expiresAt,
|
||||
updatedAt,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRetention(value: unknown): ApiSecretRetention {
|
||||
switch (value) {
|
||||
case '1d':
|
||||
case '7d':
|
||||
case '30d':
|
||||
case 'forever':
|
||||
case 'session':
|
||||
return value
|
||||
default:
|
||||
return 'session'
|
||||
}
|
||||
}
|
||||
|
||||
function retentionToExpiresAt(retention: ApiSecretRetention, now: number) {
|
||||
switch (retention) {
|
||||
case '1d':
|
||||
return now + 24 * 60 * 60 * 1000
|
||||
case '7d':
|
||||
return now + 7 * 24 * 60 * 60 * 1000
|
||||
case '30d':
|
||||
return now + 30 * 24 * 60 * 60 * 1000
|
||||
case 'forever':
|
||||
return null
|
||||
case 'session':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isExpired(record: ApiSecretRecord) {
|
||||
return record.expiresAt != null && record.expiresAt <= Date.now()
|
||||
}
|
||||
|
||||
function clearStorageRecord(storage: Storage, moduleKey: ApiSecretModuleKey) {
|
||||
storage.removeItem(buildStorageKey(moduleKey))
|
||||
}
|
||||
|
||||
function getLiveRecord(moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const sessionRecord = readStorageRecord(window.sessionStorage, moduleKey)
|
||||
if (sessionRecord) {
|
||||
return sessionRecord
|
||||
}
|
||||
|
||||
const localRecord = readStorageRecord(window.localStorage, moduleKey)
|
||||
if (!localRecord) return null
|
||||
if (isExpired(localRecord)) {
|
||||
clearStorageRecord(window.localStorage, moduleKey)
|
||||
return null
|
||||
}
|
||||
return localRecord
|
||||
}
|
||||
|
||||
export function getStoredApiSecret(moduleKey: ApiSecretModuleKey) {
|
||||
return getLiveRecord(moduleKey)?.value || ''
|
||||
}
|
||||
|
||||
export function getStoredApiSecretSnapshot(moduleKey: ApiSecretModuleKey): ApiSecretSnapshot {
|
||||
const record = getLiveRecord(moduleKey)
|
||||
if (!record) {
|
||||
return {
|
||||
value: '',
|
||||
retention: 'session',
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
exists: false,
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: record.value,
|
||||
retention: record.retention,
|
||||
expiresAt: record.expiresAt,
|
||||
updatedAt: record.updatedAt,
|
||||
exists: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoredApiSecret(
|
||||
moduleKey: ApiSecretModuleKey,
|
||||
value: string,
|
||||
retention: ApiSecretRetention,
|
||||
) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
clearStoredApiSecret(moduleKey)
|
||||
if (!trimmedValue) return
|
||||
|
||||
const now = Date.now()
|
||||
const record: ApiSecretRecord = {
|
||||
value: trimmedValue,
|
||||
retention,
|
||||
expiresAt: retentionToExpiresAt(retention, now),
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
const storage = retention === 'session' ? window.sessionStorage : window.localStorage
|
||||
storage.setItem(buildStorageKey(moduleKey), JSON.stringify(record))
|
||||
}
|
||||
|
||||
export function clearStoredApiSecret(moduleKey: ApiSecretModuleKey) {
|
||||
if (typeof window === 'undefined') return
|
||||
clearStorageRecord(window.sessionStorage, moduleKey)
|
||||
clearStorageRecord(window.localStorage, moduleKey)
|
||||
}
|
||||
Reference in New Issue
Block a user