Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandConvertTab.vue
2026-04-07 14:50:06 +08:00

986 lines
26 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="convert" />
<div class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">选择需要转换的 Excel 文件或文件夹将按所选模板生成 txt 文件</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectConvertFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectConvertFolder">选择文件夹</button>
</div>
<div class="selected-files clean-placeholder">
<template v-if="convertSelectedPaths.length">
<span v-for="path in convertDisplayPaths" :key="path">{{ path }}</span>
<span v-if="convertSelectedPaths.length > convertDisplayPaths.length" class="more-line">
还有 {{ convertSelectedPaths.length - convertDisplayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择待转换文件</span>
</div>
</div>
<div class="section-title">模板选择</div>
<div class="option-group">
<label v-for="template in convertTemplates" :key="template.id" class="radio-item"
:class="{ active: convertTemplateId === template.id }">
<input v-model="convertTemplateId" type="radio" :value="template.id" name="convert-template" />
<div class="template-item-content">
<div>
<span class="label">
{{ template.templateName }}
<span v-if="template.isDefault" class="template-tag">默认</span>
<span v-else-if="template.builtIn" class="template-tag muted">内置</span>
<span v-else class="template-tag custom">自定义</span>
</span>
<div class="desc">输出文件{{ template.outputFilename }}</div>
</div>
</div>
</label>
<div v-if="!convertTemplates.length" class="empty-option">暂无可用模板</div>
</div>
<!-- <div class="section-title">模板管理</div>
<div class="template-actions">
<button type="button" class="opt-btn" :disabled="templateUploading" @click="openTemplatePicker">
{{ templateUploading ? '上传中...' : '上传 txt 模板' }}
</button>
<button type="button" class="opt-btn small" :disabled="templateSavingDefault"
@click="saveCurrentTemplateAsDefault">
{{ templateSavingDefault ? '设置中...' : '设为默认模板' }}
</button>
<button type="button" class="opt-btn small danger" :disabled="!canDeleteCurrentTemplate || templateDeleting"
@click="removeCurrentTemplate">
{{ templateDeleting ? '删除中...' : '删除当前模板' }}
</button>
<input ref="templateInputRef" type="file" accept=".txt,text/plain" class="hidden-file-input"
@change="handleTemplateFileChange" />
<div class="template-actions-hint">
当前模板{{ currentConvertTemplate?.templateName || '未加载模板' }}
</div>
</div> -->
<!-- <div class="section-title">模板示例</div>
<div class="template-download-list">
<button type="button" class="template-download-row" @click="downloadTemplateXlsx">
<span class="template-download-icon" aria-hidden="true">XLSX</span>
<span class="template-download-text">
<span class="title">下载 Excel 模板示例</span>
<span class="hint">品牌文档格式_模板.xlsx</span>
</span>
</button>
<button type="button" class="template-download-row" @click="downloadTemplateZip">
<span class="template-download-icon" aria-hidden="true">ZIP</span>
<span class="template-download-text">
<span class="title">下载文件夹示例</span>
<span class="hint">模板2-以文件夹方式上传.zip</span>
</span>
</button>
</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">当前模板{{ currentConvertTemplate?.templateName || '未加载模板' }}</div>
</div>
</label>
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">SKU 使用雪花ID-序号</span>
<div class="desc">例如 1773816146796-1</div>
</div>
</label>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="convertRunning" @click="submitConvertRun">
{{ convertRunning ? '转换中...' : '开始转换' }}
</button>
<span class="loading-msg">
{{ convertRunning ? '正在生成并上传结果,请稍候…' : '选择文件后即可执行格式转换,结果会显示在右侧供下载' }}
</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>{{ convertSummary.total }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ convertSummary.successCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ convertSummary.failedCount }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>生成的结果压缩包列表</span>
</div>
<div v-if="convertResultItems.length === 0" class="empty-tasks">
暂无转换结果完成格式转换后会在这里展示结果压缩包
</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in convertResultItems"
:key="`${item.resultId || item.outputFilename || item.sourceFilename}`" class="task-item">
<div class="left">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">压缩包文件{{ item.outputFilename }}</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button v-if="item.success && item.downloadUrl" type="button" class="download"
@click="downloadConvertResult(item)">
下载压缩包
</button>
<button v-if="item.resultId" type="button" class="btn-delete"
@click="deleteConvertHistoryRecord(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, getBrandTemplateXlsxUrl, getBrandTemplateZipUrl } from '@/shared/api/brand'
import type { BrandExpandFolderItem } from '@/shared/api/brand'
import {
deleteConvertHistory,
deleteConvertTemplate,
getConvertHistory,
getConvertTemplates,
importConvertTemplate,
runConvert,
setDefaultConvertTemplate,
type ConvertResultItem,
type ConvertRunVo,
type ConvertTemplateVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
const convertSelectedPaths = ref<string[]>([])
const convertArchiveName = ref('')
const convertUploadedFiles = ref<UploadedJavaFile[]>([])
const convertRunning = ref(false)
const convertResultItems = ref<ConvertResultItem[]>([])
const convertSummary = ref<ConvertRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const convertTemplates = ref<ConvertTemplateVo[]>([])
const convertTemplateId = ref('')
const templateUploading = ref(false)
const templateSavingDefault = ref(false)
const templateDeleting = ref(false)
const templateInputRef = ref<HTMLInputElement>()
const convertDisplayPaths = computed(() => convertSelectedPaths.value.slice(0, 8))
const currentConvertTemplate = computed(
() => convertTemplates.value.find((item) => item.id === convertTemplateId.value) || null,
)
const canDeleteCurrentTemplate = computed(
() => Boolean(currentConvertTemplate.value && !currentConvertTemplate.value.builtIn),
)
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 loadConvertTemplates(preferredTemplateId?: string) {
const templates = await getConvertTemplates()
convertTemplates.value = templates || []
if (preferredTemplateId && convertTemplates.value.some((item) => item.id === preferredTemplateId)) {
convertTemplateId.value = preferredTemplateId
return
}
const existing = convertTemplates.value.find((item) => item.id === convertTemplateId.value)
if (existing) {
convertTemplateId.value = existing.id
return
}
const defaultTemplate = convertTemplates.value.find((item) => item.isDefault)
if (defaultTemplate) {
convertTemplateId.value = defaultTemplate.id
return
}
convertTemplateId.value = convertTemplates.value[0]?.id || ''
}
async function handleSelectedPaths(paths: string[], successMessage: string) {
convertSelectedPaths.value = paths
convertArchiveName.value = ''
convertUploadedFiles.value = await uploadPathsToJava(paths)
ElMessage.success(successMessage)
}
async function selectConvertFiles() {
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 selectConvertFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
return
}
try {
const folder = await api.select_brand_folder()
if (!folder) return
convertArchiveName.value = folder.split(/[/\\]/).filter(Boolean).pop() || ''
const result = await expandBrandFolderRecursive(folder)
if (result.success && result.items?.length) {
convertSelectedPaths.value = result.items.map((item) => item.relativePath)
convertUploadedFiles.value = await uploadPathsToJava(result.items)
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 xlsx 文件`)
return
}
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
function openTemplatePicker() {
templateInputRef.value?.click()
}
async function handleTemplateFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
try {
templateUploading.value = true
const templateContent = await file.text()
const templateName = file.name.replace(/\.[^.]+$/, '') || file.name
const template = await importConvertTemplate({
templateName,
templateContent,
})
await loadConvertTemplates(template.id)
ElMessage.success(`模板已上传:${template.templateName}`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '模板上传失败')
} finally {
templateUploading.value = false
}
}
async function saveCurrentTemplateAsDefault() {
if (!currentConvertTemplate.value) {
ElMessage.warning('请先选择模板')
return
}
try {
templateSavingDefault.value = true
await setDefaultConvertTemplate(currentConvertTemplate.value.templateCode || currentConvertTemplate.value.id)
await loadConvertTemplates(currentConvertTemplate.value.id)
ElMessage.success('默认模板已更新')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '设置默认模板失败')
} finally {
templateSavingDefault.value = false
}
}
async function removeCurrentTemplate() {
if (!currentConvertTemplate.value) {
ElMessage.warning('请先选择模板')
return
}
if (currentConvertTemplate.value.builtIn) {
ElMessage.warning('内置模板不允许删除')
return
}
try {
templateDeleting.value = true
await deleteConvertTemplate(currentConvertTemplate.value.templateCode || currentConvertTemplate.value.id)
const deletedTemplateId = currentConvertTemplate.value.id
await loadConvertTemplates()
if (convertTemplateId.value === deletedTemplateId) {
convertTemplateId.value = convertTemplates.value[0]?.id || ''
}
ElMessage.success('模板已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除模板失败')
} finally {
templateDeleting.value = false
}
}
async function saveTemplateFromUrl(url: string, fallbackFilename: string, bridgeMethod?: 'save_template_xlsx' | 'save_template_zip') {
const api = getPywebviewApi()
if (bridgeMethod && api?.[bridgeMethod]) {
const result = await api[bridgeMethod]!()
if (result.success) {
ElMessage.success(`已保存:${result.path || fallbackFilename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
if (api?.save_file_from_url_new) {
const result = await api.save_file_from_url_new(url, fallbackFilename)
if (result.success) {
ElMessage.success(`已保存:${result.path || fallbackFilename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
}
async function downloadTemplateXlsx() {
await saveTemplateFromUrl(getBrandTemplateXlsxUrl(), '品牌文档格式_模板.xlsx', 'save_template_xlsx')
}
async function downloadTemplateZip() {
await saveTemplateFromUrl(getBrandTemplateZipUrl(), '模板2-以文件夹方式上传.zip', 'save_template_zip')
}
async function submitConvertRun() {
if (!convertUploadedFiles.value.length) {
ElMessage.warning('请先选择待转换 Excel 文件或文件夹')
return
}
if (!convertTemplateId.value) {
ElMessage.warning('请先选择模板')
return
}
try {
convertRunning.value = true
const result = await runConvert({
files: convertUploadedFiles.value.map((item) => ({
fileKey: item.fileKey,
originalFilename: item.originalFilename,
relativePath: item.relativePath,
})),
templateId: convertTemplateId.value,
archiveName:
convertUploadedFiles.value.some((item) => !!item.relativePath) && convertArchiveName.value
? convertArchiveName.value
: undefined,
})
convertSummary.value = result
convertResultItems.value = result.items || []
await loadConvertHistory()
ElMessage.success('格式转换完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '格式转换失败')
} finally {
convertRunning.value = false
}
}
async function loadConvertHistory() {
try {
const response = await getConvertHistory()
convertResultItems.value = response.items || []
convertSummary.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 deleteConvertHistoryRecord(resultId: number) {
try {
await deleteConvertHistory(resultId)
await loadConvertHistory()
ElMessage.success('已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function downloadConvertResult(item: ConvertResultItem) {
const api = getPywebviewApi()
if (!item.downloadUrl) {
ElMessage.warning('当前结果没有下载地址')
return
}
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.txt`
if (api?.save_file_from_url_new) {
const result = await api.save_file_from_url_new(item.downloadUrl, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
}
onMounted(() => {
loadConvertTemplates().catch(() => undefined)
loadConvertHistory().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,
.template-download-row {
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;
}
.opt-btn.danger:hover {
color: #ff9f9f;
border-color: rgba(231, 76, 60, 0.5);
}
.opt-btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.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;
}
.option-group {
margin-bottom: 20px;
}
.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;
}
.template-item-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.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;
}
.template-tag {
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
color: #8fd0ff;
background: rgba(52, 152, 219, 0.14);
}
.template-tag.muted {
color: #b8b8b8;
background: rgba(255, 255, 255, 0.08);
}
.template-tag.custom {
color: #90f0b3;
background: rgba(46, 204, 113, 0.14);
}
.empty-option {
padding: 12px;
border: 1px dashed #3a3a3a;
border-radius: 8px;
color: #777;
font-size: 12px;
}
.template-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.template-actions-hint {
grid-column: 1 / -1;
font-size: 12px;
color: #888;
line-height: 1.5;
}
.hidden-file-input {
display: none;
}
.template-download-list {
display: grid;
gap: 10px;
margin-bottom: 20px;
}
.template-download-row {
width: 100%;
padding: 14px 16px;
background: linear-gradient(135deg, #252525 0%, #1f2b1f 100%);
border: 1px solid #2a3a2a;
border-radius: 10px;
display: flex;
align-items: center;
gap: 14px;
text-align: left;
}
.template-download-row:hover {
border-color: #2e7d32;
background: linear-gradient(135deg, #2a2a2a 0%, #243324 100%);
box-shadow: 0 4px 12px rgba(46, 125, 50, 0.15);
}
.template-download-icon {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 10px;
background: linear-gradient(145deg, #1b5e20 0%, #2e7d32 50%, #388e3c 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
.template-download-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.template-download-text .title {
font-size: 14px;
color: #e0e0e0;
font-weight: 600;
}
.template-download-text .hint {
margin: 2px 0 0;
font-size: 12px;
color: #888;
}
.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;
}
.left {
flex: 1;
min-width: 0;
}
.id {
display: inline-block;
font-weight: 600;
color: #e0e0e0;
font-size: 13px;
}
.files {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.task-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.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: none;
}
.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;
}
.clean-result-summary {
grid-template-columns: 1fr;
}
}
</style>