更改下载方法名

This commit is contained in:
super
2026-04-06 22:26:44 +08:00
parent d8d5b56c13
commit 1b02160b20
12 changed files with 1411 additions and 716 deletions

View File

@@ -154,7 +154,7 @@ class WindowAPI:
)
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 下载文件并保存。根据文件后缀动态设置保存类型。"""
if not url or not url.strip():
return {'success': False, 'error': '下载地址为空'}
@@ -360,7 +360,7 @@ def main():
)
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,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(
debug=True,
storage_path=cache_path,

View File

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

View File

@@ -103,7 +103,7 @@ public class DeleteBrandRunController {
}
@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) {
String url = deleteBrandRunService.resolveTaskDownloadUrl(taskId);
String filename = deleteBrandRunService.resolveTaskDownloadFilename(taskId);

View File

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

View File

@@ -286,22 +286,52 @@ function loadSessionTasksFromStorage() {
}
function saveSessionTask(taskId: number, items: DeleteBrandResultItem[], pushResult?: string, payloadText?: string) {
const validItems = items.filter(i => i.matchStatus === 'MATCHED' && i.success !== false)
if (validItems.length === 0) return
if (!items?.length) 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 = {
taskId,
items: validItems,
createdAt: Date.now(),
queuePushResult: pushResult,
queuePayloadText: payloadText
items: mergedItems,
createdAt: existingTask?.createdAt || Date.now(),
queuePushResult: pushResult ?? existingTask?.queuePushResult,
queuePayloadText: payloadText ?? existingTask?.queuePayloadText,
}
const existingIndex = sessionTasks.value.findIndex(t => t.taskId === taskId)
if (existingIndex >= 0) {
sessionTasks.value[existingIndex] = newTask
} else {
sessionTasks.value.push(newTask)
}
if (typeof window !== 'undefined') {
window.localStorage.setItem(getStorageKey(), JSON.stringify(sessionTasks.value))
}
@@ -318,7 +348,7 @@ function removeSessionTaskFromStorage(taskId: number) {
const historySectionItems = computed(() =>
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)
@@ -366,30 +396,28 @@ function updateCompletedItemsFromProgress(taskId: number | undefined) {
let changed = false
const info = taskDetails.value[taskId]?.line_progress?.info
const multiFileTask = isMultiFileTask(taskId)
// 多文件任务优先依据后端 finished_files 同步已完成文件数量
// 规则:按 sessionTask.items 的顺序,标记「已推送」中的前 N 个为 completed
if (isMultiFileTask(taskId) && info?.finished_files != null && info.finished_files >= 0) {
const pushedItems = sessionTask.items.filter(i => i._pushed)
const shouldCompleted = Math.max(0, Math.min(info.finished_files, pushedItems.length))
for (let i = 0; i < shouldCompleted; i += 1) {
if (!pushedItems[i]._completed) {
pushedItems[i]._completed = true
// 关键修复:多文件任务下不再用 finished_files 按顺序回填 completed
// 否则当第 1 个文件完成后,推入第 2 个文件时会因为 finished_files=1 被误判为“本文件已完成(100%)”
// 改为仅当后端进度明确指向该文件且 current_line >= total_lines 时,才标记该文件 completed。
if (info?.file_name && info.current_line != null && info.total_lines != null && info.total_lines > 0) {
if (info.current_line >= info.total_lines) {
const sessionItem = sessionTask.items.find(i => i.sourceFilename === info.file_name)
if (sessionItem && !sessionItem._completed) {
sessionItem._completed = true
changed = true
}
}
}
// 兼容单文件/兜底:当当前文件行进度到达末行时,标记活动文件完成
if (info?.file_name && info.current_line != null && info.total_lines != null && info.total_lines > 0) {
if (info.current_line >= info.total_lines) {
const activeItem = currentSectionItems.value.find(item => getItemKey(item) === activeItemKey.value)
const sessionItem = activeItem && activeItem.taskId === taskId
? findSessionItem(taskId, activeItem)
: sessionTask.items.find(i => i.sourceFilename === info.file_name)
if (sessionItem && !sessionItem._completed) {
sessionItem._completed = true
// 单文件任务兜底:任务终态时标记完成,避免偶发进度丢包导致状态卡住
if (!multiFileTask) {
const status = taskDetails.value[taskId]?.task?.status
if ((status === 'SUCCESS' || status === 'FAILED') && sessionTask.items.length === 1) {
const onlyItem = sessionTask.items[0]
if (onlyItem && !onlyItem._completed) {
onlyItem._completed = true
changed = true
}
}
@@ -531,6 +559,48 @@ function formatProgress(item: DeleteBrandResultItem) {
const detail = taskDetails.value[taskId]
const info = detail?.line_progress?.info
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) {
const parts: string[] = []
@@ -571,12 +641,32 @@ function formatProgressPercent(item: DeleteBrandResultItem) {
const taskId = item.taskId
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)
if (fp) {
return Math.max(0, Math.min(100, fp.percent ?? 0))
}
const info = taskDetails.value[taskId]?.line_progress?.info
if (!info) return 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) {
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)
if (!sessionTask) return false
const detailItems = detail?.items || []
let changed = false
// 以本地会话项为主做增量合并,保留 _pushed/_completed
// 对后端新增返回的项追加到末尾,避免单任务多店铺/后续补齐时丢项。
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
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 = {
...item,
...latest,
success: item.success ?? latest.success,
countries,
previewRows,
_pushed: item._pushed,
_completed: item._completed,
}
} as SessionDeleteBrandItem
if (JSON.stringify(merged) !== JSON.stringify(item)) {
changed = true
}
return merged
})
const validItems = mergedItems.filter(i => i.matchStatus === 'MATCHED' && i.success !== false)
if (JSON.stringify(validItems) !== JSON.stringify(sessionTask.items)) {
sessionTask.items = validItems
for (const latest of detailItems) {
if (!mergedItems.some((item) => isSameDeleteBrandItem(item, latest))) {
mergedItems.push({
...latest,
_pushed: false,
_completed: false,
} as SessionDeleteBrandItem)
changed = true
}
}
if (JSON.stringify(mergedItems) !== JSON.stringify(sessionTask.items)) {
sessionTask.items = mergedItems
changed = true
}
@@ -750,21 +868,26 @@ function scheduleNextPoll(immediate = false) {
pollingInFlight.value = true
let stateChanged = false
let shouldRefreshHistory = false
try {
await refreshTaskDetails(taskIds)
await maybeAutoAdvance()
for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) {
// 以后端任务终态为准:进入 SUCCESS/FAILED 后立即清理本地当前任务并刷新 history
// 避免 _completed 会话态偶发不同步导致持续轮询 batch。
removeSessionTaskFromStorage(taskId)
stateChanged = true
shouldRefreshHistory = true
}
}
} finally {
pollingInFlight.value = false
}
if (stateChanged) {
loadHistory()
if (shouldRefreshHistory) {
await loadHistory()
} else if (stateChanged) {
syncResultState()
}
@@ -923,8 +1046,8 @@ async function downloadTaskResult(item: DeleteBrandResultItem) {
item.sourceFilename ||
`delete-brand_${taskId}.xlsx`
if (api?.save_file_from_url) {
const result = await api.save_file_from_url(url, filename)
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 !== '用户取消') {
@@ -1174,15 +1297,20 @@ onMounted(() => {
loadSessionTasksFromStorage()
const taskIds = getPollingTaskIds()
if (taskIds.length > 0) {
refreshTaskDetails(taskIds).then(() => {
refreshTaskDetails(taskIds).then(async () => {
let stateChanged = false
let shouldRefreshHistory = false
for (const taskId of taskIds) {
if (isTaskTerminal(taskId)) {
// 首屏恢复也以后端终态为准,避免依赖 _completed 造成卡轮询。
removeSessionTaskFromStorage(taskId)
stateChanged = true
shouldRefreshHistory = true
}
}
if (stateChanged) {
if (shouldRefreshHistory) {
await loadHistory()
} else if (stateChanged) {
syncResultState()
}
ensurePolling(true)

View File

@@ -18,13 +18,8 @@
<div class="section-title">备选区域</div>
<div v-if="!candidates.length" class="empty-candidates">暂无备选店铺请先输入并确认</div>
<div v-else class="candidate-table-scroll">
<el-table
:data="candidates"
row-key="id"
height="240"
class="candidate-table"
@selection-change="onSelectionChange"
>
<el-table :data="candidates" row-key="id" height="240" class="candidate-table"
@selection-change="onSelectionChange">
<el-table-column type="selection" width="42" />
<el-table-column prop="shop_name" label="店铺名" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="72" align="center">
@@ -40,34 +35,18 @@
勾选需要处理的站点可只选 23 上方勾选区<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)"
@change="onCountryNativeChange(row.code, $event)"
/>
<label v-for="row in countryCheckboxRows" :key="row.code" 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>
</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)"
>
<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>
@@ -78,18 +57,9 @@
<div class="section-title">商品列表筛选</div>
<p class="hint listing-filter-hint">推送到 Python 队列一并下发 Python 区分处理场景</p>
<div class="listing-filter-row">
<el-select
v-model="productRiskListingFilter"
class="listing-filter-select"
teleported
placeholder="选择筛选类型"
>
<el-option
v-for="opt in PRODUCT_RISK_LISTING_FILTER_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
<el-select v-model="productRiskListingFilter" class="listing-filter-select" teleported placeholder="选择筛选类型">
<el-option v-for="opt in PRODUCT_RISK_LISTING_FILTER_OPTIONS" :key="opt.value" :label="opt.label"
:value="opt.value" />
</el-select>
</div>
@@ -97,16 +67,15 @@
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">
{{ matching ? '匹配中' : '匹配店铺' }}
</button>
<button
type="button"
class="btn-run btn-queue"
:disabled="pushing || !matchedItems.length"
@click="pushToPythonQueue"
>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length"
@click="pushToPythonQueue">
{{ pushing ? '推送中' : '推送到 Python 队列' }}
</button>
</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 class="section-title queue-debug-title">推送结果</div>
@@ -139,13 +108,8 @@
<div class="subsection-title">匹配结果</div>
<div v-if="!matchedItems.length" class="empty-tasks narrow">完成匹配店铺在此展示紫鸟索引匹配结果供核对后再推送队列</div>
<el-table
v-else
:data="matchedItems"
:row-key="rowKeyForMatch"
:highlight-current-row="false"
class="result-table match-table"
>
<el-table v-else :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 label="匹配" width="72" align="center">
<template #default="{ row }">
@@ -179,11 +143,8 @@
<div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list clean-result-list">
<li
v-for="item in currentSectionItems"
:key="`cur-${item.resultId}-${item.taskId}`"
class="task-item split-result-item"
>
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
@@ -192,12 +153,7 @@
</div>
<div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button
v-if="canDownload(item)"
type="button"
class="download"
@click="downloadResult(item)"
>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(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 class="result-subsection-title">历史记录</div>
<ul class="task-list clean-result-list">
<li
v-for="item in historySectionItems"
:key="`his-${item.resultId}-${item.taskId}`"
class="task-item split-result-item"
>
<li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
@@ -222,12 +175,7 @@
</div>
<div class="task-right split-result-actions">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button
v-if="canDownload(item)"
type="button"
class="download"
@click="downloadResult(item)"
>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(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 = [
{ value: 'SearchSuppressed', label: '在搜索结果中禁止显示' },
{ value: 'ApprovalRequired', label: '需要批准' },
{ value: 'Active', label: '在售' },
] as const
type ProductRiskListingFilter = (typeof PRODUCT_RISK_LISTING_FILTER_OPTIONS)[number]['value']
@@ -879,8 +828,8 @@ async function downloadResult(item: ProductRiskHistoryItem) {
const api = getPywebviewApi()
const url = getProductRiskResultDownloadUrl(item.resultId)
const filename = item.outputFilename || `${item.shopName || 'result'}.zip`
if (api?.save_file_from_url) {
const result = await api.save_file_from_url(url, filename)
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 !== '用户取消') {

View File

@@ -26,20 +26,18 @@
<div class="section-title">保留列设置</div>
<div class="option-group column-option-group">
<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">
<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>
</div>
</div>
<div class="column-grid" v-if="splitAvailableColumns.length">
<label
v-for="column in splitAvailableColumns"
:key="column"
class="column-item"
:class="{ active: splitSelectedColumns.includes(column) }"
>
<label v-for="column in splitAvailableColumns" :key="column" class="column-item"
:class="{ active: splitSelectedColumns.includes(column) }">
<input v-model="splitSelectedColumns" type="checkbox" :value="column" />
<span>{{ column }}</span>
</label>
@@ -80,7 +78,8 @@
<div class="option-group">
<label v-if="splitMode === 'rows_per_file'" class="split-input-card">
<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 v-else class="split-input-card">
<span class="label">拆分份数</span>
@@ -126,12 +125,15 @@
</div>
<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">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<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.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">
压缩包内容{{ formatSplitEntries(item.entries) }}
</div>
@@ -142,20 +144,12 @@
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.success && item.downloadUrl"
type="button"
class="download"
@click="downloadSplitResult(item)"
>
<button v-if="item.success && item.downloadUrl" type="button" class="download"
@click="downloadSplitResult(item)">
下载压缩包
</button>
<button
v-if="item.resultId"
type="button"
class="btn-delete"
@click="deleteSplitHistoryRecord(item.resultId)"
>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteSplitHistoryRecord(item.resultId)">
删除
</button>
</div>
@@ -360,8 +354,8 @@ async function downloadSplitResult(item: SplitResultItem) {
}
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.zip`
if (api?.save_file_from_url) {
const result = await api.save_file_from_url(item.downloadUrl, filename)
if (api?.save_file_from_url_new) {
const result = await api.save_file_from_url_new(item.downloadUrl, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
@@ -379,75 +373,516 @@ onMounted(() => {
</script>
<style scoped>
.module-page { min-height: 100vh; background: #1a1a1a; }
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); }
.left-panel { width: 380px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
.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; }
.upload-zone:hover { border-color: #3498db; background: #2a2a2a; }
.hint { color: #888; font-size: 13px; margin-bottom: 12px; line-height: 1.5; }
.btns { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.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; }
.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; } }
.module-page {
min-height: 100vh;
background: #1a1a1a;
}
.main-content {
display: flex;
min-height: calc(100vh - 56px);
height: calc(100vh - 56px);
}
.left-panel {
width: 380px;
background: #1e1e1e;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #2a2a2a;
}
.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;
}
.upload-zone:hover {
border-color: #3498db;
background: #2a2a2a;
}
.hint {
color: #888;
font-size: 13px;
margin-bottom: 12px;
line-height: 1.5;
}
.btns {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.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;
}
.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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,58 +1,91 @@
export interface UploadedJavaFile {
fileKey: string
originalFilename: string
localPath: string
size: number
relativePath?: string
fileKey: string;
originalFilename: string;
localPath: string;
size: number;
relativePath?: string;
}
export interface PywebviewApi {
close?: () => Promise<void>
minimize?: () => Promise<void>
maximize?: () => Promise<void>
toggle_maximize?: () => Promise<void>
save_image?: (urlOrData: string, filename?: string) => Promise<{ success: boolean; path?: string; error?: string }>
save_image_to_folder?: (urlOrData: string, dirPath: string, filename?: string) => Promise<{ success: boolean; path?: string; 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?: (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 }>
close?: () => Promise<void>;
minimize?: () => Promise<void>;
maximize?: () => Promise<void>;
toggle_maximize?: () => Promise<void>;
save_image?: (
urlOrData: string,
filename?: string,
) => Promise<{ success: boolean; path?: string; error?: string }>;
save_image_to_folder?: (
urlOrData: string,
dirPath: string,
filename?: string,
) => Promise<{ success: boolean; path?: string; 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 {
interface Window {
pywebview?: {
api?: PywebviewApi
}
api?: PywebviewApi;
};
}
}
let cachedPywebviewApi: PywebviewApi | undefined
let pywebviewReadyBound = false
let cachedPywebviewApi: PywebviewApi | undefined;
let pywebviewReadyBound = false;
function syncPywebviewApi() {
cachedPywebviewApi = window.pywebview?.api
return cachedPywebviewApi
cachedPywebviewApi = window.pywebview?.api;
return cachedPywebviewApi;
}
function bindPywebviewReady() {
if (pywebviewReadyBound || typeof window === 'undefined' || !window.addEventListener) return
pywebviewReadyBound = true
window.addEventListener('pywebviewready', () => {
syncPywebviewApi()
})
if (
pywebviewReadyBound ||
typeof window === "undefined" ||
!window.addEventListener
)
return;
pywebviewReadyBound = true;
window.addEventListener("pywebviewready", () => {
syncPywebviewApi();
});
}
bindPywebviewReady()
bindPywebviewReady();
export function getPywebviewApi() {
return syncPywebviewApi() || cachedPywebviewApi
return syncPywebviewApi() || cachedPywebviewApi;
}
export function hasPywebview() {
return Boolean(getPywebviewApi())
return Boolean(getPywebviewApi());
}

View File

@@ -143,7 +143,7 @@ class WindowAPI:
)
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 等。"""
if not url or not url.strip():
return {'success': False, 'error': '下载地址为空'}
@@ -332,7 +332,7 @@ def main():
easy_drag=True
)
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(
debug=True,
storage_path=cache_path,

View File

@@ -806,7 +806,7 @@
}
var zipUrl = task.result_paths.zip_url;
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';
var done = function(ret) {
if (ret && ret.success) {
@@ -817,7 +817,7 @@
a.style.pointerEvents = '';
};
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') {
ret.then(done).catch(function(err) { showToast('保存失败:' + (err && err.message || err)); a.style.pointerEvents = ''; });
} else {