修改完善这个专利部分

This commit is contained in:
super
2026-05-15 16:06:12 +08:00
parent 18b0c2d211
commit 1dc2cac19a
72 changed files with 2930 additions and 2643 deletions

View File

@@ -8,33 +8,33 @@
<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 class="dialog-title">密钥设置</div>
<div class="dialog-subtitle">按当前登录用户保存在本机外观专利和货源查询共用同一个 Coze 接口密钥</div>
</div>
</template>
<div class="secret-settings-body">
<section v-for="item in moduleStates" :key="item.moduleKey" class="secret-card">
<section 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 class="secret-card-title">通用 Coze 密钥</div>
<div class="secret-card-desc">用于外观专利检测和货源查询</div>
</div>
<button
v-if="item.exists"
v-if="secretState.exists"
type="button"
class="link-danger"
@click="clearSecret(item.moduleKey)"
@click="clearSecret"
>
清空
</button>
</div>
<input
v-model="item.value"
v-model="secretState.value"
class="secret-input"
type="password"
:placeholder="`请输入${item.title}密钥`"
placeholder="请输入 Coze 接口密钥"
autocomplete="off"
spellcheck="false"
/>
@@ -43,14 +43,14 @@
<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" />
<input v-model="secretState.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-if="secretState.exists">{{ formatRetentionText(secretState.retention, secretState.expiresAt) }}</span>
<span v-else>当前未保存</span>
</div>
</section>
@@ -73,14 +73,10 @@ import {
clearStoredApiSecret,
getStoredApiSecretSnapshot,
saveStoredApiSecret,
type ApiSecretModuleKey,
type ApiSecretRetention,
} from '@/shared/utils/api-secret-store'
type ModuleState = {
moduleKey: ApiSecretModuleKey
title: string
description: string
type SecretState = {
value: string
retention: ApiSecretRetention
expiresAt: number | null
@@ -90,25 +86,18 @@ type ModuleState = {
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: 'session', label: '本次打开有效' },
{ value: '1d', label: '1 天' },
{ value: '7d', label: '7 天' },
{ value: '30d', label: '30 天' },
{ value: 'forever', label: '长期保留' },
]
const moduleStates = ref<ModuleState[]>([])
const secretState = ref<SecretState>(emptySecretState())
function buildModuleState(
moduleKey: ApiSecretModuleKey,
title: string,
description: string,
): ModuleState {
const snapshot = getStoredApiSecretSnapshot(moduleKey)
return {
moduleKey,
title,
description,
function loadStates() {
const snapshot = getStoredApiSecretSnapshot()
secretState.value = {
value: snapshot.value,
retention: snapshot.retention,
expiresAt: snapshot.expiresAt,
@@ -116,11 +105,13 @@ function buildModuleState(
}
}
function loadStates() {
moduleStates.value = [
buildModuleState('appearance-patent', '外观专利', '用于外观专利检测的 Coze 接口密钥。'),
buildModuleState('similar-asin', '货源查询', '用于货源查询的 Coze 接口密钥。'),
]
function emptySecretState(): SecretState {
return {
value: '',
retention: 'session',
expiresAt: null,
exists: false,
}
}
function formatRetentionText(retention: ApiSecretRetention, expiresAt: number | null) {
@@ -136,16 +127,14 @@ function formatRetentionText(retention: ApiSecretRetention, expiresAt: number |
return `有效期至 ${year}-${month}-${day} ${hour}:${minute}`
}
function clearSecret(moduleKey: ApiSecretModuleKey) {
clearStoredApiSecret(moduleKey)
function clearSecret() {
clearStoredApiSecret()
loadStates()
ElMessage.success('已清空密钥')
}
function saveAll() {
for (const item of moduleStates.value) {
saveStoredApiSecret(item.moduleKey, item.value, item.retention)
}
saveStoredApiSecret(secretState.value.value, secretState.value.retention)
loadStates()
dialogVisible.value = false
ElMessage.success('密钥设置已保存')
@@ -192,6 +181,45 @@ loadStates()
line-height: 1;
}
:global(.secret-settings-dialog) {
--el-dialog-bg-color: #171b20;
--el-dialog-padding-primary: 20px;
overflow: hidden;
border: 1px solid #2c3540;
border-radius: 14px;
background: #171b20;
box-shadow: 0 24px 70px rgba(0, 0, 0, .52);
}
:global(.secret-settings-dialog .el-dialog__header) {
padding: 22px 22px 12px;
margin: 0;
background: #171b20;
}
:global(.secret-settings-dialog .el-dialog__body) {
padding: 12px 22px 16px;
background: #171b20;
}
:global(.secret-settings-dialog .el-dialog__footer) {
padding: 0 22px 22px;
background: #171b20;
}
:global(.secret-settings-dialog .el-dialog__headerbtn) {
top: 18px;
right: 18px;
}
:global(.secret-settings-dialog .el-dialog__close) {
color: #8793a0;
}
:global(.secret-settings-dialog .el-dialog__close:hover) {
color: #d8e3ee;
}
.dialog-header {
display: flex;
flex-direction: column;
@@ -217,10 +245,10 @@ loadStates()
}
.secret-card {
padding: 16px;
border: 1px solid #2f363f;
border-radius: 12px;
background: #22272d;
padding: 18px;
border: 1px solid #313b46;
border-radius: 10px;
background: #20252b;
}
.secret-card-head {
@@ -331,7 +359,11 @@ loadStates()
}
.footer-btn-primary {
background: #5aa2ea;
background: #4f8fda;
color: #fff;
}
.footer-btn-primary:hover {
background: #67a6ed;
}
</style>

View File

@@ -18,10 +18,8 @@
</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 class="section-title">新增条件要求</div>
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,填写后会追加到默认检测提示词" />
</div>
<div class="run-row">
@@ -251,11 +249,13 @@ function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) {
}
function effectiveAiPrompt() {
return aiPrompt.value.trim() || defaultAiPrompt
const extraPrompt = aiPrompt.value.trim()
if (!extraPrompt) return defaultAiPrompt
return `${defaultAiPrompt}\n\n新增条件要求\n${extraPrompt}`
}
function effectiveCozeApiKey() {
return getStoredApiSecret('appearance-patent').trim()
return getStoredApiSecret().trim()
}
function maskSecret(secret: string) {
@@ -937,8 +937,6 @@ onUnmounted(() => {
.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; }
.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; }

View File

@@ -44,29 +44,6 @@
</ul>
</div>
<div class="category-card">
<div class="category-card-head">
<div class="section-title">商品类目</div>
<div class="category-actions">
<button type="button" class="opt-btn" @click="openCategoryDialog">选择类目</button>
<button
v-if="selectedProductCategoryIds.length"
type="button"
class="link-danger"
@click="clearSelectedProductCategories"
>
清空
</button>
</div>
</div>
<div v-if="!selectedProductCategories.length" class="empty-conditions">暂未选择商品类目</div>
<div v-else class="selected-category-list">
<span v-for="category in selectedProductCategories" :key="category.id" class="category-chip">
{{ category.path || category.name }}
</span>
</div>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles">
{{ parsing ? '解析中...' : '解析并创建任务' }}
@@ -167,40 +144,6 @@
</section>
</div>
<el-dialog
v-model="categoryDialogVisible"
title="选择商品类目"
width="560px"
class="category-dialog"
>
<div class="category-dialog-toolbar">
<span class="muted">可多选确认后会追加到筛选条件 prompt 后面</span>
<button type="button" class="link-danger" @click="clearCategoryTreeSelection">清空选择</button>
</div>
<div v-if="loadingProductCategories" class="empty-tasks">正在加载商品类目...</div>
<div v-else-if="!productCategoryTree.length" class="empty-tasks">暂无商品类目</div>
<el-tree
v-else
ref="categoryTreeRef"
class="category-tree"
:data="productCategoryTree"
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'name', children: 'children' }"
>
<template #default="{ data }">
<span class="category-tree-node">
<span>{{ data.name }}</span>
<span v-if="data.description" class="category-description">{{ data.description }}</span>
</span>
</template>
</el-tree>
<template #footer>
<button type="button" class="opt-btn" @click="categoryDialogVisible = false">取消</button>
<button type="button" class="btn-run category-confirm" @click="confirmCategorySelection">确定</button>
</template>
</el-dialog>
</div>
</template>
@@ -218,10 +161,8 @@ import {
getSimilarAsinHistory,
getSimilarAsinResultDownloadUrl,
getSimilarAsinTaskProgressBatch,
listProductCategories,
listSimilarAsinFilterConditions,
parseSimilarAsin,
type ProductCategoryItemVo,
type SimilarAsinFilterConditionVo,
type SimilarAsinDashboardVo,
type SimilarAsinHistoryItem,
@@ -244,12 +185,6 @@ const queuedTaskSummary = ref<TaskSummary | null>(null)
const filterConditionInput = ref('')
const filterConditions = ref<SimilarAsinFilterConditionVo[]>([])
const selectedFilterConditionId = ref<number | null>(null)
const productCategoryTree = ref<ProductCategoryItemVo[]>([])
const productCategoryItems = ref<ProductCategoryItemVo[]>([])
const selectedProductCategoryIds = ref<number[]>([])
const categoryDialogVisible = ref(false)
const loadingProductCategories = ref(false)
const categoryTreeRef = ref<any>(null)
const parsing = ref(false)
const pushing = ref(false)
const savingCondition = ref(false)
@@ -310,78 +245,11 @@ function selectedFilterConditionText() {
}
function effectiveFilterCondition() {
return appendProductCategoriesToPrompt(filterConditionInput.value.trim() || selectedFilterConditionText())
return filterConditionInput.value.trim() || selectedFilterConditionText()
}
function effectiveCozeApiKey() {
return getStoredApiSecret('similar-asin').trim()
}
const selectedProductCategories = computed(() => {
const selected = new Set(selectedProductCategoryIds.value)
return productCategoryItems.value.filter((item) => selected.has(item.id))
})
function appendProductCategoriesToPrompt(prompt: string) {
const lines = selectedProductCategories.value
.map((item) => {
const path = item.path || item.name
const description = item.description?.trim()
return description ? `${path}${description}` : path
})
.filter(Boolean)
if (!lines.length) return prompt
const categoryBlock = [
'商品类目要求:',
...lines.map((line) => `- ${line}`),
].join('\n')
return prompt ? `${prompt}\n\n${categoryBlock}` : categoryBlock
}
async function loadProductCategories() {
if (loadingProductCategories.value) return
loadingProductCategories.value = true
try {
const res = await listProductCategories()
productCategoryTree.value = res.tree || []
productCategoryItems.value = res.items || []
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '加载商品类目失败')
} finally {
loadingProductCategories.value = false
}
}
async function openCategoryDialog() {
categoryDialogVisible.value = true
if (!productCategoryTree.value.length) {
await loadProductCategories()
}
requestAnimationFrame(() => {
categoryTreeRef.value?.setCheckedKeys?.(selectedProductCategoryIds.value)
})
}
function hasCheckedDescendant(item: ProductCategoryItemVo, checkedIds: Set<number>): boolean {
return Boolean(item.children?.some((child) => checkedIds.has(child.id) || hasCheckedDescendant(child, checkedIds)))
}
function confirmCategorySelection() {
const checkedNodes = (categoryTreeRef.value?.getCheckedNodes?.(false, false) || []) as ProductCategoryItemVo[]
const checkedIds = new Set(checkedNodes.map((item) => item.id))
selectedProductCategoryIds.value = checkedNodes
.filter((item) => !hasCheckedDescendant(item, checkedIds))
.map((item) => item.id)
categoryDialogVisible.value = false
}
function clearCategoryTreeSelection() {
categoryTreeRef.value?.setCheckedKeys?.([])
}
function clearSelectedProductCategories() {
selectedProductCategoryIds.value = []
categoryTreeRef.value?.setCheckedKeys?.([])
return getStoredApiSecret().trim()
}
function maskSecret(secret: string) {
@@ -911,7 +779,6 @@ onMounted(async () => {
loadPollingIds()
await Promise.all([
loadFilterConditions().catch(() => undefined),
loadProductCategories().catch(() => undefined),
loadDashboard().catch(() => undefined),
loadHistory().catch(() => undefined),
])
@@ -958,18 +825,6 @@ onUnmounted(() => {
.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; }
.category-card { margin: 0 0 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; }
.category-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.category-card-head .section-title { margin-bottom: 0; }
.category-actions { display: flex; align-items: center; gap: 10px; }
.category-actions .opt-btn { padding: 6px 12px; }
.selected-category-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; max-height: 96px; overflow: auto; }
.category-chip { max-width: 100%; padding: 4px 8px; border-radius: 6px; background: rgba(52, 152, 219, .16); color: #cfe7ff; font-size: 12px; line-height: 1.4; }
.category-dialog-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.category-tree { max-height: 420px; overflow: auto; padding: 8px; border: 1px solid #2f2f2f; border-radius: 8px; background: #202020; --el-tree-bg-color: #202020; --el-tree-text-color: #d8d8d8; --el-tree-node-hover-bg-color: #2a2a2a; }
.category-tree-node { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.category-description { color: #888; font-size: 12px; }
.category-confirm { padding: 8px 16px; }
.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; }

View File

@@ -1680,31 +1680,6 @@ export function getAppearancePatentResultDownloadUrl(resultId: number) {
// ========== 货源查询 ==========
export interface ProductCategoryItemVo {
id: number;
parentId?: number | null;
name: string;
categoryKey?: string;
sortOrder?: number;
description?: string;
isBuiltin?: boolean;
childCount?: number;
level?: number;
path?: string;
children?: ProductCategoryItemVo[];
}
export interface ProductCategoryListVo {
tree: ProductCategoryItemVo[];
items: ProductCategoryItemVo[];
}
export function listProductCategories() {
return unwrapJavaResponse(
get<JavaApiResponse<ProductCategoryListVo>>(`${JAVA_API_PREFIX}/admin/product-categories`),
);
}
export interface SimilarAsinFilterConditionVo {
id: number;
conditionText: string;

View File

@@ -1,4 +1,4 @@
export type ApiSecretModuleKey = 'appearance-patent' | 'similar-asin'
type LegacyApiSecretModuleKey = 'appearance-patent' | 'similar-asin'
export type ApiSecretRetention = 'session' | '1d' | '7d' | '30d' | 'forever'
@@ -18,17 +18,19 @@ export type ApiSecretSnapshot = {
}
const STORAGE_PREFIX = 'brand:api-secret'
const COMMON_SECRET_KEY = 'common'
const LEGACY_SECRET_KEYS: LegacyApiSecretModuleKey[] = ['appearance-patent', 'similar-asin']
function currentUserStorageId() {
if (typeof window === 'undefined') return '0'
return window.localStorage.getItem('uid') || '0'
}
function buildStorageKey(moduleKey: ApiSecretModuleKey) {
function buildStorageKey(moduleKey: string) {
return `${STORAGE_PREFIX}:${currentUserStorageId()}:${moduleKey}`
}
function readStorageRecord(storage: Storage, moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
function readStorageRecord(storage: Storage, moduleKey: string): ApiSecretRecord | null {
const raw = storage.getItem(buildStorageKey(moduleKey))
if (!raw) return null
try {
@@ -81,11 +83,11 @@ function isExpired(record: ApiSecretRecord) {
return record.expiresAt != null && record.expiresAt <= Date.now()
}
function clearStorageRecord(storage: Storage, moduleKey: ApiSecretModuleKey) {
function clearStorageRecord(storage: Storage, moduleKey: string) {
storage.removeItem(buildStorageKey(moduleKey))
}
function getLiveRecord(moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
function getLiveRecordFromKey(moduleKey: string): ApiSecretRecord | null {
if (typeof window === 'undefined') return null
const sessionRecord = readStorageRecord(window.sessionStorage, moduleKey)
@@ -102,12 +104,42 @@ function getLiveRecord(moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
return localRecord
}
export function getStoredApiSecret(moduleKey: ApiSecretModuleKey) {
return getLiveRecord(moduleKey)?.value || ''
function clearLegacyStoredApiSecrets() {
if (typeof window === 'undefined') return
for (const moduleKey of LEGACY_SECRET_KEYS) {
clearStorageRecord(window.sessionStorage, moduleKey)
clearStorageRecord(window.localStorage, moduleKey)
}
}
export function getStoredApiSecretSnapshot(moduleKey: ApiSecretModuleKey): ApiSecretSnapshot {
const record = getLiveRecord(moduleKey)
function migrateLegacyRecord(record: ApiSecretRecord) {
if (typeof window === 'undefined') return
const storage = record.retention === 'session' ? window.sessionStorage : window.localStorage
storage.setItem(buildStorageKey(COMMON_SECRET_KEY), JSON.stringify(record))
clearLegacyStoredApiSecrets()
}
function getLiveRecord(): ApiSecretRecord | null {
const commonRecord = getLiveRecordFromKey(COMMON_SECRET_KEY)
if (commonRecord) return commonRecord
for (const moduleKey of LEGACY_SECRET_KEYS) {
const legacyRecord = getLiveRecordFromKey(moduleKey)
if (legacyRecord) {
migrateLegacyRecord(legacyRecord)
return legacyRecord
}
}
return null
}
export function getStoredApiSecret() {
return getLiveRecord()?.value || ''
}
export function getStoredApiSecretSnapshot(): ApiSecretSnapshot {
const record = getLiveRecord()
if (!record) {
return {
value: '',
@@ -127,14 +159,13 @@ export function getStoredApiSecretSnapshot(moduleKey: ApiSecretModuleKey): ApiSe
}
export function saveStoredApiSecret(
moduleKey: ApiSecretModuleKey,
value: string,
retention: ApiSecretRetention,
) {
if (typeof window === 'undefined') return
const trimmedValue = value.trim()
clearStoredApiSecret(moduleKey)
clearStoredApiSecret()
if (!trimmedValue) return
const now = Date.now()
@@ -146,11 +177,12 @@ export function saveStoredApiSecret(
}
const storage = retention === 'session' ? window.sessionStorage : window.localStorage
storage.setItem(buildStorageKey(moduleKey), JSON.stringify(record))
storage.setItem(buildStorageKey(COMMON_SECRET_KEY), JSON.stringify(record))
}
export function clearStoredApiSecret(moduleKey: ApiSecretModuleKey) {
export function clearStoredApiSecret() {
if (typeof window === 'undefined') return
clearStorageRecord(window.sessionStorage, moduleKey)
clearStorageRecord(window.localStorage, moduleKey)
clearStorageRecord(window.sessionStorage, COMMON_SECRET_KEY)
clearStorageRecord(window.localStorage, COMMON_SECRET_KEY)
clearLegacyStoredApiSecrets()
}