Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue
T
huangzd1997 3ce0569c59 fix(提示语): 失败提示优先展示后端真实原因,修正 10+ 处误导文案
上架/删除品牌/去重/转换/拆分/撤回/查ASIN/产品风险/跟价/巡店/店铺匹配/视频工作台:
- 解析失败不再只报通用文案,逐条展示后端文件级 errorMessage(上架新增「店铺未找到」专属弹窗)
- 匹配失败不再一律「请检查店铺名」,按 matchStatus 区分 CONFLICT/PENDING
- 去重/转换/拆分全失败弹窗优先列各文件真实原因,取不到才回退猜测文案
- dispatch-guard 新增 fileErrors 选项与 collectDistinctErrors 导出
2026-09-17 23:02:15 +08:00

793 lines
24 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="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">例如 123 这种纯数字 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_12_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>