Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandPublishTab.vue
T
huangzd1997 d2d95f0b71 feat(上架): 激活时按店铺互斥,同一店铺不允许两个任务同时跑
2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一店铺被多个
任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。

- PublishTaskService.activateFile:激活前查是否已有其他任务在同一店铺 RUNNING,
  有则拒绝并带出占用中的任务号(激活是任务真正开跑的唯一入口,能最早拦住);
  校验与更新之间仍有极小竞态窗口,真正串行由客户端店铺锁保证,这层负责尽早提示;
- 前端 BrandPublishTab:文件派发失败的原因回显给用户。原来错误只写进日志、提示
  固定为"已记录并继续",用户会误以为是文件问题而反复重传,看不到真正原因;
- 新增 3 个后端单测:同店铺被占用则拒绝、无占用则放行、店铺名为空跳过校验。
2026-09-17 15:48:51 +08:00

1260 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-shell module-page">
<AmazonToolPageShell tool-id="list">
<div class="main-content">
<aside class="left-panel left-panel--with-template">
<div class="section-title">上传模块</div>
<div class="upload-zone">
<div class="hint">
选择需要上架的 Excel 文件或文件夹每个 Excel 文件名会作为店铺名进行匹配
</div>
<div class="btns">
<button type="button" class="opt-btn" :disabled="selectionDisabled" @click="selectFiles">
{{ uploading ? '上传中...' : '选择 Excel 文件' }}
</button>
<button type="button" class="opt-btn" :disabled="selectionDisabled" @click="selectFolder">
选择文件夹
</button>
</div>
<div class="selected-files clean-placeholder publish-selected-files">
<template v-if="selectedPaths.length">
<span v-for="path in displayPaths" :key="path">{{ path }}</span>
<span v-if="selectedPaths.length > displayPaths.length" class="more-line">
还有 {{ selectedPaths.length - displayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择上架文件</span>
</div>
</div>
<div class="section-title">国家选择</div>
<div class="country-group">
<div class="country-group-title">发布国家</div>
<div class="country-checks">
<label v-for="row in COUNTRY_OPTIONS" :key="row.code" class="country-check-row">
<input
v-model="publishCountry"
type="radio"
name="publish-country"
class="country-check-input"
:value="row.code"
/>
<span class="country-check-text">{{ row.label }} ({{ row.code }})</span>
</label>
</div>
</div>
<div class="country-group">
<div class="country-group-title">同步国家</div>
<div class="country-checks">
<label
v-for="row in COUNTRY_OPTIONS"
:key="row.code"
class="country-check-row"
:class="{ disabled: row.code === publishCountry }"
>
<input
type="checkbox"
class="country-check-input"
:checked="syncCountries.includes(row.code)"
:disabled="row.code === publishCountry"
@change="onSyncCountryChange(row.code, $event)"
/>
<span class="country-check-text">{{ row.label }} ({{ row.code }})</span>
</label>
</div>
<p class="country-hint">发布国家会从同步国家中自动排除</p>
</div>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<div class="run-row">
<button
type="button"
class="btn-run"
:disabled="parsing || uploading || !uploadedFiles.length"
@click="submitRun"
>
{{ parsing ? '解析中...' : queueWorkerRunning || hasQueueWork ? '加入等待队列' : '开始上架' }}
</button>
<span class="loading-msg">{{ operationHint }}</span>
</div>
<div v-if="queueMessage" class="queue-status">
<div class="section-title queue-status-title">任务状态</div>
<div>{{ queueMessage }}</div>
</div>
<ModuleTemplateDownload module-code="publish" filename="上架 文档格式.xlsx" />
</aside>
<section class="right-panel">
<TaskCenterPanel
:on-batch-delete="batchDeleteHistory"
title="上架结果"
:cards="publishCards"
:current-items="currentTaskViews"
:history-items="historyTaskViews"
current-title="当前任务"
current-empty-text="暂无上架任务完成文件选择后点击开始上架创建任务。"
history-empty-text="暂无历史记录"
>
<template #item-extra="{ item }">
<ul class="publish-file-list">
<li v-for="file in itemSource(item).files" :key="fileKey(file)" class="publish-file-row">
<div class="publish-file-row-head">
<span class="publish-file-row-name" :title="file.sourceFilename || ''">
{{ file.sourceFilename || `文件 ${file.fileId}` }}
</span>
<span class="publish-file-row-chip" :class="statusClass(file.status)">{{ statusText(file.status) }}</span>
</div>
<div class="publish-file-row-info">
<span>店铺{{ file.shopName || '-' }}</span>
<span>匹配{{ matchText(file) }}</span>
<span v-if="file.platform">平台{{ file.platform }}</span>
<span>数据{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
</div>
<template v-if="shouldShowFileProgress(file)">
<div class="publish-file-row-progress-meta">
<span>{{ file.progressMessage || statusText(file.status) }}</span>
<span>{{ fileProgressPercent(file) }}%</span>
</div>
<div class="publish-file-row-progress-track">
<div class="publish-file-row-progress-fill" :class="statusClass(file.status)"
:style="{ width: `${fileProgressPercent(file)}%` }"></div>
</div>
</template>
<div v-if="fileErrorText(file)" class="publish-file-row-error file-error">{{ fileErrorText(file) }}</div>
</li>
</ul>
</template>
<template #history-item-extra="{ item }">
<ul class="publish-file-list">
<li v-for="file in itemSource(item).files" :key="fileKey(file)" class="publish-file-row">
<div class="publish-file-row-head">
<span class="publish-file-row-name" :title="file.sourceFilename || ''">
{{ file.sourceFilename || `文件 ${file.fileId}` }}
</span>
<span class="publish-file-row-chip" :class="statusClass(file.status)">{{ statusText(file.status) }}</span>
</div>
<div class="publish-file-row-info">
<span>店铺{{ file.shopName || '-' }}</span>
<span>匹配{{ matchText(file) }}</span>
<span v-if="file.platform">平台{{ file.platform }}</span>
<span>数据{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
</div>
<template v-if="shouldShowFileProgress(file)">
<div class="publish-file-row-progress-meta">
<span>{{ file.progressMessage || statusText(file.status) }}</span>
<span>{{ fileProgressPercent(file) }}%</span>
</div>
<div class="publish-file-row-progress-track">
<div class="publish-file-row-progress-fill" :class="statusClass(file.status)"
:style="{ width: `${fileProgressPercent(file)}%` }"></div>
</div>
</template>
<div v-if="fileErrorText(file)" class="publish-file-row-error file-error">{{ fileErrorText(file) }}</div>
</li>
</ul>
</template>
<template #item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button type="button" class="btn-delete" :disabled="isDeletingTask(itemSource(item).task.id)"
@click="deleteTaskRecord(itemSource(item))">
{{ isDeletingTask(itemSource(item).task.id) ? '删除中...' : '删除' }}
</button>
</template>
<template #history-item-actions="{ item }">
<button v-if="canDownload(itemSource(item))" type="button" class="download"
@click="downloadResult(itemSource(item))">下载结果</button>
<button type="button" class="btn-delete" :disabled="isDeletingTask(itemSource(item).task.id)"
@click="deleteTaskRecord(itemSource(item))">
{{ isDeletingTask(itemSource(item).task.id) ? '删除中...' : '删除' }}
</button>
</template>
</TaskCenterPanel>
</section>
</div>
</AmazonToolPageShell>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { expandBrandFolderRecursive } from '@/shared/api/brand'
import {
activatePublishFile,
activatePublishTask,
deletePublishTask,
getPublishDashboard,
getPublishHistory,
getPublishItemsPageUrl,
getPublishTaskProgressBatch,
parsePublish,
submitPublishTaskResult,
type PublishDashboardVo,
type PublishFileItem,
type PublishTaskDetailVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import {
EXCEL_EXTENSIONS,
checkParseResult,
checkQueuePayload,
checkSelectedFiles,
} from '@/shared/dispatch-guard'
import { passGuard } from '@/shared/dispatch-guard-ui'
import { useZiniaoVersion, type ZiniaoVersion } from '@/shared/utils/ziniao-version'
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
import { runBatchDelete } from '@/shared/utils/batch-delete'
const COUNTRY_OPTIONS = [
{ code: 'DE', label: '德国' },
{ code: 'UK', label: '英国' },
{ code: 'FR', label: '法国' },
{ code: 'IT', label: '意大利' },
{ code: 'ES', label: '西班牙' },
] as const
interface PublishDispatchOptions {
publishCountry: string
syncCountries: string[]
ziniaoVersion: ZiniaoVersion
}
interface PublishQueueBatch {
taskId: number
pendingFileIds: number[]
activeFileId: number | null
files: PublishFileItem[]
options: PublishDispatchOptions
}
interface StoredPublishQueueState {
active: PublishQueueBatch | null
pending: PublishQueueBatch[]
}
const selectedPaths = ref<string[]>([])
const uploadedFiles = ref<UploadedJavaFile[]>([])
const uploading = ref(false)
const parsing = ref(false)
const queueWorkerRunning = ref(false)
const queueMessage = ref('')
const publishCountry = ref<string>('DE')
const syncCountries = ref<string[]>(['UK', 'FR', 'IT', 'ES'])
const ziniaoVersion = useZiniaoVersion()
const currentTaskId = ref<number | null>(null)
const currentFiles = ref<PublishFileItem[]>([])
const pendingFileIds = ref<number[]>([])
const activeFileId = ref<number | null>(null)
const dispatchOptions = ref<PublishDispatchOptions | null>(null)
const queuedBatches = ref<PublishQueueBatch[]>([])
const taskSnapshots = ref<Record<number, PublishTaskDetailVo>>({})
const historyItems = ref<PublishTaskDetailVo[]>([])
const missingTaskIds = ref<number[]>([])
const deletingTaskIds = ref<number[]>([])
const dashboard = ref<PublishDashboardVo>({
pendingCount: 0,
runningCount: 0,
successCount: 0,
failedCount: 0,
})
const timers = createCategorizedTimers('publish-tab')
let disposed = false
function currentUserId() {
const value = Number(typeof window === 'undefined' ? 0 : window.localStorage.getItem('uid'))
return Number.isFinite(value) && value > 0 ? value : 0
}
function queueStorageKey() {
return `publish:queue-state:${currentUserId()}`
}
function pollingStorageKey() {
return `publish:polling-task-ids:${currentUserId()}`
}
function normalizeStatus(status?: string | null) {
return String(status || '').trim().toUpperCase()
}
function isTerminalStatus(status?: string | null) {
return ['SUCCESS', 'FAILED', 'COMPLETED', 'CANCELLED'].includes(normalizeStatus(status))
}
function fileKey(file: PublishFileItem) {
if (file.fileId > 0) return `id:${file.fileId}`
if (file.fileKey) return `key:${file.fileKey}`
return `name:${file.sourceFilename || ''}`
}
function mergeFiles(base: PublishFileItem[], incoming: PublishFileItem[]) {
const merged = new Map<string, PublishFileItem>()
for (const file of base || []) merged.set(fileKey(file), file)
for (const file of incoming || []) {
const key = fileKey(file)
merged.set(key, { ...(merged.get(key) || {}), ...file })
}
return Array.from(merged.values())
}
function mergeDetail(previous: PublishTaskDetailVo | undefined, incoming: PublishTaskDetailVo) {
if (!previous) return incoming
return {
task: { ...previous.task, ...incoming.task },
files: mergeFiles(previous.files || [], incoming.files || []),
result: incoming.result
? { ...(previous.result || {}), ...incoming.result }
: previous.result,
}
}
function applyTaskSnapshot(taskId: number, detail: PublishTaskDetailVo) {
const merged = mergeDetail(taskSnapshots.value[taskId], detail)
taskSnapshots.value = { ...taskSnapshots.value, [taskId]: merged }
missingTaskIds.value = missingTaskIds.value.filter((id) => id !== taskId)
if (currentTaskId.value === taskId) {
currentFiles.value = mergeFiles(currentFiles.value, merged.files || [])
}
}
// The backend advances processedRows when Python submits result chunks; heartbeat is liveness only.
const progressLoop = useTaskProgressLoop<PublishTaskDetailVo>({
scope: 'publish-tab',
storageKey: pollingStorageKey(),
fetchProgress: async (taskIds) => {
const batch = await getPublishTaskProgressBatch(taskIds)
const requested = new Set(taskIds)
missingTaskIds.value = [
...missingTaskIds.value.filter((id) => !requested.has(id)),
...(batch.missingTaskIds || []),
]
for (const missingTaskId of batch.missingTaskIds || []) {
progressLoop.remove(missingTaskId)
}
return batch
},
extractTaskId: (detail) => detail.task?.id,
extractStatus: (detail) => detail.task?.status,
isTerminal: isTerminalStatus,
onUpdate: applyTaskSnapshot,
onTerminal: async (taskId) => {
if (currentTaskId.value === taskId && isTerminalStatus(taskSnapshots.value[taskId]?.task?.status)) {
pendingFileIds.value = []
activeFileId.value = null
saveQueueState()
}
await Promise.all([loadDashboard(), loadHistory()])
},
onError: () => {
if (hasQueueWork.value) queueMessage.value = '任务仍在执行,正在等待服务恢复...'
},
})
const displayPaths = computed(() => selectedPaths.value.slice(0, 8))
const activeBatchHasWork = computed(() => pendingFileIds.value.length > 0 || activeFileId.value != null)
const hasQueueWork = computed(() => activeBatchHasWork.value || queuedBatches.value.length > 0)
const selectionDisabled = computed(() => uploading.value || parsing.value)
const currentDetail = computed<PublishTaskDetailVo | null>(() => {
const taskId = currentTaskId.value
if (!taskId) return null
const snapshot = taskSnapshots.value[taskId]
if (snapshot) return snapshot
return {
task: { id: taskId, status: hasQueueWork.value ? 'RUNNING' : 'PENDING' },
files: currentFiles.value,
}
})
const visibleTasks = computed(() => {
const tasks = new Map<number, PublishTaskDetailVo>()
for (const detail of historyItems.value) {
if (detail.task?.id) tasks.set(detail.task.id, detail)
}
if (currentDetail.value) {
const taskId = currentDetail.value.task.id
tasks.set(taskId, mergeDetail(tasks.get(taskId), currentDetail.value))
}
return Array.from(tasks.values()).sort((left, right) => right.task.id - left.task.id)
})
function isPublishTaskTerminal(detail: PublishTaskDetailVo) {
return isTerminalStatus(detail.task?.status)
}
const publishCards = computed<TaskStatCard[]>(() => {
const visible = visibleTasks.value
const terminal = visible.filter((detail) => isPublishTaskTerminal(detail))
let successCount = 0
for (const detail of terminal) {
const value = normalizeStatus(detail.task?.status)
if (value === 'SUCCESS' || value === 'COMPLETED') successCount += 1
}
return [
{ label: '运行中任务', value: visible.length - terminal.length },
{ label: '已结束任务', value: terminal.length },
{ label: '成功任务', value: successCount },
{ label: '失败任务', value: terminal.length - successCount },
]
})
const currentTaskViews = computed<TaskItemView[]>(() =>
visibleTasks.value
.filter((detail) => !isPublishTaskTerminal(detail))
.map(toPublishTaskView),
)
const historyTaskViews = computed<TaskItemView[]>(() =>
historyItems.value
.filter((detail) => isPublishTaskTerminal(detail))
.map(toPublishTaskView),
)
function itemSource(item: TaskItemView): PublishTaskDetailVo {
return item.source as PublishTaskDetailVo
}
function toPublishTaskView(detail: PublishTaskDetailVo): TaskItemView {
const task = detail.task
const taskStatus = task?.status
const terminal = isTerminalStatus(taskStatus)
const rawPercent = Number(task?.percent || 0)
const percent = Number.isFinite(rawPercent) ? Math.max(0, Math.min(100, Math.round(rawPercent))) : 0
const errorMessage = task?.errorMessage || ''
const extraLines: string[] = []
if (errorMessage) extraLines.push(`错误:${errorMessage}`)
return {
key: `publish-${task?.id ?? 'unknown'}`,
title: `上架任务 #${task?.id ?? '-'}`,
taskId: task?.id ?? '-',
startedAt: formatDateTime(task?.startedAt || task?.createdAt),
finishedAt: task?.finishedAt ? formatDateTime(task.finishedAt) : terminal ? '-' : '进行中',
statusText: statusText(taskStatus),
statusClass: statusClass(taskStatus),
extraLines,
progress: !terminal && percent > 0 ? { percent, stage: '任务进度' } : null,
source: detail,
}
}
const operationHint = computed(() => {
if (uploading.value) return '正在逐个上传文件,请稍候...'
if (parsing.value) return '正在解析 Excel、匹配店铺并准备上架...'
if (queueWorkerRunning.value) return '当前文件上架完成后,会自动开始下一个文件。'
return '提交后会按文件逐个依次上架。'
})
watch(publishCountry, (code, previousCode) => {
const selected = new Set(syncCountries.value)
if (previousCode) selected.add(previousCode)
selected.delete(code)
syncCountries.value = COUNTRY_OPTIONS.map((item) => item.code).filter((item) => selected.has(item))
})
function onSyncCountryChange(code: string, event: Event) {
const element = event.target as HTMLInputElement | null
if (!element) return
const selected = new Set(syncCountries.value)
if (element.checked) selected.add(code)
else selected.delete(code)
selected.delete(publishCountry.value)
syncCountries.value = COUNTRY_OPTIONS.map((item) => item.code).filter((item) => selected.has(item))
}
async function selectFiles() {
const api = getPywebviewApi()
if (!api?.select_brand_xlsx_files || !api.upload_file_to_java) {
ElMessage.warning('当前环境不支持文件选择或上传,请在本机客户端中打开')
return
}
try {
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
uploading.value = true
const uploaded = await uploadPathsToJava(getPywebviewApi(), paths)
selectedPaths.value = paths
uploadedFiles.value = uploaded
ElMessage.success(`已上传 ${uploaded.length} 个待上架文件`)
} catch (error) {
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
selectedPaths.value = []
uploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
} finally {
uploading.value = false
}
}
async function selectFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder || !api.upload_file_to_java) {
ElMessage.warning('当前环境不支持文件夹选择或上传,请在本机客户端中打开')
return
}
try {
const folder = await api.select_brand_folder()
if (!folder) return
const result = await expandBrandFolderRecursive(folder)
if (!result.success || !result.items?.length) {
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
return
}
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
uploading.value = true
const uploaded = await uploadPathsToJava(getPywebviewApi(), result.items)
selectedPaths.value = result.items.map((item) => item.relativePath || item.absolutePath)
uploadedFiles.value = uploaded
ElMessage.success(`已上传文件夹内 ${uploaded.length} 个 xlsx 文件`)
} catch (error) {
selectedPaths.value = []
uploadedFiles.value = []
ElMessage.error(error instanceof Error ? error.message : '选择失败')
} finally {
uploading.value = false
}
}
function isMatchedFile(file: PublishFileItem) {
const matchStatus = normalizeStatus(file.matchStatus)
return Boolean(
file.matched
|| matchStatus === 'MATCHED'
|| (matchStatus === 'INDEX_STALE' && file.shopId),
)
}
async function submitRun() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择并上传待上架 Excel 文件或文件夹')
return
}
if (!publishCountry.value) {
ElMessage.warning('请选择发布国家')
return
}
parsing.value = true
try {
const parsed = await parsePublish({
files: uploadedFiles.value.map((file) => ({
fileKey: file.fileKey,
originalFilename: file.originalFilename,
relativePath: file.relativePath,
})),
publish_country: publishCountry.value,
sync_countries: syncCountries.value.filter((country) => country !== publishCountry.value),
})
if (!parsed.taskId) throw new Error('后端未返回有效任务标识')
// publish 的 Vo 用 files 承载明细,没有 acceptedRows;这里先卡住 taskId 与总行数
const guard = checkParseResult(
{ taskId: parsed.taskId, totalRows: parsed.totalRows, acceptedRows: parsed.totalRows },
{ requiredColumnsHint: '店铺名 / 商品行' },
)
if (!(await passGuard(guard))) return
const options: PublishDispatchOptions = {
publishCountry: publishCountry.value,
syncCountries: syncCountries.value.filter((country) => country !== publishCountry.value),
ziniaoVersion: ziniaoVersion.value,
}
const files = parsed.files || []
applyTaskSnapshot(parsed.taskId, {
task: {
id: parsed.taskId,
taskNo: parsed.taskNo,
status: 'PENDING',
sourceFileCount: parsed.sourceFileCount,
totalRows: parsed.totalRows,
},
files,
result: parsed.result,
})
const batch: PublishQueueBatch = {
taskId: parsed.taskId,
pendingFileIds: files
.filter((file) => !isTerminalStatus(file.status) && isMatchedFile(file))
.map((file) => file.fileId)
.filter((fileId) => Number.isFinite(fileId) && fileId > 0),
activeFileId: null,
files,
options,
}
progressLoop.add(parsed.taskId)
selectedPaths.value = []
uploadedFiles.value = []
await Promise.all([loadDashboard(), loadHistory()])
if (!batch.pendingFileIds.length) {
queueMessage.value = '解析完成,当前没有匹配成功且可上架的文件。'
ElMessage.warning(queueMessage.value)
return
}
const shouldWait = queueWorkerRunning.value || activeBatchHasWork.value || queuedBatches.value.length > 0
if (shouldWait) {
queuedBatches.value.push(batch)
saveQueueState()
queueMessage.value = `任务 ${parsed.taskNo || parsed.taskId} 已加入等待队列,前面还有 ${queuedBatches.value.length} 个批次`
ElMessage.success(queueMessage.value)
if (!queueWorkerRunning.value) void processQueue()
return
}
setActiveBatch(batch)
saveQueueState()
queueMessage.value = `任务 ${parsed.taskNo || parsed.taskId} 已创建,开始依次上架 ${batch.pendingFileIds.length} 个文件。`
ElMessage.success(queueMessage.value)
void processQueue()
} catch (error) {
const message = error instanceof Error ? error.message : '上架任务创建失败'
queueMessage.value = message
ElMessage.error(message)
} finally {
parsing.value = false
}
}
function saveQueueState() {
if (typeof window === 'undefined') return
const active: PublishQueueBatch | null = currentTaskId.value && dispatchOptions.value && activeBatchHasWork.value
? {
taskId: currentTaskId.value,
pendingFileIds: pendingFileIds.value,
activeFileId: activeFileId.value,
files: currentFiles.value,
options: dispatchOptions.value,
}
: null
const pending = queuedBatches.value.filter(
(batch) => batch.pendingFileIds.length > 0 || batch.activeFileId != null,
)
if (!active && !pending.length) {
window.localStorage.removeItem(queueStorageKey())
return
}
const state: StoredPublishQueueState = { active, pending }
window.localStorage.setItem(queueStorageKey(), JSON.stringify(state))
}
function setActiveBatch(batch: PublishQueueBatch, restoreForm = false) {
currentTaskId.value = batch.taskId
currentFiles.value = batch.files
pendingFileIds.value = [...batch.pendingFileIds]
activeFileId.value = batch.activeFileId
dispatchOptions.value = batch.options
if (restoreForm) {
publishCountry.value = batch.options.publishCountry
syncCountries.value = [...batch.options.syncCountries]
ziniaoVersion.value = batch.options.ziniaoVersion
}
}
function takeNextBatch() {
while (queuedBatches.value.length) {
const next = queuedBatches.value.shift()
if (!next) break
if (
missingTaskIds.value.includes(next.taskId)
|| isTerminalStatus(taskSnapshots.value[next.taskId]?.task?.status)
) {
continue
}
setActiveBatch(next)
saveQueueState()
return true
}
saveQueueState()
return false
}
function loadQueueState() {
if (typeof window === 'undefined') return
try {
const raw = window.localStorage.getItem(queueStorageKey())
if (!raw) return
const state = JSON.parse(raw) as Partial<StoredPublishQueueState> & Partial<PublishQueueBatch>
const active = state.active && Number.isFinite(state.active.taskId) && state.active.taskId > 0
? state.active
: !state.active && Number.isFinite(state.taskId) && Number(state.taskId) > 0 && state.options
? {
taskId: Number(state.taskId),
pendingFileIds: state.pendingFileIds || [],
activeFileId: state.activeFileId ?? null,
files: state.files || [],
options: state.options,
}
: null
if (active) setActiveBatch(active, true)
queuedBatches.value = (Array.isArray(state.pending) ? state.pending : [])
.filter((batch) => Number.isFinite(batch.taskId) && batch.taskId > 0 && batch.options)
.map((batch) => ({
...batch,
pendingFileIds: (batch.pendingFileIds || []).filter((id) => Number.isFinite(id) && id > 0),
activeFileId: Number.isFinite(batch.activeFileId) && Number(batch.activeFileId) > 0
? Number(batch.activeFileId)
: null,
files: Array.isArray(batch.files) ? batch.files : [],
}))
} catch {
window.localStorage.removeItem(queueStorageKey())
}
}
function getCurrentFile(fileId: number) {
const taskId = currentTaskId.value
const snapshotFiles = taskId ? taskSnapshots.value[taskId]?.files || [] : []
return snapshotFiles.find((file) => file.fileId === fileId)
|| currentFiles.value.find((file) => file.fileId === fileId)
}
function updateCurrentFile(fileId: number, patch: Partial<PublishFileItem>) {
const update = (files: PublishFileItem[]) => files.map((file) => (
file.fileId === fileId ? { ...file, ...patch } : file
))
currentFiles.value = update(currentFiles.value)
const taskId = currentTaskId.value
if (taskId && taskSnapshots.value[taskId]) {
taskSnapshots.value = {
...taskSnapshots.value,
[taskId]: {
...taskSnapshots.value[taskId],
files: update(taskSnapshots.value[taskId].files || []),
},
}
}
}
function buildQueuePayload(taskId: number, file: PublishFileItem) {
const options = dispatchOptions.value
if (!options) throw new Error('缺少当前批次的派发配置')
const pageSize = Math.max(1, Number(file.pageSize || 100))
const totalRows = Math.max(0, Number(file.totalRows || 0))
const totalPages = Math.max(0, Number(file.totalPages || (totalRows ? Math.ceil(totalRows / pageSize) : 0)))
const paginationUrl = file.pageUrl || getPublishItemsPageUrl(taskId, file.fileId, pageSize, 1)
return {
type: 'publish-run',
ts: Date.now(),
data: {
taskId,
fileId: file.fileId,
fileKey: file.fileKey || '',
sourceFilename: file.sourceFilename || '',
shopName: file.shopName || '',
shopId: file.shopId || '',
matchedUserId: file.matchedUserId,
platform: file.platform || '',
companyName: file.companyName || '',
matched: isMatchedFile(file),
matchStatus: file.matchStatus || '',
matchMessage: file.matchMessage || '',
ziniao_version: options.ziniaoVersion,
publish_country: options.publishCountry,
sync_countries: options.syncCountries,
paginationUrl,
pagination_url: paginationUrl,
items_url: paginationUrl,
pageSize,
totalPages,
totalRows,
page_size: pageSize,
total_pages: totalPages,
total_rows: totalRows,
source: 'frontend-vue-publish',
},
}
}
async function submitPublishResultWithRetry(
taskId: number,
file: PublishFileItem,
reason: string,
) {
let lastError: unknown
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await submitPublishTaskResult(taskId, {
files: [{
fileId: file.fileId,
fileKey: file.fileKey,
sourceFilename: file.sourceFilename,
error: reason,
rows: [],
}],
})
return
} catch (error) {
lastError = error
if (attempt < 3) await timers.sleep('result-compensation', attempt * 500)
}
}
throw lastError instanceof Error ? lastError : new Error('失败状态提交失败')
}
async function submitDispatchFailure(taskId: number, file: PublishFileItem, reason: string) {
await submitPublishResultWithRetry(taskId, file, reason)
updateCurrentFile(file.fileId, {
status: 'FAILED',
error: reason,
errorMessage: reason,
progressPercent: 100,
})
}
async function waitForFileTerminal(taskId: number, fileId: number) {
while (!disposed) {
if (missingTaskIds.value.includes(taskId)) return 'FAILED'
if (!progressLoop.inFlight.value) await progressLoop.refreshOnce()
const file = getCurrentFile(fileId)
const fileStatus = normalizeStatus(file?.status)
if (isTerminalStatus(fileStatus)) return fileStatus
const taskStatus = normalizeStatus(taskSnapshots.value[taskId]?.task?.status)
if (isTerminalStatus(taskStatus)) return taskStatus
await timers.sleep('queue-wait', getTaskPollIntervalMs())
}
return 'STOPPED'
}
async function processQueue() {
if (disposed || queueWorkerRunning.value || (!activeBatchHasWork.value && !queuedBatches.value.length)) return
const api = getPywebviewApi()
if (!api?.enqueue_json) {
queueMessage.value = '当前客户端未提供 enqueue_json,无法派发上架任务。'
ElMessage.error(queueMessage.value)
return
}
queueWorkerRunning.value = true
try {
while (!disposed) {
if (!currentTaskId.value || !dispatchOptions.value || !activeBatchHasWork.value) {
if (!takeNextBatch()) break
}
const taskId = currentTaskId.value
if (!taskId || !dispatchOptions.value) continue
progressLoop.add(taskId)
await activatePublishTask(taskId)
while (!disposed && (activeFileId.value != null || pendingFileIds.value.length > 0)) {
if (
missingTaskIds.value.includes(taskId)
|| isTerminalStatus(taskSnapshots.value[taskId]?.task?.status)
) {
pendingFileIds.value = []
activeFileId.value = null
saveQueueState()
break
}
if (activeFileId.value != null) {
const restoredFileId = activeFileId.value
queueMessage.value = `正在等待文件 ${getCurrentFile(restoredFileId)?.sourceFilename || restoredFileId} 完成...`
const restoredStatus = await waitForFileTerminal(taskId, restoredFileId)
if (restoredStatus === 'STOPPED') return
activeFileId.value = null
saveQueueState()
continue
}
const nextFileId = pendingFileIds.value.shift()
if (!nextFileId) break
const file = getCurrentFile(nextFileId)
if (!file || isTerminalStatus(file.status)) {
saveQueueState()
continue
}
activeFileId.value = nextFileId
saveQueueState()
try {
await activatePublishFile(taskId, nextFileId)
updateCurrentFile(nextFileId, { status: 'RUNNING', progressMessage: '已启动任务' })
const payload = buildQueuePayload(taskId, file)
// totalRows/totalPages 为 0 时 Python 端翻不到任何明细,会一直停在执行中
const guard = checkQueuePayload(payload, {
expectedType: 'publish-run',
requiredDataKeys: ['taskId', 'fileId', 'shopName', 'publish_country', 'paginationUrl'],
positiveNumberKeys: ['totalRows', 'totalPages'],
})
if (!(await passGuard(guard))) {
throw new Error(`文件 ${file.sourceFilename || nextFileId} 数据校验未通过,已阻止启动`)
}
const result = await api.enqueue_json(payload)
if (!result?.success) throw new Error(result?.error || '任务队列拒绝接收任务')
queueMessage.value = pendingFileIds.value.length
? `文件 ${file.sourceFilename || nextFileId} 已提交,完成后继续剩余 ${pendingFileIds.value.length} 个文件。`
: `文件 ${file.sourceFilename || nextFileId} 已提交,等待执行完成。`
const finalStatus = await waitForFileTerminal(taskId, nextFileId)
if (finalStatus === 'STOPPED') return
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} ${['SUCCESS', 'COMPLETED'].includes(finalStatus) ? '已完成' : '执行失败'},继续下一个文件。`
activeFileId.value = null
saveQueueState()
} catch (error) {
const reason = error instanceof Error ? error.message : '文件派发失败'
try {
await submitDispatchFailure(taskId, file, reason)
} catch (compensationError) {
const message = compensationError instanceof Error ? compensationError.message : '失败状态提交失败'
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 启动失败,后端未确认失败状态:${message}。上架已暂停。`
saveQueueState()
throw new Error(queueMessage.value)
}
// 原因必须回显给用户:后端会因「该店铺已有上架任务在执行」直接拒绝激活,
// 只写日志的话用户只看到"启动失败",会以为是文件问题而反复重传
queueMessage.value = `文件 ${file.sourceFilename || nextFileId} 启动失败:${reason}`
ElMessage.warning(queueMessage.value)
activeFileId.value = null
saveQueueState()
}
}
if (disposed) return
await progressLoop.refreshOnce()
await Promise.all([loadDashboard(), loadHistory()])
if (queuedBatches.value.length) {
queueMessage.value = `当前批次已完成,继续处理后续 ${queuedBatches.value.length} 个等待批次。`
continue
}
queueMessage.value = '所有上架批次已按顺序完成,正在生成结果文件。'
ElMessage.success('上架等待队列已完成')
break
}
} catch (error) {
if (disposed) return
const message = error instanceof Error ? error.message : '任务执行失败'
queueMessage.value = message
ElMessage.error(message)
} finally {
queueWorkerRunning.value = false
saveQueueState()
}
}
async function loadDashboard() {
try {
dashboard.value = await getPublishDashboard()
} catch {
// Dashboard failure must not interrupt the active queue.
}
}
async function loadHistory() {
try {
const history = await getPublishHistory()
historyItems.value = history.items || []
for (const detail of historyItems.value) {
const taskId = detail.task?.id
if (!taskId) continue
applyTaskSnapshot(taskId, detail)
if (!isTerminalStatus(detail.task.status)) progressLoop.add(taskId)
}
} catch {
// Keep current snapshots when history is temporarily unavailable.
}
}
function reconcileStoredQueue() {
function reconcileBatch(batch: PublishQueueBatch) {
if (missingTaskIds.value.includes(batch.taskId)) return null
const snapshot = taskSnapshots.value[batch.taskId]
if (isTerminalStatus(snapshot?.task?.status)) return null
const files = mergeFiles(batch.files || [], snapshot?.files || [])
let pendingIds = (batch.pendingFileIds || []).filter((fileId) => {
const file = files.find((item) => item.fileId === fileId)
return file && !isTerminalStatus(file.status)
})
let activeId = batch.activeFileId
if (activeId != null) {
const activeStatus = normalizeStatus(files.find((file) => file.fileId === activeId)?.status)
if (!activeStatus || activeStatus === 'PENDING') {
pendingIds = [activeId, ...pendingIds.filter((fileId) => fileId !== activeId)]
activeId = null
} else if (isTerminalStatus(activeStatus)) {
activeId = null
}
}
if (!pendingIds.length && activeId == null) return null
return { ...batch, pendingFileIds: pendingIds, activeFileId: activeId, files }
}
const taskId = currentTaskId.value
const active = taskId && dispatchOptions.value
? reconcileBatch({
taskId,
pendingFileIds: pendingFileIds.value,
activeFileId: activeFileId.value,
files: currentFiles.value,
options: dispatchOptions.value,
})
: null
if (active) {
setActiveBatch(active)
} else {
currentTaskId.value = null
currentFiles.value = []
pendingFileIds.value = []
activeFileId.value = null
dispatchOptions.value = null
}
queuedBatches.value = queuedBatches.value
.map(reconcileBatch)
.filter((batch): batch is PublishQueueBatch => batch != null)
if (active?.activeFileId != null) {
const activeId = active.activeFileId
const activeStatus = normalizeStatus(active.files.find((file) => file.fileId === activeId)?.status)
if (!activeStatus || activeStatus === 'PENDING') {
queueMessage.value = `文件 ${activeId} 尚未开始,已恢复到等待队列。`
}
}
saveQueueState()
}
function statusText(status?: string | null) {
const value = normalizeStatus(status)
if (value === 'SUCCESS' || value === 'COMPLETED') return '已完成'
if (value === 'FAILED') return '失败'
if (value === 'CANCELLED') return '已取消'
if (value === 'RUNNING') return '执行中'
return '等待中'
}
function statusClass(status?: string | null) {
const value = normalizeStatus(status)
if (value === 'SUCCESS' || value === 'COMPLETED') return 'success'
if (value === 'FAILED' || value === 'CANCELLED') return 'failed'
if (value === 'RUNNING') return 'running'
return 'pending'
}
function matchText(file: PublishFileItem) {
if (isMatchedFile(file)) return file.shopId ? `已匹配 ${file.shopId}` : '已匹配'
return file.matchMessage || file.matchStatus || '未匹配'
}
function fileErrorText(file: PublishFileItem) {
return file.errorMessage || file.error || ''
}
function shouldShowFileProgress(file: PublishFileItem) {
return ['RUNNING', 'SUCCESS', 'COMPLETED', 'FAILED', 'CANCELLED'].includes(normalizeStatus(file.status))
}
function fileProgressCurrent(file: PublishFileItem) {
return Math.max(0, Number(file.progressCurrent ?? file.processedRows ?? 0))
}
function fileProgressTotal(file: PublishFileItem) {
return Math.max(0, Number(file.progressTotal ?? file.totalRows ?? 0))
}
function fileProgressPercent(file: PublishFileItem) {
if (isTerminalStatus(file.status)) return 100
const explicit = Number(file.progressPercent ?? file.percent)
if (Number.isFinite(explicit) && explicit > 0) return Math.max(0, Math.min(100, Math.round(explicit)))
const current = fileProgressCurrent(file)
const total = fileProgressTotal(file)
return total > 0 ? Math.max(0, Math.min(100, Math.round((current / total) * 100))) : 0
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('zh-CN', { hour12: false })
}
function resultInfo(detail: PublishTaskDetailVo) {
return {
url: detail.result?.downloadUrl || detail.task.downloadUrl || '',
filename: detail.result?.resultFilename || detail.task.resultFilename || '',
}
}
function canDownload(detail: PublishTaskDetailVo) {
return Boolean(resultInfo(detail).url)
}
async function downloadResult(detail: PublishTaskDetailVo) {
const result = resultInfo(detail)
if (!result.url) {
ElMessage.warning('当前任务尚未生成下载地址')
return
}
const fallbackExtension = (detail.task.sourceFileCount || detail.files.length) > 1 ? 'zip' : 'xlsx'
const filename = result.filename || `publish_${detail.task.id}.${fallbackExtension}`
const saved = await saveUrlWithProgress(result.url, filename, `publish:${detail.task.id}`)
if (saved.success) ElMessage.success(`已保存:${saved.path || filename}`)
else if (saved.error && saved.error !== '用户取消') ElMessage.error(saved.error)
}
function isDeletingTask(taskId: number) {
return deletingTaskIds.value.includes(taskId)
}
function removeTaskLocally(taskId: number) {
progressLoop.remove(taskId)
historyItems.value = historyItems.value.filter((detail) => detail.task?.id !== taskId)
const snapshots = { ...taskSnapshots.value }
delete snapshots[taskId]
taskSnapshots.value = snapshots
missingTaskIds.value = Array.from(new Set([...missingTaskIds.value, taskId]))
queuedBatches.value = queuedBatches.value.filter((batch) => batch.taskId !== taskId)
if (currentTaskId.value === taskId) {
currentTaskId.value = null
currentFiles.value = []
pendingFileIds.value = []
activeFileId.value = null
dispatchOptions.value = null
}
saveQueueState()
}
async function deleteTaskRecord(detail: PublishTaskDetailVo) {
const taskId = detail.task?.id
if (!taskId || isDeletingTask(taskId)) return
try {
await ElMessageBox.confirm(
`确定删除上架任务 #${taskId}?任务明细和结果文件也会一并删除。`,
'删除任务',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
},
)
} catch {
return
}
deletingTaskIds.value = [...deletingTaskIds.value, taskId]
try {
await deletePublishTask(taskId)
removeTaskLocally(taskId)
ElMessage.success('任务已删除')
await Promise.all([loadDashboard(), loadHistory()])
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除任务失败')
} finally {
deletingTaskIds.value = deletingTaskIds.value.filter((id) => id !== taskId)
}
}
onMounted(async () => {
loadQueueState()
if (currentTaskId.value) progressLoop.add(currentTaskId.value)
for (const batch of queuedBatches.value) progressLoop.add(batch.taskId)
await Promise.all([loadDashboard(), loadHistory()])
if (currentTaskId.value || queuedBatches.value.length) await progressLoop.refreshOnce()
reconcileStoredQueue()
if (hasQueueWork.value) {
queueMessage.value = activeFileId.value != null
? `检测到未完成文件 ${activeFileId.value},继续等待完成后衔接后续文件。`
: `检测到未完成的任务,继续处理当前文件及后续 ${queuedBatches.value.length} 个批次。`
void processQueue()
}
})
onBeforeUnmount(() => {
disposed = true
timers.clearScope()
})
/**
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
*/
async function batchDeleteHistory(views: TaskItemView[]) {
const { total, failed } = await runBatchDelete(views, (view) => deleteTaskRecord(itemSource(view)))
if (!total) return
if (failed > 0) {
ElMessage.warning(`删除完成,${failed} 条失败`)
} else {
ElMessage.success(`已删除 ${total} 条历史记录`)
}
}
</script>
<style scoped>
.module-page { min-height: 100vh; background: #242424; }
.main-content { display: flex; min-height: calc(100vh - 88px); height: calc(100vh - 88px); }
.left-panel { width: 400px; background: #242424; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #242424; }
.section-title { margin-bottom: 10px; color: #a0acbe; font-size: 13px; }
.upload-zone { margin-bottom: 20px; padding: 20px; border: 1px dashed #3e4a62; border-radius: 8px; background: #242424; text-align: center; }
.hint, .loading-msg { color: #5e6878; font-size: 12px; line-height: 1.5; }
.btns, .run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.btns { justify-content: center; }
.opt-btn, .btn-run, .download, .btn-delete { border: 0; border-radius: 6px; cursor: pointer; transition: background-color .15s ease, color .15s ease, opacity .15s ease; }
.opt-btn { padding: 8px 16px; border: 1px solid #3e4a62; background: #242424; color: #c8d2e2; font-size: 13px; }
.opt-btn:hover:not(:disabled) { border-color: #3498db; color: #67b7ef; }
.opt-btn:disabled, .btn-run:disabled { opacity: .55; cursor: not-allowed; }
.selected-files { max-height: 120px; min-height: 72px; margin-top: 14px; overflow-y: auto; color: #5e6878; font-size: 12px; text-align: left; }
.selected-files span { display: block; margin: 4px 0; word-break: break-all; }
.more-line { color: #a0acbe; }
.country-group { margin-bottom: 12px; padding: 14px; border: 1px solid #323e58; border-radius: 8px; background: #242424; }
.country-group-title { margin-bottom: 10px; color: #a0acbe; font-size: 12px; }
.country-checks { display: flex; flex-wrap: wrap; gap: 10px 16px; }
.country-check-row { display: inline-flex; align-items: center; gap: 8px; color: #c8d2e2; cursor: pointer; font-size: 12px; user-select: none; }
.country-check-row.disabled { opacity: .45; cursor: not-allowed; }
.country-check-input { width: 16px; height: 16px; flex-shrink: 0; accent-color: #409eff; cursor: pointer; }
.country-check-input:disabled { cursor: not-allowed; }
.country-hint { margin: 10px 0 0; color: #5e6878; font-size: 11px; }
.run-row { margin-top: 16px; }
.btn-run { min-height: 38px; padding: 9px 20px; background: #3498db; color: #f5f8fc; font-size: 14px; }
.btn-run:hover:not(:disabled) { background: #2980b9; }
.queue-status { margin-top: 16px; padding: 12px; border-left: 3px solid #409eff; background: #242424; color: #b8d8ef; font-size: 12px; line-height: 1.6; }
.queue-status-title { margin-bottom: 4px; }
.download { padding: 7px 12px; background: #2d6b46; color: #dff7e8; font-size: 12px; white-space: nowrap; }
.download:hover { background: #367d54; }
.btn-delete { padding: 7px 12px; background: #472929; color: #efaaaa; font-size: 12px; white-space: nowrap; }
.btn-delete:hover:not(:disabled) { background: #5a3030; color: #ffd0d0; }
.btn-delete:disabled { cursor: wait; opacity: .55; }
.file-error { color: #ef8d8d; font-size: 12px; }
.publish-file-list { margin: 0; padding: 0; list-style: none; }
.publish-file-row { margin-top: 10px; padding-top: 10px; border-top: 1px dashed #2e3a52; }
.publish-file-row:first-child { margin-top: 0; padding-top: 0; border-top: none; }
.publish-file-row-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }
.publish-file-row-name { overflow: hidden; color: #e1e1e1; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.publish-file-row-chip { flex-shrink: 0; padding: 2px 8px; border-radius: 4px; font-size: 11px; white-space: nowrap; }
.publish-file-row-chip.pending { background: #3a3424; color: #e4c56a; }
.publish-file-row-chip.running { background: #24394a; color: #75bff1; }
.publish-file-row-chip.success { background: #233c2d; color: #72d598; }
.publish-file-row-chip.failed { background: #472929; color: #ef8d8d; }
.publish-file-row-info { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 5px; color: #5e6878; font-size: 12px; line-height: 1.5; }
.publish-file-row-progress-meta { display: flex; justify-content: space-between; gap: 12px; margin-top: 8px; color: #969696; font-size: 11px; }
.publish-file-row-progress-track { height: 6px; margin-top: 5px; overflow: hidden; border-radius: 3px; background: #333f55; }
.publish-file-row-progress-fill { height: 100%; border-radius: inherit; background: #8d7b3f; transition: width .25s ease; }
.publish-file-row-progress-fill.running { background: #409eff; }
.publish-file-row-progress-fill.success { background: #42b36b; }
.publish-file-row-progress-fill.failed { background: #d06161; }
.publish-file-row-error { margin-top: 7px; }
@media (max-width: 1100px) {
.main-content { height: auto; flex-direction: column; }
.left-panel { width: 100%; border-right: 0; border-bottom: 1px solid #2e3a52; }
.right-panel { min-height: 420px; }
}
@media (max-width: 640px) {
.publish-file-row-head { flex-direction: column; align-items: flex-start; }
}
</style>