增加公共下载进度、增加接收SKU、密钥分别存放
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ? '正在下载,请等待完成后再打开' : '正在下载,正在获取文件大小'
|
||||
}
|
||||
Reference in New Issue
Block a user