更改下载方法名
This commit is contained in:
@@ -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 !== '用户取消') {
|
||||
|
||||
@@ -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 !== '用户取消') {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 @@
|
||||
勾选需要处理的站点(可只选 2~3 国);上方勾选区<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 !== '用户取消') {
|
||||
|
||||
@@ -4,166 +4,160 @@
|
||||
|
||||
<div class="main-content">
|
||||
<aside class="left-panel">
|
||||
<div class="section-title">选择文件</div>
|
||||
<div class="upload-zone">
|
||||
<div class="hint">选择需要拆分的 Excel 文件或文件夹,按规则拆成多个 Excel 文件。</div>
|
||||
<div class="btns">
|
||||
<button type="button" class="opt-btn" @click="selectSplitFiles">选择 Excel 文件</button>
|
||||
<button type="button" class="opt-btn" @click="selectSplitFolder">选择文件夹</button>
|
||||
</div>
|
||||
<div class="section-title">选择文件</div>
|
||||
<div class="upload-zone">
|
||||
<div class="hint">选择需要拆分的 Excel 文件或文件夹,按规则拆成多个 Excel 文件。</div>
|
||||
<div class="btns">
|
||||
<button type="button" class="opt-btn" @click="selectSplitFiles">选择 Excel 文件</button>
|
||||
<button type="button" class="opt-btn" @click="selectSplitFolder">选择文件夹</button>
|
||||
</div>
|
||||
|
||||
<div class="selected-files clean-placeholder split-selected-files">
|
||||
<template v-if="splitSelectedPaths.length">
|
||||
<span v-for="path in splitDisplayPaths" :key="path">{{ path }}</span>
|
||||
<span v-if="splitSelectedPaths.length > splitDisplayPaths.length" class="more-line">
|
||||
还有 {{ splitSelectedPaths.length - splitDisplayPaths.length }} 个文件未展开显示
|
||||
</span>
|
||||
</template>
|
||||
<span v-else>暂未选择待拆分文件</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="column-actions">
|
||||
<button type="button" class="opt-btn small" @click="splitSelectedColumns = [...splitAvailableColumns]">全选</button>
|
||||
<button type="button" class="opt-btn small" @click="splitSelectedColumns = []">清空</button>
|
||||
<div class="selected-files clean-placeholder split-selected-files">
|
||||
<template v-if="splitSelectedPaths.length">
|
||||
<span v-for="path in splitDisplayPaths" :key="path">{{ path }}</span>
|
||||
<span v-if="splitSelectedPaths.length > splitDisplayPaths.length" class="more-line">
|
||||
还有 {{ splitSelectedPaths.length - splitDisplayPaths.length }} 个文件未展开显示
|
||||
</span>
|
||||
</template>
|
||||
<span v-else>暂未选择待拆分文件</span>
|
||||
</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) }"
|
||||
>
|
||||
<input v-model="splitSelectedColumns" type="checkbox" :value="column" />
|
||||
<span>{{ column }}</span>
|
||||
<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>
|
||||
<div class="column-actions">
|
||||
<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) }">
|
||||
<input v-model="splitSelectedColumns" type="checkbox" :value="column" />
|
||||
<span>{{ column }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="empty-column-state">请选择 Excel 文件后读取表头并填充可保留列</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">总条数</div>
|
||||
<div class="option-group">
|
||||
<label class="radio-item active">
|
||||
<input type="checkbox" checked disabled />
|
||||
<div>
|
||||
<span class="label">当前数据总条数</span>
|
||||
<div class="desc">{{ splitTotalRows }} 条(不含表头)</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="empty-column-state">请选择 Excel 文件后读取表头并填充可保留列</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">总条数</div>
|
||||
<div class="option-group">
|
||||
<label class="radio-item active">
|
||||
<input type="checkbox" checked disabled />
|
||||
<div>
|
||||
<span class="label">当前数据总条数</span>
|
||||
<div class="desc">{{ splitTotalRows }} 条(不含表头)</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="section-title">拆分模式</div>
|
||||
<div class="option-group">
|
||||
<label class="radio-item" :class="{ active: splitMode === 'rows_per_file' }">
|
||||
<input v-model="splitMode" type="radio" value="rows_per_file" name="split-mode" />
|
||||
<div>
|
||||
<span class="label">按每份条数</span>
|
||||
<div class="desc">例如每份 2000 条,最后一份放剩余数据</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="radio-item" :class="{ active: splitMode === 'parts' }">
|
||||
<input v-model="splitMode" type="radio" value="parts" name="split-mode" />
|
||||
<div>
|
||||
<span class="label">按份数平均拆分</span>
|
||||
<div class="desc">例如拆 5 份,按总条数尽量平均分配</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="section-title">拆分参数</div>
|
||||
<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" />
|
||||
</label>
|
||||
<label v-else class="split-input-card">
|
||||
<span class="label">拆分份数</span>
|
||||
<input v-model.number="splitParts" class="split-number-input" type="number" min="1" placeholder="例如 5" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="run-row">
|
||||
<button type="button" class="btn-run" :disabled="splitRunning" @click="submitSplitRun">
|
||||
{{ splitRunning ? '拆分中...' : '开始拆分' }}
|
||||
</button>
|
||||
<span class="loading-msg">
|
||||
{{ splitRunning ? '正在拆分并上传结果,请稍候…' : '选择文件、保留列和拆分规则后即可执行拆分,结果会显示在右侧供下载' }}
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">拆分结果</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">已处理文件</span>
|
||||
<strong>{{ splitSummary.total }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">成功结果</span>
|
||||
<strong>{{ splitSummary.successCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">失败文件</span>
|
||||
<strong>{{ splitSummary.failedCount }}</strong>
|
||||
</div>
|
||||
<div class="section-title">拆分模式</div>
|
||||
<div class="option-group">
|
||||
<label class="radio-item" :class="{ active: splitMode === 'rows_per_file' }">
|
||||
<input v-model="splitMode" type="radio" value="rows_per_file" name="split-mode" />
|
||||
<div>
|
||||
<span class="label">按每份条数</span>
|
||||
<div class="desc">例如每份 2000 条,最后一份放剩余数据</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="radio-item" :class="{ active: splitMode === 'parts' }">
|
||||
<input v-model="splitMode" type="radio" value="parts" name="split-mode" />
|
||||
<div>
|
||||
<span class="label">按份数平均拆分</span>
|
||||
<div class="desc">例如拆 5 份,按总条数尽量平均分配</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="result-list-wrap">
|
||||
<div class="result-list-header">
|
||||
<span>拆分结果压缩包列表</span>
|
||||
<div class="section-title">拆分参数</div>
|
||||
<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" />
|
||||
</label>
|
||||
<label v-else class="split-input-card">
|
||||
<span class="label">拆分份数</span>
|
||||
<input v-model.number="splitParts" class="split-number-input" type="number" min="1" placeholder="例如 5" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="run-row">
|
||||
<button type="button" class="btn-run" :disabled="splitRunning" @click="submitSplitRun">
|
||||
{{ splitRunning ? '拆分中...' : '开始拆分' }}
|
||||
</button>
|
||||
<span class="loading-msg">
|
||||
{{ splitRunning ? '正在拆分并上传结果,请稍候…' : '选择文件、保留列和拆分规则后即可执行拆分,结果会显示在右侧供下载' }}
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="right-panel">
|
||||
<div class="panel-header">拆分结果</div>
|
||||
<div class="task-list-wrap">
|
||||
<div class="clean-result-summary">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">已处理文件</span>
|
||||
<strong>{{ splitSummary.total }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">成功结果</span>
|
||||
<strong>{{ splitSummary.successCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">失败文件</span>
|
||||
<strong>{{ splitSummary.failedCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="splitResultItems.length === 0" class="empty-tasks">
|
||||
暂无拆分结果,完成数据拆分后会在这里展示压缩包文件
|
||||
</div>
|
||||
<div class="result-list-wrap">
|
||||
<div class="result-list-header">
|
||||
<span>拆分结果压缩包列表</span>
|
||||
</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">
|
||||
<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.entries && item.entries.length" class="files split-entry-list">
|
||||
压缩包内容:{{ formatSplitEntries(item.entries) }}
|
||||
<div v-if="splitResultItems.length === 0" class="empty-tasks">
|
||||
暂无拆分结果,完成数据拆分后会在这里展示压缩包文件
|
||||
</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">
|
||||
<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.entries && item.entries.length" class="files split-entry-list">
|
||||
压缩包内容:{{ formatSplitEntries(item.entries) }}
|
||||
</div>
|
||||
<div v-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
||||
</div>
|
||||
<div v-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
||||
</div>
|
||||
|
||||
<div class="task-right split-result-actions">
|
||||
<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>
|
||||
<button
|
||||
v-if="item.resultId"
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
@click="deleteSplitHistoryRecord(item.resultId)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="task-right split-result-actions">
|
||||
<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>
|
||||
<button v-if="item.resultId" type="button" class="btn-delete"
|
||||
@click="deleteSplitHistoryRecord(item.resultId)">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user