Files
smt-used-car-pc/src/views/admin/publish/publish_my_list.vue
T
2026-03-11 11:16:35 +08:00

1674 lines
51 KiB
Vue

<template>
<div class="page-content">
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
<el-form-item label="状态:" label-width="70px">
<el-select v-model="status" placeholder="请选择状态" style="min-width: 120px">
<el-option label="未售" :value="1" />
<el-option label="已售出" :value="2" />
<el-option label="已归档" :value="3" />
</el-select>
</el-form-item>
</div>
</div>
<div class="boxshadow">
<div style="text-align: right">
<ElButton @click="fetchpublishfn()" v-ripple type="primary"> 抓取 </ElButton>
</div>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="编号" prop="code" />
<ElTableColumn label="产品名称" prop="product_name" />
<ElTableColumn label="产品图">
<template #default="scope">
<div class="thumb-list" v-if="parsePreviewList(scope.row.images_pull).length">
<img
:src="parsePreviewList(scope.row.images_pull)[0]"
alt="产品图"
class="avatarimg"
@click="handleAvatarClick1(parsePreviewList(scope.row.images_pull)[0])"
/>
</div>
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn label="车价(¥)" prop="price" />
<ElTableColumn label="车源地" prop="source_place" />
<ElTableColumn label="上牌时间" prop="plate_time" />
<ElTableColumn label="排量" prop="displacement" />
<ElTableColumn label="里程(Km)" prop="mileage" />
<ElTableColumn label="马力" prop="power" />
<ElTableColumn label="车身颜色" prop="body_color" />
<ElTableColumn label="创建时间" prop="created_at" />
<ElTableColumn fixed="right" label="操作" width="200px">
<template #default="scope">
<ElRow>
<el-button link type="warning" @click="printfn(scope.row)"> 预览/下载 </el-button>
<el-button link type="success" @click="showDialog('detail', scope.row)">
详情
</el-button>
<el-button link @click="editFn(scope.row)"> 编辑 </el-button>
<el-button link type="success" @click="statusfn(scope.row)"> 标记售出 </el-button>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailDialogVisible" title="车辆详情" width="700px">
<div>
<el-descriptions v-if="detailData" :column="2" border label-width="100px">
<el-descriptions-item label="编号">
{{ detailData.code || '--' }}
</el-descriptions-item>
<el-descriptions-item label="产品名称" :span="1">
{{ detailData.product_name || '--' }}
</el-descriptions-item>
<el-descriptions-item label="车价(¥)">
{{ detailData.price || '--' }}
</el-descriptions-item>
<el-descriptions-item label="车源地">
{{ detailData.source_place || '--' }}
</el-descriptions-item>
<el-descriptions-item label="上牌时间">
{{ detailData.plate_time || '--' }}
</el-descriptions-item>
<el-descriptions-item label="排量">
{{ detailData.displacement || '--' }}
</el-descriptions-item>
<el-descriptions-item label="里程(Km)">
{{ detailData.mileage || '--' }}
</el-descriptions-item>
<el-descriptions-item label="马力">
{{ detailData.power || '--' }}
</el-descriptions-item>
<el-descriptions-item label="车身颜色">
{{ detailData.body_color || '--' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ detailData.created_at || '--' }}
</el-descriptions-item>
<el-descriptions-item label="车况介绍" :span="2">
{{ detailData.condition_desc || '--' }}
</el-descriptions-item>
<el-descriptions-item label="产品图" :span="2">
<div
class="thumb-list"
v-if="parsePreviewList(detailData.images_pull || detailData.images).length"
>
<img
v-for="(img, index) in parsePreviewList(
detailData.images_pull || detailData.images
)"
:key="index"
:src="img"
alt="产品图"
class="avatarimg"
@click="handleAvatarClick(index)"
/>
</div>
<span v-else>--</span>
</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 20px">
<FilePreview title="附件" :files="contractFiles" empty-text="暂无附件" />
</div>
</div>
<!-- <template #footer>
<span class="dialog-footer">
<el-button @click="detailDialogVisible = false">关 闭</el-button>
</span>
</template> -->
</el-dialog>
<!-- PDF 预览弹窗 -->
<el-dialog
v-model="pdf.visible"
:title="currentPdfFile?.name || '预览'"
width="90%"
top="5vh"
:close-on-click-modal="false"
class="pdf-preview-dialog"
>
<div class="pdf-preview-container">
<div class="pdf-preview-toolbar">
<div class="left"></div>
<div class="right">
<!-- <el-button type="primary" @click="downloadPdfFile" v-if="currentPdfFile">
下载
</el-button> -->
</div>
</div>
<div class="pdf-preview-body">
<template v-if="!currentPdfFile">
<div class="no-file">无要预览的文件</div>
</template>
<template v-else>
<!-- IMAGE -->
<div
v-if="pdfFileType === 'image'"
class="viewer-wrap"
:key="`image-${currentPdfFile.url}`"
>
<div class="viewer" :style="pdfViewerStyle">
<img
:src="currentPdfFile.url"
:key="currentPdfFile.url"
@error="onPdfImageError"
@load="onPdfImageLoad"
/>
</div>
</div>
<!-- PDF -->
<div
v-else-if="pdfFileType === 'pdf'"
class="viewer-wrap"
:key="`pdf-${currentPdfFile.url}`"
>
<div class="viewer" :style="pdfViewerStyle">
<object
:key="`pdf-obj-${currentPdfFile.url}`"
:data="appendPdfParams(currentPdfFile.url)"
type="application/pdf"
width="100%"
height="100%"
>
<iframe
:key="`pdf-iframe-${currentPdfFile.url}`"
:src="appendPdfParams(currentPdfFile.url)"
width="100%"
height="100%"
></iframe>
<div class="fallback">
无法在浏览器内预览 PDF。
<a :href="currentPdfFile.url" target="_blank">点击打开</a>
</div>
</object>
</div>
</div>
<!-- OFFICE (Word, Excel, PPT) -->
<div
v-else-if="pdfFileType === 'office'"
class="viewer-wrap"
:key="`office-${currentPdfFile.url}`"
>
<div class="viewer" :style="pdfViewerStyle">
<template v-if="isHttpUrl(currentPdfFile.url)">
<iframe
:key="`office-iframe-${currentPdfFile.url}`"
:src="officeViewerUrl(currentPdfFile.url)"
width="100%"
height="100%"
frameborder="0"
></iframe>
</template>
<template v-else>
<div class="fallback">
Office 文件必须是可通过公网访问的 URL 才能使用在线预览。
<a :href="currentPdfFile.url" target="_blank">点击打开或下载</a>
</div>
</template>
</div>
</div>
<!-- 文本 / 代码 -->
<div
v-else-if="pdfFileType === 'text'"
class="viewer-wrap"
:key="`text-${currentPdfFile.url}`"
>
<div class="viewer text-viewer" :style="pdfViewerStyle">
<pre v-if="pdfTextContent !== null">{{ pdfTextContent }}</pre>
<div v-else class="loading">正在加载文本内容...</div>
<div v-if="pdfTextError" class="error">加载文本失败:{{ pdfTextError }}</div>
</div>
</div>
<!-- 未知 -->
<div v-else class="viewer-wrap unknown-viewer" :key="`unknown-${currentPdfFile.url}`">
<div class="viewer" :style="pdfViewerStyle">
<div class="unknown">
<p>无法在线预览此类型文件。</p>
<p><a :href="currentPdfFile.url" target="_blank">点击打开或下载文件</a></p>
</div>
</div>
</div>
</template>
</div>
</div>
<template #footer>
<div class="footer-info">{{ currentPdfFile?.name || '' }}</div>
</template>
</el-dialog>
<!-- 图片预览组件 -->
<ElImageViewer
v-if="showImageViewer"
:url-list="imgList"
:initial-index="viewerIndex"
:hide-on-click-modal="true"
@close="showImageViewer = false"
/>
<ElImageViewer
v-if="showImageViewer1"
:url-list="[currentPdfFiles]"
:hide-on-click-modal="true"
@close="showImageViewer1 = false"
/>
<!-- 编辑弹窗 -->
<el-dialog
v-model="editDialogVisible"
title="编辑车辆信息"
width="90%"
:close-on-click-modal="false"
@close="handleEditDialogClose"
>
<el-form
ref="editFormRef"
:model="editForm"
:rules="editRules"
label-width="110px"
label-position="right"
>
<ElRow :gutter="12" class="item-row-wrap">
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="产品名称" prop="product_name">
<div class="bind-name-wrapper">
<el-input
v-model="editForm.product_name"
placeholder="请输入车辆的品牌 系列查找车型"
clearable
/>
<el-button
type="primary"
@click="onEditBindByName"
:loading="editBindState.loading"
>
绑定
</el-button>
</div>
</el-form-item>
<!-- 绑定结果提示 -->
<el-form-item label=" ">
<div class="bind-tip" v-if="editBindState.boundProduct">
<span class="bind-tip-text">
已绑定:{{
editBindState.boundProduct.name ||
editBindState.boundProduct.product_name ||
editBindState.boundProduct.title ||
'-'
}}
</span>
<el-button link type="danger" @click="clearEditBind">清除</el-button>
</div>
<div class="bind-tip" v-else>
<span>例:比亚迪海狮07DM</span>
</div>
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="车价(¥)" prop="price">
<el-input v-model="editForm.price" placeholder="请输入车价" />
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="车源地" prop="source_place">
<el-input v-model="editForm.source_place" placeholder="请输入车源地" />
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="上牌时间" prop="plate_time">
<el-date-picker
v-model="editForm.plate_time"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择时间"
style="width: 100%"
/>
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="排量" prop="displacement">
<el-input v-model="editForm.displacement" placeholder="请输入排量" />
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="马力" prop="power">
<el-input v-model="editForm.power" placeholder="请输入马力" />
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="里程(Km)" prop="mileage">
<el-input v-model="editForm.mileage" placeholder="请输入里程" />
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<el-form-item label="车身颜色" prop="body_color">
<el-input v-model="editForm.body_color" placeholder="请输入车身颜色" />
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="24" :lg="24">
<el-form-item label="车况介绍">
<el-input
v-model="editForm.condition_desc"
type="textarea"
:rows="4"
placeholder="请输入车况介绍"
/>
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="24" :lg="24">
<el-form-item label="产品图">
<el-upload
v-model:file-list="editImageFileList"
class="upload-list"
list-type="picture-card"
:limit="maxMedia"
:action="uploadAction"
:headers="uploadHeaders"
name="image"
multiple
:before-upload="handleImagePreview"
:on-success="handleEditImageSuccess"
:on-remove="handleEditImageRemove"
:on-exceed="handleEditImageExceed"
>
<el-icon><Plus /></el-icon>
</el-upload>
</el-form-item>
</ElCol>
<ElCol :sm="24" :md="24" :lg="24">
<el-form-item label="附件">
<MultiFileUpload
v-model="editAttachmentFileList"
upload-text="点击上传附件"
hint-text="支持多个文件上传"
:action-image-uplode="uploadActionFile"
@change="handleEditAttachmentChange"
/>
</el-form-item>
</ElCol>
</ElRow>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="editDialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="editSubmitLoading" @click="handleEditSubmit">
确 认
</el-button>
</span>
</template>
</el-dialog>
<!-- 编辑绑定产品弹窗 -->
<el-dialog
v-model="editBindState.showDialog"
title="选择要绑定的产品"
width="700px"
@close="closeEditBindDialog"
>
<div class="bind-search" v-if="editBindState.list && editBindState.list.length">
<el-input
v-model="editBindState.keyword"
placeholder="搜索产品名称"
clearable
style="max-width: 320px"
/>
</div>
<el-scrollbar max-height="360px">
<div v-if="editBindState.loading" class="bind-loading">加载中…</div>
<div v-else-if="!editBindState.list.length" class="bind-empty">未找到匹配的产品</div>
<div v-else-if="!filteredEditBindList.length" class="bind-empty">未找到匹配结果</div>
<div v-else>
<div
class="bind-item"
v-for="(it, idx) in filteredEditBindList"
:key="getEditBindItemId(it, idx)"
:class="{ active: editBindState.selectedId === getEditBindItemId(it, idx) }"
@click="selectEditBind(it, idx)"
>
<div class="bind-item-main">
<div class="bind-item-title">
{{ it.name || it.product_name || it.title || `产品 ${idx + 1}` }}
</div>
<div class="bind-item-sub">
{{ formatEditBindSub(it) }}
</div>
</div>
<div class="bind-item-check">
<span
class="check-dot"
v-if="editBindState.selectedId === getEditBindItemId(it, idx)"
/>
</div>
</div>
</div>
</el-scrollbar>
<template #footer>
<el-button @click="closeEditBindDialog">取 消</el-button>
<el-button type="primary" :disabled="!editBindState.selectedId" @click="confirmEditBind">
确认绑定
</el-button>
</template>
</el-dialog>
<!-- 设置价格弹窗 -->
<el-dialog v-model="priceDialogVisible" title="设置价格" width="500px">
<el-form :model="priceForm" label-width="80px">
<el-form-item label="贸易条款">
<el-select
v-model="priceForm.trade_term"
placeholder="请选择贸易条款"
style="width: 100%"
>
<el-option label="FOB" value="FOB" />
<el-option label="CIF" value="CIF" />
<el-option label="EXW" value="EXW" />
<el-option label="DDP" value="DDP" />
<el-option label="CIP" value="CIP" />
<el-option label="FCA" value="FCA" />
<el-option label="CFR" value="CFR" />
</el-select>
</el-form-item>
<el-form-item label="价格">
<div class="price-input-wrapper">
<el-input
v-model="priceForm.price"
placeholder="请输入数字"
@input="handlePriceInput"
style="flex: 1"
/>
<el-select v-model="priceForm.currency" style="width: 120px; margin-left: 12px">
<el-option label="美元" value="USD" />
<el-option label="人民币" value="CNY" />
<el-option label="卢布" value="RUB" />
<el-option label="迪拉姆" value="AED" />
<el-option label="欧元" value="EUR" />
</el-select>
</div>
</el-form-item>
<el-form-item label="语言">
<el-radio-group v-model="priceForm.lang">
<el-radio label="en">英语</el-radio>
<el-radio label="ru">俄语</el-radio>
<el-radio label="es">西班牙语</el-radio>
<el-radio label="ar">阿拉伯语</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="priceDialogVisible = false">取 消</el-button>
<el-button type="primary" @click="confirmPrice">确 认</el-button>
</span>
</template>
</el-dialog>
<!-- 抓取弹窗 -->
<el-dialog
v-model="fetchDialogVisible"
title="抓取数据"
width="80%"
:close-on-click-modal="false"
>
<div class="fetch-dialog-content">
<el-form :model="fetchForm" label-width="70px" style="margin-bottom: 20px; display: flex">
<el-form-item label="抓取链接">
<el-input
v-model="fetchForm.url"
placeholder="请输入要抓取的链接"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleFetch" :loading="fetchLoading">抓取</el-button>
</el-form-item>
</el-form>
<!-- 抓取结果显示 -->
<div v-if="fetchLoading" class="fetch-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span style="margin-left: 8px">正在抓取中...</span>
</div>
<div v-else-if="fetchData" class="fetch-result">
<el-descriptions :column="2" border label-width="160px">
<el-descriptions-item label="产品名称">
{{ fetchData.product_name || '--' }}
</el-descriptions-item>
<el-descriptions-item label="车价(¥)">
{{ fetchData.price || '--' }}
</el-descriptions-item>
<el-descriptions-item label="车源地">
{{ fetchData.source_place || '--' }}
</el-descriptions-item>
<el-descriptions-item label="上牌时间">
{{ fetchData.plate_time || '--' }}
</el-descriptions-item>
<el-descriptions-item label="排量">
{{ fetchData.displacement || '--' }}
</el-descriptions-item>
<el-descriptions-item label="里程(Km)">
{{ fetchData.mileage || '--' }}
</el-descriptions-item>
<el-descriptions-item label="马力">
{{ fetchData.power || '--' }}
</el-descriptions-item>
<el-descriptions-item label="车身颜色">
{{ fetchData.body_color || '--' }}
</el-descriptions-item>
<el-descriptions-item label="车况介绍" :span="2">
{{ fetchData.condition_desc || '--' }}
</el-descriptions-item>
<el-descriptions-item label="产品图" :span="2">
<div
class="thumb-list"
v-if="parsePreviewList(fetchData.image_pull || fetchData.image).length"
>
<img
v-for="(img, index) in parsePreviewList(fetchData.image_pull || fetchData.image)"
:key="index"
:src="img"
alt="产品图"
class="avatarimg"
@click="handleAvatarClick(index)"
/>
</div>
<span v-else>--</span>
</el-descriptions-item>
</el-descriptions>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button plain type="info" @click="fetchDialogVisible = false">关 闭</el-button>
<el-button @click="addFn()">保 存</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import {
ElLoading,
ElMessage,
type FormInstance,
type FormRules,
type UploadFile
} from 'element-plus'
import { Plus, Loading } from '@element-plus/icons-vue'
import { manageService } from '@/api/adminApi'
import { usePdfPreview } from '@/composables/usePdfPreview'
import FilePreview from '@/components/custom/FilePreview.vue'
import request from '@/utils/http'
import { useUploadHeaders } from '@/composables/useUploadHeaders'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import MultiFileUpload from '@/components/core/forms/MultiFileUpload.vue'
const { uploadHeaders } = useUploadHeaders()
const keyword = ref<string>('')
const status = ref<string | number>(1)
defineOptions({ name: 'Company' })
const loading = ref(false)
const contractFiles = ref<Array<{ name: string; url: string; file_name: string }>>([])
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
onMounted(() => {
getWorkList()
})
// 图片预览相关
const showImageViewer = ref(false)
const showImageViewer1 = ref(false)
const currentPdfFiles = ref('')
const viewerIndex = ref(0)
const imgList = computed(() =>
parsePreviewList(detailData.value.images_pull || detailData.value.images)
)
const handleAvatarClick1 = (avatarUrl: string) => {
if (avatarUrl) {
currentPdfFiles.value = avatarUrl
showImageViewer1.value = true
}
}
const handleAvatarClick = (index: number) => {
if (index !== null && index !== undefined) {
viewerIndex.value = index
showImageViewer.value = true
}
}
// 处理文件数据
const processFiles = (files: any[]) => {
if (!files || !Array.isArray(files)) return []
return files.map((file: any) => ({
name: file.name,
url: file.src_pull || file.file_url,
file_name: file.src
}))
}
// 详情弹窗相关
const detailDialogVisible = ref(false)
const detailData = ref<any | null>(null)
const showDialog = async (_type: string, row: any) => {
const res = await manageService.getpublishdetail({ id: row.id })
detailData.value = res
// 处理文件
const file_list = res?.attachment_pull
if (file_list) {
contractFiles.value = processFiles(file_list)
} else {
contractFiles.value = []
}
detailDialogVisible.value = true
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getWorkList()
}
watch([keyword, status], () => {
pagination.currentPage = 1
getWorkList()
})
// 获取列表数据
const getWorkList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize
}
if (keyword.value) {
params.keyword = keyword.value
}
params.status = status.value
params.scene = 'self'
const { records, current, size, total } = await manageService.getpublishList(params)
tableData.value = records.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
const parsePaths = (value: any): string[] => {
if (!value) return []
if (Array.isArray(value)) return value.filter(Boolean)
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) return parsed.filter(Boolean)
} catch {
// ignore json parse errors
}
return value
.split(',')
.map((p: string) => p.trim())
.filter(Boolean)
}
return []
}
const parsePreviewList = (value: any): string[] => parsePaths(value)
// PDF 预览/打印(公共)
const { pdf, openWithResponse, preparePreview, prepareImages } = usePdfPreview()
// 当前 PDF 文件信息(用于 FilesPreviews 组件)
const currentPdfFile = computed(() => {
const url = pdf.url || pdf.blobUrl
if (!url) return null
return {
name: pdf.name || 'document.pdf',
url: url,
file_name: pdf.name || 'document.pdf'
}
})
// PDF 预览相关状态和逻辑
const pdfZoom = ref(1)
const pdfRotate = ref(0)
const pdfTextContent = ref<string | null>(null)
const pdfTextError = ref<string | null>(null)
// 获取文件扩展名
const getExtension = (name = '') => {
const m = name.split('.')
return (m.length > 1 ? m[m.length - 1].toLowerCase() : '').trim()
}
// 从文件名获取 MIME 类型
const mimeFromName = (name = '') => {
const ext = getExtension(name)
const map: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
bmp: 'image/bmp',
webp: 'image/webp',
txt: 'text/plain',
md: 'text/markdown',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
}
return map[ext] ?? ''
}
// 判断文件类型
const pdfFileType = computed(() => {
const f = currentPdfFile.value
if (!f) return 'unknown'
let names = f.name || ''
if (!names) {
names = f.file_name || ''
}
const mime = mimeFromName(names).toLowerCase()
const ext = getExtension(names)
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(ext))
return 'image'
if (mime === 'application/pdf' || ext === 'pdf') return 'pdf'
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) return 'office'
if (mime.startsWith('text/') || ['txt', 'md', 'csv', 'log', 'json', 'xml'].includes(ext))
return 'text'
return 'unknown'
})
// 判断是否为 HTTP URL
const isHttpUrl = (u?: string) => {
return !!u && /^https?:\/\//i.test(u)
}
// Office 查看器 URL
const officeViewerUrl = (url: string) => {
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}`
}
// PDF 参数处理
const appendPdfParams = (url: string) => {
return url
}
// 预览样式
const pdfViewerStyle = computed(() => {
return {
transform: `scale(${pdfZoom.value}) rotate(${pdfRotate.value}deg)`,
transformOrigin: 'center top'
} as Record<string, string>
})
// 加载文本内容
const loadPdfTextIfNeeded = async () => {
if (!currentPdfFile.value) return
if (pdfFileType.value !== 'text') return
pdfTextContent.value = null
pdfTextError.value = null
try {
const resp = await fetch(currentPdfFile.value.url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const txt = await resp.text()
pdfTextContent.value = txt
} catch (err: any) {
pdfTextError.value = String(err?.message ?? err)
}
}
// 图片错误处理
const onPdfImageError = () => {
ElMessage.warning('图片加载失败')
}
// 图片加载完成
const onPdfImageLoad = () => {
// 可以在这里获取自然尺寸进行布局优化
}
// 监听文件变化,加载文本内容
watch(
() => currentPdfFile.value?.url,
(newUrl, oldUrl) => {
if (newUrl !== oldUrl && newUrl) {
pdfZoom.value = 1
pdfRotate.value = 0
loadPdfTextIfNeeded()
}
}
)
// 监听文件类型变化
watch(
() => pdfFileType.value,
() => {
if (pdfFileType.value === 'text') {
loadPdfTextIfNeeded()
}
}
)
// 价格设置弹窗相关
const priceDialogVisible = ref(false)
const currentRow = ref<any>(null)
const priceForm = reactive({
trade_term: '',
price: '',
currency: 'USD',
lang: 'en'
})
// 价格输入限制:只允许数字和小数点
const handlePriceInput = (value: string) => {
// 只保留数字和小数点
priceForm.price = value.replace(/[^\d.]/g, '')
// 确保只有一个小数点
const parts = priceForm.price.split('.')
if (parts.length > 2) {
priceForm.price = parts[0] + '.' + parts.slice(1).join('')
}
}
// 打印/预览
const printfn = (row: any) => {
currentRow.value = row
priceForm.trade_term = ''
priceForm.price = ''
priceForm.currency = 'USD'
priceForm.lang = 'en'
priceDialogVisible.value = true
}
// 确认价格设置并执行预览/下载
const confirmPrice = () => {
if (!priceForm.trade_term) {
ElMessage.warning('请选择贸易条款')
return
}
if (!priceForm.price) {
ElMessage.warning('请输入价格')
return
}
priceDialogVisible.value = false
// 调用预览/下载接口,传递价格、币种、语言参数
const params: any = {
id: currentRow.value.id,
trade_term: priceForm.trade_term,
price: priceForm.price,
currency: priceForm.currency,
lang: priceForm.lang
}
const loading = ElLoading.service({
lock: true,
text: '正在生成中…',
background: 'rgba(0, 0, 0, 0.45)'
})
manageService
.generatePdfPreview(params)
.then(() => {
downloadfn(params)
})
.catch((err) => {
console.log(err)
ElMessage.error('预览失败,请稍后重试')
})
.finally(() => {
loading.close() // ✅ 不管成功/失败都关闭
})
}
const downloadfn = (row: any) => {
manageService
.downloadPdf({ id: row.id })
.then((res) => {
openWithResponse(res)
// 优先转成图片以适配不带插件的环境
prepareImages()
// 同时保留 blob 预览作为兜底
preparePreview()
})
.catch((err) => {
console.log(err)
ElMessage.error('下载失败,请稍后重试')
})
}
// 编辑相关
const maxMedia = 200
const editDialogVisible = ref(false)
const editFormRef = ref<FormInstance>()
const editSubmitLoading = ref(false)
const uploadAction = `${import.meta.env.VITE_API_URL}/upload/image?scene=usedcarimg`
const uploadActionFile = `${import.meta.env.VITE_API_URL}/upload?scene=usedcarfile`
type UploadFileWithResponse = UploadFile & {
response?: any
url?: string
}
interface FileItem {
name: string
url: string
file_name: string
}
const editImageFileList = ref<UploadFileWithResponse[]>([])
const editAttachmentFileList = ref<FileItem[]>([])
interface UsedCarForm {
id?: number
product_name: string
vin: string
price: string
source_place: string
plate_place: string
plate_time: string
displacement: string
power: string
body_color: string
images: string[]
condition_desc: string
mileage: string
attachment: { name: string; src: string }[]
crawl_title: string
crawl_brand: string
crawl_series: string
crawl_year: string
crawl_msrp: string
crawl_car_id: string
crawl_brand_id: string
crawl_series_id: string
}
const editForm = reactive<UsedCarForm>({
id: undefined,
product_name: '',
vin: '',
price: '',
source_place: '',
plate_place: '',
plate_time: '',
displacement: '',
power: '',
body_color: '',
images: [],
condition_desc: '',
mileage: '',
attachment: [],
crawl_title: '',
crawl_brand: '',
crawl_series: '',
crawl_year: '',
crawl_msrp: '',
crawl_car_id: '',
crawl_brand_id: '',
crawl_series_id: ''
})
const editRules = reactive<FormRules>({
product_name: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
price: [{ required: true, message: '请输入车价', trigger: 'blur' }]
})
// 编辑绑定相关状态
const editBindState = reactive<{
showDialog: boolean
loading: boolean
list: any[]
selectedId: string
boundProduct: any | null
keyword: string
_selected?: any
}>({
showDialog: false,
loading: false,
list: [],
selectedId: '',
boundProduct: null,
keyword: ''
})
const formatEditBindSub = (it: any) => {
const parts: string[] = []
if (it.sku || it.code) parts.push(`编码:${it.sku || it.code}`)
if (it.category_name) parts.push(`分类:${it.category_name}`)
if (it.price) parts.push(`价格:${it.price}`)
return parts.join(' · ')
}
const filteredEditBindList = computed(() => {
const list = Array.isArray(editBindState.list) ? editBindState.list : []
const kw = String(editBindState.keyword || '')
.trim()
.toLowerCase()
if (!kw) return list
return list.filter((it, idx) => {
const id = String(it.id || it.product_id || it.code || idx).toLowerCase()
const name = String(it.name || it.product_name || it.title || '').toLowerCase()
const sub = String(formatEditBindSub(it) || '').toLowerCase()
return id.includes(kw) || name.includes(kw) || sub.includes(kw)
})
})
// 编辑函数
const editFn = async (row: any) => {
try {
const res = await manageService.getpublishdetail({ id: row.id })
// 回显基本数据
Object.assign(editForm, {
id: res.id,
product_name: res.product_name || '',
vin: res.vin || '',
price: res.price || '',
source_place: res.source_place || '',
plate_place: res.plate_place || '',
plate_time: res.plate_time || '',
displacement: res.displacement || '',
power: res.power || '',
body_color: res.body_color || '',
condition_desc: res.condition_desc || '',
mileage: res.mileage || '',
crawl_title: res.crawl_title || '',
crawl_brand: res.crawl_brand || '',
crawl_series: res.crawl_series || '',
crawl_year: res.crawl_year || '',
crawl_msrp: res.crawl_msrp || '',
crawl_car_id: res.crawl_car_id || '',
crawl_brand_id: res.crawl_brand_id || '',
crawl_series_id: res.crawl_series_id || ''
})
// 回显图片
const images = parsePaths(res.images || res.images_pull || [])
editForm.images = images
editImageFileList.value = images.map((img: string, index: number) => {
const fullUrl =
Array.isArray(res.images_pull) && res.images_pull[index]
? res.images_pull[index]
: img.startsWith('http')
? img
: `${import.meta.env.VITE_API_URL}/${img}`
return {
uid: index,
name: `image-${index}.jpg`,
url: fullUrl,
status: 'success',
response: { file_name: img }
} as UploadFileWithResponse
})
// 回显附件
const attachments = res.attachment_pull || []
if (Array.isArray(attachments) && attachments.length > 0) {
editAttachmentFileList.value = attachments.map((att: any) => {
const url = att.src_pull
const fullUrl = url
const fileName = att.src || att.file_name || att
return {
name: att.name || att.file_name || '附件',
url: fullUrl,
file_name: typeof fileName === 'string' ? fileName : String(fileName)
} as FileItem
})
// 同步到 form.attachment
editForm.attachment = editAttachmentFileList.value.map((file) => ({
name: file.name,
src: file.file_name
}))
} else {
editForm.attachment = []
editAttachmentFileList.value = []
}
// 如果有绑定信息,回显绑定状态
if (res.crawl_title || res.crawl_brand) {
editBindState.boundProduct = {
title: res.crawl_title,
brand_name: res.crawl_brand,
series_name: res.crawl_series,
year: res.crawl_year,
msrp: res.crawl_msrp,
car_id: res.crawl_car_id,
brand_id: res.crawl_brand_id,
series_id: res.crawl_series_id
}
} else {
editBindState.boundProduct = null
}
editDialogVisible.value = true
} catch (error) {
console.error('获取详情失败:', error)
ElMessage.error('获取详情失败,请稍后重试')
}
}
// 编辑弹窗关闭
const handleEditDialogClose = () => {
editFormRef.value?.resetFields()
editImageFileList.value = []
editAttachmentFileList.value = []
editBindState.boundProduct = null
editForm.attachment = []
}
// 编辑提交
const handleEditSubmit = () => {
if (!editFormRef.value) return
editFormRef.value.validate(async (valid) => {
if (!valid) return
editSubmitLoading.value = true
try {
const payload: any = {
id: editForm.id,
...editForm,
images: (editForm.images || [])
.map((s) => String(s || '').trim())
.filter(Boolean)
.join(',')
}
const res = await manageService.publishedit(payload)
const msg = (res as any)?.message || (res as any)?.data?.message || '更新成功'
ElMessage.success(msg)
editDialogVisible.value = false
getWorkList() // 刷新列表
} catch (error: any) {
ElMessage.error(error?.message || '更新失败,请稍后重试')
} finally {
editSubmitLoading.value = false
}
})
}
// 编辑绑定相关函数
const onEditBindByName = () => {
const name = String(editForm.product_name || '').trim()
if (!name) {
ElMessage.warning('请先输入产品名称')
return
}
editBindState.showDialog = true
editBindState.loading = true
editBindState.list = []
editBindState.selectedId = ''
editBindState.keyword = ''
request
.get({
url: '/common/usedcar/crawl',
params: { keyword: name }
})
.then((res: any) => {
const data = res.model || []
editBindState.list = data
})
.catch(() => {
ElMessage.error('拉取产品失败,请稍后重试')
})
.finally(() => {
editBindState.loading = false
})
}
const closeEditBindDialog = () => {
editBindState.showDialog = false
editBindState.keyword = ''
}
const getEditBindItemId = (it: any, idx: number) =>
String(it.id || it.product_id || it.requisition_item_id || it.code || idx)
const selectEditBind = (it: any, idx: number) => {
editBindState.selectedId = getEditBindItemId(it, idx)
editBindState._selected = it
}
const confirmEditBind = () => {
if (!editBindState.selectedId || !editBindState._selected) return
const it = editBindState._selected
editBindState.boundProduct = it
editForm.crawl_title = it.title || ''
editForm.crawl_brand = it.brand_name || ''
editForm.crawl_series = it.series_name || ''
editForm.crawl_year = it.year || ''
editForm.crawl_msrp = it.msrp || ''
editForm.crawl_car_id = it.car_id || ''
editForm.crawl_brand_id = it.brand_id || ''
editForm.crawl_series_id = it.series_id || ''
closeEditBindDialog()
ElMessage.success('绑定成功')
}
const clearEditBind = () => {
editForm.crawl_title = ''
editForm.crawl_brand = ''
editForm.crawl_series = ''
editForm.crawl_year = ''
editForm.crawl_msrp = ''
editForm.crawl_car_id = ''
editForm.crawl_brand_id = ''
editForm.crawl_series_id = ''
editBindState.boundProduct = null
ElMessage.success('已清除绑定')
}
const loadings = ref<ReturnType<typeof ElLoading.service> | null>(null)
const handleImagePreview = () => {
loadings.value = ElLoading.service({
lock: true,
text: '正在上传中…',
background: 'rgba(0, 0, 0, 0.45)'
})
return true
}
// 编辑图片上传处理
const handleEditImageSuccess = (response: any, file: UploadFile, fileList: UploadFile[]) => {
const url = response?.file_url || response?.data?.file_url || ''
const fileName = response?.file_name || response?.data?.file_name || ''
;(file as UploadFileWithResponse).response = response
if (url && !file.url) file.url = url
if (fileName) {
if (!editForm.images.includes(fileName)) {
editForm.images.push(fileName)
}
}
editImageFileList.value = fileList as UploadFileWithResponse[]
loadings.value?.close()
}
const handleEditImageRemove = (file: UploadFile, fileList: UploadFile[]) => {
const resp = (file as UploadFileWithResponse).response
const fileName = resp?.file_name || resp?.data?.file_name || ''
if (fileName) {
const idx = editForm.images.indexOf(fileName)
if (idx > -1) editForm.images.splice(idx, 1)
}
editImageFileList.value = fileList as UploadFileWithResponse[]
}
const handleEditImageExceed = () => {
ElMessage.warning(`最多只能上传 ${maxMedia} 张图片`)
}
// 编辑附件变化处理
const handleEditAttachmentChange = (files: FileItem[]) => {
// 同步到 form.attachment,转换为后端需要的格式
editForm.attachment = files.map((file) => ({
name: file.name,
src: file.file_name
}))
}
// 标记售出
const statusfn = (row: any) => {
ElMessageBox.confirm('确定要标记售出吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
manageService
.updatestatus({ id: row.id, status: 2 })
.then(() => {
ElMessage.success('标记售出成功')
getWorkList()
})
.catch((err) => {
console.log(err)
ElMessage.error('操作失败,请稍后重试')
})
})
.catch(() => {
// 用户点击了取消
})
}
// 抓取相关
const fetchDialogVisible = ref(false)
const fetchForm = reactive({
url: ''
})
const fetchLoading = ref(false)
const fetchData = ref<any>(null)
// 打开抓取弹窗
const fetchpublishfn = () => {
fetchDialogVisible.value = true
fetchForm.url = ''
fetchData.value = null
}
// 执行抓取
const handleFetch = async () => {
if (!fetchForm.url || !fetchForm.url.trim()) {
ElMessage.warning('请输入抓取链接')
return
}
fetchLoading.value = true
fetchData.value = null
try {
const res: any = await manageService.fetchpublish({ url: fetchForm.url.trim() })
// 根据实际返回的数据结构处理
fetchData.value = res?.data || res || {}
ElMessage.success('抓取成功')
} catch (error: any) {
console.error('抓取失败:', error)
fetchData.value = null
} finally {
fetchLoading.value = false
}
}
function formatCnYearMonth(str: string) {
const match = str.match(/(\d{4})年(\d{1,2})月/)
if (!match) return str
const year = match[1]
const month = match[2].padStart(2, '0')
return `${year}-${month}-01`
}
//保存抓取
const addFn = async () => {
fetchData.value.plate_time = formatCnYearMonth(String(fetchData.value.plate_time || '').trim())
fetchData.value.images = (fetchData.value.images || [])
.map((s: any) => String(s || '').trim())
.filter(Boolean)
.join(',')
const res = await manageService.publishAdd(fetchData.value)
const msg = (res as any)?.message || (res as any)?.data?.message || '提交成功'
ElMessage.success(msg)
fetchDialogVisible.value = false
getWorkList()
}
</script>
<style lang="scss" scoped>
:deep(.el-table__empty-block) {
height: 100% !important;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.page-content {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
}
.thumb-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.avatarimg {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 4px;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
}
.customer-search-group {
display: flex;
gap: 12px;
}
.price-input-wrapper {
display: flex;
align-items: center;
width: 100%;
}
:deep(.pdf-preview-dialog) {
.el-dialog__body {
padding: 20px;
height: calc(90vh - 120px);
min-height: 500px;
display: flex;
flex-direction: column;
}
}
.pdf-preview-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.pdf-preview-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-shrink: 0;
}
.pdf-preview-body {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.viewer-wrap {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
}
.viewer {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.viewer img {
max-width: 100%;
max-height: 100%;
display: block;
object-fit: contain;
}
.viewer object,
.viewer iframe {
width: 100%;
height: 100%;
border: none;
min-height: 400px;
}
.text-viewer pre {
white-space: pre-wrap;
word-wrap: break-word;
text-align: left;
padding: 12px;
height: 100%;
overflow: auto;
width: 100%;
}
.unknown-viewer .unknown {
text-align: center;
color: #999;
}
.no-file {
color: #999;
text-align: center;
}
.footer-info {
color: #666;
}
.loading {
color: #999;
}
.error {
color: #f56c6c;
}
.fallback {
text-align: center;
color: #999;
padding: 20px;
}
.fallback a {
color: #409eff;
text-decoration: none;
}
.fallback a:hover {
text-decoration: underline;
}
.item-row-wrap {
margin-bottom: 0;
}
.bind-name-wrapper {
display: flex;
gap: 12px;
width: 100%;
:deep(.el-input) {
flex: 1;
}
}
.bind-tip {
display: flex;
align-items: center;
gap: 8px;
color: #909399;
.bind-tip-text {
color: #606266;
}
}
.upload-list {
:deep(.el-upload-list__item-thumbnail) {
object-fit: cover;
}
}
.bind-search {
margin-bottom: 12px;
}
.bind-loading,
.bind-empty {
padding: 16px;
text-align: center;
color: #909399;
}
.bind-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
& + .bind-item {
margin-top: 6px;
}
&:hover {
background-color: #f5f7fa;
}
&.active {
background-color: #ecf5ff;
border: 1px solid #409eff;
}
}
.bind-item-main {
flex: 1;
min-width: 0;
}
.bind-item-title {
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.bind-item-sub {
font-size: 12px;
color: #909399;
}
.bind-item-check {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1px solid #dcdfe6;
display: flex;
align-items: center;
justify-content: center;
margin-left: 10px;
.check-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #409eff;
}
}
.fetch-dialog-content {
min-height: 200px;
}
.fetch-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: #909399;
}
.fetch-result {
margin-top: 20px;
}
</style>