3ce0569c59
上架/删除品牌/去重/转换/拆分/撤回/查ASIN/产品风险/跟价/巡店/店铺匹配/视频工作台: - 解析失败不再只报通用文案,逐条展示后端文件级 errorMessage(上架新增「店铺未找到」专属弹窗) - 匹配失败不再一律「请检查店铺名」,按 matchStatus 区分 CONFLICT/PENDING - 去重/转换/拆分全失败弹窗优先列各文件真实原因,取不到才回退猜测文案 - dispatch-guard 新增 fileErrors 选项与 collectDistinctErrors 导出
793 lines
24 KiB
Vue
793 lines
24 KiB
Vue
<template>
|
||
<div class="page-shell module-page">
|
||
<AmazonToolPageShell tool-id="dedupe">
|
||
|
||
<div class="main-content">
|
||
<aside class="left-panel">
|
||
<div class="section-title">选择文件</div>
|
||
<div class="upload-zone">
|
||
<div class="hint">选择需要清洗的 Excel 文件或文件夹,后续将按所选规则输出处理结果。</div>
|
||
<div class="btns">
|
||
<button type="button" class="opt-btn" @click="selectCleanFiles">选择 Excel 文件</button>
|
||
<button type="button" class="opt-btn" @click="selectCleanFolder">选择文件夹</button>
|
||
</div>
|
||
|
||
<div class="selected-files clean-placeholder">
|
||
<template v-if="cleanSelectedPaths.length">
|
||
<span v-for="path in cleanDisplayPaths" :key="path">{{ path }}</span>
|
||
<span v-if="cleanSelectedPaths.length > cleanDisplayPaths.length" class="more-line">
|
||
还有 {{ cleanSelectedPaths.length - cleanDisplayPaths.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">已选择 {{ cleanSelectedColumns.length }} / {{ cleanAvailableColumns.length }}
|
||
列</span>
|
||
<div class="column-actions">
|
||
<button type="button" class="opt-btn small" @click="selectAllCleanColumns">全选</button>
|
||
<button type="button" class="opt-btn small" @click="clearAllCleanColumns">清空</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="column-grid" v-if="cleanAvailableColumns.length">
|
||
<label v-for="column in cleanAvailableColumns" :key="column" class="column-item"
|
||
:class="{ active: cleanSelectedColumns.includes(column) }">
|
||
<input v-model="cleanSelectedColumns" type="checkbox" :value="column" />
|
||
<span>{{ column }}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div v-else class="empty-column-state">请选择 Excel 文件后读取表头并填充可保留列</div>
|
||
|
||
<div class="desc">读取首个 Excel 文件的第一行作为表头,用于动态选择需要保留的列。</div>
|
||
</div>
|
||
|
||
<div class="section-title">ID 保留规则</div>
|
||
<div class="option-group">
|
||
<!-- <label class="radio-item" :class="{ active: cleanKeepIntegerIds }">
|
||
<input v-model="cleanKeepIntegerIds" type="checkbox" />
|
||
<div>
|
||
<span class="label">保留主链接</span>
|
||
<div class="desc">例如 1、2、3 这种纯数字 ID</div>
|
||
</div>
|
||
</label> -->
|
||
|
||
<label class="radio-item" :class="{ active: cleanKeepUnderscoreIds }">
|
||
<input v-model="cleanKeepUnderscoreIds" type="checkbox" />
|
||
<div>
|
||
<span class="label">保留子链接</span>
|
||
<div class="desc">例如 1_1、2_3 这种带下划线的子项 ID</div>
|
||
</div>
|
||
</label>
|
||
|
||
<label class="radio-item" :class="{ active: cleanKeepIntegerMainIdsWhenNoSubIds }">
|
||
<input v-model="cleanKeepIntegerMainIdsWhenNoSubIds" type="checkbox" />
|
||
<div>
|
||
<span class="label">仅剩主链接无子链接</span>
|
||
<div class="desc">当某个主链接只有 13 这类主 ID、没有 13_1 这类对应子 ID 时,自动保留该主 ID</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="run-row">
|
||
<button type="button" class="btn-run" :disabled="cleanRunning" @click="submitCleanRun">
|
||
{{ cleanRunning ? '清洗中...' : '开始清洗' }}
|
||
</button>
|
||
<span class="loading-msg">
|
||
{{ cleanRunning ? `正在处理文件并上传结果,已处理 ${cleanProgressProcessed}/${cleanSummary.total || 0} 个文件…` : '选择文件、列和 ID 规则后即可执行清洗,结果会显示在右侧供下载' }}
|
||
</span>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="right-panel">
|
||
<TaskCenterPanel
|
||
:on-batch-delete="batchDeleteHistory"
|
||
title="去重结果"
|
||
:cards="dedupeCards"
|
||
:current-items="currentTaskViews"
|
||
:history-items="historyTaskViews"
|
||
current-title="当前任务"
|
||
current-empty-text="暂无当前任务,完成数据去重后会在这里展示"
|
||
history-empty-text="暂无历史记录"
|
||
>
|
||
<template #item-actions="{ item }">
|
||
<template v-if="itemSource(item)">
|
||
<button v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
|
||
type="button" class="download" @click="downloadCleanResult(itemSource(item))">
|
||
下载文件
|
||
</button>
|
||
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||
@click="deleteCleanHistoryRecord(itemSource(item).resultId!)">
|
||
删除
|
||
</button>
|
||
</template>
|
||
</template>
|
||
<!-- 历史结果只在「历史任务」弹层里展示,弹层复用独立的 actions 插槽,缺了就没有下载/删除按钮 -->
|
||
<template #history-item-actions="{ item }">
|
||
<template v-if="itemSource(item)">
|
||
<button v-if="itemSource(item).success && (itemSource(item).downloadUrl || itemSource(item).resultId)"
|
||
type="button" class="download" @click="downloadCleanResult(itemSource(item))">
|
||
下载文件
|
||
</button>
|
||
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||
@click="deleteCleanHistoryRecord(itemSource(item).resultId!)">
|
||
删除
|
||
</button>
|
||
</template>
|
||
</template>
|
||
</TaskCenterPanel>
|
||
</section>
|
||
</div>
|
||
</AmazonToolPageShell>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||
import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
||
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getDedupeRunProgress, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunProgressVo, type DedupeRunVo } from '@/shared/api/java-modules'
|
||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||
import { EXCEL_EXTENSIONS, checkSelectedFiles, collectDistinctErrors, guardBlocked } from '@/shared/dispatch-guard'
|
||
import { passGuard } from '@/shared/dispatch-guard-ui'
|
||
import { formatDateTime } from '@/shared/utils/datetime'
|
||
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
|
||
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
||
|
||
const cleanAvailableColumns = ref<string[]>([])
|
||
const cleanSelectedColumns = ref<string[]>([])
|
||
const cleanSelectedPaths = ref<string[]>([])
|
||
const cleanArchiveName = ref('')
|
||
const cleanUploadedFiles = ref<UploadedJavaFile[]>([])
|
||
const cleanKeepIntegerIds = ref(false)
|
||
const cleanKeepUnderscoreIds = ref(true)
|
||
const cleanKeepIntegerMainIdsWhenNoSubIds = ref(true)
|
||
const cleanRunning = ref(false)
|
||
const cleanProgressProcessed = ref(0)
|
||
const cleanResultItems = ref<DedupeResultItem[]>([])
|
||
const cleanSummary = ref<DedupeRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
|
||
const cleanDisplayPaths = computed(() => cleanSelectedPaths.value.slice(0, 8))
|
||
// 最近一次去重运行信息:用于右侧"当前任务"卡的占位展示(开始时间/运行ID/进度)
|
||
const latestRunId = ref('')
|
||
const cleanRunStartedAt = ref('')
|
||
|
||
function itemSource(item: TaskItemView): DedupeResultItem {
|
||
return item.source as DedupeResultItem
|
||
}
|
||
|
||
const dedupeCards = computed<TaskStatCard[]>(() => [
|
||
{ label: '运行中任务', value: currentTaskViews.value.length },
|
||
{ label: '已结束任务', value: cleanSummary.value.total },
|
||
{ label: '成功任务', value: cleanSummary.value.successCount },
|
||
{ label: '失败任务', value: cleanSummary.value.failedCount },
|
||
])
|
||
|
||
/** 当前任务:运行中的去重 run(占位卡) + 历史列表中非终态任务 */
|
||
const currentTaskViews = computed<TaskItemView[]>(() => {
|
||
const views: TaskItemView[] = []
|
||
if (cleanRunning.value && latestRunId.value) {
|
||
views.push({
|
||
key: `run-${latestRunId.value}`,
|
||
title: cleanArchiveName.value || '数据去重',
|
||
taskId: latestRunId.value,
|
||
startedAt: cleanRunStartedAt.value,
|
||
finishedAt: '',
|
||
statusText: '执行中',
|
||
statusClass: 'running',
|
||
progress: {
|
||
percent: cleanSummary.value.total
|
||
? Math.round((cleanProgressProcessed.value / cleanSummary.value.total) * 100)
|
||
: 0,
|
||
stage: `正在处理文件 ${cleanProgressProcessed.value}/${cleanSummary.value.total}`,
|
||
},
|
||
})
|
||
}
|
||
for (const item of cleanResultItems.value) {
|
||
const status = (item.taskStatus || '').toUpperCase()
|
||
if (status === 'RUNNING' || status === 'PENDING') {
|
||
views.push(toDedupeTaskView(item))
|
||
}
|
||
}
|
||
return views
|
||
})
|
||
|
||
const historyTaskViews = computed<TaskItemView[]>(() =>
|
||
cleanResultItems.value
|
||
.filter((item) => {
|
||
const status = (item.taskStatus || '').toUpperCase()
|
||
return status !== 'RUNNING' && status !== 'PENDING'
|
||
})
|
||
.map(toDedupeTaskView),
|
||
)
|
||
|
||
function toDedupeTaskView(item: DedupeResultItem): TaskItemView {
|
||
return {
|
||
key: `dedupe-${item.resultId ?? item.sourceFilename}`,
|
||
title: item.sourceFilename || '-',
|
||
taskId: item.taskId ?? '-',
|
||
startedAt: formatDateTime(item.startedAt || item.createdAt),
|
||
finishedAt: formatDateTime(item.finishedAt),
|
||
statusText: item.success ? '已完成' : '失败',
|
||
statusClass: item.success ? 'success' : 'failed',
|
||
extraLines: [
|
||
...(item.outputFilename ? [`输出文件:${item.outputFilename}`] : []),
|
||
...(item.error ? [`错误信息:${item.error}`] : []),
|
||
],
|
||
source: item,
|
||
}
|
||
}
|
||
|
||
function selectAllCleanColumns() {
|
||
cleanSelectedColumns.value = [...cleanAvailableColumns.value]
|
||
}
|
||
|
||
function clearAllCleanColumns() {
|
||
cleanSelectedColumns.value = []
|
||
}
|
||
|
||
async function loadCleanHeaders(fileKey: string) {
|
||
const result = await getExcelInfo(fileKey)
|
||
if (!result.headers?.length) {
|
||
// 接口本身成功、只是没解析出表头,说明文件内容有问题而非「读取失败」,别让用户反复重选文件
|
||
ElMessage.error('未读到表头行:请确认文件不是空表、且首行是表头')
|
||
cleanAvailableColumns.value = []
|
||
cleanSelectedColumns.value = []
|
||
return
|
||
}
|
||
|
||
const preferredHeaders = ['id', 'ASIN', '国家', '价格', '卖家名称', '品牌', 'SKU ID', '发货类型']
|
||
const defaultSelectedHeaders = ['id', 'ASIN', '国家', '价格', '品牌']
|
||
const orderedHeaders = [
|
||
...preferredHeaders.filter((header) => result.headers?.includes(header)),
|
||
...(result.headers || []).filter((header) => !preferredHeaders.includes(header)),
|
||
]
|
||
|
||
cleanAvailableColumns.value = orderedHeaders
|
||
cleanSelectedColumns.value = defaultSelectedHeaders.filter((header) => orderedHeaders.includes(header))
|
||
}
|
||
|
||
async function handleSelectedPaths(paths: string[], successMessage: string) {
|
||
cleanSelectedPaths.value = paths
|
||
cleanArchiveName.value = ''
|
||
cleanUploadedFiles.value = await uploadPathsToJava(getPywebviewApi(), paths)
|
||
if (cleanUploadedFiles.value.length > 0) {
|
||
await loadCleanHeaders(cleanUploadedFiles.value[0].fileKey)
|
||
}
|
||
ElMessage.success(successMessage)
|
||
}
|
||
|
||
async function selectCleanFiles() {
|
||
const api = getPywebviewApi()
|
||
if (!api?.select_brand_xlsx_files) {
|
||
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
|
||
await handleSelectedPaths(paths, `已选择 ${paths.length} 个待清洗文件`)
|
||
} catch (error) {
|
||
cleanSelectedPaths.value = []
|
||
cleanUploadedFiles.value = []
|
||
ElMessage.error(error instanceof Error ? error.message : '选择失败')
|
||
}
|
||
}
|
||
|
||
async function selectCleanFolder() {
|
||
const api = getPywebviewApi()
|
||
if (!api?.select_brand_folder) {
|
||
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const folder = await api.select_brand_folder()
|
||
if (!folder) return
|
||
cleanArchiveName.value = folder.split(/[/\\]/).filter(Boolean).pop() || ''
|
||
|
||
const result = await expandBrandFolderRecursive(folder)
|
||
if (result.success && result.items?.length) {
|
||
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
|
||
cleanSelectedPaths.value = result.items.map((item) => item.relativePath)
|
||
cleanUploadedFiles.value = await uploadPathsToJava(getPywebviewApi(), result.items)
|
||
if (cleanUploadedFiles.value.length > 0) {
|
||
await loadCleanHeaders(cleanUploadedFiles.value[0].fileKey)
|
||
}
|
||
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 xlsx 文件`)
|
||
return
|
||
}
|
||
|
||
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
|
||
} catch (error) {
|
||
cleanSelectedPaths.value = []
|
||
cleanUploadedFiles.value = []
|
||
ElMessage.error(error instanceof Error ? error.message : '选择失败')
|
||
}
|
||
}
|
||
|
||
async function submitCleanRun() {
|
||
if (!cleanUploadedFiles.value.length) {
|
||
ElMessage.warning('请先选择待清洗 Excel 文件或文件夹')
|
||
return
|
||
}
|
||
if (!cleanSelectedColumns.value.length) {
|
||
ElMessage.warning('请至少选择一列保留列')
|
||
return
|
||
}
|
||
if (!cleanKeepIntegerIds.value && !cleanKeepUnderscoreIds.value && !cleanKeepIntegerMainIdsWhenNoSubIds.value) {
|
||
ElMessage.warning('请至少选择一种 ID 保留规则')
|
||
return
|
||
}
|
||
|
||
try {
|
||
cleanRunning.value = true
|
||
latestRunId.value = ''
|
||
cleanRunStartedAt.value = formatDateTime(new Date().toISOString())
|
||
const progress = await runDedupe({
|
||
files: cleanUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename, relativePath: item.relativePath })),
|
||
selectedColumns: cleanSelectedColumns.value,
|
||
keepIntegerIds: cleanKeepIntegerIds.value,
|
||
keepUnderscoreIds: cleanKeepUnderscoreIds.value,
|
||
keepIntegerMainIdsWhenNoSubIds: cleanKeepIntegerMainIdsWhenNoSubIds.value,
|
||
archiveName:
|
||
cleanUploadedFiles.value.some((item) => !!item.relativePath) && cleanArchiveName.value
|
||
? cleanArchiveName.value
|
||
: undefined,
|
||
})
|
||
const result = await pollDedupeRunProgress(progress.runId, (latest) => {
|
||
cleanProgressProcessed.value = latest.processedCount
|
||
cleanSummary.value = {
|
||
total: latest.total,
|
||
successCount: latest.successCount,
|
||
failedCount: latest.failedCount,
|
||
items: cleanResultItems.value,
|
||
}
|
||
})
|
||
latestRunId.value = progress.runId
|
||
cleanSummary.value = result
|
||
cleanResultItems.value = result.items || []
|
||
await loadCleanHistory()
|
||
// 全部文件都失败时不该报「完成」,让用户看清是哪一批没处理成功
|
||
if (result.total > 0 && result.successCount === 0) {
|
||
// 逐文件原因就在 items[].error 里,优先直接列出,不再让用户对着「常见原因」猜
|
||
const details = collectDistinctErrors((result.items || []).map((item) => item.error)).slice(0, 5)
|
||
await passGuard(
|
||
guardBlocked(
|
||
'去重未成功',
|
||
`本次提交的 ${result.total} 个文件全部处理失败。\n` +
|
||
(details.length
|
||
? `失败原因:\n${details.map((message) => `· ${message}`).join('\n')}\n`
|
||
: '常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n') +
|
||
'请在右侧结果列表查看每个文件的失败原因后重试。',
|
||
'dedupe.all-failed',
|
||
),
|
||
)
|
||
return
|
||
}
|
||
ElMessage.success('数据去重完成')
|
||
} catch (error) {
|
||
// 卸载导致的轮询中止不提示(组件已不可见)
|
||
if (!disposed) {
|
||
ElMessage.error(error instanceof Error ? error.message : '去重失败')
|
||
}
|
||
} finally {
|
||
cleanRunning.value = false
|
||
}
|
||
}
|
||
|
||
// 去重任务进度轮询:2 秒一次,10 分钟超时(超时任务由后端继续执行,结果可在历史列表中查看)
|
||
const DEDUPE_POLL_INTERVAL_MS = 2000
|
||
const DEDUPE_POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||
|
||
// 组件卸载标记:轮询在用户切页后仍会继续(最长 10 分钟),并往已卸载组件的 ref 写数据、
|
||
// 触发全局确认框。卸载后立即中止轮询(服务端任务照常执行,结果在历史列表可查)。
|
||
let disposed = false
|
||
onBeforeUnmount(() => {
|
||
disposed = true
|
||
})
|
||
|
||
async function pollDedupeRunProgress(runId: string, onProgress?: (progress: DedupeRunProgressVo) => void): Promise<DedupeRunVo> {
|
||
const deadline = Date.now() + DEDUPE_POLL_TIMEOUT_MS
|
||
while (true) {
|
||
if (disposed) {
|
||
throw new Error('页面已离开,停止轮询')
|
||
}
|
||
const progress = await getDedupeRunProgress(runId)
|
||
if (progress.status === 'not_found') {
|
||
throw new Error('去重任务不存在或已过期')
|
||
}
|
||
if (progress.status !== 'running') {
|
||
if (progress.status === 'failed') {
|
||
throw new Error(progress.error || '去重任务执行失败')
|
||
}
|
||
if (!progress.result) {
|
||
throw new Error(progress.error || '去重任务未返回结果')
|
||
}
|
||
return progress.result
|
||
}
|
||
onProgress?.(progress)
|
||
if (Date.now() > deadline) {
|
||
throw new Error(
|
||
`去重任务仍在处理中(已处理 ${progress.processedCount}/${progress.total} 个文件),请稍后在历史列表中查看结果`,
|
||
)
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, DEDUPE_POLL_INTERVAL_MS))
|
||
}
|
||
}
|
||
|
||
async function loadCleanHistory() {
|
||
try {
|
||
const response = await getDedupeHistory()
|
||
cleanResultItems.value = response.items || []
|
||
cleanSummary.value = {
|
||
total: response.items?.length || 0,
|
||
successCount: response.items?.filter((item) => item.success).length || 0,
|
||
failedCount: response.items?.filter((item) => !item.success).length || 0,
|
||
items: response.items || [],
|
||
}
|
||
} catch {
|
||
// ignore history load errors
|
||
}
|
||
}
|
||
|
||
async function deleteCleanHistoryRecord(resultId: number) {
|
||
try {
|
||
await deleteDedupeHistory(resultId)
|
||
await loadCleanHistory()
|
||
ElMessage.success('已删除')
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||
}
|
||
}
|
||
|
||
async function downloadCleanResult(item: DedupeResultItem) {
|
||
const url = item.downloadUrl || (item.resultId ? getDedupeResultDownloadUrl(item.resultId) : '')
|
||
if (!url) {
|
||
ElMessage.warning('当前结果没有下载地址')
|
||
return
|
||
}
|
||
|
||
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}_cleaned.xlsx`
|
||
const result = await saveUrlWithProgress(url, filename, item.resultId ? `dedupe:${item.resultId}` : undefined)
|
||
if (result.success) {
|
||
ElMessage.success(`已保存:${result.path || filename}`)
|
||
} else if (result.error && result.error !== '用户取消') {
|
||
ElMessage.error(result.error)
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadCleanHistory().catch(() => undefined)
|
||
})
|
||
|
||
/**
|
||
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||
*/
|
||
async function batchDeleteHistory(views: TaskItemView[]) {
|
||
const { total, failed } = await runBatchDelete(views, (view) => deleteCleanHistoryRecord(itemSource(view).resultId!))
|
||
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 - 56px);
|
||
height: calc(100vh - 56px);
|
||
}
|
||
|
||
.left-panel {
|
||
width: 380px;
|
||
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 {
|
||
font-size: 13px;
|
||
color: #a0acbe;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.upload-zone {
|
||
border: 1px dashed #3e4a62;
|
||
border-radius: 10px;
|
||
padding: 24px;
|
||
text-align: center;
|
||
background: #242424;
|
||
margin-bottom: 20px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.upload-zone:hover {
|
||
border-color: #3498db;
|
||
background: #242424;
|
||
}
|
||
|
||
.hint {
|
||
color: #5e6878;
|
||
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: #c8d2e2;
|
||
background: #242424;
|
||
border: 1px solid #3e4a62;
|
||
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: #5e6878;
|
||
max-height: 112px;
|
||
overflow-y: auto;
|
||
text-align: left;
|
||
}
|
||
|
||
.selected-files span {
|
||
display: block;
|
||
margin: 4px 0;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.more-line {
|
||
color: #a0acbe;
|
||
}
|
||
|
||
.option-group {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.column-option-group {
|
||
padding: 14px;
|
||
border-radius: 10px;
|
||
background: #242424;
|
||
border: 1px solid #2e3a52;
|
||
}
|
||
|
||
.column-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.column-count {
|
||
font-size: 12px;
|
||
color: #a0acbe;
|
||
}
|
||
|
||
.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 #3e4a62;
|
||
background: #222b3d;
|
||
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 #323e58;
|
||
background: #222b3d;
|
||
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: #242424;
|
||
border: 1px solid #2e3a52;
|
||
margin-bottom: 8px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.radio-item:hover,
|
||
.radio-item.active {
|
||
background: #242424;
|
||
border-color: #3e4a62;
|
||
}
|
||
|
||
.radio-item input {
|
||
margin-top: 3px;
|
||
}
|
||
|
||
.label {
|
||
font-weight: 500;
|
||
color: #f5f8fc;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.desc {
|
||
font-size: 12px;
|
||
color: #5e6878;
|
||
margin-top: 2px;
|
||
line-height: 1.5;
|
||
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: #f5f8fc;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.btn-run:hover {
|
||
background: #2980b9;
|
||
}
|
||
|
||
.btn-run:disabled {
|
||
background: #555;
|
||
color: #a0acbe;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.loading-msg {
|
||
color: #3498db;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.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: #5e6878;
|
||
font-size: 13px;
|
||
padding: 24px 8px;
|
||
}
|
||
|
||
.clean-placeholder {
|
||
max-height: none;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.main-content {
|
||
flex-direction: column;
|
||
height: auto;
|
||
}
|
||
|
||
.left-panel {
|
||
width: 100%;
|
||
border-right: none;
|
||
border-bottom: 1px solid #2e3a52;
|
||
}
|
||
|
||
.right-panel {
|
||
min-height: 420px;
|
||
}
|
||
}
|
||
</style>
|