增加公共下载进度、增加接收SKU、密钥分别存放

This commit is contained in:
super
2026-05-28 16:41:20 +08:00
parent ca4a2cd07a
commit 2ed1250604
119 changed files with 4077 additions and 293 deletions
@@ -0,0 +1,783 @@
<template>
<div class="page-shell module-page">
<BrandTopBar active="collect-data" />
<div class="main-content">
<aside class="left-panel">
<div class="section-title">上传文件</div>
<div class="upload-zone">
<div class="hint">选择需要采集的 Excel 文件或文件夹默认根目录采集前数据.xlsx后续将按下方筛选条件交给 Python 端进行数据采集</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel</button>
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
</div>
<div class="selected-files">
<template v-if="selectedFileNames.length">
<span v-for="name in displayFileNames" :key="name">{{ name }}</span>
<span v-if="selectedFileNames.length > displayFileNames.length" class="more-line">
还有 {{ selectedFileNames.length - displayFileNames.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择文件</span>
</div>
</div>
<div class="section-title">筛选条件</div>
<div class="filter-zone">
<div class="filter-row">
<label class="filter-label">金额手动输入</label>
<input
v-model.number="filters.amount"
type="number"
step="0.01"
min="0"
class="filter-input"
placeholder="例如:59.99"
/>
</div>
<div class="filter-row">
<label class="filter-label">排名</label>
<input
v-model.number="filters.rank"
type="number"
step="1"
min="0"
class="filter-input"
placeholder="例如:100000"
/>
</div>
<div class="filter-checks">
<label class="filter-check-row">
<input type="checkbox" class="filter-check-input" v-model="filters.fba" />
<span class="filter-check-text">FBA</span>
</label>
<label class="filter-check-row">
<input type="checkbox" class="filter-check-input" v-model="filters.fbm" />
<span class="filter-check-text">FBM</span>
</label>
</div>
</div>
<div class="section-title country-section-title">
<span>国家与顺序</span>
<span v-if="countryPrefSaving" class="country-pref-saving">保存中</span>
</div>
<p class="hint country-pref-hint">
勾选需要采集的国家暂定 5 至少保留 1 上方勾选区<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)"
:disabled="isCountrySelectionLocked(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)"
>
<span class="drag-handle" title="拖动排序"></span>
<span class="country-drag-label">{{ countryLabel(code) }}{{ code }}</span>
</div>
</div>
</div>
<div class="run-row">
<button
type="button"
class="btn-run"
:disabled="submitting || !uploadedFiles.length"
@click="submitCollect"
>
{{ submitting ? '提交中...' : '提交采集任务' }}
</button>
<button
type="button"
class="btn-run btn-queue"
:disabled="pushing || !lastTaskId"
@click="pushToPythonQueue"
>
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
</button>
</div>
<p class="loading-msg">提交后由 Java 解析 Excel 并落库为待采集任务PENDING推送到 Python 队列将激活任务RUNNINGPython 端按 50 /页拉取明细执行采集</p>
<div v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">推送结果</div>
<div class="queue-debug-line">{{ queuePushResult }}</div>
<pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre>
</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>{{ dashboard.pendingTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">已结束任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配任务</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>当前任务</span>
</div>
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '采集数据' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div>
</div>
<div class="task-right">
<span class="status running">{{ statusText(item) }}</span>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>历史记录</span>
</div>
<div v-if="!historyItems.length" class="empty-tasks">暂无历史记录</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in historyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '采集数据' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div class="files">开始时间{{ formatDateTime(item.startedAt || item.createdAt) }}</div>
<div class="files">结束时间{{ formatDateTime(item.finishedAt) }}</div>
<div v-if="item.resultFilename" class="files">{{ item.resultFilename }}</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="item.resultId" type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { useTaskProgressLoop } from '@/shared/composables/useTaskProgressLoop'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import {
parseCollectData,
activateCollectDataTask,
getCollectDataDashboard,
getCollectDataHistory,
getCollectDataCountryPreference,
putCollectDataCountryPreference,
getCollectDataTaskProgressBatch,
deleteCollectDataTask,
deleteCollectDataHistory,
type CollectDataDashboardVo,
type CollectDataHistoryItem,
type CollectDataParseVo,
type CollectDataTaskDetailVo,
type UploadFileVo,
} from '@/shared/api/java-modules'
const COUNTRY_OPTIONS = [
{ code: 'DE', label: '德国' },
{ code: 'UK', label: '英国' },
{ code: 'FR', label: '法国' },
{ code: 'IT', label: '意大利' },
{ code: 'ES', label: '西班牙' },
] as const
const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
const submitting = ref(false)
const pushing = ref(false)
const filters = reactive<{
amount: number | null
rank: number | null
fba: boolean
fbm: boolean
}>({
amount: null,
rank: null,
fba: false,
fbm: false,
})
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
const dragCountryIndex = ref<number | null>(null)
/** 防止接口慢回包覆盖用户已经做出的修改 */
const countryPrefUserTouched = ref(false)
const countryPrefSaving = ref(false)
const timers = createCategorizedTimers('collect-data-tab')
let countryPrefSaveTimer: number | null = null
const lastTaskId = ref<number | null>(null)
const lastParseVo = ref<CollectDataParseVo | null>(null)
const queuePushResult = ref('')
const queuePayloadText = ref('')
const dashboard = ref<CollectDataDashboardVo>({
pendingTaskCount: 0,
processedTaskCount: 0,
successTaskCount: 0,
failedTaskCount: 0,
})
const currentItems = ref<CollectDataHistoryItem[]>([])
const historyItems = ref<CollectDataHistoryItem[]>([])
/** 轮询过程中收到的任务最新快照,按 taskId 缓存,用于历史返回前的占位展示 */
const taskSnapshots = ref<Record<number, CollectDataTaskDetailVo>>({})
const POLLING_STORAGE_KEY = 'brand:collect-data:polling-task-ids'
const progressLoop = useTaskProgressLoop<CollectDataTaskDetailVo>({
scope: 'collect-data-tab',
storageKey: POLLING_STORAGE_KEY,
fetchProgress: (ids) => getCollectDataTaskProgressBatch(ids),
extractTaskId: (detail) => detail.task?.id ?? null,
extractStatus: (detail) => detail.task?.status ?? '',
onUpdate: (taskId, detail) => {
const prev = taskSnapshots.value[taskId]
taskSnapshots.value = {
...taskSnapshots.value,
[taskId]: prev
? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) }, items: detail.items ?? prev.items }
: detail,
}
},
onTerminal: async (taskId) => {
if (taskSnapshots.value[taskId]) {
const next = { ...taskSnapshots.value }
delete next[taskId]
taskSnapshots.value = next
}
await Promise.all([loadDashboard(), loadHistory()])
},
})
const displayFileNames = computed(() => selectedFileNames.value.slice(0, 8))
function countryLabel(code: string) {
const row = COUNTRY_OPTIONS.find((o) => o.code === code)
return row?.label ?? code
}
const countryCheckboxRows = computed(() => {
const sel = orderedCountryCodes.value
const selectedSet = new Set(sel)
const selectedPart = sel.map((code) => ({ code, label: countryLabel(code) }))
const rest = COUNTRY_OPTIONS.filter((o) => !selectedSet.has(o.code)).map((o) => ({
code: o.code,
label: o.label,
}))
return [...selectedPart, ...rest]
})
function isCountrySelected(code: string) {
return orderedCountryCodes.value.includes(code)
}
function isCountrySelectionLocked(code: string) {
return orderedCountryCodes.value.length === 1 && orderedCountryCodes.value[0] === code
}
function onCountryNativeChange(code: string, e: Event) {
const el = e.target as HTMLInputElement | null
if (!el) return
if (!el.checked && isCountrySelectionLocked(code)) {
el.checked = true
ElMessage.warning('至少保留 1 个国家')
return
}
countryPrefUserTouched.value = true
if (el.checked) {
if (!orderedCountryCodes.value.includes(code)) {
orderedCountryCodes.value = [...orderedCountryCodes.value, code]
}
} else {
if (orderedCountryCodes.value.length <= 1) {
ElMessage.warning('至少保留 1 个国家')
return
}
orderedCountryCodes.value = orderedCountryCodes.value.filter((c) => c !== code)
}
scheduleSaveCountryPref()
}
function onCountryDragStart(index: number) {
dragCountryIndex.value = index
}
function onCountryDragEnd() {
dragCountryIndex.value = null
}
function onCountryDrop(toIndex: number) {
const from = dragCountryIndex.value
dragCountryIndex.value = null
if (from == null || from === toIndex) return
countryPrefUserTouched.value = true
const arr = [...orderedCountryCodes.value]
const [item] = arr.splice(from, 1)
arr.splice(toIndex, 0, item)
orderedCountryCodes.value = arr
scheduleSaveCountryPref()
}
function scheduleSaveCountryPref() {
if (countryPrefSaveTimer != null) {
timers.clearTimer('country-pref-save', countryPrefSaveTimer)
}
countryPrefSaveTimer = timers.setTimeout('country-pref-save', () => {
countryPrefSaveTimer = null
void persistCountryPref()
}, 450)
}
async function persistCountryPref() {
if (!orderedCountryCodes.value.length) return
countryPrefSaving.value = true
try {
await putCollectDataCountryPreference([...orderedCountryCodes.value])
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存国家顺序失败')
} finally {
countryPrefSaving.value = false
}
}
async function loadCountryPreference() {
try {
const vo = await getCollectDataCountryPreference()
if (countryPrefUserTouched.value) return
const codes = vo?.country_codes
if (Array.isArray(codes) && codes.length) {
orderedCountryCodes.value = [...codes]
}
} catch {
/* 接口失败时保留本地默认 */
}
}
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) {
throw new Error('当前桌面端未提供文件上传能力')
}
const files: UploadFileVo[] = []
for (const item of paths) {
const filePath = typeof item === 'string' ? item : item.absolutePath
const relativePath = typeof item === 'string' ? undefined : item.relativePath
const uploaded = await api.upload_file_to_java(filePath, relativePath)
if (!uploaded?.success || !uploaded.data) {
throw new Error(uploaded?.error || uploaded?.message || `上传失败:${filePath}`)
}
files.push(uploaded.data)
}
return files
}
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
const files = await uploadPathsToJava(paths)
uploadedFiles.value = files
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
lastTaskId.value = null
lastParseVo.value = null
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '文件选择失败')
}
}
async function selectFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder) {
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
}
const files = await uploadPathsToJava(result.items)
uploadedFiles.value = files
selectedFileNames.value = result.items.map((item) => item.relativePath || item.absolutePath)
lastTaskId.value = null
lastParseVo.value = null
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 Excel 文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
function buildFiltersPayload() {
return {
amount: filters.amount === null || filters.amount === undefined || Number.isNaN(filters.amount)
? null
: filters.amount,
rank: filters.rank === null || filters.rank === undefined || Number.isNaN(filters.rank)
? null
: filters.rank,
fba: !!filters.fba,
fbm: !!filters.fbm,
countryCodes: [...orderedCountryCodes.value],
}
}
async function submitCollect() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择 Excel 文件')
return
}
if (!orderedCountryCodes.value.length) {
ElMessage.warning('至少保留 1 个国家')
return
}
submitting.value = true
try {
const vo = await parseCollectData({
files: uploadedFiles.value.map((f) => ({
fileKey: f.fileKey,
originalFilename: f.originalFilename,
relativePath: f.relativePath,
})),
task_type: 'collect-data',
filters: buildFiltersPayload(),
})
lastParseVo.value = vo
lastTaskId.value = vo.taskId ?? null
if (vo.taskId) {
progressLoop.add(vo.taskId)
}
ElMessage.success(`提交成功:任务 ${vo.taskNo || vo.taskId},落库 ${vo.acceptedRows ?? 0}`)
await Promise.all([loadDashboard(), loadHistory()])
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败')
} finally {
submitting.value = false
}
}
async function pushToPythonQueue() {
if (!lastTaskId.value) {
ElMessage.warning('请先提交采集任务')
return
}
const api = getPywebviewApi()
if (!api?.enqueue_json) {
ElMessage.warning('当前环境未提供 Python 队列能力')
return
}
pushing.value = true
try {
await activateCollectDataTask(lastTaskId.value)
const payload = {
type: 'collect-data-run',
ts: Date.now(),
data: {
taskId: lastTaskId.value,
taskNo: lastParseVo.value?.taskNo,
taskType: 'collect-data',
totalRows: lastParseVo.value?.acceptedRows ?? 0,
pageSize: lastParseVo.value?.pageSize ?? 50,
filters: buildFiltersPayload(),
},
}
const result = await api.enqueue_json(payload)
if (!result?.success) {
throw new Error(result?.error || '入队失败')
}
queuePushResult.value = `已推送至 Python 队列(队列长度:${result.queue_size ?? '-'}`
queuePayloadText.value = JSON.stringify(payload, null, 2)
if (lastTaskId.value) {
progressLoop.add(lastTaskId.value)
}
ElMessage.success('已推送到 Python 队列')
await loadDashboard()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '推送失败')
} finally {
pushing.value = false
}
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function normalizeTaskStatus(item: CollectDataHistoryItem) {
return (item.taskStatus || '').toUpperCase()
}
function statusText(item: CollectDataHistoryItem) {
const status = normalizeTaskStatus(item)
if (status === 'RUNNING') return '执行中'
if (status === 'PENDING') return '等待中'
if (status === 'SUCCESS' || item.success) return '已完成'
if (status === 'FAILED') return '失败'
return '等待中'
}
function statusClass(item: CollectDataHistoryItem) {
const status = normalizeTaskStatus(item)
if (status === 'RUNNING') return 'running'
if (status === 'FAILED') return 'failed'
if (status === 'SUCCESS' || item.success) return 'success'
return 'pending'
}
async function loadDashboard() {
try {
dashboard.value = await getCollectDataDashboard()
} catch (error) {
/* 忽略:dashboard 失败不打断主流程 */
}
}
async function loadHistory() {
try {
const vo = await getCollectDataHistory(50)
const items = vo.items || []
const running = items.filter((it) => {
const s = (it.taskStatus || '').toUpperCase()
return s === 'PENDING' || s === 'RUNNING'
})
historyItems.value = items.filter((it) => {
const s = (it.taskStatus || '').toUpperCase()
return s === 'SUCCESS' || s === 'FAILED'
})
// 历史接口里运行中的任务自动加入轮询;若返回里已没有该 taskId,则停掉对应轮询
const seenTaskIds = new Set<number>()
for (const item of running) {
if (item.taskId) {
seenTaskIds.add(item.taskId)
progressLoop.add(item.taskId)
}
}
for (const taskId of [...progressLoop.taskIds.value]) {
const onScreen = items.some((it) => it.taskId === taskId)
if (!onScreen) {
progressLoop.remove(taskId)
}
}
// 合并:历史里 RUNNING/PENDING 的条目 + 轮询缓存里有快照但尚未在历史返回中的占位
const seen = new Set<number>(running.map((it) => it.taskId).filter((id): id is number => typeof id === 'number'))
const merged: CollectDataHistoryItem[] = [...running]
for (const taskId of progressLoop.taskIds.value) {
if (seen.has(taskId)) continue
const detail = taskSnapshots.value[taskId]
const placeholder = detail?.items?.[0]
if (placeholder) {
merged.push(placeholder)
} else if (detail?.task) {
merged.push({
taskId: detail.task.id,
taskNo: detail.task.taskNo,
taskStatus: detail.task.status,
startedAt: detail.task.createdAt,
finishedAt: detail.task.finishedAt,
sourceFilename: '采集数据',
} as CollectDataHistoryItem)
}
}
currentItems.value = merged
} catch {
/* 忽略:历史接口失败时保持空列表 */
}
}
async function deleteTaskRecord(item: CollectDataHistoryItem) {
try {
if (item.resultId) {
await deleteCollectDataHistory(item.resultId)
} else if (item.taskId) {
await deleteCollectDataTask(item.taskId)
} else {
return
}
if (item.taskId) {
progressLoop.remove(item.taskId)
}
ElMessage.success('已删除')
await Promise.all([loadDashboard(), loadHistory()])
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
onMounted(() => {
void loadCountryPreference()
void loadDashboard()
void loadHistory()
})
onBeforeUnmount(() => {
if (countryPrefSaveTimer != null) {
timers.clearTimer('country-pref-save', countryPrefSaveTimer)
countryPrefSaveTimer = null
}
timers.clearScope()
})
</script>
<style scoped>
.module-page { min-height: 100vh; background: #1a1a1a; }
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 400px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
.right-panel { flex: 1; min-width: 0; background: #1a1a1a; display: flex; flex-direction: column; }
.section-title, .subsection-title { font-size: 13px; color: #bbb; margin: 12px 0 10px; }
.upload-zone { border: 1px dashed #3a3a3a; border-radius: 10px; padding: 18px; background: #252525; margin-bottom: 18px; }
.hint, .loading-msg, .files, .muted { color: #888; font-size: 12px; line-height: 1.5; }
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
.opt-btn, .btn-run, .btn-delete, .download { border: none; cursor: pointer; border-radius: 7px; }
.opt-btn { padding: 8px 14px; color: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; }
.btn-run { padding: 10px 18px; color: #fff; background: #3498db; font-weight: 600; }
.btn-run.btn-queue { background: #27ae60; }
.btn-run:disabled { opacity: .55; cursor: not-allowed; }
.selected-files { margin-top: 14px; color: #999; font-size: 12px; word-break: break-all; }
.selected-files span { display: block; margin: 4px 0; }
.selected-files .more-line { color: #777; }
.filter-zone {
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 12px;
background: #252525;
margin-bottom: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.filter-row {
display: flex;
align-items: center;
gap: 10px;
}
.filter-label {
width: 110px;
color: #bbb;
font-size: 12px;
}
.filter-input {
flex: 1;
padding: 6px 10px;
background: #1e1e1e;
border: 1px solid #3a3a3a;
border-radius: 6px;
color: #ddd;
font-size: 13px;
outline: none;
}
.filter-input:focus { border-color: #3498db; }
.filter-checks { display: flex; gap: 16px; }
.filter-check-row { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; color: #ccc; font-size: 12px; }
.filter-check-input { width: 16px; height: 16px; accent-color: #409eff; cursor: pointer; }
.country-pref-hint { margin-top: -4px; }
.country-section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.country-pref-saving { color: #3498db; font-size: 11px; }
.country-pref-checks { display: flex; flex-wrap: wrap; gap: 10px 16px; margin-bottom: 12px; }
.country-check-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 12px; color: #ccc; }
.country-check-input { width: 16px; height: 16px; accent-color: #409eff; cursor: pointer; flex-shrink: 0; }
.country-check-text { line-height: 1.3; }
.country-order-panel { border: 1px solid #333; border-radius: 8px; padding: 10px 12px; background: #252525; margin-bottom: 14px; }
.country-order-caption { font-size: 11px; color: #777; margin-bottom: 8px; }
.country-order-list { display: flex; flex-direction: column; gap: 6px; }
.country-drag-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: #2c2c2c; border-radius: 6px; border: 1px solid #3a3a3a; cursor: grab; user-select: none; }
.country-drag-row.dragging { opacity: 0.55; border-color: #3498db; }
.drag-handle { color: #666; font-size: 12px; letter-spacing: -2px; }
.country-drag-label { font-size: 12px; color: #ccc; }
.queue-debug-card { margin-top: 14px; border: 1px solid #2a2a2a; border-radius: 8px; padding: 10px 12px; background: #1f1f1f; }
.queue-debug-title { margin-top: 0; }
.queue-debug-line { color: #9ad; font-size: 12px; }
.queue-debug-payload { margin-top: 8px; padding: 8px 10px; background: #181818; border-radius: 6px; color: #bbb; font-size: 11px; overflow: auto; max-height: 200px; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 8px; background: #1e1e1e; }
.summary-card strong { display: block; margin-top: 8px; color: #eaf4ff; font-size: 22px; }
.summary-label { color: #8d8d8d; font-size: 12px; }
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 8px; background: #1e1e1e; min-height: 180px; margin: 0 0 16px; }
.result-list-header { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #2a2a2a; color: #ddd; font-size: 14px; }
.empty-tasks { color: #666; font-size: 13px; padding: 18px; text-align: center; }
.task-list { list-style: none; margin: 0; padding: 12px; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.id { color: #e0e0e0; font-size: 13px; font-weight: 600; }
.task-right { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
.status.success { background: rgba(46, 204, 113, .18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, .18); color: #ff6b6b; }
.status.running { background: rgba(52, 152, 219, .18); color: #3498db; }
.status.pending { background: rgba(149, 165, 166, .18); color: #bdc3c7; }
.btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); }
@media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>