Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandSplitTab.vue
2026-03-22 12:45:58 +08:00

454 lines
22 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">
<BrandTopBar active="split" />
<div class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">选择需要拆分的 Excel 文件或文件夹按规则拆成多个 Excel 文件</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectSplitFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectSplitFolder">选择文件夹</button>
</div>
<div class="selected-files clean-placeholder split-selected-files">
<template v-if="splitSelectedPaths.length">
<span v-for="path in splitDisplayPaths" :key="path">{{ path }}</span>
<span v-if="splitSelectedPaths.length > splitDisplayPaths.length" class="more-line">
还有 {{ splitSelectedPaths.length - splitDisplayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择待拆分文件</span>
</div>
</div>
<div class="section-title">保留列设置</div>
<div class="option-group column-option-group">
<div class="column-toolbar">
<span class="column-count">已选择 {{ splitSelectedColumns.length }} / {{ splitAvailableColumns.length }} </span>
<div class="column-actions">
<button type="button" class="opt-btn small" @click="splitSelectedColumns = [...splitAvailableColumns]">全选</button>
<button type="button" class="opt-btn small" @click="splitSelectedColumns = []">清空</button>
</div>
</div>
<div class="column-grid" v-if="splitAvailableColumns.length">
<label
v-for="column in splitAvailableColumns"
:key="column"
class="column-item"
:class="{ active: splitSelectedColumns.includes(column) }"
>
<input v-model="splitSelectedColumns" type="checkbox" :value="column" />
<span>{{ column }}</span>
</label>
</div>
<div v-else class="empty-column-state">请选择 Excel 文件后读取表头并填充可保留列</div>
</div>
<div class="section-title">总条数</div>
<div class="option-group">
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">当前数据总条数</span>
<div class="desc">{{ splitTotalRows }} 不含表头</div>
</div>
</label>
</div>
<div class="section-title">拆分模式</div>
<div class="option-group">
<label class="radio-item" :class="{ active: splitMode === 'rows_per_file' }">
<input v-model="splitMode" type="radio" value="rows_per_file" name="split-mode" />
<div>
<span class="label">按每份条数</span>
<div class="desc">例如每份 2000 最后一份放剩余数据</div>
</div>
</label>
<label class="radio-item" :class="{ active: splitMode === 'parts' }">
<input v-model="splitMode" type="radio" value="parts" name="split-mode" />
<div>
<span class="label">按份数平均拆分</span>
<div class="desc">例如拆 5 按总条数尽量平均分配</div>
</div>
</label>
</div>
<div class="section-title">拆分参数</div>
<div class="option-group">
<label v-if="splitMode === 'rows_per_file'" class="split-input-card">
<span class="label">每份条数</span>
<input v-model.number="splitRowsPerFile" class="split-number-input" type="number" min="1" placeholder="例如 2000" />
</label>
<label v-else class="split-input-card">
<span class="label">拆分份数</span>
<input v-model.number="splitParts" class="split-number-input" type="number" min="1" placeholder="例如 5" />
</label>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="splitRunning" @click="submitSplitRun">
{{ splitRunning ? '拆分中...' : '开始拆分' }}
</button>
<span class="loading-msg">
{{ splitRunning ? '正在拆分并上传结果,请稍候…' : '选择文件、保留列和拆分规则后即可执行拆分,结果会显示在右侧供下载' }}
</span>
</div>
</aside>
<section class="right-panel">
<div class="panel-header">拆分结果</div>
<div class="task-list-wrap">
<div class="clean-result-summary">
<div class="summary-card">
<span class="summary-label">已处理文件</span>
<strong>{{ splitSummary.total }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ splitSummary.successCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ splitSummary.failedCount }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>拆分结果压缩包列表</span>
</div>
<div v-if="splitResultItems.length === 0" class="empty-tasks">
暂无拆分结果完成数据拆分后会在这里展示压缩包文件
</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in splitResultItems" :key="`${item.resultId || item.outputFilename || item.sourceFilename}-${item.rowCount || 0}`" class="task-item split-result-item">
<div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">压缩包文件{{ item.outputFilename }}</div>
<div v-if="item.rowCount !== undefined" class="time">压缩包共包含 {{ item.rowCount }} 条数据</div>
<div v-if="item.entryCount && item.entryCount > 0" class="files">压缩包内共 {{ item.entryCount }} 个拆分文件</div>
<div v-if="item.entries && item.entries.length" class="files split-entry-list">
压缩包内容{{ formatSplitEntries(item.entries) }}
</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.success && item.downloadUrl"
type="button"
class="download"
@click="downloadSplitResult(item)"
>
下载压缩包
</button>
<button
v-if="item.resultId"
type="button"
class="btn-delete"
@click="deleteSplitHistoryRecord(item.resultId)"
>
删除
</button>
</div>
</li>
</ul>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from './BrandTopBar.vue'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { deleteSplitHistory, getExcelInfo, getSplitHistory, runSplit, type SplitResultItem, type SplitRunVo } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
const splitSelectedPaths = ref<string[]>([])
const splitArchiveName = ref('')
const splitUploadedFiles = ref<UploadedJavaFile[]>([])
const splitAvailableColumns = ref<string[]>([])
const splitSelectedColumns = ref<string[]>([])
const splitTotalRows = ref(0)
const splitMode = ref<'rows_per_file' | 'parts'>('rows_per_file')
const splitRowsPerFile = ref<number | null>(2000)
const splitParts = ref<number | null>(5)
const splitRunning = ref(false)
const splitResultItems = ref<SplitResultItem[]>([])
const splitSummary = ref<SplitRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const splitDisplayPaths = computed(() => splitSelectedPaths.value.slice(0, 8))
function updateSplitSummaryFromItems(items: SplitResultItem[]) {
splitSummary.value = {
total: items.length,
successCount: items.filter((item) => item.success).length,
failedCount: items.filter((item) => !item.success).length,
items,
}
}
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) {
throw new Error('当前桌面端未提供文件上传桥接能力')
}
const uploaded: UploadedJavaFile[] = []
for (const item of paths) {
const filePath = typeof item === 'string' ? item : item.absolutePath
const relativePath = typeof item === 'string' ? undefined : item.relativePath
const result = await api.upload_file_to_java(filePath, relativePath)
if (!result?.success || !result.data) {
throw new Error(result?.error || result?.message || `上传失败:${filePath}`)
}
uploaded.push(result.data)
}
return uploaded
}
async function loadSplitInfo(fileKey: string) {
const result = await getExcelInfo(fileKey)
splitAvailableColumns.value = result.headers || []
splitSelectedColumns.value = [...(result.headers || [])]
splitTotalRows.value = result.totalRows || 0
}
async function handleSelectedPaths(paths: string[], successMessage: string) {
splitSelectedPaths.value = paths
splitArchiveName.value = ''
splitUploadedFiles.value = await uploadPathsToJava(paths)
if (splitUploadedFiles.value.length > 0) {
await loadSplitInfo(splitUploadedFiles.value[0].fileKey)
}
ElMessage.success(successMessage)
}
async function selectSplitFiles() {
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
await handleSelectedPaths(paths, `已选择 ${paths.length} 个待拆分文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function selectSplitFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
return
}
try {
const folder = await api.select_brand_folder()
if (!folder) return
splitArchiveName.value = folder.split(/[/\\]/).filter(Boolean).pop() || ''
const result = await expandBrandFolderRecursive(folder)
if (result.success && result.items?.length) {
splitSelectedPaths.value = result.items.map((item) => item.relativePath)
splitUploadedFiles.value = await uploadPathsToJava(result.items)
if (splitUploadedFiles.value.length > 0) {
await loadSplitInfo(splitUploadedFiles.value[0].fileKey)
}
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 xlsx 文件`)
return
}
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function submitSplitRun() {
if (!splitUploadedFiles.value.length) {
ElMessage.warning('请先选择待拆分 Excel 文件或文件夹')
return
}
if (!splitSelectedColumns.value.length) {
ElMessage.warning('请至少选择一列保留列')
return
}
if (splitMode.value === 'rows_per_file' && (!splitRowsPerFile.value || splitRowsPerFile.value <= 0)) {
ElMessage.warning('请输入有效的每份条数')
return
}
if (splitMode.value === 'parts' && (!splitParts.value || splitParts.value <= 0)) {
ElMessage.warning('请输入有效的拆分份数')
return
}
try {
splitRunning.value = true
const result = await runSplit({
files: splitUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename, relativePath: item.relativePath })),
selectedColumns: splitSelectedColumns.value,
splitMode: splitMode.value,
rowsPerFile: splitMode.value === 'rows_per_file' ? splitRowsPerFile.value || undefined : undefined,
parts: splitMode.value === 'parts' ? splitParts.value || undefined : undefined,
archiveName:
splitUploadedFiles.value.some((item) => !!item.relativePath) && splitArchiveName.value
? splitArchiveName.value
: undefined,
})
splitSummary.value = result
splitResultItems.value = result.items || []
await loadSplitHistory()
ElMessage.success('数据拆分完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据拆分失败')
} finally {
splitRunning.value = false
}
}
async function loadSplitHistory() {
try {
const response = await getSplitHistory()
splitResultItems.value = response.items || []
updateSplitSummaryFromItems(splitResultItems.value)
} catch {
// ignore history load errors
}
}
async function deleteSplitHistoryRecord(resultId: number) {
try {
await deleteSplitHistory(resultId)
await loadSplitHistory()
ElMessage.success('已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
function formatSplitEntries(entries: NonNullable<SplitResultItem['entries']>) {
const preview = entries.slice(0, 4).map((entry) => {
if (entry.rowCount !== undefined) {
return `${entry.filename}${entry.rowCount}条)`
}
return entry.filename
})
const hiddenCount = entries.length - preview.length
return hiddenCount > 0 ? `${preview.join('、')}${entries.length} 个文件` : preview.join('、')
}
async function downloadSplitResult(item: SplitResultItem) {
const api = getPywebviewApi()
if (!item.downloadUrl) {
ElMessage.warning('当前结果没有下载地址')
return
}
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.zip`
if (api?.save_file_from_url) {
const result = await api.save_file_from_url(item.downloadUrl, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
window.open(item.downloadUrl, '_blank')
}
onMounted(() => {
loadSplitHistory().catch(() => undefined)
})
</script>
<style scoped>
.module-page { min-height: 100vh; background: #1a1a1a; }
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); }
.left-panel { width: 380px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #1a1a1a; }
.section-title { font-size: 13px; color: #bbb; margin-bottom: 10px; }
.upload-zone { border: 1px dashed #3a3a3a; border-radius: 10px; padding: 24px; text-align: center; background: #252525; margin-bottom: 20px; transition: all 0.2s ease; }
.upload-zone:hover { border-color: #3498db; background: #2a2a2a; }
.hint { color: #888; font-size: 13px; margin-bottom: 12px; line-height: 1.5; }
.btns { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.opt-btn, .btn-run, .download, .btn-delete { border: none; cursor: pointer; transition: all 0.2s ease; }
.opt-btn { padding: 8px 16px; font-size: 13px; color: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 6px; }
.opt-btn.small { padding: 6px 10px; font-size: 12px; }
.opt-btn:hover { color: #3498db; background: #333; border-color: #3498db; }
.selected-files { margin-top: 14px; font-size: 12px; color: #888; max-height: 112px; overflow-y: auto; text-align: left; }
.selected-files span { display: block; margin: 4px 0; word-break: break-all; }
.more-line { color: #b8c1cc; }
.split-selected-files { max-height: 120px; min-height: 72px; padding-right: 4px; }
.option-group { margin-bottom: 20px; }
.column-option-group { padding: 14px; border-radius: 10px; background: #252525; border: 1px solid #2a2a2a; }
.column-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.column-count { font-size: 12px; color: #a9b4bf; }
.column-actions { display: flex; gap: 8px; }
.column-grid { display: grid; grid-template-columns: 1fr; gap: 8px; max-height: 280px; overflow-y: auto; }
.empty-column-state { padding: 16px 12px; border-radius: 8px; border: 1px dashed #3a3a3a; background: #202020; color: #7f8790; font-size: 12px; line-height: 1.6; }
.column-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; border: 1px solid #2f2f2f; background: #202020; cursor: pointer; transition: all 0.2s ease; font-size: 13px; color: #d7d7d7; }
.column-item:hover, .column-item.active { border-color: #3b6f98; background: #26313a; }
.column-item input { margin: 0; }
.column-item span { word-break: break-all; }
.radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; }
.radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; }
.radio-item input { margin-top: 3px; }
.label { font-weight: 500; color: #e0e0e0; font-size: 13px; }
.desc { font-size: 12px; color: #888; margin-top: 2px; line-height: 1.5; font-weight: 400; }
.split-input-card { display: flex; flex-direction: column; gap: 10px; padding: 14px 16px; border-radius: 10px; border: 1px solid #2f2f2f; background: #252525; }
.split-number-input { width: 100%; padding: 12px 14px; border-radius: 8px; border: 1px solid #3a3a3a; background: #1b1b1b; color: #eaf4ff; font-size: 22px; font-weight: 700; line-height: 1.2; outline: none; transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; }
.split-number-input:focus { border-color: #3498db; box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.18); background: #20252b; }
.split-number-input::placeholder { color: #708090; font-size: 14px; font-weight: 400; }
.run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 12px; margin-top: 16px; }
.btn-run { padding: 10px 22px; background: #3498db; color: #fff; border-radius: 8px; font-size: 14px; }
.btn-run:hover { background: #2980b9; }
.btn-run:disabled { background: #555; color: #999; cursor: not-allowed; }
.loading-msg { color: #3498db; font-size: 13px; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
.task-list-wrap { flex: 1; overflow-y: auto; padding: 16px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 10px; background: #1e1e1e; }
.split-result-item { align-items: flex-start; }
.left { flex: 1; min-width: 0; }
.split-result-main { display: flex; flex-direction: column; gap: 4px; }
.id { display: inline-block; font-weight: 600; color: #e0e0e0; font-size: 13px; }
.files { font-size: 12px; color: #888; margin-top: 4px; }
.time { font-size: 12px; color: #666; margin-top: 2px; }
.split-entry-list { line-height: 1.6; word-break: break-all; max-width: 100%; }
.task-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.split-result-actions { flex-shrink: 0; min-width: 180px; justify-content: flex-end; align-self: center; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; }
.download:hover { background: rgba(52, 152, 219, 0.28); }
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
.empty-tasks { color: #666; font-size: 13px; padding: 24px 8px; }
.clean-placeholder { max-height: 112px; }
.clean-result-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 220px)); gap: 16px; margin-bottom: 18px; }
.summary-card { padding: 16px 18px; border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; }
.summary-card strong { display: block; margin-top: 8px; font-size: 24px; color: #eaf4ff; }
.summary-label { font-size: 12px; color: #8d8d8d; }
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; min-height: 260px; }
.result-list-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #2a2a2a; font-size: 14px; color: #ddd; }
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; } .right-panel { min-height: 420px; } .task-item { flex-direction: column; align-items: flex-start; } .task-right { width: 100%; justify-content: flex-start; } .split-result-actions { min-width: 0; align-self: flex-start; } .clean-result-summary { grid-template-columns: 1fr; } }
</style>