更改下载方法名

This commit is contained in:
super
2026-04-06 22:26:44 +08:00
parent afffb7aad2
commit c9947c4ac8
17 changed files with 1419 additions and 724 deletions

File diff suppressed because one or more lines are too long

View File

@@ -154,7 +154,7 @@ class WindowAPI:
) )
return result[0] if result else '' return result[0] if result else ''
def save_file_from_url(self, url, default_filename='download.bin'): def save_file_from_url_new(self, url, default_filename='download.bin'):
"""弹窗选择保存位置,从 url 下载文件并保存。根据文件后缀动态设置保存类型。""" """弹窗选择保存位置,从 url 下载文件并保存。根据文件后缀动态设置保存类型。"""
if not url or not url.strip(): if not url or not url.strip():
return {'success': False, 'error': '下载地址为空'} return {'success': False, 'error': '下载地址为空'}
@@ -360,7 +360,7 @@ def main():
) )
api = WindowAPI(window) api = WindowAPI(window)
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder, window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder,
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del) api.save_file_from_url_new, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del)
webview.start( webview.start(
debug=True, debug=True,
storage_path=cache_path, storage_path=cache_path,

View File

@@ -8,7 +8,7 @@
<link rel="modulepreload" crossorigin href="/assets/pywebview-V_UIajJm.js"> <link rel="modulepreload" crossorigin href="/assets/pywebview-V_UIajJm.js">
<link rel="modulepreload" crossorigin href="/assets/brand-1nUVsWrm.js"> <link rel="modulepreload" crossorigin href="/assets/brand-1nUVsWrm.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Q4glvYu5.css"> <link rel="stylesheet" crossorigin href="/assets/pywebview-Q4glvYu5.css">
<link rel="stylesheet" crossorigin href="/assets/delete-brand-_jGFWTAn.css"> <link rel="stylesheet" crossorigin href="/assets/delete-brand-DLyg-H8J.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -927,7 +927,7 @@
} }
var zipUrl = task.result_paths.zip_url; var zipUrl = task.result_paths.zip_url;
var filename = 'brand_task_' + taskId + '.zip'; var filename = 'brand_task_' + taskId + '.zip';
if (api && api.save_file_from_url) { if (api && api.save_file_from_url_new) {
a.style.pointerEvents = 'none'; a.style.pointerEvents = 'none';
var done = function(ret) { var done = function(ret) {
if (ret && ret.success) { if (ret && ret.success) {
@@ -938,7 +938,7 @@
a.style.pointerEvents = ''; a.style.pointerEvents = '';
}; };
try { try {
var ret = api.save_file_from_url(zipUrl, filename); var ret = api.save_file_from_url_new(zipUrl, filename);
if (ret && typeof ret.then === 'function') { if (ret && typeof ret.then === 'function') {
ret.then(done).catch(function(err) { showToast('保存失败:' + (err && err.message || err)); a.style.pointerEvents = ''; }); ret.then(done).catch(function(err) { showToast('保存失败:' + (err && err.message || err)); a.style.pointerEvents = ''; });
} else { } else {

View File

@@ -995,7 +995,7 @@
} }
var zipUrl = task.result_paths.zip_url; var zipUrl = task.result_paths.zip_url;
var filename = 'brand_task_' + taskId + '.zip'; var filename = 'brand_task_' + taskId + '.zip';
if (api && api.save_file_from_url) { if (api && api.save_file_from_url_new) {
a.style.pointerEvents = 'none'; a.style.pointerEvents = 'none';
var done = function(ret) { var done = function(ret) {
if (ret && ret.success) { if (ret && ret.success) {
@@ -1006,7 +1006,7 @@
a.style.pointerEvents = ''; a.style.pointerEvents = '';
}; };
try { try {
var ret = api.save_file_from_url(zipUrl, filename); var ret = api.save_file_from_url_new(zipUrl, filename);
if (ret && typeof ret.then === 'function') { if (ret && typeof ret.then === 'function') {
ret.then(done).catch(function(err) { showToast('保存失败:' + (err && err.message || err)); a.style.pointerEvents = ''; }); ret.then(done).catch(function(err) { showToast('保存失败:' + (err && err.message || err)); a.style.pointerEvents = ''; });
} else { } else {

View File

@@ -877,7 +877,7 @@
} }
var zipUrl = task.result_paths.zip_url; var zipUrl = task.result_paths.zip_url;
var filename = 'brand_task_' + taskId + '.zip'; var filename = 'brand_task_' + taskId + '.zip';
if (api && api.save_file_from_url) { if (api && api.save_file_from_url_new) {
a.style.pointerEvents = 'none'; a.style.pointerEvents = 'none';
var done = function(ret) { var done = function(ret) {
if (ret && ret.success) { if (ret && ret.success) {
@@ -888,7 +888,7 @@
a.style.pointerEvents = ''; a.style.pointerEvents = '';
}; };
try { try {
var ret = api.save_file_from_url(zipUrl, filename); var ret = api.save_file_from_url_new(zipUrl, filename);
if (ret && typeof ret.then === 'function') { if (ret && typeof ret.then === 'function') {
ret.then(done).catch(function(err) { showToast('保存失败:' + (err && err.message || err)); a.style.pointerEvents = ''; }); ret.then(done).catch(function(err) { showToast('保存失败:' + (err && err.message || err)); a.style.pointerEvents = ''; });
} else { } else {

View File

@@ -95,7 +95,7 @@ public class ConvertRunController {
} }
@GetMapping("/results/{resultId}/download") @GetMapping("/results/{resultId}/download")
@Operation(summary = "下载格式转换结果", description = "根据结果记录 ID 下载当前用户的转换结果文件。桌面端前端可继续通过 save_file_from_url 选择保存位置。") @Operation(summary = "下载格式转换结果", description = "根据结果记录 ID 下载当前用户的转换结果文件。桌面端前端可继续通过 save_file_from_url_new 选择保存位置。")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在")

View File

@@ -103,7 +103,7 @@ public class DeleteBrandRunController {
} }
@GetMapping("/tasks/{taskId}/download") @GetMapping("/tasks/{taskId}/download")
@Operation(summary = "下载删除品牌任务结果", description = "返回任务结果文件流,便于桌面端通过 save_file_from_url 直接保存。") @Operation(summary = "下载删除品牌任务结果", description = "返回任务结果文件流,便于桌面端通过 save_file_from_url_new 直接保存。")
public void download(@PathVariable Long taskId, jakarta.servlet.http.HttpServletResponse response) { public void download(@PathVariable Long taskId, jakarta.servlet.http.HttpServletResponse response) {
String url = deleteBrandRunService.resolveTaskDownloadUrl(taskId); String url = deleteBrandRunService.resolveTaskDownloadUrl(taskId);
String filename = deleteBrandRunService.resolveTaskDownloadFilename(taskId); String filename = deleteBrandRunService.resolveTaskDownloadFilename(taskId);

View File

@@ -391,8 +391,8 @@ async function saveTemplateFromUrl(url: string, fallbackFilename: string, bridge
return return
} }
if (api?.save_file_from_url) { if (api?.save_file_from_url_new) {
const result = await api.save_file_from_url(url, fallbackFilename) const result = await api.save_file_from_url_new(url, fallbackFilename)
if (result.success) { if (result.success) {
ElMessage.success(`已保存:${result.path || fallbackFilename}`) ElMessage.success(`已保存:${result.path || fallbackFilename}`)
} else if (result.error && result.error !== '用户取消') { } else if (result.error && result.error !== '用户取消') {
@@ -480,8 +480,8 @@ async function downloadConvertResult(item: ConvertResultItem) {
} }
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.txt` const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.txt`
if (api?.save_file_from_url) { if (api?.save_file_from_url_new) {
const result = await api.save_file_from_url(item.downloadUrl, filename) const result = await api.save_file_from_url_new(item.downloadUrl, filename)
if (result.success) { if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`) ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') { } else if (result.error && result.error !== '用户取消') {

View File

@@ -335,8 +335,8 @@ async function downloadCleanResult(item: DedupeResultItem) {
} }
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}_cleaned.xlsx` const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}_cleaned.xlsx`
if (api?.save_file_from_url) { if (api?.save_file_from_url_new) {
const result = await api.save_file_from_url(item.downloadUrl, filename) const result = await api.save_file_from_url_new(item.downloadUrl, filename)
if (result.success) { if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`) ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') { } else if (result.error && result.error !== '用户取消') {

View File

@@ -286,22 +286,52 @@ function loadSessionTasksFromStorage() {
} }
function saveSessionTask(taskId: number, items: DeleteBrandResultItem[], pushResult?: string, payloadText?: string) { function saveSessionTask(taskId: number, items: DeleteBrandResultItem[], pushResult?: string, payloadText?: string) {
const validItems = items.filter(i => i.matchStatus === 'MATCHED' && i.success !== false) if (!items?.length) return
if (validItems.length === 0) return
const existingIndex = sessionTasks.value.findIndex(t => t.taskId === taskId)
const existingTask = existingIndex >= 0 ? sessionTasks.value[existingIndex] : null
// 全量保留任务项(不在存储层按 matched/success 过滤),避免单任务多店铺时出现“未处理项丢失”。
// 同时尽量继承已有的 _pushed/_completed 会话态。
const mergedItems = items.map((item) => {
const prev = existingTask?.items.find((i) => isSameDeleteBrandItem(i, item))
// 保护大字段:当新值为空数组时,保留本地已有的非空数据,避免被轮询详情的精简 payload 覆盖。
const nextCountries = Array.isArray(item.countries) ? item.countries : undefined
const prevCountries = Array.isArray(prev?.countries) ? prev?.countries : undefined
const countries = (nextCountries && nextCountries.length > 0)
? nextCountries
: (prevCountries && prevCountries.length > 0 ? prevCountries : (nextCountries ?? prevCountries))
const nextPreviewRows = Array.isArray(item.previewRows) ? item.previewRows : undefined
const prevPreviewRows = Array.isArray(prev?.previewRows) ? prev?.previewRows : undefined
const previewRows = (nextPreviewRows && nextPreviewRows.length > 0)
? nextPreviewRows
: (prevPreviewRows && prevPreviewRows.length > 0 ? prevPreviewRows : (nextPreviewRows ?? prevPreviewRows))
return {
...item,
countries,
previewRows,
_pushed: prev?._pushed ?? (item as SessionDeleteBrandItem)._pushed ?? false,
_completed: prev?._completed ?? (item as SessionDeleteBrandItem)._completed ?? false,
} as SessionDeleteBrandItem
})
const newTask: StoredCurrentTask = { const newTask: StoredCurrentTask = {
taskId, taskId,
items: validItems, items: mergedItems,
createdAt: Date.now(), createdAt: existingTask?.createdAt || Date.now(),
queuePushResult: pushResult, queuePushResult: pushResult ?? existingTask?.queuePushResult,
queuePayloadText: payloadText queuePayloadText: payloadText ?? existingTask?.queuePayloadText,
} }
const existingIndex = sessionTasks.value.findIndex(t => t.taskId === taskId)
if (existingIndex >= 0) { if (existingIndex >= 0) {
sessionTasks.value[existingIndex] = newTask sessionTasks.value[existingIndex] = newTask
} else { } else {
sessionTasks.value.push(newTask) sessionTasks.value.push(newTask)
} }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value)) window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
} }
@@ -318,7 +348,7 @@ function removeSessionTaskFromStorage(taskId: number) {
const historySectionItems = computed(() => const historySectionItems = computed(() =>
historyItems.value.filter( historyItems.value.filter(
(item) => !currentSectionItems.value.some(c => c.taskId === item.taskId || (c.resultId && c.resultId === item.resultId)) (item) => !currentSectionItems.value.some(c => isSameDeleteBrandItem(c, item))
), ),
); );
const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0) const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
@@ -366,30 +396,28 @@ function updateCompletedItemsFromProgress(taskId: number | undefined) {
let changed = false let changed = false
const info = taskDetails.value[taskId]?.line_progress?.info const info = taskDetails.value[taskId]?.line_progress?.info
const multiFileTask = isMultiFileTask(taskId)
// 多文件任务优先依据后端 finished_files 同步已完成文件数量 // 关键修复:多文件任务下不再用 finished_files 按顺序回填 completed
// 规则:按 sessionTask.items 的顺序,标记「已推送」中的前 N 个为 completed // 否则当第 1 个文件完成后,推入第 2 个文件时会因为 finished_files=1 被误判为“本文件已完成(100%)”
if (isMultiFileTask(taskId) && info?.finished_files != null && info.finished_files >= 0) { // 改为仅当后端进度明确指向该文件且 current_line >= total_lines 时,才标记该文件 completed。
const pushedItems = sessionTask.items.filter(i => i._pushed) if (info?.file_name && info.current_line != null && info.total_lines != null && info.total_lines > 0) {
const shouldCompleted = Math.max(0, Math.min(info.finished_files, pushedItems.length)) if (info.current_line >= info.total_lines) {
const sessionItem = sessionTask.items.find(i => i.sourceFilename === info.file_name)
for (let i = 0; i < shouldCompleted; i += 1) { if (sessionItem && !sessionItem._completed) {
if (!pushedItems[i]._completed) { sessionItem._completed = true
pushedItems[i]._completed = true
changed = true changed = true
} }
} }
} }
// 兼容单文件/兜底:当当前文件行进度到达末行时,标记活动文件完成 // 单文件任务兜底:任务终态时标记完成,避免偶发进度丢包导致状态卡住
if (info?.file_name && info.current_line != null && info.total_lines != null && info.total_lines > 0) { if (!multiFileTask) {
if (info.current_line >= info.total_lines) { const status = taskDetails.value[taskId]?.task?.status
const activeItem = currentSectionItems.value.find(item => getItemKey(item) === activeItemKey.value) if ((status === 'SUCCESS' || status === 'FAILED') && sessionTask.items.length === 1) {
const sessionItem = activeItem && activeItem.taskId === taskId const onlyItem = sessionTask.items[0]
? findSessionItem(taskId, activeItem) if (onlyItem && !onlyItem._completed) {
: sessionTask.items.find(i => i.sourceFilename === info.file_name) onlyItem._completed = true
if (sessionItem && !sessionItem._completed) {
sessionItem._completed = true
changed = true changed = true
} }
} }
@@ -531,6 +559,48 @@ function formatProgress(item: DeleteBrandResultItem) {
const detail = taskDetails.value[taskId] const detail = taskDetails.value[taskId]
const info = detail?.line_progress?.info const info = detail?.line_progress?.info
const fp = getFileProgress(item) const fp = getFileProgress(item)
const multiFileTask = isMultiFileTask(taskId)
// 多文件任务优先按“当前运行文件 + 本地会话态”展示,避免后端 finished_files 按顺序分配导致某个文件被误判 COMPLETED。
if (multiFileTask) {
const sessionItem = findSessionItem(taskId, item)
const status = detail?.task?.status
const isRunningTarget = Boolean(info?.file_name && info.file_name === item.sourceFilename)
let displayStatus = sessionItem?._completed ? 'COMPLETED' : (sessionItem?._pushed ? 'PENDING' : 'PENDING')
let processedRows = 0
let totalRows = Math.max(0, item.totalRows ?? fp?.totalRows ?? 0)
if (status === 'SUCCESS') {
displayStatus = 'COMPLETED'
processedRows = totalRows
} else if (status === 'FAILED' && sessionItem?._completed) {
displayStatus = 'COMPLETED'
processedRows = totalRows
} else if (isRunningTarget) {
displayStatus = 'RUNNING'
processedRows = Math.max(0, Math.min(totalRows || Number.MAX_SAFE_INTEGER, info?.current_line ?? 0))
if (!totalRows && info?.total_lines && info.total_lines > 0) {
totalRows = info.total_lines
}
} else if (sessionItem?._completed) {
displayStatus = 'COMPLETED'
processedRows = totalRows
} else if (sessionItem?._pushed) {
displayStatus = 'PENDING'
processedRows = 0
}
const parts: string[] = []
parts.push(`文件状态:${displayStatus}`)
parts.push(`文件进度:${processedRows}/${totalRows}`)
if (info?.finished_files != null) {
const total = info.file_total && info.file_total > 0 ? `/${info.file_total}` : ''
parts.push(`已完成文件:${info.finished_files}${total}`)
}
if (info?.phase) parts.push(`阶段:${info.phase}`)
return parts.join('')
}
if (fp) { if (fp) {
const parts: string[] = [] const parts: string[] = []
@@ -571,12 +641,32 @@ function formatProgressPercent(item: DeleteBrandResultItem) {
const taskId = item.taskId const taskId = item.taskId
if (!taskId) return 0 if (!taskId) return 0
const info = taskDetails.value[taskId]?.line_progress?.info
const multiFileTask = isMultiFileTask(taskId)
if (multiFileTask) {
const sessionItem = findSessionItem(taskId, item)
const taskStatus = taskDetails.value[taskId]?.task?.status
if (taskStatus === 'SUCCESS' || sessionItem?._completed) return 100
const totalRows = Math.max(0, item.totalRows ?? getFileProgress(item)?.totalRows ?? 0)
const isRunningTarget = Boolean(info?.file_name && info.file_name === item.sourceFilename)
if (isRunningTarget) {
if (info?.current_line != null && info?.total_lines && info.total_lines > 0) {
return Math.max(0, Math.min(100, Math.round((info.current_line / info.total_lines) * 100)))
}
if (info?.current_line != null && totalRows > 0) {
return Math.max(0, Math.min(100, Math.round((info.current_line / totalRows) * 100)))
}
}
return 0
}
const fp = getFileProgress(item) const fp = getFileProgress(item)
if (fp) { if (fp) {
return Math.max(0, Math.min(100, fp.percent ?? 0)) return Math.max(0, Math.min(100, fp.percent ?? 0))
} }
const info = taskDetails.value[taskId]?.line_progress?.info
if (!info) return 0 if (!info) return 0
if (info.current_line != null && info.total_lines && info.total_lines > 0) { if (info.current_line != null && info.total_lines && info.total_lines > 0) {
@@ -667,30 +757,58 @@ function canDownloadTaskResult(item: DeleteBrandResultItem) {
function mergeTaskDetailItemsIntoSession(detail: DeleteBrandTaskDetailVo) { function mergeTaskDetailItemsIntoSession(detail: DeleteBrandTaskDetailVo) {
const taskId = detail?.task?.id const taskId = detail?.task?.id
if (!taskId || !detail?.items?.length) return false if (!taskId) return false
const sessionTask = sessionTasks.value.find(t => t.taskId === taskId) const sessionTask = sessionTasks.value.find(t => t.taskId === taskId)
if (!sessionTask) return false if (!sessionTask) return false
const detailItems = detail?.items || []
let changed = false let changed = false
// 以本地会话项为主做增量合并,保留 _pushed/_completed
// 对后端新增返回的项追加到末尾,避免单任务多店铺/后续补齐时丢项。
const mergedItems = sessionTask.items.map((item) => { const mergedItems = sessionTask.items.map((item) => {
const latest = detail.items?.find((candidate) => isSameDeleteBrandItem(candidate, item)) const latest = detailItems.find((candidate) => isSameDeleteBrandItem(candidate, item))
if (!latest) return item if (!latest) return item
const latestCountries = Array.isArray(latest.countries) ? latest.countries : undefined
const itemCountries = Array.isArray(item.countries) ? item.countries : undefined
const countries = (latestCountries && latestCountries.length > 0)
? latestCountries
: (itemCountries && itemCountries.length > 0 ? itemCountries : (latestCountries ?? itemCountries))
const latestPreviewRows = Array.isArray(latest.previewRows) ? latest.previewRows : undefined
const itemPreviewRows = Array.isArray(item.previewRows) ? item.previewRows : undefined
const previewRows = (latestPreviewRows && latestPreviewRows.length > 0)
? latestPreviewRows
: (itemPreviewRows && itemPreviewRows.length > 0 ? itemPreviewRows : (latestPreviewRows ?? itemPreviewRows))
const merged = { const merged = {
...item, ...item,
...latest, ...latest,
success: item.success ?? latest.success, countries,
previewRows,
_pushed: item._pushed, _pushed: item._pushed,
_completed: item._completed, _completed: item._completed,
} } as SessionDeleteBrandItem
if (JSON.stringify(merged) !== JSON.stringify(item)) { if (JSON.stringify(merged) !== JSON.stringify(item)) {
changed = true changed = true
} }
return merged return merged
}) })
const validItems = mergedItems.filter(i => i.matchStatus === 'MATCHED' && i.success !== false) for (const latest of detailItems) {
if (JSON.stringify(validItems) !== JSON.stringify(sessionTask.items)) { if (!mergedItems.some((item) => isSameDeleteBrandItem(item, latest))) {
sessionTask.items = validItems mergedItems.push({
...latest,
_pushed: false,
_completed: false,
} as SessionDeleteBrandItem)
changed = true
}
}
if (JSON.stringify(mergedItems) !== JSON.stringify(sessionTask.items)) {
sessionTask.items = mergedItems
changed = true changed = true
} }
@@ -750,21 +868,26 @@ function scheduleNextPoll(immediate = false) {
pollingInFlight.value = true pollingInFlight.value = true
let stateChanged = false let stateChanged = false
let shouldRefreshHistory = false
try { try {
await refreshTaskDetails(taskIds) await refreshTaskDetails(taskIds)
await maybeAutoAdvance() await maybeAutoAdvance()
for (const taskId of taskIds) { for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) { if (isTaskTerminal(taskId)) {
// 以后端任务终态为准:进入 SUCCESS/FAILED 后立即清理本地当前任务并刷新 history
// 避免 _completed 会话态偶发不同步导致持续轮询 batch。
removeSessionTaskFromStorage(taskId) removeSessionTaskFromStorage(taskId)
stateChanged = true stateChanged = true
shouldRefreshHistory = true
} }
} }
} finally { } finally {
pollingInFlight.value = false pollingInFlight.value = false
} }
if (stateChanged) { if (shouldRefreshHistory) {
loadHistory() await loadHistory()
} else if (stateChanged) {
syncResultState() syncResultState()
} }
@@ -923,8 +1046,8 @@ async function downloadTaskResult(item: DeleteBrandResultItem) {
item.sourceFilename || item.sourceFilename ||
`delete-brand_${taskId}.xlsx` `delete-brand_${taskId}.xlsx`
if (api?.save_file_from_url) { if (api?.save_file_from_url_new) {
const result = await api.save_file_from_url(url, filename) const result = await api.save_file_from_url_new(url, filename)
if (result.success) { if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`) ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') { } else if (result.error && result.error !== '用户取消') {
@@ -1174,15 +1297,20 @@ onMounted(() => {
loadSessionTasksFromStorage() loadSessionTasksFromStorage()
const taskIds = getPollingTaskIds() const taskIds = getPollingTaskIds()
if (taskIds.length > 0) { if (taskIds.length > 0) {
refreshTaskDetails(taskIds).then(() => { refreshTaskDetails(taskIds).then(async () => {
let stateChanged = false let stateChanged = false
let shouldRefreshHistory = false
for (const taskId of taskIds) { for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) { if (isTaskTerminal(taskId)) {
// 首屏恢复也以后端终态为准,避免依赖 _completed 造成卡轮询。
removeSessionTaskFromStorage(taskId) removeSessionTaskFromStorage(taskId)
stateChanged = true stateChanged = true
shouldRefreshHistory = true
} }
} }
if (stateChanged) { if (shouldRefreshHistory) {
await loadHistory()
} else if (stateChanged) {
syncResultState() syncResultState()
} }
ensurePolling(true) ensurePolling(true)

View File

@@ -18,13 +18,8 @@
<div class="section-title">备选区域</div> <div class="section-title">备选区域</div>
<div v-if="!candidates.length" class="empty-candidates">暂无备选店铺请先输入并确认</div> <div v-if="!candidates.length" class="empty-candidates">暂无备选店铺请先输入并确认</div>
<div v-else class="candidate-table-scroll"> <div v-else class="candidate-table-scroll">
<el-table <el-table :data="candidates" row-key="id" height="240" class="candidate-table"
:data="candidates" @selection-change="onSelectionChange">
row-key="id"
height="240"
class="candidate-table"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="42" /> <el-table-column type="selection" width="42" />
<el-table-column prop="shop_name" label="店铺名" min-width="120" show-overflow-tooltip /> <el-table-column prop="shop_name" label="店铺名" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="72" align="center"> <el-table-column label="操作" width="72" align="center">
@@ -40,34 +35,18 @@
勾选需要处理的站点可只选 23 上方勾选区<strong>先按已选顺序</strong>排列其余未选国家排在后面拖拽下方列表调整顺序后会与上方同步设置会按当前登录用户保存 勾选需要处理的站点可只选 23 上方勾选区<strong>先按已选顺序</strong>排列其余未选国家排在后面拖拽下方列表调整顺序后会与上方同步设置会按当前登录用户保存
</p> </p>
<div class="country-pref-checks"> <div class="country-pref-checks">
<label <label v-for="row in countryCheckboxRows" :key="row.code" class="country-check-row">
v-for="row in countryCheckboxRows" <input type="checkbox" class="country-check-input" :checked="isCountrySelected(row.code)"
:key="row.code" @change="onCountryNativeChange(row.code, $event)" />
class="country-check-row"
>
<input
type="checkbox"
class="country-check-input"
:checked="isCountrySelected(row.code)"
@change="onCountryNativeChange(row.code, $event)"
/>
<span class="country-check-text">{{ row.label }}{{ row.code }}</span> <span class="country-check-text">{{ row.label }}{{ row.code }}</span>
</label> </label>
</div> </div>
<div v-if="orderedCountryCodes.length" class="country-order-panel"> <div v-if="orderedCountryCodes.length" class="country-order-panel">
<div class="country-order-caption">已选顺序拖拽 调整</div> <div class="country-order-caption">已选顺序拖拽 调整</div>
<div class="country-order-list"> <div class="country-order-list">
<div <div v-for="(code, idx) in orderedCountryCodes" :key="code" class="country-drag-row"
v-for="(code, idx) in orderedCountryCodes" :class="{ dragging: dragCountryIndex === idx }" draggable="true" @dragstart="onCountryDragStart(idx)"
:key="code" @dragend="onCountryDragEnd" @dragover.prevent @drop.prevent="onCountryDrop(idx)">
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="drag-handle" title="拖动排序"></span>
<span class="country-drag-label">{{ countryLabel(code) }}{{ code }}</span> <span class="country-drag-label">{{ countryLabel(code) }}{{ code }}</span>
</div> </div>
@@ -78,18 +57,9 @@
<div class="section-title">商品列表筛选</div> <div class="section-title">商品列表筛选</div>
<p class="hint listing-filter-hint">推送到 Python 队列一并下发 Python 区分处理场景</p> <p class="hint listing-filter-hint">推送到 Python 队列一并下发 Python 区分处理场景</p>
<div class="listing-filter-row"> <div class="listing-filter-row">
<el-select <el-select v-model="productRiskListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
v-model="productRiskListingFilter" <el-option v-for="opt in PRODUCT_RISK_LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
class="listing-filter-select" :value="opt.value" />
teleported
placeholder="选择筛选类型"
>
<el-option
v-for="opt in PRODUCT_RISK_LISTING_FILTER_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select> </el-select>
</div> </div>
@@ -97,16 +67,15 @@
<button type="button" class="btn-run" :disabled="matching" @click="runMatch"> <button type="button" class="btn-run" :disabled="matching" @click="runMatch">
{{ matching ? '匹配中' : '匹配店铺' }} {{ matching ? '匹配中' : '匹配店铺' }}
</button> </button>
<button <button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length"
type="button" @click="pushToPythonQueue">
class="btn-run btn-queue"
:disabled="pushing || !matchedItems.length"
@click="pushToPythonQueue"
>
{{ pushing ? '推送中' : '推送到 Python 队列' }} {{ pushing ? '推送中' : '推送到 Python 队列' }}
</button> </button>
</div> </div>
<p class="loading-msg">与删除 ASIN 品牌相同先创建任务再按店铺<strong>逐条</strong>入队每条 <code>data.items</code> 仅含一个店铺队列串行执行Python 每处理完一店可 POST <code>/api/product-risk-resolve/tasks/{taskId}/result</code>可多次每次可只含已完成的店铺全部完成后任务结束前端轮询任务状态</p> <p class="loading-msg">与删除 ASIN 品牌相同先创建任务再按店铺<strong>逐条</strong>入队每条 <code>data.items</code>
仅含一个店铺队列串行执行Python
每处理完一店可 POST <code>/api/product-risk-resolve/tasks/{taskId}/result</code>可多次每次可只含已完成的店铺全部完成后任务结束前端轮询任务状态
</p>
<div v-if="queuePushResult" class="queue-debug-card"> <div v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">推送结果</div> <div class="section-title queue-debug-title">推送结果</div>
@@ -139,13 +108,8 @@
<div class="subsection-title">匹配结果</div> <div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div> <div v-if="!matchedItems.length" class="empty-tasks narrow">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<el-table <el-table v-else :data="matchedItems" :row-key="rowKeyForMatch" :highlight-current-row="false"
v-else class="result-table match-table">
:data="matchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
>
<el-table-column prop="shopName" label="店铺名" min-width="100" /> <el-table-column prop="shopName" label="店铺名" min-width="100" />
<el-table-column label="匹配" width="72" align="center"> <el-table-column label="匹配" width="72" align="center">
<template #default="{ row }"> <template #default="{ row }">
@@ -179,11 +143,8 @@
<div v-if="currentSectionItems.length" class="result-subsection"> <div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div> <div class="result-subsection-title">当前任务</div>
<ul class="task-list clean-result-list"> <ul class="task-list clean-result-list">
<li <li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
v-for="item in currentSectionItems" class="task-item split-result-item">
:key="`cur-${item.resultId}-${item.taskId}`"
class="task-item split-result-item"
>
<div class="left split-result-main"> <div class="left split-result-main">
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span> <span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div> <div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
@@ -192,12 +153,7 @@
</div> </div>
<div class="task-right split-result-actions"> <div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span> <span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button <button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
v-if="canDownload(item)"
type="button"
class="download"
@click="downloadResult(item)"
>
下载 下载
</button> </button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button> <button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
@@ -209,11 +165,8 @@
<div v-if="historySectionItems.length" class="result-subsection"> <div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史记录</div> <div class="result-subsection-title">历史记录</div>
<ul class="task-list clean-result-list"> <ul class="task-list clean-result-list">
<li <li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
v-for="item in historySectionItems" class="task-item split-result-item">
:key="`his-${item.resultId}-${item.taskId}`"
class="task-item split-result-item"
>
<div class="left split-result-main"> <div class="left split-result-main">
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span> <span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div> <div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
@@ -222,12 +175,7 @@
</div> </div>
<div class="task-right split-result-actions"> <div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span> <span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button <button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">
v-if="canDownload(item)"
type="button"
class="download"
@click="downloadResult(item)"
>
下载 下载
</button> </button>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button> <button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
@@ -293,6 +241,7 @@ const COUNTRY_OPTIONS = [
const PRODUCT_RISK_LISTING_FILTER_OPTIONS = [ const PRODUCT_RISK_LISTING_FILTER_OPTIONS = [
{ value: 'SearchSuppressed', label: '在搜索结果中禁止显示' }, { value: 'SearchSuppressed', label: '在搜索结果中禁止显示' },
{ value: 'ApprovalRequired', label: '需要批准' }, { value: 'ApprovalRequired', label: '需要批准' },
{ value: 'Active', label: '在售' },
] as const ] as const
type ProductRiskListingFilter = (typeof PRODUCT_RISK_LISTING_FILTER_OPTIONS)[number]['value'] type ProductRiskListingFilter = (typeof PRODUCT_RISK_LISTING_FILTER_OPTIONS)[number]['value']
@@ -879,8 +828,8 @@ async function downloadResult(item: ProductRiskHistoryItem) {
const api = getPywebviewApi() const api = getPywebviewApi()
const url = getProductRiskResultDownloadUrl(item.resultId) const url = getProductRiskResultDownloadUrl(item.resultId)
const filename = item.outputFilename || `${item.shopName || 'result'}.zip` const filename = item.outputFilename || `${item.shopName || 'result'}.zip`
if (api?.save_file_from_url) { if (api?.save_file_from_url_new) {
const result = await api.save_file_from_url(url, filename) const result = await api.save_file_from_url_new(url, filename)
if (result.success) { if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`) ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') { } else if (result.error && result.error !== '用户取消') {

View File

@@ -26,20 +26,18 @@
<div class="section-title">保留列设置</div> <div class="section-title">保留列设置</div>
<div class="option-group column-option-group"> <div class="option-group column-option-group">
<div class="column-toolbar"> <div class="column-toolbar">
<span class="column-count">已选择 {{ splitSelectedColumns.length }} / {{ splitAvailableColumns.length }} </span> <span class="column-count">已选择 {{ splitSelectedColumns.length }} / {{ splitAvailableColumns.length }}
</span>
<div class="column-actions"> <div class="column-actions">
<button type="button" class="opt-btn small" @click="splitSelectedColumns = [...splitAvailableColumns]">全选</button> <button type="button" class="opt-btn small"
@click="splitSelectedColumns = [...splitAvailableColumns]">全选</button>
<button type="button" class="opt-btn small" @click="splitSelectedColumns = []">清空</button> <button type="button" class="opt-btn small" @click="splitSelectedColumns = []">清空</button>
</div> </div>
</div> </div>
<div class="column-grid" v-if="splitAvailableColumns.length"> <div class="column-grid" v-if="splitAvailableColumns.length">
<label <label v-for="column in splitAvailableColumns" :key="column" class="column-item"
v-for="column in splitAvailableColumns" :class="{ active: splitSelectedColumns.includes(column) }">
:key="column"
class="column-item"
:class="{ active: splitSelectedColumns.includes(column) }"
>
<input v-model="splitSelectedColumns" type="checkbox" :value="column" /> <input v-model="splitSelectedColumns" type="checkbox" :value="column" />
<span>{{ column }}</span> <span>{{ column }}</span>
</label> </label>
@@ -80,7 +78,8 @@
<div class="option-group"> <div class="option-group">
<label v-if="splitMode === 'rows_per_file'" class="split-input-card"> <label v-if="splitMode === 'rows_per_file'" class="split-input-card">
<span class="label">每份条数</span> <span class="label">每份条数</span>
<input v-model.number="splitRowsPerFile" class="split-number-input" type="number" min="1" placeholder="例如 2000" /> <input v-model.number="splitRowsPerFile" class="split-number-input" type="number" min="1"
placeholder="例如 2000" />
</label> </label>
<label v-else class="split-input-card"> <label v-else class="split-input-card">
<span class="label">拆分份数</span> <span class="label">拆分份数</span>
@@ -126,12 +125,15 @@
</div> </div>
<ul v-else class="task-list clean-result-list"> <ul v-else class="task-list clean-result-list">
<li v-for="item in splitResultItems" :key="`${item.resultId || item.outputFilename || item.sourceFilename}-${item.rowCount || 0}`" class="task-item split-result-item"> <li v-for="item in splitResultItems"
:key="`${item.resultId || item.outputFilename || item.sourceFilename}-${item.rowCount || 0}`"
class="task-item split-result-item">
<div class="left split-result-main"> <div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span> <span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">压缩包文件{{ item.outputFilename }}</div> <div v-if="item.outputFilename" class="files">压缩包文件{{ item.outputFilename }}</div>
<div v-if="item.rowCount !== undefined" class="time">压缩包共包含 {{ item.rowCount }} 条数据</div> <div v-if="item.rowCount !== undefined" class="time">压缩包共包含 {{ item.rowCount }} 条数据</div>
<div v-if="item.entryCount && item.entryCount > 0" class="files">压缩包内共 {{ item.entryCount }} 个拆分文件</div> <div v-if="item.entryCount && item.entryCount > 0" class="files">压缩包内共 {{ item.entryCount }} 个拆分文件
</div>
<div v-if="item.entries && item.entries.length" class="files split-entry-list"> <div v-if="item.entries && item.entries.length" class="files split-entry-list">
压缩包内容{{ formatSplitEntries(item.entries) }} 压缩包内容{{ formatSplitEntries(item.entries) }}
</div> </div>
@@ -142,20 +144,12 @@
<span class="status" :class="item.success ? 'success' : 'failed'"> <span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }} {{ item.success ? '已完成' : '失败' }}
</span> </span>
<button <button v-if="item.success && item.downloadUrl" type="button" class="download"
v-if="item.success && item.downloadUrl" @click="downloadSplitResult(item)">
type="button"
class="download"
@click="downloadSplitResult(item)"
>
下载压缩包 下载压缩包
</button> </button>
<button <button v-if="item.resultId" type="button" class="btn-delete"
v-if="item.resultId" @click="deleteSplitHistoryRecord(item.resultId)">
type="button"
class="btn-delete"
@click="deleteSplitHistoryRecord(item.resultId)"
>
删除 删除
</button> </button>
</div> </div>
@@ -360,8 +354,8 @@ async function downloadSplitResult(item: SplitResultItem) {
} }
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.zip` const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.zip`
if (api?.save_file_from_url) { if (api?.save_file_from_url_new) {
const result = await api.save_file_from_url(item.downloadUrl, filename) const result = await api.save_file_from_url_new(item.downloadUrl, filename)
if (result.success) { if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`) ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') { } else if (result.error && result.error !== '用户取消') {
@@ -379,75 +373,516 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.module-page { min-height: 100vh; background: #1a1a1a; } .module-page {
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); } min-height: 100vh;
.left-panel { width: 380px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; } background: #1a1a1a;
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #1a1a1a; } }
.section-title { font-size: 13px; color: #bbb; margin-bottom: 10px; }
.upload-zone { border: 1px dashed #3a3a3a; border-radius: 10px; padding: 24px; text-align: center; background: #252525; margin-bottom: 20px; transition: all 0.2s ease; } .main-content {
.upload-zone:hover { border-color: #3498db; background: #2a2a2a; } display: flex;
.hint { color: #888; font-size: 13px; margin-bottom: 12px; line-height: 1.5; } min-height: calc(100vh - 56px);
.btns { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } height: calc(100vh - 56px);
.opt-btn, .btn-run, .download, .btn-delete { border: none; cursor: pointer; transition: all 0.2s ease; } }
.opt-btn { padding: 8px 16px; font-size: 13px; color: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 6px; }
.opt-btn.small { padding: 6px 10px; font-size: 12px; } .left-panel {
.opt-btn:hover { color: #3498db; background: #333; border-color: #3498db; } width: 380px;
.selected-files { margin-top: 14px; font-size: 12px; color: #888; max-height: 112px; overflow-y: auto; text-align: left; } background: #1e1e1e;
.selected-files span { display: block; margin: 4px 0; word-break: break-all; } padding: 20px;
.more-line { color: #b8c1cc; } overflow-y: auto;
.split-selected-files { max-height: 120px; min-height: 72px; padding-right: 4px; } border-right: 1px solid #2a2a2a;
.option-group { margin-bottom: 20px; } }
.column-option-group { padding: 14px; border-radius: 10px; background: #252525; border: 1px solid #2a2a2a; }
.column-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; } .right-panel {
.column-count { font-size: 12px; color: #a9b4bf; } flex: 1;
.column-actions { display: flex; gap: 8px; } display: flex;
.column-grid { display: grid; grid-template-columns: 1fr; gap: 8px; max-height: 280px; overflow-y: auto; } flex-direction: column;
.empty-column-state { padding: 16px 12px; border-radius: 8px; border: 1px dashed #3a3a3a; background: #202020; color: #7f8790; font-size: 12px; line-height: 1.6; } min-width: 0;
.column-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; border: 1px solid #2f2f2f; background: #202020; cursor: pointer; transition: all 0.2s ease; font-size: 13px; color: #d7d7d7; } background: #1a1a1a;
.column-item:hover, .column-item.active { border-color: #3b6f98; background: #26313a; } }
.column-item input { margin: 0; }
.column-item span { word-break: break-all; } .section-title {
.radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; } font-size: 13px;
.radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; } color: #bbb;
.radio-item input { margin-top: 3px; } margin-bottom: 10px;
.label { font-weight: 500; color: #e0e0e0; font-size: 13px; } }
.desc { font-size: 12px; color: #888; margin-top: 2px; line-height: 1.5; font-weight: 400; }
.split-input-card { display: flex; flex-direction: column; gap: 10px; padding: 14px 16px; border-radius: 10px; border: 1px solid #2f2f2f; background: #252525; } .upload-zone {
.split-number-input { width: 100%; padding: 12px 14px; border-radius: 8px; border: 1px solid #3a3a3a; background: #1b1b1b; color: #eaf4ff; font-size: 22px; font-weight: 700; line-height: 1.2; outline: none; transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; } border: 1px dashed #3a3a3a;
.split-number-input:focus { border-color: #3498db; box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.18); background: #20252b; } border-radius: 10px;
.split-number-input::placeholder { color: #708090; font-size: 14px; font-weight: 400; } padding: 24px;
.run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 12px; margin-top: 16px; } text-align: center;
.btn-run { padding: 10px 22px; background: #3498db; color: #fff; border-radius: 8px; font-size: 14px; } background: #252525;
.btn-run:hover { background: #2980b9; } margin-bottom: 20px;
.btn-run:disabled { background: #555; color: #999; cursor: not-allowed; } transition: all 0.2s ease;
.loading-msg { color: #3498db; font-size: 13px; } }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
.task-list-wrap { flex: 1; overflow-y: auto; padding: 16px; } .upload-zone:hover {
.task-list { list-style: none; margin: 0; padding: 0; } border-color: #3498db;
.task-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 10px; background: #1e1e1e; } background: #2a2a2a;
.split-result-item { align-items: flex-start; } }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; } .hint {
.id { display: inline-block; font-weight: 600; color: #e0e0e0; font-size: 13px; } color: #888;
.files { font-size: 12px; color: #888; margin-top: 4px; } font-size: 13px;
.time { font-size: 12px; color: #666; margin-top: 2px; } margin-bottom: 12px;
.split-entry-list { line-height: 1.6; word-break: break-all; max-width: 100%; } line-height: 1.5;
.task-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; } }
.split-result-actions { flex-shrink: 0; min-width: 180px; justify-content: flex-end; align-self: center; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; } .btns {
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; } display: flex;
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; } gap: 10px;
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; } justify-content: center;
.download:hover { background: rgba(52, 152, 219, 0.28); } flex-wrap: wrap;
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; } }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
.empty-tasks { color: #666; font-size: 13px; padding: 24px 8px; } .opt-btn,
.clean-placeholder { max-height: 112px; } .btn-run,
.clean-result-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 220px)); gap: 16px; margin-bottom: 18px; } .download,
.summary-card { padding: 16px 18px; border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; } .btn-delete {
.summary-card strong { display: block; margin-top: 8px; font-size: 24px; color: #eaf4ff; } border: none;
.summary-label { font-size: 12px; color: #8d8d8d; } cursor: pointer;
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; min-height: 260px; } transition: all 0.2s ease;
.result-list-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #2a2a2a; font-size: 14px; color: #ddd; } }
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; } .right-panel { min-height: 420px; } .task-item { flex-direction: column; align-items: flex-start; } .task-right { width: 100%; justify-content: flex-start; } .split-result-actions { min-width: 0; align-self: flex-start; } .clean-result-summary { grid-template-columns: 1fr; } }
.opt-btn {
padding: 8px 16px;
font-size: 13px;
color: #ccc;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
}
.opt-btn.small {
padding: 6px 10px;
font-size: 12px;
}
.opt-btn:hover {
color: #3498db;
background: #333;
border-color: #3498db;
}
.selected-files {
margin-top: 14px;
font-size: 12px;
color: #888;
max-height: 112px;
overflow-y: auto;
text-align: left;
}
.selected-files span {
display: block;
margin: 4px 0;
word-break: break-all;
}
.more-line {
color: #b8c1cc;
}
.split-selected-files {
max-height: 120px;
min-height: 72px;
padding-right: 4px;
}
.option-group {
margin-bottom: 20px;
}
.column-option-group {
padding: 14px;
border-radius: 10px;
background: #252525;
border: 1px solid #2a2a2a;
}
.column-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.column-count {
font-size: 12px;
color: #a9b4bf;
}
.column-actions {
display: flex;
gap: 8px;
}
.column-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
max-height: 280px;
overflow-y: auto;
}
.empty-column-state {
padding: 16px 12px;
border-radius: 8px;
border: 1px dashed #3a3a3a;
background: #202020;
color: #7f8790;
font-size: 12px;
line-height: 1.6;
}
.column-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #2f2f2f;
background: #202020;
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
color: #d7d7d7;
}
.column-item:hover,
.column-item.active {
border-color: #3b6f98;
background: #26313a;
}
.column-item input {
margin: 0;
}
.column-item span {
word-break: break-all;
}
.radio-item {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
padding: 10px 12px;
border-radius: 8px;
background: #252525;
border: 1px solid #2a2a2a;
margin-bottom: 8px;
transition: all 0.2s ease;
}
.radio-item:hover,
.radio-item.active {
background: #2a2a2a;
border-color: #3a3a3a;
}
.radio-item input {
margin-top: 3px;
}
.label {
font-weight: 500;
color: #e0e0e0;
font-size: 13px;
}
.desc {
font-size: 12px;
color: #888;
margin-top: 2px;
line-height: 1.5;
font-weight: 400;
}
.split-input-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
border-radius: 10px;
border: 1px solid #2f2f2f;
background: #252525;
}
.split-number-input {
width: 100%;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid #3a3a3a;
background: #1b1b1b;
color: #eaf4ff;
font-size: 22px;
font-weight: 700;
line-height: 1.2;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.split-number-input:focus {
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.18);
background: #20252b;
}
.split-number-input::placeholder {
color: #708090;
font-size: 14px;
font-weight: 400;
}
.run-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
margin-top: 16px;
}
.btn-run {
padding: 10px 22px;
background: #3498db;
color: #fff;
border-radius: 8px;
font-size: 14px;
}
.btn-run:hover {
background: #2980b9;
}
.btn-run:disabled {
background: #555;
color: #999;
cursor: not-allowed;
}
.loading-msg {
color: #3498db;
font-size: 13px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #2a2a2a;
font-size: 15px;
font-weight: 600;
color: #ddd;
}
.task-list-wrap {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid #2a2a2a;
border-radius: 8px;
margin-bottom: 10px;
background: #1e1e1e;
}
.split-result-item {
align-items: flex-start;
}
.left {
flex: 1;
min-width: 0;
}
.split-result-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.id {
display: inline-block;
font-weight: 600;
color: #e0e0e0;
font-size: 13px;
}
.files {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.time {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.split-entry-list {
line-height: 1.6;
word-break: break-all;
max-width: 100%;
}
.task-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.split-result-actions {
flex-shrink: 0;
min-width: 180px;
justify-content: flex-end;
align-self: center;
}
.status {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
}
.status.success {
background: rgba(46, 204, 113, 0.18);
color: #2ecc71;
}
.status.failed {
background: rgba(231, 76, 60, 0.18);
color: #ff6b6b;
}
.download {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(52, 152, 219, 0.18);
color: #69b6ff;
}
.download:hover {
background: rgba(52, 152, 219, 0.28);
}
.btn-delete {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
background: rgba(231, 76, 60, 0.12);
color: #ff8f8f;
}
.btn-delete:hover {
background: rgba(231, 76, 60, 0.22);
}
.empty-tasks {
color: #666;
font-size: 13px;
padding: 24px 8px;
}
.clean-placeholder {
max-height: 112px;
}
.clean-result-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 220px));
gap: 16px;
margin-bottom: 18px;
}
.summary-card {
padding: 16px 18px;
border: 1px solid #2a2a2a;
border-radius: 10px;
background: #1e1e1e;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 24px;
color: #eaf4ff;
}
.summary-label {
font-size: 12px;
color: #8d8d8d;
}
.result-list-wrap {
border: 1px solid #2a2a2a;
border-radius: 10px;
background: #1e1e1e;
min-height: 260px;
}
.result-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #2a2a2a;
font-size: 14px;
color: #ddd;
}
@media (max-width: 1100px) {
.main-content {
flex-direction: column;
height: auto;
}
.left-panel {
width: 100%;
border-right: none;
border-bottom: 1px solid #2a2a2a;
}
.right-panel {
min-height: 420px;
}
.task-item {
flex-direction: column;
align-items: flex-start;
}
.task-right {
width: 100%;
justify-content: flex-start;
}
.split-result-actions {
min-width: 0;
align-self: flex-start;
}
.clean-result-summary {
grid-template-columns: 1fr;
}
}
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,58 +1,91 @@
export interface UploadedJavaFile { export interface UploadedJavaFile {
fileKey: string fileKey: string;
originalFilename: string originalFilename: string;
localPath: string localPath: string;
size: number size: number;
relativePath?: string relativePath?: string;
} }
export interface PywebviewApi { export interface PywebviewApi {
close?: () => Promise<void> close?: () => Promise<void>;
minimize?: () => Promise<void> minimize?: () => Promise<void>;
maximize?: () => Promise<void> maximize?: () => Promise<void>;
toggle_maximize?: () => Promise<void> toggle_maximize?: () => Promise<void>;
save_image?: (urlOrData: string, filename?: string) => Promise<{ success: boolean; path?: string; error?: string }> save_image?: (
save_image_to_folder?: (urlOrData: string, dirPath: string, filename?: string) => Promise<{ success: boolean; path?: string; error?: string }> urlOrData: string,
select_folder?: () => Promise<string | null> filename?: string,
select_brand_xlsx_files?: () => Promise<string[]> ) => Promise<{ success: boolean; path?: string; error?: string }>;
select_brand_folder?: () => Promise<string | null> save_image_to_folder?: (
upload_file_to_java?: (filePath: string, relativePath?: string) => Promise<{ success: boolean; message?: string; data?: UploadedJavaFile; error?: string }> urlOrData: string,
save_file_from_url?: (url: string, filename: string) => Promise<{ success: boolean; path?: string; error?: string }> dirPath: string,
save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }> filename?: string,
save_template_zip?: () => Promise<{ success: boolean; path?: string; error?: string }> ) => Promise<{ success: boolean; path?: string; error?: string }>;
enqueue_json?: (data: unknown) => Promise<{ success: boolean; queue_size?: number; error?: string }> select_folder?: () => Promise<string | null>;
select_brand_xlsx_files?: () => Promise<string[]>;
select_brand_folder?: () => Promise<string | null>;
upload_file_to_java?: (
filePath: string,
relativePath?: string,
) => Promise<{
success: boolean;
message?: string;
data?: UploadedJavaFile;
error?: string;
}>;
save_file_from_url_new?: (
url: string,
filename: string,
) => Promise<{ success: boolean; path?: string; error?: string }>;
save_template_xlsx?: () => Promise<{
success: boolean;
path?: string;
error?: string;
}>;
save_template_zip?: () => Promise<{
success: boolean;
path?: string;
error?: string;
}>;
enqueue_json?: (
data: unknown,
) => Promise<{ success: boolean; queue_size?: number; error?: string }>;
} }
declare global { declare global {
interface Window { interface Window {
pywebview?: { pywebview?: {
api?: PywebviewApi api?: PywebviewApi;
} };
} }
} }
let cachedPywebviewApi: PywebviewApi | undefined let cachedPywebviewApi: PywebviewApi | undefined;
let pywebviewReadyBound = false let pywebviewReadyBound = false;
function syncPywebviewApi() { function syncPywebviewApi() {
cachedPywebviewApi = window.pywebview?.api cachedPywebviewApi = window.pywebview?.api;
return cachedPywebviewApi return cachedPywebviewApi;
} }
function bindPywebviewReady() { function bindPywebviewReady() {
if (pywebviewReadyBound || typeof window === 'undefined' || !window.addEventListener) return if (
pywebviewReadyBound = true pywebviewReadyBound ||
window.addEventListener('pywebviewready', () => { typeof window === "undefined" ||
syncPywebviewApi() !window.addEventListener
}) )
return;
pywebviewReadyBound = true;
window.addEventListener("pywebviewready", () => {
syncPywebviewApi();
});
} }
bindPywebviewReady() bindPywebviewReady();
export function getPywebviewApi() { export function getPywebviewApi() {
return syncPywebviewApi() || cachedPywebviewApi return syncPywebviewApi() || cachedPywebviewApi;
} }
export function hasPywebview() { export function hasPywebview() {
return Boolean(getPywebviewApi()) return Boolean(getPywebviewApi());
} }

View File

@@ -143,7 +143,7 @@ class WindowAPI:
) )
return result[0] if result else '' return result[0] if result else ''
def save_file_from_url(self, url, default_filename='download.zip'): def save_file_from_url_new(self, url, default_filename='download.zip'):
"""弹窗选择保存位置,从 url 下载文件并保存。用于品牌任务结果 zip 等。""" """弹窗选择保存位置,从 url 下载文件并保存。用于品牌任务结果 zip 等。"""
if not url or not url.strip(): if not url or not url.strip():
return {'success': False, 'error': '下载地址为空'} return {'success': False, 'error': '下载地址为空'}
@@ -332,7 +332,7 @@ def main():
easy_drag=True easy_drag=True
) )
api = WindowAPI(window) api = WindowAPI(window)
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder, api.save_file_from_url, api.save_template_xlsx,api.save_template_zip) window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder, api.save_file_from_url_new, api.save_template_xlsx,api.save_template_zip)
webview.start( webview.start(
debug=True, debug=True,
storage_path=cache_path, storage_path=cache_path,

View File

@@ -806,7 +806,7 @@
} }
var zipUrl = task.result_paths.zip_url; var zipUrl = task.result_paths.zip_url;
var filename = 'brand_task_' + taskId + '.zip'; var filename = 'brand_task_' + taskId + '.zip';
if (api && api.save_file_from_url) { if (api && api.save_file_from_url_new) {
a.style.pointerEvents = 'none'; a.style.pointerEvents = 'none';
var done = function(ret) { var done = function(ret) {
if (ret && ret.success) { if (ret && ret.success) {
@@ -817,7 +817,7 @@
a.style.pointerEvents = ''; a.style.pointerEvents = '';
}; };
try { try {
var ret = api.save_file_from_url(zipUrl, filename); var ret = api.save_file_from_url_new(zipUrl, filename);
if (ret && typeof ret.then === 'function') { if (ret && typeof ret.then === 'function') {
ret.then(done).catch(function(err) { showToast('保存失败:' + (err && err.message || err)); a.style.pointerEvents = ''; }); ret.then(done).catch(function(err) { showToast('保存失败:' + (err && err.message || err)); a.style.pointerEvents = ''; });
} else { } else {