增加公共下载进度、增加接收SKU、密钥分别存放
This commit is contained in:
12
frontend-vue/collect-data.html
Normal file
12
frontend-vue/collect-data.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>采集数据</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/collect-data-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
7
frontend-vue/src/collect-data-main.ts
Normal file
7
frontend-vue/src/collect-data-main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandCollectDataTab from '@/pages/brand/components/BrandCollectDataTab.vue'
|
||||
|
||||
createApp(BrandCollectDataTab).use(ElementPlus).mount('#app')
|
||||
@@ -5,33 +5,33 @@
|
||||
<span>密钥设置</span>
|
||||
</button>
|
||||
|
||||
<el-dialog v-model="dialogVisible" width="560px" class="secret-settings-dialog" :append-to-body="true">
|
||||
<el-dialog v-model="dialogVisible" width="620px" class="secret-settings-dialog" :append-to-body="true">
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div class="dialog-title">密钥设置</div>
|
||||
<div class="dialog-subtitle">按当前登录用户保存在本机,外观专利和货源查询共用同一个 Coze 接口密钥。</div>
|
||||
<div class="dialog-subtitle">按当前登录用户保存在本机,外观专利和货源查询分别使用独立的 Coze 接口密钥。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="secret-settings-body">
|
||||
<section class="secret-card">
|
||||
<section v-for="config in secretConfigs" :key="config.key" class="secret-card">
|
||||
<div class="secret-card-head">
|
||||
<div>
|
||||
<div class="secret-card-title">通用 Coze 密钥</div>
|
||||
<div class="secret-card-desc">用于外观专利检测和货源查询。</div>
|
||||
<div class="secret-card-title">{{ config.title }}</div>
|
||||
<div class="secret-card-desc">{{ config.description }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="secretState.exists"
|
||||
v-if="secretStates[config.key].exists"
|
||||
type="button"
|
||||
class="link-danger"
|
||||
@click="clearSecret"
|
||||
@click="clearSecret(config.key)"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="secretState.value"
|
||||
v-model="secretStates[config.key].value"
|
||||
class="secret-input"
|
||||
type="password"
|
||||
placeholder="请输入 Coze 接口密钥"
|
||||
@@ -43,14 +43,16 @@
|
||||
<div class="retention-label">保留时长</div>
|
||||
<div class="retention-options">
|
||||
<label v-for="option in retentionOptions" :key="option.value" class="retention-option">
|
||||
<input v-model="secretState.retention" type="radio" :value="option.value" />
|
||||
<input v-model="secretStates[config.key].retention" type="radio" :value="option.value" />
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-meta">
|
||||
<span v-if="secretState.exists">{{ formatRetentionText(secretState.retention, secretState.expiresAt) }}</span>
|
||||
<span v-if="secretStates[config.key].exists">
|
||||
{{ formatRetentionText(secretStates[config.key].retention, secretStates[config.key].expiresAt) }}
|
||||
</span>
|
||||
<span v-else>当前未保存</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -73,6 +75,7 @@ import {
|
||||
clearStoredApiSecret,
|
||||
getStoredApiSecretSnapshot,
|
||||
saveStoredApiSecret,
|
||||
type ApiSecretModuleKey,
|
||||
type ApiSecretRetention,
|
||||
} from '@/shared/utils/api-secret-store'
|
||||
|
||||
@@ -93,16 +96,36 @@ const retentionOptions: Array<{ value: ApiSecretRetention; label: string }> = [
|
||||
{ value: 'forever', label: '长期保留' },
|
||||
]
|
||||
|
||||
const secretState = ref<SecretState>(emptySecretState())
|
||||
const secretConfigs: Array<{ key: ApiSecretModuleKey; title: string; description: string }> = [
|
||||
{
|
||||
key: 'appearance-patent',
|
||||
title: '外观专利密钥',
|
||||
description: '仅用于外观专利检测。',
|
||||
},
|
||||
{
|
||||
key: 'similar-asin',
|
||||
title: '货源查询密钥',
|
||||
description: '仅用于货源查询。',
|
||||
},
|
||||
]
|
||||
|
||||
const secretStates = ref<Record<ApiSecretModuleKey, SecretState>>({
|
||||
'appearance-patent': emptySecretState(),
|
||||
'similar-asin': emptySecretState(),
|
||||
})
|
||||
|
||||
function loadStates() {
|
||||
const snapshot = getStoredApiSecretSnapshot()
|
||||
secretState.value = {
|
||||
value: snapshot.value,
|
||||
retention: snapshot.retention,
|
||||
expiresAt: snapshot.expiresAt,
|
||||
exists: snapshot.exists,
|
||||
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 {
|
||||
@@ -127,14 +150,17 @@ function formatRetentionText(retention: ApiSecretRetention, expiresAt: number |
|
||||
return `有效期至 ${year}-${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
function clearSecret() {
|
||||
clearStoredApiSecret()
|
||||
function clearSecret(moduleKey: ApiSecretModuleKey) {
|
||||
clearStoredApiSecret(moduleKey)
|
||||
loadStates()
|
||||
ElMessage.success('已清空密钥')
|
||||
}
|
||||
|
||||
function saveAll() {
|
||||
saveStoredApiSecret(secretState.value.value, secretState.value.retention)
|
||||
for (const config of secretConfigs) {
|
||||
const state = secretStates.value[config.key]
|
||||
saveStoredApiSecret(config.key, state.value, state.retention)
|
||||
}
|
||||
loadStates()
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('密钥设置已保存')
|
||||
@@ -242,6 +268,9 @@ loadStates()
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.secret-card {
|
||||
|
||||
@@ -161,6 +161,7 @@ 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'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
|
||||
const selectedFileNames = ref<string[]>([])
|
||||
const uploadedFiles = ref<UploadFileVo[]>([])
|
||||
@@ -257,7 +258,7 @@ function queueAiPrompt() {
|
||||
}
|
||||
|
||||
function effectiveCozeApiKey() {
|
||||
return getStoredApiSecret().trim()
|
||||
return getStoredApiSecret('appearance-patent').trim()
|
||||
}
|
||||
|
||||
function maskSecret(secret: string) {
|
||||
@@ -462,6 +463,7 @@ async function pushToPythonQueue() {
|
||||
id: row.displayId,
|
||||
asin: row.asin,
|
||||
country: row.country,
|
||||
sku: row.sku || '',
|
||||
url: row.url || '',
|
||||
title: row.title || '',
|
||||
})),
|
||||
@@ -880,14 +882,9 @@ function shouldShowFallbackJobProgress(item: AppearancePatentHistoryItem) {
|
||||
|
||||
async function downloadResult(item: AppearancePatentHistoryItem) {
|
||||
if (!item.resultId) return
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.save_file_from_url_new) {
|
||||
ElMessage.error('当前客户端未提供下载能力')
|
||||
return
|
||||
}
|
||||
const url = item.downloadUrl || getAppearancePatentResultDownloadUrl(item.resultId)
|
||||
const filename = item.resultFilename || `${item.sourceFilename || 'appearance-patent'}.xlsx`
|
||||
const result = await api.save_file_from_url_new(url, filename)
|
||||
const result = await saveUrlWithProgress(url, filename, `appearance-patent:${item.resultId}`)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存: ${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
|
||||
783
frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue
Normal file
783
frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue
Normal file
@@ -0,0 +1,783 @@
|
||||
<template>
|
||||
<div class="page-shell module-page">
|
||||
<BrandTopBar active="collect-data" />
|
||||
|
||||
<div class="main-content">
|
||||
<aside class="left-panel">
|
||||
<div class="section-title">上传文件</div>
|
||||
<div class="upload-zone">
|
||||
<div class="hint">选择需要采集的 Excel 文件或文件夹(默认根目录:采集前数据.xlsx),后续将按下方筛选条件交给 Python 端进行数据采集。</div>
|
||||
<div class="btns">
|
||||
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel</button>
|
||||
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
|
||||
</div>
|
||||
<div class="selected-files">
|
||||
<template v-if="selectedFileNames.length">
|
||||
<span v-for="name in displayFileNames" :key="name">{{ name }}</span>
|
||||
<span v-if="selectedFileNames.length > displayFileNames.length" class="more-line">
|
||||
还有 {{ selectedFileNames.length - displayFileNames.length }} 个文件未展开显示
|
||||
</span>
|
||||
</template>
|
||||
<span v-else>暂未选择文件</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">筛选条件</div>
|
||||
<div class="filter-zone">
|
||||
<div class="filter-row">
|
||||
<label class="filter-label">金额(手动输入)</label>
|
||||
<input
|
||||
v-model.number="filters.amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="filter-input"
|
||||
placeholder="例如:59.99"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<label class="filter-label">排名</label>
|
||||
<input
|
||||
v-model.number="filters.rank"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
class="filter-input"
|
||||
placeholder="例如:100000"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-checks">
|
||||
<label class="filter-check-row">
|
||||
<input type="checkbox" class="filter-check-input" v-model="filters.fba" />
|
||||
<span class="filter-check-text">FBA</span>
|
||||
</label>
|
||||
<label class="filter-check-row">
|
||||
<input type="checkbox" class="filter-check-input" v-model="filters.fbm" />
|
||||
<span class="filter-check-text">FBM</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title country-section-title">
|
||||
<span>国家与顺序</span>
|
||||
<span v-if="countryPrefSaving" class="country-pref-saving">保存中…</span>
|
||||
</div>
|
||||
<p class="hint country-pref-hint">
|
||||
勾选需要采集的国家(暂定 5 国,至少保留 1 个);上方勾选区<strong>先按已选顺序</strong>排列,其余未选国家排在后面。拖拽下方列表可调整顺序。
|
||||
</p>
|
||||
<div class="country-pref-checks">
|
||||
<label v-for="row in countryCheckboxRows" :key="row.code" class="country-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="country-check-input"
|
||||
:checked="isCountrySelected(row.code)"
|
||||
:disabled="isCountrySelectionLocked(row.code)"
|
||||
@change="onCountryNativeChange(row.code, $event)"
|
||||
/>
|
||||
<span class="country-check-text">{{ row.label }}({{ row.code }})</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="orderedCountryCodes.length" class="country-order-panel">
|
||||
<div class="country-order-caption">已选顺序(拖拽 ⋮⋮ 调整)</div>
|
||||
<div class="country-order-list">
|
||||
<div
|
||||
v-for="(code, idx) in orderedCountryCodes"
|
||||
:key="code"
|
||||
class="country-drag-row"
|
||||
:class="{ dragging: dragCountryIndex === idx }"
|
||||
draggable="true"
|
||||
@dragstart="onCountryDragStart(idx)"
|
||||
@dragend="onCountryDragEnd"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onCountryDrop(idx)"
|
||||
>
|
||||
<span class="drag-handle" title="拖动排序">⋮⋮</span>
|
||||
<span class="country-drag-label">{{ countryLabel(code) }}({{ code }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="run-row">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-run"
|
||||
:disabled="submitting || !uploadedFiles.length"
|
||||
@click="submitCollect"
|
||||
>
|
||||
{{ submitting ? '提交中...' : '提交采集任务' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-run btn-queue"
|
||||
:disabled="pushing || !lastTaskId"
|
||||
@click="pushToPythonQueue"
|
||||
>
|
||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">提交后由 Java 解析 Excel 并落库为待采集任务(PENDING);推送到 Python 队列将激活任务(RUNNING),Python 端按 50 条/页拉取明细执行采集。</p>
|
||||
|
||||
<div v-if="queuePushResult" class="queue-debug-card">
|
||||
<div class="section-title queue-debug-title">推送结果</div>
|
||||
<div class="queue-debug-line">{{ queuePushResult }}</div>
|
||||
<pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">采集数据</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">运行中任务</span>
|
||||
<strong>{{ dashboard.pendingTaskCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">已结束任务</span>
|
||||
<strong>{{ dashboard.processedTaskCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">成功任务</span>
|
||||
<strong>{{ dashboard.successTaskCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">失败任务</span>
|
||||
<strong>{{ dashboard.failedTaskCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection-title">匹配任务</div>
|
||||
<div class="result-list-wrap">
|
||||
<div class="result-list-header">
|
||||
<span>当前任务</span>
|
||||
</div>
|
||||
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
|
||||
<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 || '采集数据' }}</span>
|
||||
<div class="files">任务 ID:{{ item.taskId }}</div>
|
||||
<div class="files">开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
|
||||
<div class="files">行数:{{ item.rowCount ?? '-' }}</div>
|
||||
</div>
|
||||
<div class="task-right">
|
||||
<span class="status running">{{ statusText(item) }}</span>
|
||||
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="result-list-wrap">
|
||||
<div class="result-list-header">
|
||||
<span>历史记录</span>
|
||||
</div>
|
||||
<div v-if="!historyItems.length" class="empty-tasks">暂无历史记录</div>
|
||||
<ul v-else class="task-list clean-result-list">
|
||||
<li v-for="item in historyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
|
||||
<div class="left">
|
||||
<span class="id">{{ item.sourceFilename || '采集数据' }}</span>
|
||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||||
<div class="files">开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
|
||||
<div class="files">结束时间:{{ formatDateTime(item.finishedAt) }}</div>
|
||||
<div v-if="item.resultFilename" class="files">{{ item.resultFilename }}</div>
|
||||
<div v-if="item.error" class="files">错误:{{ item.error }}</div>
|
||||
</div>
|
||||
<div class="task-right">
|
||||
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
|
||||
<button v-if="item.resultId" type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
import {
|
||||
parseCollectData,
|
||||
activateCollectDataTask,
|
||||
getCollectDataDashboard,
|
||||
getCollectDataHistory,
|
||||
getCollectDataCountryPreference,
|
||||
putCollectDataCountryPreference,
|
||||
getCollectDataTaskProgressBatch,
|
||||
deleteCollectDataTask,
|
||||
deleteCollectDataHistory,
|
||||
type CollectDataDashboardVo,
|
||||
type CollectDataHistoryItem,
|
||||
type CollectDataParseVo,
|
||||
type CollectDataTaskDetailVo,
|
||||
type UploadFileVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
|
||||
const COUNTRY_OPTIONS = [
|
||||
{ code: 'DE', label: '德国' },
|
||||
{ code: 'UK', label: '英国' },
|
||||
{ code: 'FR', label: '法国' },
|
||||
{ code: 'IT', label: '意大利' },
|
||||
{ code: 'ES', label: '西班牙' },
|
||||
] as const
|
||||
|
||||
const selectedFileNames = ref<string[]>([])
|
||||
const uploadedFiles = ref<UploadFileVo[]>([])
|
||||
const submitting = ref(false)
|
||||
const pushing = ref(false)
|
||||
|
||||
const filters = reactive<{
|
||||
amount: number | null
|
||||
rank: number | null
|
||||
fba: boolean
|
||||
fbm: boolean
|
||||
}>({
|
||||
amount: null,
|
||||
rank: null,
|
||||
fba: false,
|
||||
fbm: false,
|
||||
})
|
||||
|
||||
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
|
||||
const dragCountryIndex = ref<number | null>(null)
|
||||
/** 防止接口慢回包覆盖用户已经做出的修改 */
|
||||
const countryPrefUserTouched = ref(false)
|
||||
const countryPrefSaving = ref(false)
|
||||
const timers = createCategorizedTimers('collect-data-tab')
|
||||
let countryPrefSaveTimer: number | null = null
|
||||
|
||||
const lastTaskId = ref<number | null>(null)
|
||||
const lastParseVo = ref<CollectDataParseVo | null>(null)
|
||||
const queuePushResult = ref('')
|
||||
const queuePayloadText = ref('')
|
||||
|
||||
const dashboard = ref<CollectDataDashboardVo>({
|
||||
pendingTaskCount: 0,
|
||||
processedTaskCount: 0,
|
||||
successTaskCount: 0,
|
||||
failedTaskCount: 0,
|
||||
})
|
||||
const currentItems = ref<CollectDataHistoryItem[]>([])
|
||||
const historyItems = ref<CollectDataHistoryItem[]>([])
|
||||
/** 轮询过程中收到的任务最新快照,按 taskId 缓存,用于历史返回前的占位展示 */
|
||||
const taskSnapshots = ref<Record<number, CollectDataTaskDetailVo>>({})
|
||||
|
||||
const POLLING_STORAGE_KEY = 'brand:collect-data:polling-task-ids'
|
||||
|
||||
const progressLoop = useTaskProgressLoop<CollectDataTaskDetailVo>({
|
||||
scope: 'collect-data-tab',
|
||||
storageKey: POLLING_STORAGE_KEY,
|
||||
fetchProgress: (ids) => getCollectDataTaskProgressBatch(ids),
|
||||
extractTaskId: (detail) => detail.task?.id ?? null,
|
||||
extractStatus: (detail) => detail.task?.status ?? '',
|
||||
onUpdate: (taskId, detail) => {
|
||||
const prev = taskSnapshots.value[taskId]
|
||||
taskSnapshots.value = {
|
||||
...taskSnapshots.value,
|
||||
[taskId]: prev
|
||||
? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) }, items: detail.items ?? prev.items }
|
||||
: detail,
|
||||
}
|
||||
},
|
||||
onTerminal: async (taskId) => {
|
||||
if (taskSnapshots.value[taskId]) {
|
||||
const next = { ...taskSnapshots.value }
|
||||
delete next[taskId]
|
||||
taskSnapshots.value = next
|
||||
}
|
||||
await Promise.all([loadDashboard(), loadHistory()])
|
||||
},
|
||||
})
|
||||
|
||||
const displayFileNames = computed(() => selectedFileNames.value.slice(0, 8))
|
||||
|
||||
function countryLabel(code: string) {
|
||||
const row = COUNTRY_OPTIONS.find((o) => o.code === code)
|
||||
return row?.label ?? code
|
||||
}
|
||||
|
||||
const countryCheckboxRows = computed(() => {
|
||||
const sel = orderedCountryCodes.value
|
||||
const selectedSet = new Set(sel)
|
||||
const selectedPart = sel.map((code) => ({ code, label: countryLabel(code) }))
|
||||
const rest = COUNTRY_OPTIONS.filter((o) => !selectedSet.has(o.code)).map((o) => ({
|
||||
code: o.code,
|
||||
label: o.label,
|
||||
}))
|
||||
return [...selectedPart, ...rest]
|
||||
})
|
||||
|
||||
function isCountrySelected(code: string) {
|
||||
return orderedCountryCodes.value.includes(code)
|
||||
}
|
||||
|
||||
function isCountrySelectionLocked(code: string) {
|
||||
return orderedCountryCodes.value.length === 1 && orderedCountryCodes.value[0] === code
|
||||
}
|
||||
|
||||
function onCountryNativeChange(code: string, e: Event) {
|
||||
const el = e.target as HTMLInputElement | null
|
||||
if (!el) return
|
||||
if (!el.checked && isCountrySelectionLocked(code)) {
|
||||
el.checked = true
|
||||
ElMessage.warning('至少保留 1 个国家')
|
||||
return
|
||||
}
|
||||
countryPrefUserTouched.value = true
|
||||
if (el.checked) {
|
||||
if (!orderedCountryCodes.value.includes(code)) {
|
||||
orderedCountryCodes.value = [...orderedCountryCodes.value, code]
|
||||
}
|
||||
} else {
|
||||
if (orderedCountryCodes.value.length <= 1) {
|
||||
ElMessage.warning('至少保留 1 个国家')
|
||||
return
|
||||
}
|
||||
orderedCountryCodes.value = orderedCountryCodes.value.filter((c) => c !== code)
|
||||
}
|
||||
scheduleSaveCountryPref()
|
||||
}
|
||||
|
||||
function onCountryDragStart(index: number) {
|
||||
dragCountryIndex.value = index
|
||||
}
|
||||
|
||||
function onCountryDragEnd() {
|
||||
dragCountryIndex.value = null
|
||||
}
|
||||
|
||||
function onCountryDrop(toIndex: number) {
|
||||
const from = dragCountryIndex.value
|
||||
dragCountryIndex.value = null
|
||||
if (from == null || from === toIndex) return
|
||||
countryPrefUserTouched.value = true
|
||||
const arr = [...orderedCountryCodes.value]
|
||||
const [item] = arr.splice(from, 1)
|
||||
arr.splice(toIndex, 0, item)
|
||||
orderedCountryCodes.value = arr
|
||||
scheduleSaveCountryPref()
|
||||
}
|
||||
|
||||
function scheduleSaveCountryPref() {
|
||||
if (countryPrefSaveTimer != null) {
|
||||
timers.clearTimer('country-pref-save', countryPrefSaveTimer)
|
||||
}
|
||||
countryPrefSaveTimer = timers.setTimeout('country-pref-save', () => {
|
||||
countryPrefSaveTimer = null
|
||||
void persistCountryPref()
|
||||
}, 450)
|
||||
}
|
||||
|
||||
async function persistCountryPref() {
|
||||
if (!orderedCountryCodes.value.length) return
|
||||
countryPrefSaving.value = true
|
||||
try {
|
||||
await putCollectDataCountryPreference([...orderedCountryCodes.value])
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存国家顺序失败')
|
||||
} finally {
|
||||
countryPrefSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCountryPreference() {
|
||||
try {
|
||||
const vo = await getCollectDataCountryPreference()
|
||||
if (countryPrefUserTouched.value) return
|
||||
const codes = vo?.country_codes
|
||||
if (Array.isArray(codes) && codes.length) {
|
||||
orderedCountryCodes.value = [...codes]
|
||||
}
|
||||
} catch {
|
||||
/* 接口失败时保留本地默认 */
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.upload_file_to_java) {
|
||||
throw new Error('当前桌面端未提供文件上传能力')
|
||||
}
|
||||
const files: UploadFileVo[] = []
|
||||
for (const item of paths) {
|
||||
const filePath = typeof item === 'string' ? item : item.absolutePath
|
||||
const relativePath = typeof item === 'string' ? undefined : item.relativePath
|
||||
const uploaded = await api.upload_file_to_java(filePath, relativePath)
|
||||
if (!uploaded?.success || !uploaded.data) {
|
||||
throw new Error(uploaded?.error || uploaded?.message || `上传失败:${filePath}`)
|
||||
}
|
||||
files.push(uploaded.data)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function selectFiles() {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.select_brand_xlsx_files || !api.upload_file_to_java) {
|
||||
ElMessage.warning('当前环境不支持文件选择或上传')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const paths = await api.select_brand_xlsx_files()
|
||||
if (!paths?.length) return
|
||||
const files = await uploadPathsToJava(paths)
|
||||
uploadedFiles.value = files
|
||||
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
|
||||
lastTaskId.value = null
|
||||
lastParseVo.value = null
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '文件选择失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFolder() {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.select_brand_folder) {
|
||||
ElMessage.warning('当前环境不支持文件夹选择,请在本地客户端中打开')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const folder = await api.select_brand_folder()
|
||||
if (!folder) return
|
||||
const result = await expandBrandFolderRecursive(folder)
|
||||
if (!result.success || !result.items?.length) {
|
||||
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
|
||||
return
|
||||
}
|
||||
const files = await uploadPathsToJava(result.items)
|
||||
uploadedFiles.value = files
|
||||
selectedFileNames.value = result.items.map((item) => item.relativePath || item.absolutePath)
|
||||
lastTaskId.value = null
|
||||
lastParseVo.value = null
|
||||
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 Excel 文件`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '选择失败')
|
||||
}
|
||||
}
|
||||
|
||||
function buildFiltersPayload() {
|
||||
return {
|
||||
amount: filters.amount === null || filters.amount === undefined || Number.isNaN(filters.amount)
|
||||
? null
|
||||
: filters.amount,
|
||||
rank: filters.rank === null || filters.rank === undefined || Number.isNaN(filters.rank)
|
||||
? null
|
||||
: filters.rank,
|
||||
fba: !!filters.fba,
|
||||
fbm: !!filters.fbm,
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCollect() {
|
||||
if (!uploadedFiles.value.length) {
|
||||
ElMessage.warning('请先选择 Excel 文件')
|
||||
return
|
||||
}
|
||||
if (!orderedCountryCodes.value.length) {
|
||||
ElMessage.warning('至少保留 1 个国家')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const vo = await parseCollectData({
|
||||
files: uploadedFiles.value.map((f) => ({
|
||||
fileKey: f.fileKey,
|
||||
originalFilename: f.originalFilename,
|
||||
relativePath: f.relativePath,
|
||||
})),
|
||||
task_type: 'collect-data',
|
||||
filters: buildFiltersPayload(),
|
||||
})
|
||||
lastParseVo.value = vo
|
||||
lastTaskId.value = vo.taskId ?? null
|
||||
if (vo.taskId) {
|
||||
progressLoop.add(vo.taskId)
|
||||
}
|
||||
ElMessage.success(`提交成功:任务 ${vo.taskNo || vo.taskId},落库 ${vo.acceptedRows ?? 0} 行`)
|
||||
await Promise.all([loadDashboard(), loadHistory()])
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToPythonQueue() {
|
||||
if (!lastTaskId.value) {
|
||||
ElMessage.warning('请先提交采集任务')
|
||||
return
|
||||
}
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.enqueue_json) {
|
||||
ElMessage.warning('当前环境未提供 Python 队列能力')
|
||||
return
|
||||
}
|
||||
pushing.value = true
|
||||
try {
|
||||
await activateCollectDataTask(lastTaskId.value)
|
||||
const payload = {
|
||||
type: 'collect-data-run',
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
taskId: lastTaskId.value,
|
||||
taskNo: lastParseVo.value?.taskNo,
|
||||
taskType: 'collect-data',
|
||||
totalRows: lastParseVo.value?.acceptedRows ?? 0,
|
||||
pageSize: lastParseVo.value?.pageSize ?? 50,
|
||||
filters: buildFiltersPayload(),
|
||||
},
|
||||
}
|
||||
const result = await api.enqueue_json(payload)
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.error || '入队失败')
|
||||
}
|
||||
queuePushResult.value = `已推送至 Python 队列(队列长度:${result.queue_size ?? '-'})`
|
||||
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
||||
if (lastTaskId.value) {
|
||||
progressLoop.add(lastTaskId.value)
|
||||
}
|
||||
ElMessage.success('已推送到 Python 队列')
|
||||
await loadDashboard()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '推送失败')
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function normalizeTaskStatus(item: CollectDataHistoryItem) {
|
||||
return (item.taskStatus || '').toUpperCase()
|
||||
}
|
||||
|
||||
function statusText(item: CollectDataHistoryItem) {
|
||||
const status = normalizeTaskStatus(item)
|
||||
if (status === 'RUNNING') return '执行中'
|
||||
if (status === 'PENDING') return '等待中'
|
||||
if (status === 'SUCCESS' || item.success) return '已完成'
|
||||
if (status === 'FAILED') return '失败'
|
||||
return '等待中'
|
||||
}
|
||||
|
||||
function statusClass(item: CollectDataHistoryItem) {
|
||||
const status = normalizeTaskStatus(item)
|
||||
if (status === 'RUNNING') return 'running'
|
||||
if (status === 'FAILED') return 'failed'
|
||||
if (status === 'SUCCESS' || item.success) return 'success'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
dashboard.value = await getCollectDataDashboard()
|
||||
} catch (error) {
|
||||
/* 忽略:dashboard 失败不打断主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const vo = await getCollectDataHistory(50)
|
||||
const items = vo.items || []
|
||||
const running = items.filter((it) => {
|
||||
const s = (it.taskStatus || '').toUpperCase()
|
||||
return s === 'PENDING' || s === 'RUNNING'
|
||||
})
|
||||
historyItems.value = items.filter((it) => {
|
||||
const s = (it.taskStatus || '').toUpperCase()
|
||||
return s === 'SUCCESS' || s === 'FAILED'
|
||||
})
|
||||
// 历史接口里运行中的任务自动加入轮询;若返回里已没有该 taskId,则停掉对应轮询
|
||||
const seenTaskIds = new Set<number>()
|
||||
for (const item of running) {
|
||||
if (item.taskId) {
|
||||
seenTaskIds.add(item.taskId)
|
||||
progressLoop.add(item.taskId)
|
||||
}
|
||||
}
|
||||
for (const taskId of [...progressLoop.taskIds.value]) {
|
||||
const onScreen = items.some((it) => it.taskId === taskId)
|
||||
if (!onScreen) {
|
||||
progressLoop.remove(taskId)
|
||||
}
|
||||
}
|
||||
// 合并:历史里 RUNNING/PENDING 的条目 + 轮询缓存里有快照但尚未在历史返回中的占位
|
||||
const seen = new Set<number>(running.map((it) => it.taskId).filter((id): id is number => typeof id === 'number'))
|
||||
const merged: CollectDataHistoryItem[] = [...running]
|
||||
for (const taskId of progressLoop.taskIds.value) {
|
||||
if (seen.has(taskId)) continue
|
||||
const detail = taskSnapshots.value[taskId]
|
||||
const placeholder = detail?.items?.[0]
|
||||
if (placeholder) {
|
||||
merged.push(placeholder)
|
||||
} else if (detail?.task) {
|
||||
merged.push({
|
||||
taskId: detail.task.id,
|
||||
taskNo: detail.task.taskNo,
|
||||
taskStatus: detail.task.status,
|
||||
startedAt: detail.task.createdAt,
|
||||
finishedAt: detail.task.finishedAt,
|
||||
sourceFilename: '采集数据',
|
||||
} as CollectDataHistoryItem)
|
||||
}
|
||||
}
|
||||
currentItems.value = merged
|
||||
} catch {
|
||||
/* 忽略:历史接口失败时保持空列表 */
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTaskRecord(item: CollectDataHistoryItem) {
|
||||
try {
|
||||
if (item.resultId) {
|
||||
await deleteCollectDataHistory(item.resultId)
|
||||
} else if (item.taskId) {
|
||||
await deleteCollectDataTask(item.taskId)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if (item.taskId) {
|
||||
progressLoop.remove(item.taskId)
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await Promise.all([loadDashboard(), loadHistory()])
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCountryPreference()
|
||||
void loadDashboard()
|
||||
void loadHistory()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (countryPrefSaveTimer != null) {
|
||||
timers.clearTimer('country-pref-save', countryPrefSaveTimer)
|
||||
countryPrefSaveTimer = null
|
||||
}
|
||||
timers.clearScope()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.module-page { min-height: 100vh; background: #1a1a1a; }
|
||||
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
|
||||
.left-panel { width: 400px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
|
||||
.right-panel { flex: 1; min-width: 0; background: #1a1a1a; display: flex; flex-direction: column; }
|
||||
.section-title, .subsection-title { font-size: 13px; color: #bbb; margin: 12px 0 10px; }
|
||||
.upload-zone { border: 1px dashed #3a3a3a; border-radius: 10px; padding: 18px; background: #252525; margin-bottom: 18px; }
|
||||
.hint, .loading-msg, .files, .muted { color: #888; font-size: 12px; line-height: 1.5; }
|
||||
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.opt-btn, .btn-run, .btn-delete, .download { border: none; cursor: pointer; border-radius: 7px; }
|
||||
.opt-btn { padding: 8px 14px; color: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; }
|
||||
.btn-run { padding: 10px 18px; color: #fff; background: #3498db; font-weight: 600; }
|
||||
.btn-run.btn-queue { background: #27ae60; }
|
||||
.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; }
|
||||
.selected-files .more-line { color: #777; }
|
||||
|
||||
.filter-zone {
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #252525;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.filter-label {
|
||||
width: 110px;
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
}
|
||||
.filter-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
color: #ddd;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.filter-input:focus { border-color: #3498db; }
|
||||
.filter-checks { display: flex; gap: 16px; }
|
||||
.filter-check-row { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; color: #ccc; font-size: 12px; }
|
||||
.filter-check-input { width: 16px; height: 16px; accent-color: #409eff; cursor: pointer; }
|
||||
|
||||
.country-pref-hint { margin-top: -4px; }
|
||||
.country-section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.country-pref-saving { color: #3498db; font-size: 11px; }
|
||||
.country-pref-checks { display: flex; flex-wrap: wrap; gap: 10px 16px; margin-bottom: 12px; }
|
||||
.country-check-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 12px; color: #ccc; }
|
||||
.country-check-input { width: 16px; height: 16px; accent-color: #409eff; cursor: pointer; flex-shrink: 0; }
|
||||
.country-check-text { line-height: 1.3; }
|
||||
.country-order-panel { border: 1px solid #333; border-radius: 8px; padding: 10px 12px; background: #252525; margin-bottom: 14px; }
|
||||
.country-order-caption { font-size: 11px; color: #777; margin-bottom: 8px; }
|
||||
.country-order-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.country-drag-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: #2c2c2c; border-radius: 6px; border: 1px solid #3a3a3a; cursor: grab; user-select: none; }
|
||||
.country-drag-row.dragging { opacity: 0.55; border-color: #3498db; }
|
||||
.drag-handle { color: #666; font-size: 12px; letter-spacing: -2px; }
|
||||
.country-drag-label { font-size: 12px; color: #ccc; }
|
||||
|
||||
.queue-debug-card { margin-top: 14px; border: 1px solid #2a2a2a; border-radius: 8px; padding: 10px 12px; background: #1f1f1f; }
|
||||
.queue-debug-title { margin-top: 0; }
|
||||
.queue-debug-line { color: #9ad; font-size: 12px; }
|
||||
.queue-debug-payload { margin-top: 8px; padding: 8px 10px; background: #181818; border-radius: 6px; color: #bbb; font-size: 11px; overflow: auto; max-height: 200px; }
|
||||
|
||||
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
|
||||
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
|
||||
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.summary-card { padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 8px; background: #1e1e1e; }
|
||||
.summary-card strong { display: block; margin-top: 8px; color: #eaf4ff; font-size: 22px; }
|
||||
.summary-label { color: #8d8d8d; font-size: 12px; }
|
||||
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 8px; background: #1e1e1e; min-height: 180px; margin: 0 0 16px; }
|
||||
.result-list-header { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #2a2a2a; color: #ddd; font-size: 14px; }
|
||||
.empty-tasks { color: #666; font-size: 13px; padding: 18px; text-align: center; }
|
||||
.task-list { list-style: none; margin: 0; padding: 12px; }
|
||||
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 8px; background: #222; }
|
||||
.left { flex: 1; min-width: 0; }
|
||||
.id { color: #e0e0e0; font-size: 13px; font-weight: 600; }
|
||||
.task-right { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
|
||||
.status.success { background: rgba(46, 204, 113, .18); color: #2ecc71; }
|
||||
.status.failed { background: rgba(231, 76, 60, .18); color: #ff6b6b; }
|
||||
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
|
||||
.status.pending { background: rgba(149, 165, 166, .18); color: #bdc3c7; }
|
||||
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
|
||||
@media (max-width: 1100px) {
|
||||
.main-content { flex-direction: column; height: auto; }
|
||||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; }
|
||||
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
@@ -189,6 +189,7 @@ import {
|
||||
type ConvertTemplateVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
|
||||
const convertSelectedPaths = ref<string[]>([])
|
||||
const convertArchiveName = ref('')
|
||||
@@ -392,14 +393,11 @@ async function saveTemplateFromUrl(url: string, fallbackFilename: string, bridge
|
||||
return
|
||||
}
|
||||
|
||||
if (api?.save_file_from_url_new) {
|
||||
const result = await api.save_file_from_url_new(url, fallbackFilename)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || fallbackFilename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
return
|
||||
const result = await saveUrlWithProgress(url, fallbackFilename)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || fallbackFilename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,7 +470,6 @@ async function deleteConvertHistoryRecord(resultId: number) {
|
||||
}
|
||||
|
||||
async function downloadConvertResult(item: ConvertResultItem) {
|
||||
const api = getPywebviewApi()
|
||||
const url = item.downloadUrl || (item.resultId ? getConvertResultDownloadUrl(item.resultId) : '')
|
||||
if (!url) {
|
||||
ElMessage.warning('当前结果没有下载地址')
|
||||
@@ -480,14 +477,11 @@ async function downloadConvertResult(item: ConvertResultItem) {
|
||||
}
|
||||
|
||||
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.txt`
|
||||
if (api?.save_file_from_url_new) {
|
||||
const result = await api.save_file_from_url_new(url, filename)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
return
|
||||
const result = await saveUrlWithProgress(url, filename, item.resultId ? `convert:${item.resultId}` : undefined)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ import BrandTopBar from './BrandTopBar.vue'
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunVo } from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
|
||||
const cleanAvailableColumns = ref<string[]>([])
|
||||
const cleanSelectedColumns = ref<string[]>([])
|
||||
@@ -328,7 +329,6 @@ async function deleteCleanHistoryRecord(resultId: number) {
|
||||
}
|
||||
|
||||
async function downloadCleanResult(item: DedupeResultItem) {
|
||||
const api = getPywebviewApi()
|
||||
const url = item.downloadUrl || (item.resultId ? getDedupeResultDownloadUrl(item.resultId) : '')
|
||||
if (!url) {
|
||||
ElMessage.warning('当前结果没有下载地址')
|
||||
@@ -336,14 +336,11 @@ async function downloadCleanResult(item: DedupeResultItem) {
|
||||
}
|
||||
|
||||
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}_cleaned.xlsx`
|
||||
if (api?.save_file_from_url_new) {
|
||||
const result = await api.save_file_from_url_new(url, filename)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
return
|
||||
const result = await saveUrlWithProgress(url, filename, item.resultId ? `dedupe:${item.resultId}` : undefined)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ import {
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
|
||||
interface SessionDeleteBrandItem extends DeleteBrandResultItem {
|
||||
_pushed?: boolean
|
||||
@@ -1129,21 +1130,17 @@ async function downloadTaskResult(item: DeleteBrandResultItem) {
|
||||
ElMessage.warning('未找到任务ID')
|
||||
return
|
||||
}
|
||||
const api = getPywebviewApi()
|
||||
const url = item.resultId ? getDeleteBrandResultDownloadUrl(item.resultId) : getDeleteBrandTaskDownloadUrl(taskId)
|
||||
const filename =
|
||||
item.outputFilename ||
|
||||
item.sourceFilename ||
|
||||
`delete-brand_${taskId}.xlsx`
|
||||
|
||||
if (api?.save_file_from_url_new) {
|
||||
const result = await api.save_file_from_url_new(url, filename)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
return
|
||||
const result = await saveUrlWithProgress(url, filename, item.resultId ? `delete-brand:${item.resultId}` : `delete-brand-task:${taskId}`)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -392,6 +392,7 @@ import {
|
||||
import { getPywebviewApi } from "@/shared/bridges/pywebview";
|
||||
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
|
||||
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
|
||||
import { saveUrlWithProgress } from "@/shared/utils/download-progress";
|
||||
|
||||
const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"] as const;
|
||||
const MAX_TRANSIENT_ERRORS = 30;
|
||||
@@ -1173,15 +1174,10 @@ async function downloadResult(item: PatrolDeleteHistoryItem) {
|
||||
Boolean(row.resultId && (row.fileReady || row.downloadUrl)),
|
||||
);
|
||||
if (!downloadable?.resultId) return;
|
||||
const api = getPywebviewApi();
|
||||
if (!api?.save_file_from_url_new) {
|
||||
ElMessage.error("当前客户端未提供下载能力");
|
||||
return;
|
||||
}
|
||||
const url = getPatrolDeleteResultDownloadUrl(downloadable.resultId);
|
||||
const filename =
|
||||
downloadable.outputFilename || `${item.shopName || "patrol-delete-result"}.xlsx`;
|
||||
const result = await api.save_file_from_url_new(url, filename);
|
||||
const result = await saveUrlWithProgress(url, filename, `patrol-delete:${downloadable.resultId}`);
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存: ${result.path || filename}`);
|
||||
} else if (result.error && result.error !== "用户取消") {
|
||||
|
||||
@@ -266,6 +266,7 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
import {
|
||||
addPriceTrackCandidate,
|
||||
completePriceTrackLoopChild,
|
||||
@@ -1604,14 +1605,9 @@ function canDownload(item: PriceTrackHistoryItem) {
|
||||
|
||||
async function downloadResult(item: PriceTrackHistoryItem) {
|
||||
if (!item.resultId) return
|
||||
const api = getPywebviewApi()
|
||||
const filename = item.outputFilename || `${item.shopName || 'result'}.xlsx`
|
||||
if (!api?.save_file_from_url_new) {
|
||||
ElMessage.error('当前客户端未提供 save_file_from_url_new,无法下载文件')
|
||||
return
|
||||
}
|
||||
const url = getPriceTrackResultDownloadUrl(item.resultId)
|
||||
const result = await api.save_file_from_url_new(url, filename)
|
||||
const result = await saveUrlWithProgress(url, filename, `price-track:${item.resultId}`)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
|
||||
@@ -221,6 +221,7 @@ import {
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
|
||||
const shopInput = ref('')
|
||||
const candidates = ref<ProductRiskCandidateVo[]>([])
|
||||
@@ -981,16 +982,10 @@ function canDownload(item: ProductRiskHistoryItem) {
|
||||
|
||||
async function downloadResult(item: ProductRiskHistoryItem) {
|
||||
if (!item.resultId) return
|
||||
const api = getPywebviewApi()
|
||||
const url = getProductRiskResultDownloadUrl(item.resultId)
|
||||
const filename = item.outputFilename || `${item.shopName || 'result'}.zip`
|
||||
|
||||
if (!api?.save_file_from_url_new) {
|
||||
ElMessage.error('当前客户端未提供 save_file_from_url_new,无法下载文件')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await api.save_file_from_url_new(url, filename)
|
||||
const result = await saveUrlWithProgress(url, filename, `product-risk:${item.resultId}`)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
|
||||
@@ -340,6 +340,7 @@ import {
|
||||
import { getPywebviewApi } from "@/shared/bridges/pywebview";
|
||||
import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
|
||||
import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
|
||||
import { saveUrlWithProgress } from "@/shared/utils/download-progress";
|
||||
|
||||
const MAX_TRANSIENT_ERRORS = 30;
|
||||
|
||||
@@ -1105,14 +1106,9 @@ async function pushToPythonQueue() {
|
||||
|
||||
async function downloadResult(item: QueryAsinHistoryItem) {
|
||||
if (!item.resultId) return;
|
||||
const api = getPywebviewApi();
|
||||
if (!api?.save_file_from_url_new) {
|
||||
ElMessage.error("当前客户端未提供下载能力");
|
||||
return;
|
||||
}
|
||||
const url = getQueryAsinResultDownloadUrl(item.resultId);
|
||||
const filename = item.outputFilename || `${item.shopName || "result"}.xlsx`;
|
||||
const result = await api.save_file_from_url_new(url, filename);
|
||||
const result = await saveUrlWithProgress(url, filename, `query-asin:${item.resultId}`);
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存: ${result.path || filename}`);
|
||||
} else if (result.error && result.error !== "用户取消") {
|
||||
|
||||
@@ -112,6 +112,7 @@ import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, dele
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
|
||||
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
|
||||
const shopInput = ref('')
|
||||
@@ -400,7 +401,7 @@ function nextScheduledDisplay(item: ShopMatchHistoryItem) { const snapshot = tas
|
||||
function statusText(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); if (status === 'SCHEDULED') return '待执行'; if (status === 'RUNNING') return '执行中'; if (status === 'SUCCESS' || status === 'COMPLETED') return '已完成'; if (status === 'FAILED') return '失败'; return item.success ? '已完成' : '未知' }
|
||||
function statusClass(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
|
||||
function canDownload(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return !!item.resultId && (!!item.fileReady || !!item.downloadUrl) && (status === 'SUCCESS' || status === 'COMPLETED') }
|
||||
async function downloadResult(item: ShopMatchHistoryItem) { if (!item.resultId) return; const api = getPywebviewApi(); if (!api?.save_file_from_url_new) { ElMessage.error('当前客户端未提供下载能力'); return } const url = getShopMatchResultDownloadUrl(item.resultId); const filename = item.outputFilename || `${item.shopName || 'result'}.xlsx`; const result = await api.save_file_from_url_new(url, filename); if (result.success) ElMessage.success(`已保存: ${result.path || filename}`); else if (result.error && result.error !== '用户取消') ElMessage.error(result.error) }
|
||||
async function downloadResult(item: ShopMatchHistoryItem) { if (!item.resultId) return; const url = getShopMatchResultDownloadUrl(item.resultId); const filename = item.outputFilename || `${item.shopName || 'result'}.xlsx`; const result = await saveUrlWithProgress(url, filename, `shop-match:${item.resultId}`); if (result.success) ElMessage.success(`已保存: ${result.path || filename}`); else if (result.error && result.error !== '用户取消') ElMessage.error(result.error) }
|
||||
async function deleteTaskRecord(item: ShopMatchHistoryItem) {
|
||||
const taskId = normalizeTaskId(item.taskId)
|
||||
const resultId = Number(item.resultId || 0)
|
||||
|
||||
@@ -162,6 +162,7 @@ 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'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
|
||||
const selectedFileNames = ref<string[]>([])
|
||||
const uploadedFiles = ref<UploadFileVo[]>([])
|
||||
@@ -221,7 +222,7 @@ const historyOnlyItems = computed(() =>
|
||||
)
|
||||
|
||||
function effectiveCozeApiKey() {
|
||||
return getStoredApiSecret().trim()
|
||||
return getStoredApiSecret('similar-asin').trim()
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string) {
|
||||
@@ -822,14 +823,9 @@ function shouldShowFallbackJobProgress(item: SimilarAsinHistoryItem) {
|
||||
|
||||
async function downloadResult(item: SimilarAsinHistoryItem) {
|
||||
if (!item.resultId) return
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.save_file_from_url_new) {
|
||||
ElMessage.error('当前客户端未提供下载能力')
|
||||
return
|
||||
}
|
||||
const url = getSimilarAsinResultDownloadUrl(item.resultId)
|
||||
const url = item.downloadUrl || getSimilarAsinResultDownloadUrl(item.resultId)
|
||||
const filename = item.resultFilename || `${item.sourceFilename || 'similar-asin'}.xlsx`
|
||||
const result = await api.save_file_from_url_new(url, filename)
|
||||
const result = await saveUrlWithProgress(url, filename, `similar-asin:${item.resultId}`)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存: ${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
|
||||
@@ -169,6 +169,7 @@ import BrandTopBar from './BrandTopBar.vue'
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem, type SplitRunVo } from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
|
||||
const splitSelectedPaths = ref<string[]>([])
|
||||
const splitArchiveName = ref('')
|
||||
@@ -347,7 +348,6 @@ function formatSplitEntries(entries: NonNullable<SplitResultItem['entries']>) {
|
||||
}
|
||||
|
||||
async function downloadSplitResult(item: SplitResultItem) {
|
||||
const api = getPywebviewApi()
|
||||
const url = item.downloadUrl || (item.resultId ? getSplitResultDownloadUrl(item.resultId) : '')
|
||||
if (!url) {
|
||||
ElMessage.warning('当前结果没有下载地址')
|
||||
@@ -355,14 +355,11 @@ async function downloadSplitResult(item: SplitResultItem) {
|
||||
}
|
||||
|
||||
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.zip`
|
||||
if (api?.save_file_from_url_new) {
|
||||
const result = await api.save_file_from_url_new(url, filename)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
return
|
||||
const result = await saveUrlWithProgress(url, filename, item.resultId ? `split:${item.resultId}` : undefined)
|
||||
if (result.success) {
|
||||
ElMessage.success(`已保存:${result.path || filename}`)
|
||||
} else if (result.error && result.error !== '用户取消') {
|
||||
ElMessage.error(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<div class="top-right">
|
||||
<BrandApiSecretSettingsButton />
|
||||
</div>
|
||||
<DownloadProgressPanel />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -45,6 +46,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import BrandApiSecretSettingsButton from '@/pages/brand/components/BrandApiSecretSettingsButton.vue'
|
||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||
import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue'
|
||||
|
||||
type ActiveNavKey =
|
||||
| 'brand'
|
||||
@@ -59,6 +61,7 @@ type ActiveNavKey =
|
||||
| 'pricing'
|
||||
| 'patrol-delete'
|
||||
| 'query-asin'
|
||||
| 'collect-data'
|
||||
|
||||
type NavItem = {
|
||||
key: string
|
||||
@@ -84,7 +87,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
||||
columnKey: 'brand_front_tools',
|
||||
label: '前端工具',
|
||||
items: [
|
||||
{ key: 'collect', label: '采集数据' },
|
||||
{ key: 'collect-data', label: '采集数据', href: '/new_web_source/collect-data.html' },
|
||||
{ key: 'variant', label: '变体分析' },
|
||||
{ key: 'brand', label: '品牌检测', href: '/brand' },
|
||||
{ key: 'appearance-patent', label: '外观专利检测', href: '/new_web_source/appearance-patent.html' },
|
||||
|
||||
@@ -1498,6 +1498,7 @@ export interface AppearancePatentParsedRow {
|
||||
asin: string;
|
||||
country: string;
|
||||
price?: string;
|
||||
sku?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
}
|
||||
@@ -2220,6 +2221,226 @@ export function deletePendingPriceTrackShopResult(shopName: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 采集数据 ==========
|
||||
|
||||
export interface CollectDataSourceFile {
|
||||
fileKey: string;
|
||||
originalFilename?: string;
|
||||
relativePath?: string;
|
||||
}
|
||||
|
||||
export interface CollectDataFilters {
|
||||
amount?: number | string | null;
|
||||
rank?: number | null;
|
||||
fba?: boolean | null;
|
||||
fbm?: boolean | null;
|
||||
countryCodes?: string[];
|
||||
}
|
||||
|
||||
export interface CollectDataParseRequest {
|
||||
user_id: number;
|
||||
files: CollectDataSourceFile[];
|
||||
task_type?: string;
|
||||
filters?: CollectDataFilters;
|
||||
}
|
||||
|
||||
export interface CollectDataParseVo {
|
||||
taskId: number;
|
||||
taskNo?: string;
|
||||
sourceFilename?: string;
|
||||
sourceFileCount?: number;
|
||||
totalRows?: number;
|
||||
acceptedRows?: number;
|
||||
droppedRows?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface CollectDataDashboardVo {
|
||||
pendingTaskCount: number;
|
||||
processedTaskCount: number;
|
||||
successTaskCount: number;
|
||||
failedTaskCount: number;
|
||||
}
|
||||
|
||||
export interface CollectDataHistoryItem {
|
||||
resultId?: number;
|
||||
taskId?: number;
|
||||
taskNo?: string;
|
||||
sourceFilename?: string;
|
||||
resultFilename?: string;
|
||||
downloadUrl?: string;
|
||||
taskStatus?: string;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
rowCount?: number;
|
||||
createdAt?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
taskType?: string;
|
||||
filters?: CollectDataFilters;
|
||||
}
|
||||
|
||||
export interface CollectDataHistoryVo {
|
||||
items: CollectDataHistoryItem[];
|
||||
}
|
||||
|
||||
export interface CollectDataTaskSummary {
|
||||
id?: number;
|
||||
taskNo?: string;
|
||||
status?: string;
|
||||
errorMessage?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
taskType?: string;
|
||||
}
|
||||
|
||||
export interface CollectDataTaskDetailVo {
|
||||
task?: CollectDataTaskSummary;
|
||||
items?: CollectDataHistoryItem[];
|
||||
}
|
||||
|
||||
export interface CollectDataTaskBatchVo {
|
||||
items: CollectDataTaskDetailVo[];
|
||||
missingTaskIds?: number[];
|
||||
}
|
||||
|
||||
export interface CollectDataItemVo {
|
||||
id?: number;
|
||||
rowIndex?: number;
|
||||
sourceFileKey?: string;
|
||||
sourceFilename?: string;
|
||||
keyword?: string;
|
||||
statusValue?: string;
|
||||
extra?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CollectDataItemsPageVo {
|
||||
taskId?: number;
|
||||
taskNo?: string;
|
||||
taskType?: string;
|
||||
taskStatus?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
count?: number;
|
||||
total?: number;
|
||||
totalPages?: number;
|
||||
filters?: CollectDataFilters;
|
||||
items: CollectDataItemVo[];
|
||||
}
|
||||
|
||||
export function parseCollectData(
|
||||
request: Omit<CollectDataParseRequest, "user_id"> | CollectDataParseRequest,
|
||||
) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<CollectDataParseVo>, CollectDataParseRequest>(
|
||||
`${JAVA_API_PREFIX}/collect-data/parse`,
|
||||
{ ...request, user_id: getCurrentUserId() },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function activateCollectDataTask(taskId: number) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<null>, undefined>(
|
||||
`${JAVA_API_PREFIX}/collect-data/tasks/${taskId}/activate?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCollectDataItemsPage(
|
||||
taskId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 50,
|
||||
) {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<CollectDataItemsPageVo>>(
|
||||
`${JAVA_API_PREFIX}/collect-data/tasks/${taskId}/items`,
|
||||
{
|
||||
params: {
|
||||
user_id: getCurrentUserId(),
|
||||
page,
|
||||
page_size: pageSize,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCollectDataDashboard() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<CollectDataDashboardVo>>(
|
||||
`${JAVA_API_PREFIX}/collect-data/dashboard`,
|
||||
{ params: { user_id: getCurrentUserId() } },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCollectDataHistory(limit: number = 50) {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<CollectDataHistoryVo>>(
|
||||
`${JAVA_API_PREFIX}/collect-data/history`,
|
||||
{ params: { user_id: getCurrentUserId(), limit } },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCollectDataTaskProgressBatch(
|
||||
taskIds: number[],
|
||||
options: TaskProgressBatchOptions = {},
|
||||
) {
|
||||
return postTaskProgressBatch<CollectDataTaskBatchVo>(
|
||||
`${JAVA_API_PREFIX}/collect-data/tasks/progress/batch`,
|
||||
taskIds,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCollectDataTask(taskId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(
|
||||
`${JAVA_API_PREFIX}/collect-data/tasks/${taskId}`,
|
||||
{ params: { user_id: getCurrentUserId() } },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCollectDataHistory(resultId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(
|
||||
`${JAVA_API_PREFIX}/collect-data/history/${resultId}`,
|
||||
{ params: { user_id: getCurrentUserId() } },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export interface CollectDataCountryPreferenceVo {
|
||||
country_codes: string[];
|
||||
}
|
||||
|
||||
export function getCollectDataCountryPreference() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<CollectDataCountryPreferenceVo>>(
|
||||
`${JAVA_API_PREFIX}/collect-data/country-preference`,
|
||||
{ params: { user_id: getCurrentUserId() } },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function putCollectDataCountryPreference(countryCodes: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
put<
|
||||
JavaApiResponse<CollectDataCountryPreferenceVo>,
|
||||
{ user_id: number; country_codes: string[] }
|
||||
>(`${JAVA_API_PREFIX}/collect-data/country-preference`, {
|
||||
user_id: getCurrentUserId(),
|
||||
country_codes: countryCodes,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getJavaDownloadUrl(path: string) {
|
||||
let raw =
|
||||
path.startsWith("http://") || path.startsWith("https://")
|
||||
|
||||
@@ -45,6 +45,11 @@ export interface PywebviewApi {
|
||||
url: string,
|
||||
filename: string,
|
||||
) => Promise<{ success: boolean; path?: string; error?: string }>;
|
||||
save_file_from_url_with_progress?: (
|
||||
url: string,
|
||||
filename: string,
|
||||
downloadId: string,
|
||||
) => Promise<{ success: boolean; path?: string; error?: string }>;
|
||||
save_template_xlsx?: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
|
||||
133
frontend-vue/src/shared/components/DownloadProgressPanel.vue
Normal file
133
frontend-vue/src/shared/components/DownloadProgressPanel.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div v-if="visibleItems.length" class="download-progress-panel">
|
||||
<div class="download-progress-title">下载进度</div>
|
||||
<div v-for="item in visibleItems" :key="item.id" class="download-progress-item" :class="item.status">
|
||||
<div class="download-progress-head">
|
||||
<span class="download-progress-name" :title="item.filename">{{ item.filename }}</span>
|
||||
<button type="button" class="download-progress-close" @click="clearDownloadProgress(item.id)">×</button>
|
||||
</div>
|
||||
<div class="download-progress-meta">
|
||||
<span>{{ downloadProgressText(item) }}</span>
|
||||
<span>{{ item.percent }}%</span>
|
||||
</div>
|
||||
<div class="download-progress-track">
|
||||
<div class="download-progress-bar" :style="{ width: `${item.percent}%` }"></div>
|
||||
</div>
|
||||
<div class="download-progress-size">
|
||||
{{ formatDownloadBytes(item.downloaded) }} / {{ formatDownloadBytes(item.total) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
clearDownloadProgress,
|
||||
downloadProgressText,
|
||||
formatDownloadBytes,
|
||||
useDownloadProgress,
|
||||
} from '@/shared/utils/download-progress'
|
||||
|
||||
const { items } = useDownloadProgress()
|
||||
const visibleItems = computed(() => items.value.slice(0, 5))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.download-progress-panel {
|
||||
position: fixed;
|
||||
right: 22px;
|
||||
bottom: 22px;
|
||||
z-index: 3000;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 44px);
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(105, 135, 166, .42);
|
||||
border-radius: 14px;
|
||||
background: rgba(18, 23, 30, .96);
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, .48);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.download-progress-title {
|
||||
margin-bottom: 10px;
|
||||
color: #eef6ff;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.download-progress-item {
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(77, 96, 116, .5);
|
||||
border-radius: 10px;
|
||||
background: rgba(31, 39, 49, .92);
|
||||
}
|
||||
|
||||
.download-progress-item + .download-progress-item {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.download-progress-head,
|
||||
.download-progress-meta,
|
||||
.download-progress-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.download-progress-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #f4f8fc;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.download-progress-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8d9cab;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.download-progress-meta {
|
||||
margin-top: 8px;
|
||||
color: #9fcfff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.download-progress-track {
|
||||
height: 8px;
|
||||
margin-top: 7px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #27313b;
|
||||
}
|
||||
|
||||
.download-progress-bar {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #35d0ba, #4aa3ff);
|
||||
transition: width .2s ease;
|
||||
}
|
||||
|
||||
.download-progress-size {
|
||||
margin-top: 6px;
|
||||
color: #8794a1;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.download-progress-item.success .download-progress-meta {
|
||||
color: #64d98a;
|
||||
}
|
||||
|
||||
.download-progress-item.failed .download-progress-meta {
|
||||
color: #ff8d8d;
|
||||
}
|
||||
</style>
|
||||
284
frontend-vue/src/shared/composables/useTaskProgressLoop.ts
Normal file
284
frontend-vue/src/shared/composables/useTaskProgressLoop.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
|
||||
/**
|
||||
* 通用任务进度轮询组合式函数。
|
||||
*
|
||||
* 各模块统一使用:保存 taskId 列表(可选 localStorage 持久化),按当前可见性
|
||||
* 周期性请求批量进度接口,每个任务到达终态时回调上层做状态同步、列表刷新等。
|
||||
*
|
||||
* 使用方式:
|
||||
* const loop = useTaskProgressLoop<MyTaskDetailVo>({
|
||||
* scope: 'collect-data',
|
||||
* storageKey: 'brand:collect-data:polling-task-ids',
|
||||
* fetchProgress: (ids) => getCollectDataTaskProgressBatch(ids),
|
||||
* extractTaskId: (detail) => detail.task?.id,
|
||||
* extractStatus: (detail) => detail.task?.status,
|
||||
* isTerminal: (status) => status === 'SUCCESS' || status === 'FAILED',
|
||||
* onUpdate: (taskId, detail) => { ... },
|
||||
* onTerminal: async (taskId, detail) => { await refreshHistory() },
|
||||
* })
|
||||
* loop.add(taskId) // 触发轮询
|
||||
* loop.dispose() // 组件卸载时调用(自动通过 onBeforeUnmount 清理)
|
||||
*/
|
||||
export interface TaskProgressLoopOptions<TDetail> {
|
||||
/** 用于 categorized-timers 的命名空间,例如 'collect-data';同一页面内须唯一 */
|
||||
scope: string
|
||||
/** localStorage 持久化的 key;省略则不持久化 */
|
||||
storageKey?: string
|
||||
/** 拉取批量进度的接口;返回 items 数组 */
|
||||
fetchProgress: (taskIds: number[]) => Promise<{ items?: TDetail[] }>
|
||||
/** 从单条进度详情中提取 taskId */
|
||||
extractTaskId: (detail: TDetail) => number | null | undefined
|
||||
/** 从单条进度详情中提取状态字符串(如 'PENDING'/'RUNNING'/'SUCCESS'/'FAILED') */
|
||||
extractStatus: (detail: TDetail) => string | null | undefined
|
||||
/** 判定是否终态;默认 SUCCESS / FAILED 视为终态 */
|
||||
isTerminal?: (status: string) => boolean
|
||||
/** 每条进度落地时调用,用于上层缓存最新快照 */
|
||||
onUpdate?: (taskId: number, detail: TDetail) => void
|
||||
/**
|
||||
* 任意一个任务进入终态时调用;可以是 async(轮询会等待完成再调度下一轮)。
|
||||
* 若多个任务同一轮到达终态,会被分别回调。
|
||||
*/
|
||||
onTerminal?: (taskId: number, detail: TDetail | undefined, status: string) => void | Promise<void>
|
||||
/** 轮询周期失败时的回调;默认静默 */
|
||||
onError?: (error: unknown) => void
|
||||
/** 自定义轮询间隔;默认根据 document.visibilityState 自适应(5s/30s) */
|
||||
getIntervalMs?: () => number
|
||||
}
|
||||
|
||||
export interface TaskProgressLoopHandle<TDetail> {
|
||||
taskIds: Ref<number[]>
|
||||
taskStatuses: Ref<Record<number, string>>
|
||||
inFlight: Ref<boolean>
|
||||
add: (taskId: number) => void
|
||||
remove: (taskId: number) => void
|
||||
reset: (taskIds: number[]) => void
|
||||
ensure: (immediate?: boolean) => void
|
||||
stop: () => void
|
||||
refreshOnce: () => Promise<void>
|
||||
isTerminal: (taskId: number) => boolean
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL = (status: string) => status === 'SUCCESS' || status === 'FAILED'
|
||||
|
||||
function readIdsFromStorage(key?: string): number[] {
|
||||
if (!key || typeof window === 'undefined') return []
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return Array.isArray(parsed) ? parsed.filter((n): n is number => typeof n === 'number' && n > 0) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeIdsToStorage(key: string | undefined, ids: number[]) {
|
||||
if (!key || typeof window === 'undefined') return
|
||||
try {
|
||||
if (ids.length === 0) {
|
||||
window.localStorage.removeItem(key)
|
||||
} else {
|
||||
window.localStorage.setItem(key, JSON.stringify(ids))
|
||||
}
|
||||
} catch {
|
||||
/* 写本地存储失败不影响功能 */
|
||||
}
|
||||
}
|
||||
|
||||
export function useTaskProgressLoop<TDetail>(
|
||||
options: TaskProgressLoopOptions<TDetail>,
|
||||
): TaskProgressLoopHandle<TDetail> {
|
||||
const timers = createCategorizedTimers(`task-progress-loop:${options.scope}`)
|
||||
const isTerminal = options.isTerminal ?? DEFAULT_TERMINAL
|
||||
const intervalMs = options.getIntervalMs ?? getTaskPollIntervalMs
|
||||
|
||||
const taskIds = ref<number[]>(readIdsFromStorage(options.storageKey))
|
||||
const taskStatuses = ref<Record<number, string>>({})
|
||||
const inFlight = ref(false)
|
||||
let pollTimer: number | null = null
|
||||
let disposed = false
|
||||
|
||||
function persist() {
|
||||
writeIdsToStorage(options.storageKey, taskIds.value)
|
||||
}
|
||||
|
||||
function add(taskId: number) {
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) return
|
||||
if (taskIds.value.includes(taskId)) return
|
||||
taskIds.value = [...taskIds.value, taskId]
|
||||
persist()
|
||||
ensure(true)
|
||||
}
|
||||
|
||||
function remove(taskId: number) {
|
||||
if (!taskIds.value.includes(taskId)) return
|
||||
taskIds.value = taskIds.value.filter((id) => id !== taskId)
|
||||
persist()
|
||||
if (taskStatuses.value[taskId]) {
|
||||
const next = { ...taskStatuses.value }
|
||||
delete next[taskId]
|
||||
taskStatuses.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function reset(ids: number[]) {
|
||||
const cleaned = Array.from(new Set(ids.filter((n) => Number.isFinite(n) && n > 0)))
|
||||
taskIds.value = cleaned
|
||||
persist()
|
||||
}
|
||||
|
||||
function isTerminalById(taskId: number) {
|
||||
const s = taskStatuses.value[taskId]
|
||||
return !!s && isTerminal(s)
|
||||
}
|
||||
|
||||
async function refreshOnce() {
|
||||
if (disposed) return
|
||||
const ids = taskIds.value.filter((id) => id > 0)
|
||||
if (!ids.length) return
|
||||
inFlight.value = true
|
||||
try {
|
||||
const result = await options.fetchProgress(ids)
|
||||
const items = result?.items || []
|
||||
const terminalEvents: Array<{ taskId: number; detail: TDetail | undefined; status: string }> = []
|
||||
const nextStatuses = { ...taskStatuses.value }
|
||||
for (const detail of items) {
|
||||
const id = options.extractTaskId(detail)
|
||||
if (typeof id !== 'number' || id <= 0) continue
|
||||
const status = options.extractStatus(detail) || ''
|
||||
try {
|
||||
options.onUpdate?.(id, detail)
|
||||
} catch {
|
||||
/* onUpdate 抛错不应中断本轮 */
|
||||
}
|
||||
if (status) {
|
||||
nextStatuses[id] = status
|
||||
if (isTerminal(status)) {
|
||||
terminalEvents.push({ taskId: id, detail, status })
|
||||
}
|
||||
}
|
||||
}
|
||||
taskStatuses.value = nextStatuses
|
||||
for (const event of terminalEvents) {
|
||||
remove(event.taskId)
|
||||
try {
|
||||
await options.onTerminal?.(event.taskId, event.detail, event.status)
|
||||
} catch {
|
||||
/* onTerminal 抛错只影响一次回调 */
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
options.onError?.(error)
|
||||
} finally {
|
||||
inFlight.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearPollTimer() {
|
||||
if (pollTimer != null) {
|
||||
timers.clearTimer('task-poll', pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext(immediate = false) {
|
||||
if (disposed) return
|
||||
if (pollTimer != null && !immediate) return
|
||||
clearPollTimer()
|
||||
|
||||
const run = async () => {
|
||||
pollTimer = null
|
||||
if (disposed) return
|
||||
if (!taskIds.value.length) return
|
||||
if (inFlight.value) {
|
||||
// 上一次还没回,500ms 后再试
|
||||
pollTimer = timers.setTimeout('task-poll', run, 500)
|
||||
return
|
||||
}
|
||||
await refreshOnce()
|
||||
if (!disposed && taskIds.value.length > 0) {
|
||||
pollTimer = timers.setTimeout('task-poll', run, intervalMs())
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
void run()
|
||||
} else {
|
||||
pollTimer = timers.setTimeout('task-poll', run, intervalMs())
|
||||
}
|
||||
}
|
||||
|
||||
function ensure(immediate = false) {
|
||||
if (disposed) return
|
||||
if (!taskIds.value.length) return
|
||||
if (pollTimer != null && !immediate) return
|
||||
scheduleNext(immediate)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearPollTimer()
|
||||
}
|
||||
|
||||
// 任务列表清空时自动停止;新增时自动启动一轮
|
||||
watch(
|
||||
taskIds,
|
||||
(ids, prev) => {
|
||||
if (disposed) return
|
||||
if (!ids.length) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if (!prev || prev.length === 0) {
|
||||
scheduleNext(true)
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
// 切到前台后立刻拉一次,让用户回到页面看到的是最新状态
|
||||
let visibilityHandler: (() => void) | null = null
|
||||
if (typeof document !== 'undefined') {
|
||||
visibilityHandler = () => {
|
||||
if (document.visibilityState === 'visible' && taskIds.value.length > 0) {
|
||||
scheduleNext(true)
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityHandler)
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
stop()
|
||||
timers.clearScope()
|
||||
if (visibilityHandler && typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', visibilityHandler)
|
||||
visibilityHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => dispose())
|
||||
|
||||
// 初始化时若已有任务(从 storage 恢复),立即开始一轮
|
||||
if (taskIds.value.length > 0) {
|
||||
scheduleNext(true)
|
||||
}
|
||||
|
||||
return {
|
||||
taskIds,
|
||||
taskStatuses,
|
||||
inFlight,
|
||||
add,
|
||||
remove,
|
||||
reset,
|
||||
ensure,
|
||||
stop,
|
||||
refreshOnce,
|
||||
isTerminal: isTerminalById,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
type LegacyApiSecretModuleKey = 'appearance-patent' | 'similar-asin'
|
||||
export type ApiSecretModuleKey = 'appearance-patent' | 'similar-asin'
|
||||
|
||||
export type ApiSecretRetention = 'session' | '1d' | '7d' | '30d' | 'forever'
|
||||
|
||||
@@ -19,7 +19,7 @@ export type ApiSecretSnapshot = {
|
||||
|
||||
const STORAGE_PREFIX = 'brand:api-secret'
|
||||
const COMMON_SECRET_KEY = 'common'
|
||||
const LEGACY_SECRET_KEYS: LegacyApiSecretModuleKey[] = ['appearance-patent', 'similar-asin']
|
||||
const MODULE_SECRET_KEYS: ApiSecretModuleKey[] = ['appearance-patent', 'similar-asin']
|
||||
|
||||
function currentUserStorageId() {
|
||||
if (typeof window === 'undefined') return '0'
|
||||
@@ -106,40 +106,40 @@ function getLiveRecordFromKey(moduleKey: string): ApiSecretRecord | null {
|
||||
|
||||
function clearLegacyStoredApiSecrets() {
|
||||
if (typeof window === 'undefined') return
|
||||
for (const moduleKey of LEGACY_SECRET_KEYS) {
|
||||
for (const moduleKey of MODULE_SECRET_KEYS) {
|
||||
clearStorageRecord(window.sessionStorage, moduleKey)
|
||||
clearStorageRecord(window.localStorage, moduleKey)
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLegacyRecord(record: ApiSecretRecord) {
|
||||
function migrateCommonRecord(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
|
||||
for (const moduleKey of MODULE_SECRET_KEYS) {
|
||||
if (!getLiveRecordFromKey(moduleKey)) {
|
||||
storage.setItem(buildStorageKey(moduleKey), JSON.stringify(record))
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
clearStorageRecord(window.sessionStorage, COMMON_SECRET_KEY)
|
||||
clearStorageRecord(window.localStorage, COMMON_SECRET_KEY)
|
||||
}
|
||||
|
||||
export function getStoredApiSecret() {
|
||||
return getLiveRecord()?.value || ''
|
||||
function getLiveRecord(moduleKey: ApiSecretModuleKey): ApiSecretRecord | null {
|
||||
const moduleRecord = getLiveRecordFromKey(moduleKey)
|
||||
if (moduleRecord) return moduleRecord
|
||||
|
||||
const commonRecord = getLiveRecordFromKey(COMMON_SECRET_KEY)
|
||||
if (!commonRecord) return null
|
||||
migrateCommonRecord(commonRecord)
|
||||
return getLiveRecordFromKey(moduleKey)
|
||||
}
|
||||
|
||||
export function getStoredApiSecretSnapshot(): ApiSecretSnapshot {
|
||||
const record = getLiveRecord()
|
||||
export function getStoredApiSecret(moduleKey: ApiSecretModuleKey) {
|
||||
return getLiveRecord(moduleKey)?.value || ''
|
||||
}
|
||||
|
||||
export function getStoredApiSecretSnapshot(moduleKey: ApiSecretModuleKey): ApiSecretSnapshot {
|
||||
const record = getLiveRecord(moduleKey)
|
||||
if (!record) {
|
||||
return {
|
||||
value: '',
|
||||
@@ -159,13 +159,14 @@ export function getStoredApiSecretSnapshot(): ApiSecretSnapshot {
|
||||
}
|
||||
|
||||
export function saveStoredApiSecret(
|
||||
moduleKey: ApiSecretModuleKey,
|
||||
value: string,
|
||||
retention: ApiSecretRetention,
|
||||
) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
clearStoredApiSecret()
|
||||
clearStoredApiSecret(moduleKey)
|
||||
if (!trimmedValue) return
|
||||
|
||||
const now = Date.now()
|
||||
@@ -177,10 +178,16 @@ export function saveStoredApiSecret(
|
||||
}
|
||||
|
||||
const storage = retention === 'session' ? window.sessionStorage : window.localStorage
|
||||
storage.setItem(buildStorageKey(COMMON_SECRET_KEY), JSON.stringify(record))
|
||||
storage.setItem(buildStorageKey(moduleKey), JSON.stringify(record))
|
||||
}
|
||||
|
||||
export function clearStoredApiSecret() {
|
||||
export function clearStoredApiSecret(moduleKey: ApiSecretModuleKey) {
|
||||
if (typeof window === 'undefined') return
|
||||
clearStorageRecord(window.sessionStorage, moduleKey)
|
||||
clearStorageRecord(window.localStorage, moduleKey)
|
||||
}
|
||||
|
||||
export function clearAllStoredApiSecrets() {
|
||||
if (typeof window === 'undefined') return
|
||||
clearStorageRecord(window.sessionStorage, COMMON_SECRET_KEY)
|
||||
clearStorageRecord(window.localStorage, COMMON_SECRET_KEY)
|
||||
|
||||
180
frontend-vue/src/shared/utils/download-progress.ts
Normal file
180
frontend-vue/src/shared/utils/download-progress.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
|
||||
export type DownloadProgressStatus = 'selecting' | 'running' | 'success' | 'failed' | 'cancelled'
|
||||
|
||||
export type DownloadProgressItem = {
|
||||
id: string
|
||||
filename: string
|
||||
status: DownloadProgressStatus
|
||||
path?: string
|
||||
downloaded: number
|
||||
total: number
|
||||
percent: number
|
||||
error?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
type PywebviewDownloadProgressEvent = {
|
||||
id: string
|
||||
status: 'running' | 'success' | 'failed'
|
||||
path?: string
|
||||
downloaded: number
|
||||
total: number
|
||||
percent: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
const progressItems = reactive<Record<string, DownloadProgressItem>>({})
|
||||
let progressListenerBound = false
|
||||
|
||||
function normalizePercent(value: number) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.max(0, Math.min(100, Math.round(value)))
|
||||
}
|
||||
|
||||
function now() {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
function upsertProgress(partial: Omit<Partial<DownloadProgressItem>, 'id'> & { id: string }) {
|
||||
const existing = progressItems[partial.id]
|
||||
const timestamp = now()
|
||||
progressItems[partial.id] = {
|
||||
id: partial.id,
|
||||
filename: partial.filename || existing?.filename || '下载文件',
|
||||
status: partial.status || existing?.status || 'running',
|
||||
path: partial.path ?? existing?.path,
|
||||
downloaded: Number(partial.downloaded ?? existing?.downloaded ?? 0),
|
||||
total: Number(partial.total ?? existing?.total ?? 0),
|
||||
percent: normalizePercent(Number(partial.percent ?? existing?.percent ?? 0)),
|
||||
error: partial.error ?? existing?.error,
|
||||
createdAt: existing?.createdAt || timestamp,
|
||||
updatedAt: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
function handlePywebviewProgress(event: Event) {
|
||||
const detail = (event as CustomEvent<PywebviewDownloadProgressEvent>).detail
|
||||
if (!detail?.id) return
|
||||
upsertProgress({
|
||||
id: detail.id,
|
||||
status: detail.status,
|
||||
path: detail.path,
|
||||
downloaded: detail.downloaded,
|
||||
total: detail.total,
|
||||
percent: detail.percent,
|
||||
error: detail.error,
|
||||
})
|
||||
}
|
||||
|
||||
export function ensureDownloadProgressListener() {
|
||||
if (progressListenerBound || typeof window === 'undefined') return
|
||||
progressListenerBound = true
|
||||
window.addEventListener('pywebview-download-progress', handlePywebviewProgress)
|
||||
}
|
||||
|
||||
export function useDownloadProgress() {
|
||||
ensureDownloadProgressListener()
|
||||
const items = computed(() =>
|
||||
Object.values(progressItems)
|
||||
.filter((item) => item.status !== 'cancelled')
|
||||
.sort((a, b) => b.createdAt - a.createdAt),
|
||||
)
|
||||
return {
|
||||
items,
|
||||
clearDownloadProgress,
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDownloadProgress(id: string) {
|
||||
delete progressItems[id]
|
||||
}
|
||||
|
||||
function buildDownloadId(filename: string) {
|
||||
const safeName = filename.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 60) || 'download'
|
||||
return `download:${Date.now()}:${Math.random().toString(16).slice(2)}:${safeName}`
|
||||
}
|
||||
|
||||
export async function saveUrlWithProgress(url: string, filename: string, id = buildDownloadId(filename)) {
|
||||
ensureDownloadProgressListener()
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.save_file_from_url_new) {
|
||||
upsertProgress({
|
||||
id,
|
||||
filename,
|
||||
status: 'failed',
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
error: '当前客户端未提供下载能力',
|
||||
})
|
||||
return { success: false, error: '当前客户端未提供下载能力' }
|
||||
}
|
||||
|
||||
upsertProgress({
|
||||
id,
|
||||
filename,
|
||||
status: 'selecting',
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
})
|
||||
|
||||
const result = api.save_file_from_url_with_progress
|
||||
? await api.save_file_from_url_with_progress(url, filename, id)
|
||||
: await api.save_file_from_url_new(url, filename)
|
||||
|
||||
if (result.success) {
|
||||
const existing = progressItems[id]
|
||||
upsertProgress({
|
||||
id,
|
||||
filename,
|
||||
status: 'success',
|
||||
path: result.path,
|
||||
downloaded: existing?.downloaded || existing?.total || 0,
|
||||
total: existing?.total || existing?.downloaded || 0,
|
||||
percent: 100,
|
||||
})
|
||||
} else if (result.error === '用户取消') {
|
||||
upsertProgress({
|
||||
id,
|
||||
filename,
|
||||
status: 'cancelled',
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
})
|
||||
clearDownloadProgress(id)
|
||||
} else {
|
||||
upsertProgress({
|
||||
id,
|
||||
filename,
|
||||
status: 'failed',
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
error: result.error || '下载失败',
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function formatDownloadBytes(value: number) {
|
||||
const size = Number(value || 0)
|
||||
if (!Number.isFinite(size) || size <= 0) return '未知大小'
|
||||
if (size < 1024) return `${size} B`
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
export function downloadProgressText(item: DownloadProgressItem) {
|
||||
if (item.status === 'selecting') return '等待选择保存位置'
|
||||
if (item.status === 'success') return '下载完成'
|
||||
if (item.status === 'failed') return item.error || '下载失败'
|
||||
return item.total > 0 ? '正在下载,请等待完成后再打开' : '正在下载,正在获取文件大小'
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export default defineConfig({
|
||||
'price-track': resolve(__dirname, 'price-track.html'),
|
||||
'patrol-delete': resolve(__dirname, 'patrol-delete.html'),
|
||||
'query-asin': resolve(__dirname, 'query-asin.html'),
|
||||
'collect-data': resolve(__dirname, 'collect-data.html'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'assets/[name].js',
|
||||
|
||||
Reference in New Issue
Block a user