提交
This commit is contained in:
@@ -0,0 +1,719 @@
|
||||
<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="80px">
|
||||
<el-select
|
||||
v-model="auditStatus"
|
||||
placeholder="请选择状态"
|
||||
style="min-width: 120px"
|
||||
clearable
|
||||
>
|
||||
<el-option label="待审核" :value="0" />
|
||||
<el-option label="审核通过" :value="1" />
|
||||
<el-option label="审核拒绝" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围:" label-width="80px">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="min-width: 240px"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格区域 -->
|
||||
<div class="boxshadow">
|
||||
<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="handleAvatarClick(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="status">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusColor(scope.row.status)">
|
||||
{{ getStatusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="提交时间" prop="created_at" />
|
||||
<ElTableColumn fixed="right" label="操作">
|
||||
<template #default="scope">
|
||||
<el-button link @click="showDetail(scope.row)">详情</el-button>
|
||||
<el-button link type="primary" @click="showAuditRecords(scope.row)"
|
||||
>审核记录</el-button
|
||||
>
|
||||
<el-button
|
||||
link
|
||||
type="success"
|
||||
v-if="scope.row.status === 0"
|
||||
@click="openAuditDialog(scope.row, 1)"
|
||||
:disabled="scope.row.status !== 0"
|
||||
>
|
||||
通过
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
v-if="scope.row.status === 0"
|
||||
@click="openAuditDialog(scope.row, 2)"
|
||||
:disabled="scope.row.status !== 0"
|
||||
>
|
||||
拒绝
|
||||
</el-button>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="车辆详情" width="700px">
|
||||
<el-descriptions v-if="detailData" :column="2" border label-width="100px">
|
||||
<el-descriptions-item label="编号">{{ detailData.code || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="产品名称">{{ 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="审核状态">
|
||||
<el-tag :type="getStatusColor(detailData.status)">
|
||||
{{ getStatusText(detailData.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="提交时间" :span="2">{{ detailData.created_at || '--' }}</el-descriptions-item>
|
||||
<!-- <el-descriptions-item label="审核时间" v-if="detailData.status !== 0">
|
||||
{{ detailData.audit_time || '--' }}
|
||||
</el-descriptions-item> -->
|
||||
<el-descriptions-item label="审核意见" :span="2" v-if="detailData.audit_opinion">
|
||||
{{ detailData.audit_opinion }}
|
||||
</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="handleDetailAvatarClick(index)"
|
||||
/>
|
||||
</div>
|
||||
<span v-else>--</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<!-- 视频展示 -->
|
||||
<el-descriptions-item
|
||||
label="介绍视频"
|
||||
:span="2"
|
||||
v-if="parseVideoList(detailData.video_pull).length"
|
||||
>
|
||||
<div class="video-list">
|
||||
<div
|
||||
v-for="(video, index) in parseVideoList(detailData.video_pull)"
|
||||
:key="index"
|
||||
class="video-item"
|
||||
>
|
||||
<video :src="video" controls class="video-player" />
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
<!-- FAQ展示 -->
|
||||
<el-descriptions-item label="常见问题" :span="2" v-if="parseFAQList(detailData.faqs).length">
|
||||
<div class="faq-list">
|
||||
<div v-for="(faq, index) in parseFAQList(detailData.faqs)" :key="index" class="faq-item">
|
||||
<div class="faq-question">
|
||||
<span class="faq-label">问:</span>
|
||||
<span>{{ faq.question }}</span>
|
||||
</div>
|
||||
<div class="faq-answer">
|
||||
<span class="faq-label">答:</span>
|
||||
<span>{{ faq.answer }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 审核意见弹窗 -->
|
||||
<el-dialog v-model="auditDialogVisible" :title="auditDialogTitle" width="600px" :close-on-click-modal="false">
|
||||
<el-form :model="auditForm" label-width="100px" ref="auditFormRef">
|
||||
<el-form-item label="审核结果">
|
||||
<el-tag :type="auditForm.status === 1 ? 'success' : 'danger'">
|
||||
{{ auditForm.status === 1 ? '审核通过' : '审核拒绝' }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="车辆信息">
|
||||
<div class="car-info" v-if="currentAuditCar">
|
||||
<img
|
||||
:src="parsePreviewList(currentAuditCar.images_pull)[0]"
|
||||
class="car-thumb"
|
||||
v-if="parsePreviewList(currentAuditCar.images_pull).length"
|
||||
/>
|
||||
<div class="car-details">
|
||||
<p><strong>产品名称:</strong> {{ currentAuditCar.product_name }}</p>
|
||||
<p><strong>编号:</strong> {{ currentAuditCar.code }}</p>
|
||||
<p><strong>价格:</strong> {{ currentAuditCar.price }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="审核意见"
|
||||
prop="audit_opinion"
|
||||
:rules="[
|
||||
{
|
||||
required: auditForm.status === 2,
|
||||
message: '审核拒绝时必须填写审核意见',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]"
|
||||
>
|
||||
<el-input
|
||||
v-model="auditForm.audit_opinion"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="auditForm.status === 1 ? '请输入审核意见(可选)' : '请输入拒绝原因(必填)'"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="auditDialogVisible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="confirmAudit" :loading="auditLoading"> 确 认 </el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 审核记录弹窗 -->
|
||||
<el-dialog v-model="auditRecordsDialogVisible" title="审核记录" width="700px">
|
||||
<div v-loading="auditRecordsLoading" class="audit-records-container">
|
||||
<el-empty v-if="!auditRecordsList.length" description="暂无审核记录" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item
|
||||
v-for="(record, index) in auditRecordsList"
|
||||
:key="index"
|
||||
:timestamp="record.created_at"
|
||||
placement="top"
|
||||
:type="getTimelineColor(record.audit_status)"
|
||||
>
|
||||
<el-card class="audit-record-card">
|
||||
<div class="audit-record-header">
|
||||
<el-tag :type="getStatusColor(record.audit_status)">
|
||||
{{ record.audit_status_text || '--' }}
|
||||
</el-tag>
|
||||
<span class="auditor-info">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ record.auditor_name || '--' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="audit-record-content" v-if="record.audit_reason">
|
||||
<div class="content-label">审核理由:</div>
|
||||
<div class="content-value">{{ record.audit_reason }}</div>
|
||||
</div>
|
||||
<div class="audit-record-status-change">
|
||||
<span class="status-before">{{ getStatusText(record.before_status) }}</span>
|
||||
<el-icon class="status-arrow"><ArrowRight /></el-icon>
|
||||
<span class="status-after">{{ getStatusText(record.after_status) }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="auditRecordsDialogVisible = false">关 闭</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 图片预览组件 -->
|
||||
<ElImageViewer
|
||||
v-if="showImageViewer"
|
||||
:url-list="imgList"
|
||||
:initial-index="viewerIndex"
|
||||
:hide-on-click-modal="true"
|
||||
@close="showImageViewer = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { manageService } from '@/api/adminApi'
|
||||
import { AuditStatusMap } from '@/enums/auditEnum'
|
||||
|
||||
defineOptions({ name: 'AuditList' })
|
||||
|
||||
// 响应式数据
|
||||
const keyword = ref<string>('')
|
||||
const auditStatus = ref<number | undefined>(undefined) // 默认全部
|
||||
const dateRange = ref<any>(undefined)
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 详情弹窗
|
||||
const detailDialogVisible = ref(false)
|
||||
const detailData = ref<any>(null)
|
||||
|
||||
// 审核弹窗
|
||||
const auditDialogVisible = ref(false)
|
||||
const auditLoading = ref(false)
|
||||
const currentAuditCar = ref<any>(null)
|
||||
const auditForm = reactive({
|
||||
id: 0,
|
||||
status: 0,
|
||||
audit_opinion: ''
|
||||
})
|
||||
const auditFormRef = ref()
|
||||
|
||||
// 审核记录相关
|
||||
const auditRecordsDialogVisible = ref(false)
|
||||
const auditRecordsList = ref<any[]>([])
|
||||
const auditRecordsLoading = ref(false)
|
||||
|
||||
// 图片预览
|
||||
const showImageViewer = ref(false)
|
||||
const viewerIndex = ref(0)
|
||||
const imgList = ref<string[]>([])
|
||||
|
||||
// 计算属性
|
||||
const auditDialogTitle = computed(() => {
|
||||
return auditForm.status === 1 ? '审核通过' : '审核拒绝'
|
||||
})
|
||||
|
||||
// 状态相关方法
|
||||
const getStatusText = (status: number) => AuditStatusMap[status] || '未知'
|
||||
const getStatusColor = (status: number): 'success' | 'warning' | 'danger' | 'info' => {
|
||||
const colorMap: { [key: number]: 'success' | 'warning' | 'danger' | 'info' } = {
|
||||
0: 'warning',
|
||||
1: 'success',
|
||||
2: 'danger'
|
||||
}
|
||||
return colorMap[status] || 'info'
|
||||
}
|
||||
const getTimelineColor = (status: number) => {
|
||||
return getStatusColor(status)
|
||||
}
|
||||
|
||||
// 解析图片列表
|
||||
const parsePreviewList = (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 parseVideoList = (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 []
|
||||
}
|
||||
|
||||
// 解析FAQ列表
|
||||
const parseFAQList = (value: any): Array<{ question: string; answer: 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 []
|
||||
}
|
||||
|
||||
// 图片预览处理
|
||||
const handleAvatarClick = (imgSrc: string) => {
|
||||
viewerIndex.value = 0
|
||||
imgList.value = [imgSrc]
|
||||
showImageViewer.value = true
|
||||
}
|
||||
|
||||
const handleDetailAvatarClick = (index: number) => {
|
||||
viewerIndex.value = index
|
||||
showImageViewer.value = true
|
||||
}
|
||||
|
||||
// 获取审核列表
|
||||
const getAuditList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { currentPage, pageSize } = pagination
|
||||
const params: any = {
|
||||
current: currentPage,
|
||||
size: pageSize,
|
||||
scene: 'self'
|
||||
}
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
// 使用 status 参数(0=待审核, 1=审核通过, 2=审核拒绝)
|
||||
if (auditStatus.value !== null) params.status = auditStatus.value
|
||||
if (dateRange.value) {
|
||||
params.start_time = dateRange.value[0]
|
||||
params.end_time = dateRange.value[1]
|
||||
}
|
||||
|
||||
const { records, current, size, total } = await manageService.getpublishListpro(params)
|
||||
tableData.value = records
|
||||
Object.assign(pagination, { currentPage: current, pageSize: size, total })
|
||||
} catch (error) {
|
||||
console.error('获取列表失败:', error)
|
||||
ElMessage.error('获取列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const showDetail = async (row: any) => {
|
||||
try {
|
||||
const res = await manageService.getpublishdetailpro({ id: row.id })
|
||||
detailData.value = res
|
||||
detailDialogVisible.value = true
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
ElMessage.error('获取详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 打开审核弹窗
|
||||
const openAuditDialog = (row: any, status: number) => {
|
||||
currentAuditCar.value = row
|
||||
auditForm.id = row.id
|
||||
auditForm.status = status
|
||||
auditForm.audit_opinion = ''
|
||||
auditDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 确认审核
|
||||
const confirmAudit = async () => {
|
||||
// 审核拒绝时意见必填验证
|
||||
if (auditForm.status === 2 && !auditForm.audit_opinion.trim()) {
|
||||
ElMessage.warning('审核拒绝时必须填写审核意见')
|
||||
return
|
||||
}
|
||||
|
||||
auditLoading.value = true
|
||||
try {
|
||||
await manageService.auditCarPro({
|
||||
car_id: auditForm.id,
|
||||
audit_status: auditForm.status,
|
||||
audit_reason: auditForm.audit_opinion
|
||||
})
|
||||
ElMessage.success('审核成功')
|
||||
auditDialogVisible.value = false
|
||||
getAuditList()
|
||||
} catch (error) {
|
||||
console.error('审核失败:', error)
|
||||
ElMessage.error('审核失败')
|
||||
} finally {
|
||||
auditLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示审核记录
|
||||
const showAuditRecords = async (row: any) => {
|
||||
auditRecordsLoading.value = true
|
||||
auditRecordsDialogVisible.value = true
|
||||
try {
|
||||
const records = await manageService.getAuditRecords({ id: row.id })
|
||||
auditRecordsList.value = records || []
|
||||
} catch (error) {
|
||||
console.error('获取审核记录失败:', error)
|
||||
ElMessage.error('获取审核记录失败')
|
||||
} finally {
|
||||
auditRecordsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handleSizeChange = (newPageSize: number) => {
|
||||
pagination.pageSize = newPageSize
|
||||
getAuditList()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (newCurrentPage: number) => {
|
||||
pagination.currentPage = newCurrentPage
|
||||
getAuditList()
|
||||
}
|
||||
|
||||
// 监听搜索条件变化
|
||||
watch([keyword, auditStatus, dateRange], () => {
|
||||
pagination.currentPage = 1
|
||||
getAuditList()
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
getAuditList()
|
||||
})
|
||||
</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;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.customer-search-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.car-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
|
||||
.car-thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.car-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 视频列表样式
|
||||
.video-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
.video-player {
|
||||
width: 280px;
|
||||
height: 160px;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
}
|
||||
}
|
||||
|
||||
// FAQ列表样式
|
||||
.faq-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
padding: 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
|
||||
.faq-question,
|
||||
.faq-answer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-label {
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 审核记录时间轴样式
|
||||
.audit-records-container {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.audit-record-card {
|
||||
border-radius: 8px;
|
||||
|
||||
.audit-record-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.auditor-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.audit-record-content {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.content-label {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.content-value {
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.audit-record-status-change {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
|
||||
.status-before,
|
||||
.status-after {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-before {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.status-after {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.status-arrow {
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,570 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="boxshadow publish-form">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
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="form.product_name"
|
||||
placeholder="请输入车辆的品牌 系列查找车型"
|
||||
clearable
|
||||
/>
|
||||
<el-button type="primary" @click="onBindByName" :loading="bindState.loading">
|
||||
绑定
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<!-- 绑定结果提示 -->
|
||||
<el-form-item label=" ">
|
||||
<div class="bind-tip" v-if="bindState.boundProduct">
|
||||
<span class="bind-tip-text">
|
||||
已绑定:{{
|
||||
bindState.boundProduct.name ||
|
||||
bindState.boundProduct.product_name ||
|
||||
bindState.boundProduct.title ||
|
||||
'-'
|
||||
}}
|
||||
</span>
|
||||
<el-button link type="danger" @click="clearBind">清除</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="form.price" placeholder="请输入车价" />
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="12" :lg="12">
|
||||
<el-form-item label="车源地" prop="source_place">
|
||||
<el-input v-model="form.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="form.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="form.displacement" placeholder="请输入排量" />
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="12" :lg="12">
|
||||
<el-form-item label="马力" prop="power">
|
||||
<el-input v-model="form.power" placeholder="请输入马力" />
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="12" :lg="12">
|
||||
<el-form-item label="里程(Km)" prop="mileage">
|
||||
<el-input v-model="form.mileage" placeholder="请输入里程" />
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="12" :lg="12">
|
||||
<el-form-item label="车身颜色" prop="body_color">
|
||||
<el-input v-model="form.body_color" placeholder="请输入车身颜色" />
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="24" :lg="24">
|
||||
<el-form-item label="车况介绍">
|
||||
<el-input
|
||||
v-model="form.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="imageFileList"
|
||||
class="upload-list"
|
||||
list-type="picture-card"
|
||||
:limit="maxMedia"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
name="file"
|
||||
multiple
|
||||
:before-upload="handleImagePreview"
|
||||
:on-success="handleImageSuccess"
|
||||
:on-remove="handleImageRemove"
|
||||
:on-exceed="handleImageExceed"
|
||||
>
|
||||
<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="attachmentFileList"
|
||||
upload-text="点击上传附件"
|
||||
hint-text="支持多个文件上传"
|
||||
:action-image-uplode="uploadActionFile"
|
||||
@change="handleAttachmentChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<div style="text-align: center">
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
提交
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 绑定产品弹窗 -->
|
||||
<el-dialog
|
||||
v-model="bindState.showDialog"
|
||||
title="选择要绑定的产品"
|
||||
width="700px"
|
||||
@close="closeBindDialog"
|
||||
>
|
||||
<div class="bind-search" v-if="bindState.list && bindState.list.length">
|
||||
<el-input
|
||||
v-model="bindState.keyword"
|
||||
placeholder="搜索产品名称"
|
||||
clearable
|
||||
style="max-width: 320px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-scrollbar max-height="360px">
|
||||
<div v-if="bindState.loading" class="bind-loading">加载中…</div>
|
||||
<div v-else-if="!bindState.list.length" class="bind-empty">未找到匹配的产品</div>
|
||||
<div v-else-if="!filteredBindList.length" class="bind-empty">未找到匹配结果</div>
|
||||
<div v-else>
|
||||
<div
|
||||
class="bind-item"
|
||||
v-for="(it, idx) in filteredBindList"
|
||||
:key="getBindItemId(it, idx)"
|
||||
:class="{ active: bindState.selectedId === getBindItemId(it, idx) }"
|
||||
@click="selectBind(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">
|
||||
{{ formatBindSub(it) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bind-item-check">
|
||||
<span class="check-dot" v-if="bindState.selectedId === getBindItemId(it, idx)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="closeBindDialog">取 消</el-button>
|
||||
<el-button type="primary" :disabled="!bindState.selectedId" @click="confirmBind">
|
||||
确认绑定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import {
|
||||
ElMessage,
|
||||
ElLoading,
|
||||
type FormInstance,
|
||||
type FormRules,
|
||||
type UploadFile
|
||||
} from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { manageService } from '@/api/adminApi'
|
||||
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 maxMedia = 200
|
||||
|
||||
interface FileItem {
|
||||
name: string
|
||||
url: string
|
||||
file_name: string
|
||||
}
|
||||
|
||||
interface UsedCarForm {
|
||||
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 form = reactive<UsedCarForm>({
|
||||
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 formRef = ref<FormInstance>()
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
product_name: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
|
||||
price: [{ required: true, message: '请输入车价', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
// 绑定相关状态
|
||||
const bindState = 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 formatBindSub = (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 filteredBindList = computed(() => {
|
||||
const list = Array.isArray(bindState.list) ? bindState.list : []
|
||||
const kw = String(bindState.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(formatBindSub(it) || '').toLowerCase()
|
||||
return id.includes(kw) || name.includes(kw) || sub.includes(kw)
|
||||
})
|
||||
})
|
||||
|
||||
const uploadAction = `${import.meta.env.VITE_API_URL}/upload?scene=usedcarimg`
|
||||
const uploadActionFile = `${import.meta.env.VITE_API_URL}/upload?scene=usedcarfile`
|
||||
|
||||
type UploadFileWithResponse = UploadFile & {
|
||||
response?: any
|
||||
}
|
||||
|
||||
const imageFileList = ref<UploadFileWithResponse[]>([])
|
||||
const attachmentFileList = ref<FileItem[]>([])
|
||||
|
||||
const submitLoading = ref(false)
|
||||
|
||||
const onBindByName = () => {
|
||||
const name = String(form.product_name || '').trim()
|
||||
if (!name) {
|
||||
ElMessage.warning('请先输入产品名称')
|
||||
return
|
||||
}
|
||||
|
||||
bindState.showDialog = true
|
||||
bindState.loading = true
|
||||
bindState.list = []
|
||||
bindState.selectedId = ''
|
||||
bindState.keyword = ''
|
||||
|
||||
request
|
||||
.get({
|
||||
url: '/common/usedcar/crawl',
|
||||
params: { keyword: name }
|
||||
})
|
||||
.then((res: any) => {
|
||||
const data = res.model || []
|
||||
bindState.list = data
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('拉取产品失败,请稍后重试')
|
||||
})
|
||||
.finally(() => {
|
||||
bindState.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
const closeBindDialog = () => {
|
||||
bindState.showDialog = false
|
||||
bindState.keyword = ''
|
||||
}
|
||||
|
||||
const getBindItemId = (it: any, idx: number) =>
|
||||
String(it.id || it.product_id || it.requisition_item_id || it.code || idx)
|
||||
|
||||
const selectBind = (it: any, idx: number) => {
|
||||
bindState.selectedId = getBindItemId(it, idx)
|
||||
bindState._selected = it
|
||||
}
|
||||
|
||||
const confirmBind = () => {
|
||||
if (!bindState.selectedId || !bindState._selected) return
|
||||
const it = bindState._selected
|
||||
|
||||
bindState.boundProduct = it
|
||||
form.crawl_title = it.title || ''
|
||||
form.crawl_brand = it.brand_name || ''
|
||||
form.crawl_series = it.series_name || ''
|
||||
form.crawl_year = it.year || ''
|
||||
form.crawl_msrp = it.msrp || ''
|
||||
form.crawl_car_id = it.car_id || ''
|
||||
form.crawl_brand_id = it.brand_id || ''
|
||||
form.crawl_series_id = it.series_id || ''
|
||||
|
||||
closeBindDialog()
|
||||
ElMessage.success('绑定成功')
|
||||
}
|
||||
|
||||
const clearBind = () => {
|
||||
form.crawl_title = ''
|
||||
form.crawl_brand = ''
|
||||
form.crawl_series = ''
|
||||
form.crawl_year = ''
|
||||
form.crawl_msrp = ''
|
||||
form.crawl_car_id = ''
|
||||
form.crawl_brand_id = ''
|
||||
form.crawl_series_id = ''
|
||||
bindState.boundProduct = null
|
||||
ElMessage.success('已清除绑定')
|
||||
}
|
||||
const loading = ref<ReturnType<typeof ElLoading.service> | null>(null)
|
||||
const handleImagePreview = () => {
|
||||
loading.value = ElLoading.service({
|
||||
lock: true,
|
||||
text: '正在上传中…',
|
||||
background: 'rgba(0, 0, 0, 0.45)'
|
||||
})
|
||||
return true
|
||||
}
|
||||
const handleImageSuccess = (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 (!form.images.includes(fileName)) {
|
||||
form.images.push(fileName)
|
||||
}
|
||||
}
|
||||
imageFileList.value = fileList as UploadFileWithResponse[]
|
||||
loading.value?.close()
|
||||
}
|
||||
|
||||
const handleImageRemove = (file: UploadFile, fileList: UploadFile[]) => {
|
||||
const resp = (file as UploadFileWithResponse).response
|
||||
const fileName = resp?.file_name || resp?.data?.file_name || ''
|
||||
if (fileName) {
|
||||
const idx = form.images.indexOf(fileName)
|
||||
if (idx > -1) form.images.splice(idx, 1)
|
||||
}
|
||||
imageFileList.value = fileList as UploadFileWithResponse[]
|
||||
}
|
||||
|
||||
const handleImageExceed = () => {
|
||||
ElMessage.warning(`最多只能上传 ${maxMedia} 张图片`)
|
||||
}
|
||||
|
||||
// 附件变化处理
|
||||
const handleAttachmentChange = (files: FileItem[]) => {
|
||||
// 同步到 form.attachment,转换为后端需要的格式
|
||||
form.attachment = files.map((file) => ({
|
||||
name: file.name,
|
||||
src: file.file_name
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.value) return
|
||||
formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
...form,
|
||||
images: (form.images || [])
|
||||
.map((s) => String(s || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
const res = await manageService.publishAdd(payload)
|
||||
const msg = (res as any)?.message || (res as any)?.data?.message || '提交成功'
|
||||
ElMessage.success(msg)
|
||||
window.location.href = '/admin/publish/publish_list'
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '提交失败,请稍后重试')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.publish-form {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,792 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="boxshadow publish-form">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
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="form.product_name"
|
||||
placeholder="请输入车辆的品牌 系列查找车型"
|
||||
clearable
|
||||
/>
|
||||
<el-button type="primary" @click="onBindByName" :loading="bindState.loading">
|
||||
绑定
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<!-- 绑定结果提示 -->
|
||||
<el-form-item label=" ">
|
||||
<div class="bind-tip" v-if="bindState.boundProduct">
|
||||
<span class="bind-tip-text">
|
||||
已绑定:{{
|
||||
bindState.boundProduct.name ||
|
||||
bindState.boundProduct.product_name ||
|
||||
bindState.boundProduct.title ||
|
||||
'-'
|
||||
}}
|
||||
</span>
|
||||
<el-button link type="danger" @click="clearBind">清除</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="form.price" placeholder="请输入车价" />
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="12" :lg="12">
|
||||
<el-form-item label="车源地" prop="source_place">
|
||||
<el-input v-model="form.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="form.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="form.displacement" placeholder="请输入排量" />
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="12" :lg="12">
|
||||
<el-form-item label="马力" prop="power">
|
||||
<el-input v-model="form.power" placeholder="请输入马力" />
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="12" :lg="12">
|
||||
<el-form-item label="里程(Km)" prop="mileage">
|
||||
<el-input v-model="form.mileage" placeholder="请输入里程" />
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="12" :lg="12">
|
||||
<el-form-item label="车身颜色" prop="body_color">
|
||||
<el-input v-model="form.body_color" placeholder="请输入车身颜色" />
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="24" :lg="24">
|
||||
<el-form-item label="车况介绍">
|
||||
<el-input
|
||||
v-model="form.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="imageFileList"
|
||||
class="upload-list"
|
||||
list-type="picture-card"
|
||||
:limit="maxMedia"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
name="file"
|
||||
multiple
|
||||
:before-upload="handleImagePreview"
|
||||
:on-success="handleImageSuccess"
|
||||
:on-remove="handleImageRemove"
|
||||
:on-exceed="handleImageExceed"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<!-- 视频上传 -->
|
||||
<ElCol :sm="24" :md="24" :lg="24">
|
||||
<el-form-item label="介绍视频">
|
||||
<el-upload
|
||||
v-model:file-list="videoFileList"
|
||||
class="upload-list"
|
||||
:limit="maxVideos"
|
||||
:action="uploadActionVideo"
|
||||
:headers="uploadHeaders"
|
||||
name="file"
|
||||
accept="video/*"
|
||||
multiple
|
||||
:before-upload="handleVideoPreview"
|
||||
:on-success="handleVideoSuccess"
|
||||
:on-remove="handleVideoRemove"
|
||||
:on-exceed="handleVideoExceed"
|
||||
>
|
||||
<el-button type="primary">
|
||||
<el-icon><Plus /></el-icon>
|
||||
上传视频
|
||||
</el-button>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">支持 mp4/avi/mov 等视频格式</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
<!-- 视频预览列表 -->
|
||||
<div v-if="videoPreviewList.length" class="video-preview-list">
|
||||
<div v-for="(video, index) in videoPreviewList" :key="index" class="video-item">
|
||||
<video :src="video.url" controls class="video-preview"></video>
|
||||
<el-button link type="danger" @click="removeVideoByIndex(index)" size="small">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="24" :lg="24">
|
||||
<el-form-item label="附件">
|
||||
<MultiFileUpload
|
||||
v-model="attachmentFileList"
|
||||
upload-text="点击上传附件"
|
||||
hint-text="支持多个文件上传"
|
||||
:action-image-uplode="uploadActionFile"
|
||||
@change="handleAttachmentChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
<!-- FAQ 问答 -->
|
||||
<ElCol :sm="24" :md="24" :lg="24">
|
||||
<el-form-item label="FAQ 问答">
|
||||
<div class="faq-container">
|
||||
<div v-for="(faq, index) in form.faqs" :key="index" class="faq-item">
|
||||
<div class="faq-header">
|
||||
<span class="faq-index">Q{{ index + 1 }}</span>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="removeFaq(index)"
|
||||
v-if="form.faqs.length > 1"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-form-item :label="`问题 ${index + 1}`" :prop="`faqs.${index}.question`">
|
||||
<el-input
|
||||
v-model="faq.question"
|
||||
placeholder="请输入问题"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="`答案 ${index + 1}`" :prop="`faqs.${index}.answer`">
|
||||
<el-input
|
||||
v-model="faq.answer"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入答案"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-button type="primary" @click="addFaq" :icon="Plus">
|
||||
添加问答
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<div style="text-align: center">
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
提交
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 绑定产品弹窗 -->
|
||||
<el-dialog
|
||||
v-model="bindState.showDialog"
|
||||
title="选择要绑定的产品"
|
||||
width="700px"
|
||||
@close="closeBindDialog"
|
||||
>
|
||||
<div class="bind-search" v-if="bindState.list && bindState.list.length">
|
||||
<el-input
|
||||
v-model="bindState.keyword"
|
||||
placeholder="搜索产品名称"
|
||||
clearable
|
||||
style="max-width: 320px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-scrollbar max-height="360px">
|
||||
<div v-if="bindState.loading" class="bind-loading">加载中…</div>
|
||||
<div v-else-if="!bindState.list.length" class="bind-empty">未找到匹配的产品</div>
|
||||
<div v-else-if="!filteredBindList.length" class="bind-empty">未找到匹配结果</div>
|
||||
<div v-else>
|
||||
<div
|
||||
class="bind-item"
|
||||
v-for="(it, idx) in filteredBindList"
|
||||
:key="getBindItemId(it, idx)"
|
||||
:class="{ active: bindState.selectedId === getBindItemId(it, idx) }"
|
||||
@click="selectBind(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">
|
||||
{{ formatBindSub(it) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bind-item-check">
|
||||
<span class="check-dot" v-if="bindState.selectedId === getBindItemId(it, idx)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="closeBindDialog">取 消</el-button>
|
||||
<el-button type="primary" :disabled="!bindState.selectedId" @click="confirmBind">
|
||||
确认绑定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import {
|
||||
ElMessage,
|
||||
ElLoading,
|
||||
type FormInstance,
|
||||
type FormRules,
|
||||
type UploadFile
|
||||
} from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { manageService } from '@/api/adminApi'
|
||||
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'
|
||||
|
||||
defineOptions({ name: 'PublishAddPro' })
|
||||
|
||||
const { uploadHeaders } = useUploadHeaders()
|
||||
|
||||
const maxMedia = 200
|
||||
const maxVideos = 5 // 最多上传5个视频
|
||||
|
||||
interface FileItem {
|
||||
name: string
|
||||
url: string
|
||||
file_name: string
|
||||
}
|
||||
|
||||
interface FAQItem {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
interface UsedCarForm {
|
||||
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[]
|
||||
intro_video: string[]
|
||||
condition_desc: string
|
||||
mileage: string
|
||||
attachment: { name: string; src: string }[]
|
||||
faqs: FAQItem[]
|
||||
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 form = reactive<UsedCarForm>({
|
||||
product_name: '',
|
||||
vin: '',
|
||||
price: '',
|
||||
source_place: '',
|
||||
plate_place: '',
|
||||
plate_time: '',
|
||||
displacement: '',
|
||||
power: '',
|
||||
body_color: '',
|
||||
images: [],
|
||||
intro_video: [],
|
||||
condition_desc: '',
|
||||
mileage: '',
|
||||
attachment: [],
|
||||
faqs: [{ question: '', answer: '' }],
|
||||
crawl_title: '',
|
||||
crawl_brand: '',
|
||||
crawl_series: '',
|
||||
crawl_year: '',
|
||||
crawl_msrp: '',
|
||||
crawl_car_id: '',
|
||||
crawl_brand_id: '',
|
||||
crawl_series_id: ''
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
product_name: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
|
||||
price: [{ required: true, message: '请输入车价', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
// 绑定相关状态
|
||||
const bindState = 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 formatBindSub = (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 filteredBindList = computed(() => {
|
||||
const list = Array.isArray(bindState.list) ? bindState.list : []
|
||||
const kw = String(bindState.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(formatBindSub(it) || '').toLowerCase()
|
||||
return id.includes(kw) || name.includes(kw) || sub.includes(kw)
|
||||
})
|
||||
})
|
||||
|
||||
const uploadAction = `${import.meta.env.VITE_API_URL}/upload?scene=usedcarimg`
|
||||
const uploadActionFile = `${import.meta.env.VITE_API_URL}/upload?scene=usedcarfile`
|
||||
const uploadActionVideo = `${import.meta.env.VITE_API_URL}/upload?scene=usedcarvideo`
|
||||
|
||||
type UploadFileWithResponse = UploadFile & {
|
||||
response?: any
|
||||
}
|
||||
|
||||
const imageFileList = ref<UploadFileWithResponse[]>([])
|
||||
const videoFileList = ref<UploadFileWithResponse[]>([])
|
||||
const attachmentFileList = ref<FileItem[]>([])
|
||||
const videoPreviewList = ref<{ url: string; name: string }[]>([])
|
||||
|
||||
const submitLoading = ref(false)
|
||||
|
||||
const onBindByName = () => {
|
||||
const name = String(form.product_name || '').trim()
|
||||
if (!name) {
|
||||
ElMessage.warning('请先输入产品名称')
|
||||
return
|
||||
}
|
||||
|
||||
bindState.showDialog = true
|
||||
bindState.loading = true
|
||||
bindState.list = []
|
||||
bindState.selectedId = ''
|
||||
bindState.keyword = ''
|
||||
|
||||
request
|
||||
.get({
|
||||
url: '/common/usedcar/crawl',
|
||||
params: { keyword: name }
|
||||
})
|
||||
.then((res: any) => {
|
||||
const data = res.model || []
|
||||
bindState.list = data
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('拉取产品失败,请稍后重试')
|
||||
})
|
||||
.finally(() => {
|
||||
bindState.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
const closeBindDialog = () => {
|
||||
bindState.showDialog = false
|
||||
bindState.keyword = ''
|
||||
}
|
||||
|
||||
const getBindItemId = (it: any, idx: number) =>
|
||||
String(it.id || it.product_id || it.requisition_item_id || it.code || idx)
|
||||
|
||||
const selectBind = (it: any, idx: number) => {
|
||||
bindState.selectedId = getBindItemId(it, idx)
|
||||
bindState._selected = it
|
||||
}
|
||||
|
||||
const confirmBind = () => {
|
||||
if (!bindState.selectedId || !bindState._selected) return
|
||||
const it = bindState._selected
|
||||
|
||||
bindState.boundProduct = it
|
||||
form.crawl_title = it.title || ''
|
||||
form.crawl_brand = it.brand_name || ''
|
||||
form.crawl_series = it.series_name || ''
|
||||
form.crawl_year = it.year || ''
|
||||
form.crawl_msrp = it.msrp || ''
|
||||
form.crawl_car_id = it.car_id || ''
|
||||
form.crawl_brand_id = it.brand_id || ''
|
||||
form.crawl_series_id = it.series_id || ''
|
||||
|
||||
closeBindDialog()
|
||||
ElMessage.success('绑定成功')
|
||||
}
|
||||
|
||||
const clearBind = () => {
|
||||
form.crawl_title = ''
|
||||
form.crawl_brand = ''
|
||||
form.crawl_series = ''
|
||||
form.crawl_year = ''
|
||||
form.crawl_msrp = ''
|
||||
form.crawl_car_id = ''
|
||||
form.crawl_brand_id = ''
|
||||
form.crawl_series_id = ''
|
||||
bindState.boundProduct = null
|
||||
ElMessage.success('已清除绑定')
|
||||
}
|
||||
|
||||
const loading = ref<ReturnType<typeof ElLoading.service> | null>(null)
|
||||
|
||||
// 图片上传处理
|
||||
const handleImagePreview = () => {
|
||||
loading.value = ElLoading.service({
|
||||
lock: true,
|
||||
text: '正在上传中…',
|
||||
background: 'rgba(0, 0, 0, 0.45)'
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const handleImageSuccess = (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 (!form.images.includes(fileName)) {
|
||||
form.images.push(fileName)
|
||||
}
|
||||
}
|
||||
imageFileList.value = fileList as UploadFileWithResponse[]
|
||||
loading.value?.close()
|
||||
}
|
||||
|
||||
const handleImageRemove = (file: UploadFile, fileList: UploadFile[]) => {
|
||||
const resp = (file as UploadFileWithResponse).response
|
||||
const fileName = resp?.file_name || resp?.data?.file_name || ''
|
||||
if (fileName) {
|
||||
const idx = form.images.indexOf(fileName)
|
||||
if (idx > -1) form.images.splice(idx, 1)
|
||||
}
|
||||
imageFileList.value = fileList as UploadFileWithResponse[]
|
||||
}
|
||||
|
||||
const handleImageExceed = () => {
|
||||
ElMessage.warning(`最多只能上传 ${maxMedia} 张图片`)
|
||||
}
|
||||
|
||||
// 视频上传处理
|
||||
const handleVideoPreview = () => {
|
||||
loading.value = ElLoading.service({
|
||||
lock: true,
|
||||
text: '正在上传视频…',
|
||||
background: 'rgba(0, 0, 0, 0.45)'
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const handleVideoSuccess = (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 (!form.intro_video.includes(fileName)) {
|
||||
form.intro_video.push(fileName)
|
||||
}
|
||||
// 添加到预览列表
|
||||
if (!videoPreviewList.value.find((v) => v.url === url)) {
|
||||
videoPreviewList.value.push({ url, name: file.name })
|
||||
}
|
||||
}
|
||||
videoFileList.value = fileList as UploadFileWithResponse[]
|
||||
loading.value?.close()
|
||||
}
|
||||
|
||||
const handleVideoRemove = (file: UploadFile, fileList: UploadFile[]) => {
|
||||
const resp = (file as UploadFileWithResponse).response
|
||||
const fileName = resp?.file_name || resp?.data?.file_name || ''
|
||||
if (fileName) {
|
||||
const idx = form.intro_video.indexOf(fileName)
|
||||
if (idx > -1) form.intro_video.splice(idx, 1)
|
||||
}
|
||||
// 同时从预览列表中移除
|
||||
const url = (file as UploadFileWithResponse).response?.file_url || file.url || ''
|
||||
const previewIdx = videoPreviewList.value.findIndex((v) => v.url === url)
|
||||
if (previewIdx > -1) {
|
||||
videoPreviewList.value.splice(previewIdx, 1)
|
||||
}
|
||||
videoFileList.value = fileList as UploadFileWithResponse[]
|
||||
}
|
||||
|
||||
const handleVideoExceed = () => {
|
||||
ElMessage.warning(`最多只能上传 ${maxVideos} 个视频`)
|
||||
}
|
||||
|
||||
const removeVideoByIndex = (index: number) => {
|
||||
form.intro_video.splice(index, 1)
|
||||
videoPreviewList.value.splice(index, 1)
|
||||
videoFileList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 附件变化处理
|
||||
const handleAttachmentChange = (files: FileItem[]) => {
|
||||
// 同步到 form.attachment,转换为后端需要的格式
|
||||
form.attachment = files.map((file) => ({
|
||||
name: file.name,
|
||||
src: file.file_name
|
||||
}))
|
||||
}
|
||||
|
||||
// FAQ 操作
|
||||
const addFaq = () => {
|
||||
form.faqs.push({ question: '', answer: '' })
|
||||
}
|
||||
|
||||
const removeFaq = (index: number) => {
|
||||
if (form.faqs.length > 1) {
|
||||
form.faqs.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.value) return
|
||||
formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
// 过滤空的 FAQ
|
||||
const validFaqs = form.faqs.filter((faq) => faq.question.trim() || faq.answer.trim())
|
||||
|
||||
const payload: any = {
|
||||
...form,
|
||||
images: (form.images || [])
|
||||
.map((s) => String(s || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(','),
|
||||
intro_video: (form.intro_video || [])
|
||||
.map((s) => String(s || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(','),
|
||||
faqs: validFaqs.length > 0 ? validFaqs : undefined
|
||||
}
|
||||
|
||||
const res = await manageService.publishAddpro(payload)
|
||||
const msg = (res as any)?.message || (res as any)?.data?.message || '提交成功'
|
||||
ElMessage.success(msg)
|
||||
window.location.href = '/admin/publish/publish_my_listpro'
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '提交失败,请稍后重试')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.publish-form {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.video-preview-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
|
||||
.video-item {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
|
||||
.video-preview {
|
||||
width: 100%;
|
||||
max-height: 120px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.faq-container {
|
||||
width: 100%;
|
||||
|
||||
.faq-item {
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
|
||||
.faq-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.faq-index {
|
||||
font-weight: 500;
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,858 @@
|
||||
<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="archive" />
|
||||
<el-option label="已售出" :value="2" />
|
||||
<el-option label="已归档" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="boxshadow">
|
||||
<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 @click="showDialog('detail', 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="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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElLoading, ElMessage } from 'element-plus'
|
||||
import { manageService } from '@/api/adminApi'
|
||||
import { usePdfPreview } from '@/composables/usePdfPreview'
|
||||
import FilePreview from '@/components/custom/FilePreview.vue'
|
||||
|
||||
const keyword = ref<string>('')
|
||||
const status = ref<string | number>('archive')
|
||||
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
|
||||
|
||||
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 文件(使用和 FilePreview.vue 一样的逻辑)
|
||||
const downloadPdfFile = async () => {
|
||||
if (!currentPdfFile.value) return
|
||||
const file = currentPdfFile.value
|
||||
try {
|
||||
// 对所有文件类型都使用 fetch 下载,确保真正的下载
|
||||
const response = await fetch(file.url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: '*/*'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = file.name || file.file_name || 'download.pdf'
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
ElMessage.success('下载成功')
|
||||
} catch (error) {
|
||||
console.warn('fetch 下载失败,降级为直链打开:', error)
|
||||
const link = document.createElement('a')
|
||||
link.href = file.url
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
// ElMessage.warning('已使用直链方式打开下载(若需自定义文件名请配置服务器CORS或使用下载代理)')
|
||||
}
|
||||
}
|
||||
|
||||
// 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 || !currentRow.value) {
|
||||
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('下载失败,请稍后重试')
|
||||
})
|
||||
}
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,774 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="boxshadow">
|
||||
<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="handleAvatarClick(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 @click="showDialog('detail', 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>
|
||||
<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(img)"
|
||||
/>
|
||||
</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="40px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<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="[currentImageUrl]"
|
||||
:hide-on-click-modal="true"
|
||||
@close="showImageViewer = false"
|
||||
/>
|
||||
|
||||
<!-- 设置价格弹窗 -->
|
||||
<el-dialog v-model="priceDialogVisible" title="设置价格" width="500px">
|
||||
<el-form :model="priceForm" label-width="80px">
|
||||
<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="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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { manageService } from '@/api/adminApi'
|
||||
import { usePdfPreview } from '@/composables/usePdfPreview'
|
||||
import FilePreview from '@/components/custom/FilePreview.vue'
|
||||
const keyword = ref<string>('')
|
||||
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 currentImageUrl = ref('')
|
||||
const handleAvatarClick = (avatarUrl: string) => {
|
||||
if (avatarUrl) {
|
||||
currentImageUrl.value = avatarUrl
|
||||
showImageViewer.value = true
|
||||
}
|
||||
}
|
||||
// 处理文件数据
|
||||
const processFiles = (files: any[]) => {
|
||||
if (!files || !Array.isArray(files)) return []
|
||||
return files.map((file: any) => ({
|
||||
name: file.name,
|
||||
url: file.src || file.file_url,
|
||||
file_name: file.name
|
||||
}))
|
||||
}
|
||||
// 详情弹窗相关
|
||||
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], () => {
|
||||
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
|
||||
}
|
||||
|
||||
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 文件(使用和 FilePreview.vue 一样的逻辑)
|
||||
const downloadPdfFile = async () => {
|
||||
if (!currentPdfFile.value) return
|
||||
const file = currentPdfFile.value
|
||||
try {
|
||||
// 对所有文件类型都使用 fetch 下载,确保真正的下载
|
||||
const response = await fetch(file.url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: '*/*'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = file.name || file.file_name || 'download.pdf'
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
ElMessage.success('下载成功')
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||
ElMessage.error(`下载失败: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 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({
|
||||
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.price = ''
|
||||
priceForm.currency = 'USD'
|
||||
priceForm.lang = 'en'
|
||||
priceDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 确认价格设置并执行预览/下载
|
||||
const confirmPrice = () => {
|
||||
if (!priceForm.price || !currentRow.value) {
|
||||
ElMessage.warning('请输入价格')
|
||||
return
|
||||
}
|
||||
|
||||
priceDialogVisible.value = false
|
||||
|
||||
// 调用预览/下载接口,传递价格、币种、语言参数
|
||||
const params: any = {
|
||||
id: currentRow.value.id,
|
||||
price: priceForm.price,
|
||||
currency: priceForm.currency,
|
||||
lang: priceForm.lang
|
||||
}
|
||||
|
||||
manageService
|
||||
.generatePdfPreview(params)
|
||||
.then(() => {
|
||||
downloadfn(params)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
ElMessage.error('预览失败,请稍后重试')
|
||||
})
|
||||
}
|
||||
const downloadfn = (row: any) => {
|
||||
manageService
|
||||
.downloadPdf({ id: row.id })
|
||||
.then((res) => {
|
||||
openWithResponse(res)
|
||||
// 优先转成图片以适配不带插件的环境
|
||||
prepareImages()
|
||||
// 同时保留 blob 预览作为兜底
|
||||
preparePreview()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
ElMessage.error('下载失败,请稍后重试')
|
||||
})
|
||||
}
|
||||
</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%;
|
||||
}
|
||||
.pdf-preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.pdf-preview-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pdf-preview-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(80vh - 100px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.viewer img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,886 @@
|
||||
<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="''" />
|
||||
<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">
|
||||
<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="240px">
|
||||
<template #default="scope">
|
||||
<ElRow>
|
||||
<el-button link type="warning" @click="printfn(scope.row)"> 预览/下载 </el-button>
|
||||
<el-button link @click="showDialog('detail', 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="showImageViewer1"
|
||||
:url-list="[currentPdfFiles]"
|
||||
:hide-on-click-modal="true"
|
||||
@close="showImageViewer1 = false"
|
||||
/>
|
||||
<ElImageViewer
|
||||
v-if="showImageViewer"
|
||||
:url-list="imgList"
|
||||
:initial-index="viewerIndex"
|
||||
:hide-on-click-modal="true"
|
||||
@close="showImageViewer = false"
|
||||
/>
|
||||
|
||||
<!-- 设置价格弹窗 -->
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElLoading, ElMessage } from 'element-plus'
|
||||
import { manageService } from '@/api/adminApi'
|
||||
import { usePdfPreview } from '@/composables/usePdfPreview'
|
||||
import FilePreview from '@/components/custom/FilePreview.vue'
|
||||
|
||||
const keyword = ref<string>('')
|
||||
const status = ref<string | number>('')
|
||||
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 viewerIndex = ref(0)
|
||||
const currentPdfFiles = ref('')
|
||||
const imgList = computed(() =>
|
||||
parsePreviewList(detailData.value.images_pull || detailData.value.images)
|
||||
)
|
||||
const handleAvatarClick1 = (index: string) => {
|
||||
if (index !== null && index !== undefined) {
|
||||
currentPdfFiles.value = index
|
||||
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
|
||||
}
|
||||
if (status.value !== '') {
|
||||
params.status = status.value
|
||||
}
|
||||
|
||||
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 文件(使用和 FilePreview.vue 一样的逻辑)
|
||||
const downloadPdfFile = async () => {
|
||||
if (!currentPdfFile.value) return
|
||||
const file = currentPdfFile.value
|
||||
try {
|
||||
// 对所有文件类型都使用 fetch 下载,确保真正的下载
|
||||
const response = await fetch(file.url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: '*/*'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = file.name || file.file_name || 'download.pdf'
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
ElMessage.success('下载成功')
|
||||
} catch (error) {
|
||||
console.warn('fetch 下载失败,降级为直链打开:', error)
|
||||
const link = document.createElement('a')
|
||||
link.href = file.url
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
// ElMessage.warning('已使用直链方式打开下载(若需自定义文件名请配置服务器CORS或使用下载代理)')
|
||||
}
|
||||
}
|
||||
|
||||
// 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 || !currentRow.value) {
|
||||
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 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(() => {
|
||||
// 用户点击了取消
|
||||
})
|
||||
}
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<div class="page-content user">
|
||||
<div class="content">
|
||||
<div class="left-wrap">
|
||||
<div class="user-wrap box-style">
|
||||
<!-- <img class="bg" src="@imgs/user/bg.webp" /> -->
|
||||
<img
|
||||
class="avatar"
|
||||
:src="userInfo.avatar_pull != '' ? userInfo.avatar_pull : imageUrluser"
|
||||
/>
|
||||
<h2 class="name">{{ userInfo.name }}</h2>
|
||||
<div class="outer-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">电 话:</span>
|
||||
<span class="info-value">{{ userInfo.phone }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">邮 箱:</span>
|
||||
<span class="info-value">{{ userInfo.email }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">英文名:</span>
|
||||
<span class="info-value">{{ userInfo.name_en }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">whatsapp:</span>
|
||||
<span class="info-value">{{ userInfo.whatsapp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-wrap">
|
||||
<div class="info box-style">
|
||||
<h1 class="title"><span class="border" style="margin-right: 5px"></span>基本信息</h1>
|
||||
|
||||
<ElForm
|
||||
:model="form"
|
||||
class="form"
|
||||
ref="ruleFormRef"
|
||||
label-width="86px"
|
||||
label-position="left"
|
||||
>
|
||||
<ElFormItem label="头像" prop="avatar">
|
||||
<el-upload
|
||||
class="qrcode-upload"
|
||||
:action="actionImageUplode"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleUploadavatar"
|
||||
:disabled="!isEdit"
|
||||
>
|
||||
<div class="qrcode-upload-box">
|
||||
<img class="avatar" :src="imageUrlavatar != '' ? imageUrlavatar : imageUrluser" />
|
||||
</div>
|
||||
</el-upload>
|
||||
</ElFormItem>
|
||||
<ElRow>
|
||||
<ElFormItem label="英文名" prop="name_en">
|
||||
<el-input v-model="form.name_en" :disabled="!isEdit" placeholder="用作水印" />
|
||||
<!-- <el-text type="warning">用作水印</el-text> -->
|
||||
</ElFormItem>
|
||||
<ElFormItem label="邮箱" prop="email" class="right-input">
|
||||
<ElInput v-model="form.email" :disabled="!isEdit" />
|
||||
</ElFormItem>
|
||||
</ElRow>
|
||||
<ElRow>
|
||||
<ElFormItem label="联系方式" prop="phone">
|
||||
<ElInput v-model="form.phone" :disabled="!isEdit" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="whatsapp" prop="whatsapp" class="right-input">
|
||||
<ElInput v-model="form.whatsapp" :disabled="!isEdit" />
|
||||
</ElFormItem>
|
||||
</ElRow>
|
||||
<ElRow>
|
||||
<ElFormItem label="二维码" prop="social" :class="isEdit ? 'qrcode-radio-group' : ''">
|
||||
<el-radio-group v-model="form.social" v-if="isEdit">
|
||||
<el-radio label="微信">微信</el-radio>
|
||||
<el-radio label="WhatsApp">WhatsApp</el-radio>
|
||||
</el-radio-group>
|
||||
<template v-else>
|
||||
<img
|
||||
v-if="form.social_code_pull"
|
||||
:src="form.social_code_pull"
|
||||
class="qrcode-img"
|
||||
/>
|
||||
<span v-else class="qrcode-upload-text">暂无二维码</span>
|
||||
</template>
|
||||
</ElFormItem>
|
||||
<ElFormItem v-if="isEdit" label="上传二维码" prop="social_code" class="right-input">
|
||||
<el-upload
|
||||
class="qrcode-upload"
|
||||
:action="actionImageUplode"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleUploadavatarqrcode"
|
||||
>
|
||||
<div class="qrcode-upload-box">
|
||||
<img v-if="form.social_code_pull" :src="social_code_pull" class="qrcode-img" />
|
||||
<span v-else class="qrcode-upload-text">
|
||||
<img :src="imageUrls" class="qrcode-img" />
|
||||
</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</ElFormItem>
|
||||
</ElRow>
|
||||
|
||||
<div class="el-form-item-right">
|
||||
<ElButton type="primary" style="width: 90px" v-ripple @click="edit">
|
||||
<i v-if="!isEdit" class="iconfont-sys"> </i>
|
||||
{{ isEdit ? '保存' : '编辑' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElForm, FormInstance, FormRules } from 'element-plus'
|
||||
import imageUrl from '@/assets/img/admin/upimg.png'
|
||||
import imageUrluser from '@/assets/img/user/avatar.webp'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
||||
const { uploadHeaders } = useUploadHeaders()
|
||||
let actionImageUplode = `${import.meta.env.VITE_API_URL}/upload`
|
||||
defineOptions({ name: 'AdminUserCenter' })
|
||||
// 定义客户详情类型
|
||||
interface CustomerInfo {
|
||||
[key: string]: any
|
||||
}
|
||||
const imageUrls = ref(imageUrl)
|
||||
const imageUrlavatar = ref('')
|
||||
const social_code = ref('')
|
||||
const social_code_pull = ref('')
|
||||
// const countryOptions = ref<any>()
|
||||
const form = ref<CustomerInfo>({})
|
||||
const userInfo = ref<CustomerInfo>({})
|
||||
const getuserInfo = async () => {
|
||||
const data = await UserService.getUserInfo()
|
||||
userInfo.value = data.data
|
||||
form.value = data.data
|
||||
imageUrlavatar.value = data.data.avatar_pull
|
||||
social_code_pull.value = data.data.social_code_pull
|
||||
social_code.value = data.data.social_code
|
||||
}
|
||||
const isEdit = ref(false)
|
||||
const showReSignQR = ref(false)
|
||||
onMounted(async () => {
|
||||
// await getarea()
|
||||
await getuserInfo()
|
||||
})
|
||||
const edit = async () => {
|
||||
if (isEdit.value) {
|
||||
const payload: any = { ...(form.value as any) }
|
||||
const res = await UserService.updateUserInfo({
|
||||
phone: payload.phone,
|
||||
email: payload.email,
|
||||
name_en: payload.name_en,
|
||||
whatsapp: payload.whatsapp,
|
||||
social: payload.social,
|
||||
social_code: payload.social_code,
|
||||
avatar: payload.avatar
|
||||
})
|
||||
ElMessage.success(res?.message || '操作成功')
|
||||
getuserInfo()
|
||||
}
|
||||
isEdit.value = !isEdit.value
|
||||
if (isEdit.value === false) {
|
||||
showReSignQR.value = false
|
||||
}
|
||||
}
|
||||
//头像上传
|
||||
function handleUploadavatar(response: any) {
|
||||
// 假设返回图片地址字段为 response.url 或 response.data.url
|
||||
imageUrlavatar.value = response.url || (response.data && response.data.file_url) || ''
|
||||
// 如需同步到 form.picture
|
||||
form.value.avatar = response.url || (response.data && response.data.file_name) || ''
|
||||
}
|
||||
//二维码上传
|
||||
function handleUploadavatarqrcode(response: any) {
|
||||
// 假设返回图片地址字段为 response.url 或 response.data.url
|
||||
social_code_pull.value = response.url || (response.data && response.data.file_url) || ''
|
||||
imageUrls.value = response.url || (response.data && response.data.file_url) || ''
|
||||
// 如需同步到 form.picture
|
||||
form.value.social_code = response.url || (response.data && response.data.file_name) || ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.user {
|
||||
.icon {
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
overflow: hidden;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentcolor;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.qrcode-radio-group .el-form-item__content) {
|
||||
height: 40px;
|
||||
}
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.qrcode-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
.page-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
$box-radius: calc(var(--custom-radius) + 4px);
|
||||
|
||||
.box-style {
|
||||
border: 1px solid var(--art-border-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
|
||||
.left-wrap {
|
||||
width: 350px;
|
||||
margin-right: 25px;
|
||||
|
||||
.user-wrap {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
padding: 35px 30px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
// background: var(--art-main-bg-color);
|
||||
background: linear-gradient(180deg, #e9f0ff 0%, #ffffff 67.9%);
|
||||
|
||||
border-radius: $box-radius;
|
||||
.avatar {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
// margin-top: 120px;
|
||||
object-fit: cover;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin-top: 20px;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.des {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.outer-info {
|
||||
// width: 260px;
|
||||
margin: 0 auto;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.info-label {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
color: #222;
|
||||
text-align: right;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.info-value {
|
||||
color: #333;
|
||||
margin-left: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.lables {
|
||||
margin-top: 40px;
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 15px;
|
||||
|
||||
> div {
|
||||
padding: 3px 6px;
|
||||
margin: 0 10px 10px 0;
|
||||
font-size: 12px;
|
||||
background: var(--art-main-bg-color);
|
||||
border: 1px solid var(--art-border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gallery {
|
||||
margin-top: 25px;
|
||||
border-radius: 10px;
|
||||
|
||||
.item {
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: $box-radius;
|
||||
|
||||
.info {
|
||||
background: var(--art-main-bg-color);
|
||||
border-radius: $box-radius;
|
||||
|
||||
.title {
|
||||
padding: 15px 25px;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--art-text-gray-800);
|
||||
// border-bottom: 1px solid var(--art-border-color);
|
||||
}
|
||||
|
||||
.form {
|
||||
box-sizing: border-box;
|
||||
padding: 30px 25px;
|
||||
|
||||
> .el-row {
|
||||
.el-form-item {
|
||||
width: calc(50% - 10px);
|
||||
}
|
||||
|
||||
.el-input,
|
||||
.el-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.right-input {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.el-form-item-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
.el-button {
|
||||
width: 110px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad-vertical) {
|
||||
.page-content {
|
||||
.content {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
|
||||
.left-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.signature-img {
|
||||
width: 160px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="feishu-callback">
|
||||
<div v-if="loading">正在登录,请稍候...</div>
|
||||
<div v-else-if="error">{{ error }}</div>
|
||||
<div v-else>登录成功,正在跳转...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
|
||||
defineOptions({ name: 'FeishuCallback' })
|
||||
|
||||
// 角色设置辅助函数
|
||||
const getRoles = (role: number, role_wp: number | null) => {
|
||||
// 基础角色
|
||||
const baseRole = role == 1 ? 'business' : role == 2 ? 'procurement' : 'alls'
|
||||
const roles = [baseRole]
|
||||
|
||||
// 根据 role_wp 添加额外角色
|
||||
if (role_wp == 1) {
|
||||
roles.push('businesspro')
|
||||
} else if (role_wp == 2) {
|
||||
roles.push('supplier')
|
||||
} else if (role_wp == 3) {
|
||||
roles.push('adminpro')
|
||||
}
|
||||
|
||||
return roles
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
onMounted(async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const app_id = localStorage.getItem('app_id')
|
||||
|
||||
if (!code) {
|
||||
error.value = '未获取到飞书授权 code'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!app_id) {
|
||||
error.value = '未找到 app_id,请重新登录'
|
||||
loading.value = false
|
||||
setTimeout(() => router.replace('/'), 2000)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await UserService.login({
|
||||
feishu_code: code,
|
||||
app_id: app_id,
|
||||
is_pc: 1
|
||||
})
|
||||
const data = result.data
|
||||
// 存储token和用户信息
|
||||
userStore.setToken(data.union_id)
|
||||
|
||||
// 设置角色(支持多角色数组)
|
||||
const roles = getRoles(data.role, data.role_wp)
|
||||
localStorage.setItem('role', JSON.stringify(roles))
|
||||
const userInfo = await UserService.getUserInfo()
|
||||
userStore.setUserInfo(userInfo)
|
||||
userStore.setLoginStatus(true)
|
||||
router.push(data.home)
|
||||
} catch (e: any) {
|
||||
error.value = e?.response?.data?.message || e?.message || '登录失败,请重试'
|
||||
setTimeout(() => router.replace('/'), 2000)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feishu-callback {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="login register">
|
||||
<LoginLeftView></LoginLeftView>
|
||||
<div class="right-wrap">
|
||||
<div class="header">
|
||||
<ArtLogo class="icon" />
|
||||
<h1>{{ systemName }}</h1>
|
||||
</div>
|
||||
<div class="login-wrap">
|
||||
<div class="form">
|
||||
<h3 class="title">{{ $t('forgetPassword.title') }}</h3>
|
||||
<p class="sub-title">{{ $t('forgetPassword.subTitle') }}</p>
|
||||
<div class="input-wrap">
|
||||
<span class="input-label" v-if="showInputLabel">账号</span>
|
||||
<ElInput :placeholder="$t('forgetPassword.placeholder')" v-model.trim="username" />
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<ElButton
|
||||
class="login-btn"
|
||||
type="primary"
|
||||
@click="register"
|
||||
:loading="loading"
|
||||
v-ripple
|
||||
>
|
||||
{{ $t('forgetPassword.submitBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<ElButton class="back-btn" plain @click="toLogin">
|
||||
{{ $t('forgetPassword.backBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
|
||||
defineOptions({ name: 'ForgetPassword' })
|
||||
|
||||
const router = useRouter()
|
||||
const showInputLabel = ref(false)
|
||||
|
||||
const systemName = AppConfig.systemInfo.name
|
||||
const username = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const register = async () => {}
|
||||
|
||||
const toLogin = () => {
|
||||
router.push(RoutesAlias.Login)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../login/index';
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
|
||||
.login {
|
||||
background: url('@imgs/login/pcbg1.png') no-repeat center center;
|
||||
background-size: 100% 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
|
||||
.el-input__inner {
|
||||
&:focus {
|
||||
border: 1px solid #4e83fd;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input--medium .el-input__inner {
|
||||
height: var(--el-component-custom-height);
|
||||
line-height: var(--el-component-custom-height);
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
|
||||
.top-right-wrap {
|
||||
position: fixed;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 5px;
|
||||
margin-left: 15px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.3s;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 440px;
|
||||
height: 610px;
|
||||
padding: 0 5px;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
background-size: cover;
|
||||
border-radius: 5px;
|
||||
|
||||
.form {
|
||||
box-sizing: border-box;
|
||||
// height: 100%;
|
||||
widows: 100%;
|
||||
padding: 40px 30px;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
|
||||
.title {
|
||||
margin-left: -2px;
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
color: var(--art-text-gray-900) !important;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-500) !important;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
margin-top: 25px;
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
padding-bottom: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
}
|
||||
|
||||
.account-select :deep(.el-select__wrapper),
|
||||
.el-input,
|
||||
.login-btn {
|
||||
height: 50px !important;
|
||||
}
|
||||
|
||||
.drag-verify {
|
||||
position: relative;
|
||||
// padding-bottom: 20px;
|
||||
margin-top: 25px;
|
||||
|
||||
.drag-verify-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.error {
|
||||
border-color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #f56c6c;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.show-error-text {
|
||||
transform: translateY(40px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forget-password {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-500);
|
||||
|
||||
a {
|
||||
color: var(--main-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 40px !important;
|
||||
color: #fff;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 100%;
|
||||
height: 40px !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-800);
|
||||
|
||||
a {
|
||||
color: var(--main-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad-pro) {
|
||||
.login {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
|
||||
.right-wrap {
|
||||
margin: auto;
|
||||
|
||||
.login-wrap {
|
||||
position: relative;
|
||||
width: 440px;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
|
||||
.form {
|
||||
margin-top: 10vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-phone) {
|
||||
.login {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
.right-wrap {
|
||||
box-sizing: border-box;
|
||||
width: 100% !important;
|
||||
padding: 0 30px;
|
||||
margin: auto;
|
||||
|
||||
.login-wrap {
|
||||
width: 100%;
|
||||
|
||||
.form {
|
||||
margin-top: 12vh;
|
||||
|
||||
.input-wrap {
|
||||
.input-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrap,
|
||||
.drag-verify {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<!-- 左侧展示区域 -->
|
||||
<div class="login-left">
|
||||
<div class="logo-section">
|
||||
<h1 style="color: #fff;">USED CAR</h1>
|
||||
<!-- <p>二手车管理系统</p> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧登录表单 -->
|
||||
<div class="login-right">
|
||||
<div class="login-form-wrapper">
|
||||
<!-- 登录方式切换 -->
|
||||
<div class="login-type-tabs">
|
||||
<span
|
||||
:class="{ active: activeLoginType === 'password' }"
|
||||
@click="switchLoginType('password')"
|
||||
>账号密码登录</span>
|
||||
<span
|
||||
:class="{ active: activeLoginType === 'qrcode' }"
|
||||
@click="switchLoginType('qrcode')"
|
||||
>飞书登录</span>
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录表单 -->
|
||||
<div v-if="activeLoginType === 'password'" class="password-login">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
@keyup.enter="handlePasswordLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="formData.username"
|
||||
placeholder="请输入账号"
|
||||
prefix-icon="User"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
prefix-icon="Lock"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
:loading="loading"
|
||||
@click="handlePasswordLogin"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 飞书扫码登录 -->
|
||||
<div v-else class="qrcode-login">
|
||||
<el-form :model="formData">
|
||||
<el-form-item prop="company">
|
||||
<el-select
|
||||
v-model="formData.company"
|
||||
placeholder="请选择公司"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
@change="handleCompanyChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in companyList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-if="!isInFeishu && formData.company" class="qrcode-container">
|
||||
<div id="feishu_qr_login_container"></div>
|
||||
<p class="qrcode-tip">请使用飞书App扫码登录</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isInFeishu && formData.company" class="feishu-auto-login">
|
||||
<p>正在跳转飞书授权...</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!formData.company" class="qrcode-placeholder">
|
||||
<p>请先选择公司</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import AppConfig from '@/config'
|
||||
|
||||
defineOptions({ name: 'Login' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 系统名称
|
||||
const systemName = AppConfig.systemInfo.name || '二手车管理系统'
|
||||
|
||||
// 登录方式
|
||||
const activeLoginType = ref<'password' | 'qrcode'>('password')
|
||||
|
||||
// 是否在飞书环境中
|
||||
const isInFeishu = /Lark|Feishu/i.test(navigator.userAgent)
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 公司列表
|
||||
const companyList = ref<any[]>([])
|
||||
|
||||
// 飞书配置
|
||||
const feishuConfig = ref<any>(null)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
company: undefined as number | undefined
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = computed<FormRules>(() => ({
|
||||
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
// 公司选择仅在飞书登录时必填,账号密码登录时可选
|
||||
company: activeLoginType.value === 'qrcode'
|
||||
? [{ required: true, message: '请选择公司', trigger: 'change' }]
|
||||
: []
|
||||
}))
|
||||
|
||||
// 角色设置辅助函数
|
||||
const getRoles = (role: number, role_wp: number | null) => {
|
||||
// 基础角色
|
||||
const baseRole = role == 1 ? 'business' : role == 2 ? 'procurement' : 'alls'
|
||||
const roles = [baseRole]
|
||||
|
||||
// 根据 role_wp 添加额外角色
|
||||
if (role_wp == 1) {
|
||||
roles.push('businesspro')
|
||||
} else if (role_wp == 2) {
|
||||
roles.push('supplier')
|
||||
} else if (role_wp == 3) {
|
||||
roles.push('adminpro')
|
||||
}
|
||||
|
||||
return roles
|
||||
}
|
||||
|
||||
// 切换登录方式
|
||||
const switchLoginType = (type: 'password' | 'qrcode') => {
|
||||
activeLoginType.value = type
|
||||
// 保存登录类型到 userStore
|
||||
if (type === 'qrcode') {
|
||||
userStore.setLoginType('feishu')
|
||||
} else {
|
||||
userStore.setLoginType('upwd')
|
||||
}
|
||||
|
||||
if (type === 'qrcode' && formData.company) {
|
||||
initFeishuLogin()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取公司列表
|
||||
const getCompanyList = async () => {
|
||||
try {
|
||||
const response = await UserService.getCompanyList()
|
||||
companyList.value = response || []
|
||||
} catch (error) {
|
||||
console.error('获取公司列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 公司变更处理
|
||||
const handleCompanyChange = (companyId: number) => {
|
||||
const company = companyList.value.find(c => c.id === companyId)
|
||||
if (company) {
|
||||
userStore.setCompany(String(companyId), company.abbr || '')
|
||||
|
||||
// 构建飞书配置,确保 r_url 存在
|
||||
const defaultRUrl = 'http://web-usedcar.dev.hnxinyouhe.com/auth/feishu-callback'
|
||||
feishuConfig.value = {
|
||||
...company,
|
||||
r_url: company.r_url || defaultRUrl,
|
||||
state: company.state || 'xinyouhe'
|
||||
}
|
||||
|
||||
// 保存 app_id 供回调页面使用
|
||||
if (company.app_id) {
|
||||
localStorage.setItem('app_id', company.app_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeLoginType.value === 'qrcode' && companyId) {
|
||||
initFeishuLogin()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化飞书登录
|
||||
const initFeishuLogin = async () => {
|
||||
if (!formData.company || !feishuConfig.value) return
|
||||
|
||||
// 飞书配置已在 handleCompanyChange 中从公司列表获取
|
||||
|
||||
if (isInFeishu) {
|
||||
// 飞书内环境,自动跳转授权
|
||||
await handleFeishuLogin()
|
||||
} else {
|
||||
// 普通浏览器环境,显示二维码
|
||||
await nextTick()
|
||||
await loadFeishuQRCode()
|
||||
}
|
||||
}
|
||||
|
||||
// 加载飞书二维码
|
||||
const loadFeishuQRCode = async () => {
|
||||
// 等待飞书SDK加载
|
||||
const waitForSDK = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
// @ts-ignore
|
||||
if (window.QRLogin) resolve()
|
||||
else setTimeout(check, 100)
|
||||
}
|
||||
check()
|
||||
})
|
||||
|
||||
try {
|
||||
await waitForSDK()
|
||||
|
||||
const container = document.getElementById('feishu_qr_login_container')
|
||||
if (!container) {
|
||||
ElMessage.error('未找到二维码容器')
|
||||
return
|
||||
}
|
||||
|
||||
container.innerHTML = ''
|
||||
|
||||
const { app_id, r_url, state } = feishuConfig.value || {}
|
||||
if (!app_id || !r_url || !state) {
|
||||
ElMessage.error('飞书配置不完整,请重新选择公司')
|
||||
return
|
||||
}
|
||||
|
||||
// URL编码,确保redirect_uri正确传递
|
||||
const encodedRedirectUri = encodeURIComponent(r_url)
|
||||
const gotoUrl = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=${app_id}&redirect_uri=${encodedRedirectUri}&response_type=code&state=${state}`
|
||||
|
||||
// @ts-ignore
|
||||
const QRLoginObj = window.QRLogin({
|
||||
id: 'feishu_qr_login_container',
|
||||
goto: gotoUrl,
|
||||
width: '250',
|
||||
height: '250',
|
||||
style: 'width:280px;height:280px;border:none'
|
||||
})
|
||||
|
||||
// 监听扫码消息
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// @ts-ignore
|
||||
if (
|
||||
QRLoginObj.matchOrigin &&
|
||||
QRLoginObj.matchData &&
|
||||
QRLoginObj.matchOrigin(event.origin) &&
|
||||
QRLoginObj.matchData(event.data)
|
||||
) {
|
||||
const loginTmpCode = event.data.tmp_code
|
||||
// 使用编码后的跳转URL
|
||||
window.location.href = `${gotoUrl}&tmp_code=${loginTmpCode}`
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage, false)
|
||||
} catch {
|
||||
ElMessage.error('加载飞书二维码失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 飞书登录(飞书内环境)
|
||||
const handleFeishuLogin = async () => {
|
||||
if (!formData.company) {
|
||||
ElMessage.warning('请先选择公司')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { auth_url } = await UserService.getfeishuscope({
|
||||
app_id: feishuConfig.value?.app_id,
|
||||
is_pc: 1
|
||||
})
|
||||
window.location.href = auth_url
|
||||
} catch (error) {
|
||||
ElMessage.error('获取飞书授权失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 账号密码登录
|
||||
const handlePasswordLogin = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
|
||||
const { data } = await UserService.login({
|
||||
account: formData.username,
|
||||
password: formData.password
|
||||
})
|
||||
|
||||
// 保存token(union_id为token)
|
||||
if (data.union_id) {
|
||||
userStore.setToken(data.union_id)
|
||||
}
|
||||
|
||||
// 设置角色(支持多角色数组)
|
||||
const roles = getRoles(data.role, data.role_wp)
|
||||
localStorage.setItem('role', JSON.stringify(roles))
|
||||
|
||||
// 保存用户信息
|
||||
userStore.setUserInfo(data)
|
||||
userStore.setLoginStatus(true)
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
// 跳转首页
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
// router.push(redirect)
|
||||
router.push(data.home)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '登录失败,请检查账号密码')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听登录方式切换
|
||||
watch(activeLoginType, (newType) => {
|
||||
if (newType === 'qrcode' && formData.company) {
|
||||
initFeishuLogin()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
getCompanyList()
|
||||
// 设置默认登录类型为账号密码登录
|
||||
userStore.setLoginType('upwd')
|
||||
|
||||
// 检查URL参数中的公司ID
|
||||
const companyId = route.query.corp_id as string
|
||||
if (companyId) {
|
||||
formData.company = Number(companyId)
|
||||
handleCompanyChange(Number(companyId))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 900px;
|
||||
height: 550px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-left {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
padding: 40px;
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-right {
|
||||
flex: 1;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-form-wrapper {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.login-type-tabs {
|
||||
display: flex;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 2px solid #eee;
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.active {
|
||||
color: #667eea;
|
||||
border-bottom-color: #667eea;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.password-login,
|
||||
.qrcode-login {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-button) {
|
||||
border-radius: 8px;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.qrcode-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
|
||||
#feishu_qr_login_container {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.qrcode-tip {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.feishu-btn-login {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.feishu-auto-login {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 280px;
|
||||
color: #667eea;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.qrcode-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 280px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="analysis-dashboard">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xl="14" :lg="15" :xs="24">
|
||||
<TodaySales />
|
||||
</el-col>
|
||||
<el-col :xl="10" :lg="9" :xs="24">
|
||||
<VisitorInsights />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-20">
|
||||
<el-col :xl="10" :lg="10" :xs="24">
|
||||
<TotalRevenue />
|
||||
</el-col>
|
||||
<el-col :xl="7" :lg="7" :xs="24">
|
||||
<CustomerSatisfaction />
|
||||
</el-col>
|
||||
<el-col :xl="7" :lg="7" :xs="24">
|
||||
<TargetVsReality />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="mt-20">
|
||||
<el-col :xl="10" :lg="10" :xs="24">
|
||||
<TopProducts />
|
||||
</el-col>
|
||||
<el-col :xl="7" :lg="7" :xs="24">
|
||||
<SalesMappingByCountry />
|
||||
</el-col>
|
||||
<el-col :xl="7" :lg="7" :xs="24">
|
||||
<VolumeServiceLevel />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TodaySales from './widget/TodaySales.vue'
|
||||
import VisitorInsights from './widget/VisitorInsights.vue'
|
||||
import TotalRevenue from './widget/TotalRevenue.vue'
|
||||
import CustomerSatisfaction from './widget/CustomerSatisfaction.vue'
|
||||
import TargetVsReality from './widget/TargetVsReality.vue'
|
||||
import TopProducts from './widget/TopProducts.vue'
|
||||
import SalesMappingByCountry from './widget/SalesMappingByCountry.vue'
|
||||
import VolumeServiceLevel from './widget/VolumeServiceLevel.vue'
|
||||
|
||||
defineOptions({ name: 'Analysis' })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
.analysis-dashboard {
|
||||
padding-bottom: 20px;
|
||||
|
||||
:deep(.custom-card) {
|
||||
background: var(--art-main-bg-color);
|
||||
border-radius: calc(var(--custom-radius) + 4px) !important;
|
||||
}
|
||||
|
||||
// 卡片头部
|
||||
:deep(.custom-card-header) {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--art-text-gray-900);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 21px;
|
||||
font-size: 13px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border: 1px solid #e8ebf1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mt-20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.analysis-dashboard {
|
||||
:deep(.custom-card) {
|
||||
box-shadow: 0 4px 20px rgb(0 0 0 / 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.analysis-dashboard {
|
||||
.mt-20 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:deep(.custom-card) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card customer-satisfaction">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.customerSatisfaction.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<div ref="chartRef" style="height: 300px; margin-top: 10px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as echarts from 'echarts'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
const options: () => echarts.EChartsOption = () => ({
|
||||
grid: {
|
||||
top: 30,
|
||||
right: 20,
|
||||
bottom: 50,
|
||||
left: 20,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true
|
||||
},
|
||||
legend: {
|
||||
data: [
|
||||
t('analysis.customerSatisfaction.legend.lastMonth'),
|
||||
t('analysis.customerSatisfaction.legend.thisMonth')
|
||||
],
|
||||
bottom: 0,
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
color: isDark.value ? '#808290' : '#222B45'
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: ['Week 0', 'Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5', 'Week 6'],
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false } // 隐藏 x 轴标签
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: {
|
||||
show: false // 将 show 设置为 false 以去除水平线条
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('analysis.customerSatisfaction.legend.lastMonth'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [1800, 2800, 1800, 2300, 2600, 2500, 3000],
|
||||
areaStyle: {
|
||||
opacity: 0.8,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(0,157,255,0.33)' },
|
||||
{ offset: 1, color: 'rgba(255,255,255,0)' }
|
||||
])
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#0086E1'
|
||||
},
|
||||
symbol: 'none',
|
||||
itemStyle: {
|
||||
color: '#0095FF'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t('analysis.customerSatisfaction.legend.thisMonth'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [4000, 3500, 4300, 3700, 4500, 3500, 4000],
|
||||
areaStyle: {
|
||||
opacity: 0.8,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(147,241,180,0.33)' },
|
||||
{ offset: 1, color: 'rgba(255,255,255,0)' }
|
||||
])
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: '#14DEB9'
|
||||
},
|
||||
symbol: 'none',
|
||||
itemStyle: {
|
||||
color: '#14DEB9'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.custom-card {
|
||||
height: 400px;
|
||||
|
||||
&-body {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-notebook) {
|
||||
.custom-card {
|
||||
height: 350px;
|
||||
|
||||
&-body {
|
||||
> div {
|
||||
height: 260px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card sales-mapping-country">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.salesMappingCountry.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<div ref="chartRef" class="sales-mapping-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, initChart, isDark } = useChart()
|
||||
|
||||
const chartData = [
|
||||
{ value: 1048, name: 'Beijing', itemStyle: { color: 'rgba(99, 102, 241, 0.9)' } },
|
||||
{ value: 735, name: 'Shanghai', itemStyle: { color: 'rgba(134, 239, 172, 0.9)' } },
|
||||
{ value: 580, name: 'Guangzhou', itemStyle: { color: 'rgba(253, 224, 71, 0.9)' } },
|
||||
{ value: 484, name: 'Shenzhen', itemStyle: { color: 'rgba(248, 113, 113, 0.9)' } },
|
||||
{ value: 300, name: 'Chengdu', itemStyle: { color: 'rgba(125, 211, 252, 0.9)' } }
|
||||
]
|
||||
|
||||
const options: () => EChartsOption = () => ({
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Sales Mapping',
|
||||
type: 'pie',
|
||||
radius: ['40%', '60%'],
|
||||
avoidLabelOverlap: false,
|
||||
padAngle: 5,
|
||||
itemStyle: {
|
||||
borderRadius: 10
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 40,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: chartData
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sales-mapping-country {
|
||||
height: 330px;
|
||||
|
||||
.sales-mapping-chart {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card target-vs-reality">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.targetVsReality.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<div ref="chartRef" style="height: 160px"></div>
|
||||
</div>
|
||||
<div class="custom-card-footer">
|
||||
<div class="total-item">
|
||||
<div class="label">
|
||||
<i class="iconfont-sys"></i>
|
||||
<div class="label-text">
|
||||
<span>{{ t('analysis.targetVsReality.realitySales.label') }}</span>
|
||||
<span>{{ t('analysis.targetVsReality.realitySales.sublabel') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="value text-color-green">8,823</div>
|
||||
</div>
|
||||
<div class="total-item">
|
||||
<div class="label">
|
||||
<i class="iconfont-sys"></i>
|
||||
<div class="label-text">
|
||||
<span>{{ t('analysis.targetVsReality.targetSales.label') }}</span>
|
||||
<span>{{ t('analysis.targetVsReality.targetSales.sublabel') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="value text-color-orange">12,122</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { EChartsOption } from 'echarts'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
const options: () => EChartsOption = () => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'June', 'July'],
|
||||
axisLabel: {
|
||||
color: '#7B91B0'
|
||||
},
|
||||
axisLine: {
|
||||
show: false // 隐藏 x 轴线
|
||||
},
|
||||
axisTick: {
|
||||
show: false // 隐藏刻度线
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
show: false // 隐藏 y 轴文字
|
||||
},
|
||||
splitLine: {
|
||||
show: false // 隐藏 y 轴分割线
|
||||
},
|
||||
axisLine: {
|
||||
show: false // 隐藏 y 轴线
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Reality Sales',
|
||||
type: 'bar',
|
||||
data: [8000, 7000, 6000, 8500, 9000, 10000, 9500],
|
||||
barWidth: '15',
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: '#2B8DFA'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Target Sales',
|
||||
type: 'bar',
|
||||
data: [10000, 9000, 11000, 10000, 12000, 12500, 11500],
|
||||
barWidth: '15',
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 4, 4],
|
||||
color: '#95E0FB'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-card {
|
||||
height: 400px;
|
||||
|
||||
&-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
&-footer {
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
margin-top: 15px;
|
||||
|
||||
.total-item {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
|
||||
&:first-of-type .label .iconfont-sys {
|
||||
color: #2b8dfa !important;
|
||||
background-color: #e6f7ff !important;
|
||||
}
|
||||
|
||||
&:last-of-type .label .iconfont-sys {
|
||||
color: #1cb8fc !important;
|
||||
background-color: #e6f7ff !important;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 60%;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
|
||||
.iconfont-sys {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
background-color: #f2f2f2;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
span {
|
||||
&:first-of-type {
|
||||
font-size: 16px;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #737791;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
|
||||
&.text-color-green {
|
||||
color: #2b8dfa !important;
|
||||
}
|
||||
|
||||
&.text-color-orange {
|
||||
color: #1cb8fc !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-notebook) {
|
||||
.custom-card {
|
||||
height: 350px;
|
||||
|
||||
&-body {
|
||||
padding-top: 10px;
|
||||
|
||||
> div {
|
||||
height: 140px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-footer {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.custom-card {
|
||||
&-footer {
|
||||
.total-item {
|
||||
&:first-of-type .label .iconfont-sys {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
|
||||
&:last-of-type .label .iconfont-sys {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card today-sales">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.todaySales.title') }}</span>
|
||||
<span class="subtitle">{{ t('analysis.todaySales.subtitle') }}</span>
|
||||
<div class="export-btn">
|
||||
<i class="iconfont-sys"></i>
|
||||
<span>{{ t('analysis.todaySales.export') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sales-summary">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6" :xs="24" v-for="(item, index) in salesData" :key="index">
|
||||
<div :class="['sales-card art-custom-card']">
|
||||
<i class="iconfont-sys" :class="item.class" v-html="item.iconfont"></i>
|
||||
<h2>
|
||||
<CountTo
|
||||
class="number box-title"
|
||||
:endVal="item.value"
|
||||
:duration="1000"
|
||||
separator=""
|
||||
></CountTo>
|
||||
</h2>
|
||||
<p>{{ item.label }}</p>
|
||||
<small>{{ item.change }} {{ t('analysis.todaySales.fromYesterday') }}</small>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { CountTo } from 'vue3-count-to'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const salesData = ref([
|
||||
{
|
||||
label: t('analysis.todaySales.cards.totalSales.label'),
|
||||
value: 999,
|
||||
change: t('analysis.todaySales.cards.totalSales.change'),
|
||||
iconfont: '',
|
||||
class: 'bg-primary'
|
||||
},
|
||||
{
|
||||
label: t('analysis.todaySales.cards.totalOrder.label'),
|
||||
value: 300,
|
||||
change: t('analysis.todaySales.cards.totalOrder.change'),
|
||||
iconfont: '',
|
||||
class: 'bg-warning'
|
||||
},
|
||||
{
|
||||
label: t('analysis.todaySales.cards.productSold.label'),
|
||||
value: 56,
|
||||
change: t('analysis.todaySales.cards.productSold.change'),
|
||||
iconfont: '',
|
||||
class: 'bg-error'
|
||||
},
|
||||
{
|
||||
label: t('analysis.todaySales.cards.newCustomers.label'),
|
||||
value: 68,
|
||||
change: t('analysis.todaySales.cards.newCustomers.change'),
|
||||
iconfont: '',
|
||||
class: 'bg-success'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.today-sales {
|
||||
height: 330px;
|
||||
|
||||
.export-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 66px;
|
||||
padding: 6px 0;
|
||||
color: var(--art-gray-600);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--art-border-dashed-color);
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
border-color: var(--main-color);
|
||||
}
|
||||
|
||||
.iconfont-sys {
|
||||
margin-right: 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.sales-summary {
|
||||
padding: 20px;
|
||||
|
||||
.sales-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 220px;
|
||||
padding: 0 20px;
|
||||
overflow: hidden;
|
||||
border-radius: calc(var(--custom-radius) / 2 + 4px) !important;
|
||||
|
||||
.iconfont-sys {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 20px;
|
||||
line-height: 48px;
|
||||
color: #fff;
|
||||
color: var(--el-color-primary);
|
||||
text-align: center;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 10px;
|
||||
font-size: 26px;
|
||||
font-weight: 400;
|
||||
color: var(--art-text-gray-900) !important;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
color: var(--art-text-gray-700) !important;
|
||||
|
||||
@include ellipsis;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: var(--art-text-gray-500) !important;
|
||||
|
||||
@include ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗黑模式降低颜色强度
|
||||
.dark {
|
||||
.today-sales {
|
||||
.sales-summary {
|
||||
.sales-card {
|
||||
.iconfont-sys {
|
||||
&.red,
|
||||
&.yellow,
|
||||
&.green,
|
||||
&.purple {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-notebook) {
|
||||
.today-sales {
|
||||
height: 280px;
|
||||
|
||||
.sales-summary {
|
||||
.sales-card {
|
||||
height: 170px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.today-sales {
|
||||
height: auto;
|
||||
|
||||
.sales-summary {
|
||||
padding-bottom: 0;
|
||||
|
||||
.sales-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card top-products">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.topProducts.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<art-table
|
||||
:data="products"
|
||||
style="width: 100%"
|
||||
:pagination="false"
|
||||
size="large"
|
||||
:border="false"
|
||||
:stripe="false"
|
||||
:show-header-background="false"
|
||||
>
|
||||
<el-table-column prop="name" :label="t('analysis.topProducts.columns.name')" width="200" />
|
||||
<el-table-column prop="popularity" :label="t('analysis.topProducts.columns.popularity')">
|
||||
<template #default="scope">
|
||||
<el-progress
|
||||
:percentage="scope.row.popularity"
|
||||
:color="getColor(scope.row.popularity)"
|
||||
:stroke-width="5"
|
||||
:show-text="false"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sales" :label="t('analysis.topProducts.columns.sales')" width="80">
|
||||
<template #default="scope">
|
||||
<span
|
||||
:style="{
|
||||
color: getColor(scope.row.popularity),
|
||||
backgroundColor: `rgba(${hexToRgb(getColor(scope.row.popularity))}, 0.08)`,
|
||||
border: '1px solid',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}"
|
||||
>{{ scope.row.sales }}</span
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</art-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { hexToRgb } from '@/utils/ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
// 使用 computed 来创建响应式的产品数据
|
||||
const products = computed(() => [
|
||||
{
|
||||
name: t('analysis.topProducts.products.homeDecor.name'),
|
||||
popularity: 10,
|
||||
sales: t('analysis.topProducts.products.homeDecor.sales')
|
||||
},
|
||||
{
|
||||
name: t('analysis.topProducts.products.disneyBag.name'),
|
||||
popularity: 29,
|
||||
sales: t('analysis.topProducts.products.disneyBag.sales')
|
||||
},
|
||||
{
|
||||
name: t('analysis.topProducts.products.bathroom.name'),
|
||||
popularity: 65,
|
||||
sales: t('analysis.topProducts.products.bathroom.sales')
|
||||
},
|
||||
{
|
||||
name: t('analysis.topProducts.products.smartwatch.name'),
|
||||
popularity: 32,
|
||||
sales: t('analysis.topProducts.products.smartwatch.sales')
|
||||
},
|
||||
{
|
||||
name: t('analysis.topProducts.products.fitness.name'),
|
||||
popularity: 78,
|
||||
sales: t('analysis.topProducts.products.fitness.sales')
|
||||
},
|
||||
{
|
||||
name: t('analysis.topProducts.products.earbuds.name'),
|
||||
popularity: 41,
|
||||
sales: t('analysis.topProducts.products.earbuds.sales')
|
||||
}
|
||||
])
|
||||
|
||||
const getColor = (percentage: number) => {
|
||||
if (percentage < 25) return '#00E096'
|
||||
if (percentage < 50) return '#0095FF'
|
||||
if (percentage < 75) return '#884CFF'
|
||||
return '#FE8F0E'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-card {
|
||||
height: 330px;
|
||||
overflow-y: scroll;
|
||||
|
||||
// 隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-body {
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.custom-card {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card total-revenue">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.totalRevenue.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<div ref="chartRef" style="height: 300px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { EChartsOption } from 'echarts'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
// 创建图表选项
|
||||
const options: () => EChartsOption = () => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 3,
|
||||
bottom: 40,
|
||||
left: 3,
|
||||
containLabel: true
|
||||
},
|
||||
legend: {
|
||||
data: [
|
||||
t('analysis.totalRevenue.legend.onlineSales'),
|
||||
t('analysis.totalRevenue.legend.offlineSales')
|
||||
],
|
||||
bottom: 0,
|
||||
icon: 'circle',
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemGap: 15,
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
color: isDark.value ? '#808290' : '#222B45'
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
||||
axisLine: {
|
||||
show: false
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: isDark.value ? '#808290' : '#7B91B0'
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : '#EFF1F3',
|
||||
width: 0.8
|
||||
}
|
||||
},
|
||||
axisLabel: { color: isDark.value ? '#808290' : '#7B91B0' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('analysis.totalRevenue.legend.onlineSales'),
|
||||
type: 'bar',
|
||||
data: [12, 13, 5, 15, 10, 15, 18],
|
||||
barWidth: '15',
|
||||
itemStyle: {
|
||||
color: '#0095FF',
|
||||
borderRadius: [4, 4, 4, 4]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t('analysis.totalRevenue.legend.offlineSales'),
|
||||
type: 'bar',
|
||||
data: [10, 11, 20, 5, 11, 13, 10],
|
||||
barWidth: '15',
|
||||
itemStyle: {
|
||||
color: '#95E0FB',
|
||||
borderRadius: [4, 4, 4, 4]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-card {
|
||||
height: 400px;
|
||||
|
||||
&-body {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-notebook) {
|
||||
.custom-card {
|
||||
height: 350px;
|
||||
|
||||
&-body {
|
||||
> div {
|
||||
height: 260px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card visitor-insights">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.visitorInsights.title') }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div ref="chartRef" style="height: 250px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { EChartsOption } from 'echarts'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: width.value < 600 ? 80 : 40,
|
||||
left: 20,
|
||||
containLabel: true
|
||||
},
|
||||
legend: {
|
||||
data: [
|
||||
t('analysis.visitorInsights.legend.loyalCustomers'),
|
||||
t('analysis.visitorInsights.legend.newCustomers')
|
||||
],
|
||||
bottom: 0,
|
||||
left: 'center',
|
||||
itemWidth: 14,
|
||||
itemHeight: 14,
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
color: isDark.value ? '#808290' : '#222B45'
|
||||
},
|
||||
icon: 'roundRect',
|
||||
itemStyle: {
|
||||
borderRadius: 4
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: isDark.value ? '#808290' : '#7B91B0' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : '#EFF1F3',
|
||||
width: 0.8
|
||||
}
|
||||
},
|
||||
axisLabel: { color: isDark.value ? '#808290' : '#7B91B0' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('analysis.visitorInsights.legend.loyalCustomers'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
data: [260, 200, 150, 130, 180, 270, 340, 380, 300, 220, 170, 130],
|
||||
lineStyle: {
|
||||
color: '#2B8DFA',
|
||||
width: 3
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#2B8DFA'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t('analysis.visitorInsights.legend.newCustomers'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
data: [280, 350, 300, 250, 230, 210, 240, 280, 320, 350, 300, 200],
|
||||
lineStyle: {
|
||||
color: '#49BEFF',
|
||||
width: 3
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#49BEFF'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.visitor-insights {
|
||||
height: 330px;
|
||||
}
|
||||
|
||||
@media (max-width: $device-notebook) {
|
||||
.visitor-insights {
|
||||
height: 280px;
|
||||
|
||||
.card-body {
|
||||
> div {
|
||||
height: 210px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-phone) {
|
||||
.visitor-insights {
|
||||
height: 315px;
|
||||
|
||||
.card-body {
|
||||
> div {
|
||||
height: 240px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="custom-card art-custom-card volume-service-level">
|
||||
<div class="custom-card-header">
|
||||
<span class="title">{{ t('analysis.volumeServiceLevel.title') }}</span>
|
||||
</div>
|
||||
<div class="custom-card-body">
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { EChartsOption } from 'echarts'
|
||||
const { t } = useI18n()
|
||||
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
// 模拟数据
|
||||
const chartData = [
|
||||
{ volume: 800, services: 400 },
|
||||
{ volume: 1000, services: 600 },
|
||||
{ volume: 750, services: 300 },
|
||||
{ volume: 600, services: 250 },
|
||||
{ volume: 450, services: 200 },
|
||||
{ volume: 500, services: 300 }
|
||||
]
|
||||
|
||||
const options: () => EChartsOption = () => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: [
|
||||
t('analysis.volumeServiceLevel.legend.volume'),
|
||||
t('analysis.volumeServiceLevel.legend.services')
|
||||
],
|
||||
bottom: 20,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
icon: 'circle',
|
||||
textStyle: {
|
||||
fontSize: 12,
|
||||
color: isDark.value ? '#808290' : '#222B45'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '20',
|
||||
right: '20',
|
||||
bottom: '60',
|
||||
top: '30',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.map((_, index) => `${index + 1}`),
|
||||
axisLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : '#EFF1F3',
|
||||
width: 0.8
|
||||
}
|
||||
},
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('analysis.volumeServiceLevel.legend.volume'),
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: chartData.map((item) => item.volume),
|
||||
itemStyle: {
|
||||
color: '#0095FF',
|
||||
borderRadius: [0, 0, 4, 4]
|
||||
},
|
||||
barWidth: '15'
|
||||
},
|
||||
{
|
||||
name: t('analysis.volumeServiceLevel.legend.services'),
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: chartData.map((item) => item.services),
|
||||
itemStyle: {
|
||||
color: '#95E0FB',
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
},
|
||||
barWidth: '50%'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.volume-service-level {
|
||||
height: 330px;
|
||||
|
||||
.custom-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="ecommerce">
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="24" :lg="16">
|
||||
<Banner />
|
||||
</el-col>
|
||||
<el-col :sm="12" :md="12" :lg="4">
|
||||
<TotalOrderVolume />
|
||||
</el-col>
|
||||
<el-col :sm="12" :md="12" :lg="4">
|
||||
<TotalProducts />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="12" :md="12" :lg="8">
|
||||
<SalesTrend />
|
||||
</el-col>
|
||||
<el-col :sm="12" :md="12" :lg="8">
|
||||
<SalesClassification />
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="24" :lg="8">
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12" :lg="12">
|
||||
<ProductSales />
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12" :lg="12">
|
||||
<SalesGrowth />
|
||||
</el-col>
|
||||
<el-col :span="24" class="no-margin-bottom">
|
||||
<CartConversionRate />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :sm="24" :md="12" :lg="8">
|
||||
<HotCommodity />
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="12" :lg="8">
|
||||
<AnnualSales />
|
||||
</el-col>
|
||||
<el-col :sm="24" :md="24" :lg="8">
|
||||
<TransactionList />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :md="24" :lg="8">
|
||||
<RecentTransaction />
|
||||
</el-col>
|
||||
<el-col :md="24" :lg="16" class="no-margin-bottom">
|
||||
<HotProductsList />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Banner from './widget/Banner.vue'
|
||||
import TotalOrderVolume from './widget/TotalOrderVolume.vue'
|
||||
import TotalProducts from './widget/TotalProducts.vue'
|
||||
import SalesTrend from './widget/SalesTrend.vue'
|
||||
import SalesClassification from './widget/SalesClassification.vue'
|
||||
import TransactionList from './widget/TransactionList.vue'
|
||||
import HotCommodity from './widget/HotCommodity.vue'
|
||||
import RecentTransaction from './widget/RecentTransaction.vue'
|
||||
import AnnualSales from './widget/AnnualSales.vue'
|
||||
import ProductSales from './widget/ProductSales.vue'
|
||||
import SalesGrowth from './widget/SalesGrowth.vue'
|
||||
import CartConversionRate from './widget/CartConversionRate.vue'
|
||||
import HotProductsList from './widget/HotProductsList.vue'
|
||||
|
||||
defineOptions({ name: 'Ecommerce' })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
.ecommerce {
|
||||
:deep(.card) {
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: var(--custom-radius);
|
||||
|
||||
.card-header {
|
||||
padding-bottom: 15px;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-900);
|
||||
|
||||
i {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.icon-text-widget) {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-right: 10px;
|
||||
line-height: 42px;
|
||||
color: var(--main-color);
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 8px;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
p {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-margin-bottom {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.el-col {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="card art-custom-card yearly-card" style="height: 28.2rem">
|
||||
<div class="card-header">
|
||||
<p class="title">年度销售额</p>
|
||||
<p class="subtitle">按季度统计</p>
|
||||
</div>
|
||||
|
||||
<ArtBarChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 80, 50, 90, 60, 70, 50]"
|
||||
barWidth="26px"
|
||||
height="16rem"
|
||||
/>
|
||||
<div class="icon-text-widget" style="margin-top: 50px">
|
||||
<div class="item">
|
||||
<div class="icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>¥200,858</p>
|
||||
<span>线上销售</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>¥102,927</p>
|
||||
<span>线下销售</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<ArtBasicBanner
|
||||
class="banner"
|
||||
height="13.3rem"
|
||||
:title="`欢迎回来 ${userInfo.userName}`"
|
||||
backgroundColor="var(--el-color-primary-light-9)"
|
||||
titleColor="var(--art-gray-900)"
|
||||
:decoration="false"
|
||||
:meteorConfig="{
|
||||
enabled: true,
|
||||
count: 10
|
||||
}"
|
||||
:buttonConfig="{
|
||||
show: false,
|
||||
text: ''
|
||||
}"
|
||||
:imageConfig="{
|
||||
src: bannerCover,
|
||||
width: '18rem',
|
||||
bottom: '-7.5rem'
|
||||
}"
|
||||
@click="handleBannerClick"
|
||||
>
|
||||
<div class="banner-slot">
|
||||
<div class="item">
|
||||
<p class="title">¥2,340<i class="iconfont-sys text-success"></i></p>
|
||||
<p class="subtitle">今日销售额</p>
|
||||
</div>
|
||||
<div class="item">
|
||||
<p class="title">35%<i class="iconfont-sys text-success"></i></p>
|
||||
<p class="subtitle">较昨日</p>
|
||||
</div>
|
||||
</div>
|
||||
</ArtBasicBanner>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import bannerCover from '@imgs/login/lf_icon2.webp'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
const userStore = useUserStore()
|
||||
|
||||
const userInfo = computed(() => userStore.getUserInfo)
|
||||
|
||||
const handleBannerClick = () => {}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.banner {
|
||||
justify-content: center;
|
||||
|
||||
.banner-slot {
|
||||
display: flex;
|
||||
margin-top: 24px;
|
||||
|
||||
.item {
|
||||
margin-right: 30px;
|
||||
|
||||
&:first-of-type {
|
||||
padding-right: 30px;
|
||||
border-right: 1px solid var(--art-gray-300);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 30px;
|
||||
color: var(--art-gray-900) !important;
|
||||
|
||||
i {
|
||||
position: relative;
|
||||
top: -10px;
|
||||
margin-left: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-700) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<ArtLineChartCard
|
||||
class="margin-bottom-0"
|
||||
:value="2545"
|
||||
label="购物车转化率"
|
||||
:percentage="1.2"
|
||||
:height="13.5"
|
||||
:chartData="[120, 132, 101, 134, 90, 230, 210]"
|
||||
:showAreaColor="true"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="card art-custom-card weekly-card" style="height: 28.2rem">
|
||||
<div class="card-header">
|
||||
<p class="title">热销商品</p>
|
||||
<p class="subtitle">本周销售排行</p>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:showAreaColor="true"
|
||||
:data="[8, 40, 82, 35, 90, 52, 35]"
|
||||
height="9rem"
|
||||
/>
|
||||
<div class="content">
|
||||
<div class="item" v-for="item in weeklyList" :key="item.title">
|
||||
<div class="icon" :class="item.color">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="text">
|
||||
<p class="title">{{ item.title }}</p>
|
||||
<span class="subtitle">{{ item.subtitle }}</span>
|
||||
</div>
|
||||
<div class="value" :class="item.color">
|
||||
<span>+{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const weeklyList = [
|
||||
{
|
||||
icon: '',
|
||||
title: '智能手表Pro',
|
||||
subtitle: '电子产品',
|
||||
value: '1,286件',
|
||||
color: 'bg-primary'
|
||||
},
|
||||
{
|
||||
icon: '',
|
||||
title: '时尚连衣裙',
|
||||
subtitle: '女装服饰',
|
||||
value: '892件',
|
||||
color: 'bg-success'
|
||||
},
|
||||
{
|
||||
icon: '',
|
||||
title: '厨房小家电',
|
||||
subtitle: '家居用品',
|
||||
value: '756件',
|
||||
color: 'bg-error'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.weekly-card {
|
||||
.content {
|
||||
margin-top: 40px;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
|
||||
.icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
line-height: 42px;
|
||||
text-align: center;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 8px;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-left: 10px;
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-800);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
padding: 6px 12px;
|
||||
margin-left: auto;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 27.8rem">
|
||||
<div class="card-header">
|
||||
<p class="title">热销产品</p>
|
||||
<p class="subtitle">本月销售情况</p>
|
||||
</div>
|
||||
<div class="table">
|
||||
<el-scrollbar style="height: 21.55rem">
|
||||
<art-table
|
||||
:data="tableData"
|
||||
:pagination="false"
|
||||
style="margin-top: 0 !important"
|
||||
size="large"
|
||||
:border="false"
|
||||
:stripe="false"
|
||||
:show-header-background="false"
|
||||
>
|
||||
<template #default>
|
||||
<el-table-column label="产品" prop="product" width="220px">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; align-items: center">
|
||||
<img class="product-image" :src="scope.row.image" />
|
||||
<div class="product-info">
|
||||
<div class="product-name">{{ scope.row.name }}</div>
|
||||
<div class="product-category">{{ scope.row.category }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" prop="price">
|
||||
<template #default="scope">
|
||||
<span class="price">¥{{ scope.row.price.toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" prop="stock">
|
||||
<template #default="scope">
|
||||
<div class="stock-badge" :class="getStockClass(scope.row.stock)">
|
||||
{{ getStockStatus(scope.row.stock) }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="销量" prop="sales" />
|
||||
<el-table-column label="销售趋势" width="240">
|
||||
<template #default="scope">
|
||||
<el-progress
|
||||
:percentage="scope.row.pro"
|
||||
:color="scope.row.color"
|
||||
:stroke-width="4"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</art-table>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
|
||||
// 导入产品图片
|
||||
import product1 from '@/assets/img/3d/icon1.webp'
|
||||
import product2 from '@/assets/img/3d/icon2.webp'
|
||||
import product3 from '@/assets/img/3d/icon3.webp'
|
||||
import product4 from '@/assets/img/3d/icon4.webp'
|
||||
import product5 from '@/assets/img/3d/icon5.webp'
|
||||
import product6 from '@/assets/img/3d/icon6.webp'
|
||||
|
||||
const tableData = reactive([
|
||||
{
|
||||
name: '智能手表 Pro',
|
||||
category: '电子设备',
|
||||
price: 1299,
|
||||
stock: 156,
|
||||
sales: 423,
|
||||
percentage: 75,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-primary)) !important',
|
||||
image: product1
|
||||
},
|
||||
{
|
||||
name: '无线蓝牙耳机',
|
||||
category: '音频设备',
|
||||
price: 499,
|
||||
stock: 89,
|
||||
sales: 652,
|
||||
percentage: 85,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-success)) !important',
|
||||
image: product2
|
||||
},
|
||||
{
|
||||
name: '机械键盘',
|
||||
category: '电脑配件',
|
||||
price: 399,
|
||||
stock: 12,
|
||||
sales: 238,
|
||||
percentage: 45,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-warning)) !important',
|
||||
image: product3
|
||||
},
|
||||
{
|
||||
name: '超薄笔记本电脑',
|
||||
category: '电子设备',
|
||||
price: 5999,
|
||||
stock: 0,
|
||||
sales: 126,
|
||||
percentage: 30,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-error)) !important',
|
||||
image: product4
|
||||
},
|
||||
{
|
||||
name: '智能音箱',
|
||||
category: '智能家居',
|
||||
price: 799,
|
||||
stock: 45,
|
||||
sales: 321,
|
||||
percentage: 60,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-info)) !important',
|
||||
image: product5
|
||||
},
|
||||
{
|
||||
name: '游戏手柄',
|
||||
category: '游戏配件',
|
||||
price: 299,
|
||||
stock: 78,
|
||||
sales: 489,
|
||||
percentage: 70,
|
||||
pro: 0,
|
||||
color: 'rgb(var(--art-secondary)) !important',
|
||||
image: product6
|
||||
}
|
||||
])
|
||||
|
||||
// 根据库存获取状态文本
|
||||
const getStockStatus = (stock: number) => {
|
||||
if (stock === 0) return '缺货'
|
||||
if (stock < 20) return '低库存'
|
||||
if (stock < 50) return '适中'
|
||||
return '充足'
|
||||
}
|
||||
|
||||
// 根据库存获取状态类名
|
||||
const getStockClass = (stock: number) => {
|
||||
if (stock === 0) return 'out-of-stock'
|
||||
if (stock < 20) return 'low-stock'
|
||||
if (stock < 50) return 'medium-stock'
|
||||
return 'in-stock'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addAnimation()
|
||||
})
|
||||
|
||||
const addAnimation = () => {
|
||||
setTimeout(() => {
|
||||
for (let i = 0; i < tableData.length; i++) {
|
||||
let item = tableData[i]
|
||||
tableData[i].pro = item.percentage
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.table {
|
||||
width: 100%;
|
||||
|
||||
.card-header {
|
||||
padding-left: 25px !important;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-category {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stock-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.in-stock {
|
||||
color: rgb(var(--art-success));
|
||||
background-color: rgba(var(--art-success-rgb), 0.1);
|
||||
}
|
||||
|
||||
.medium-stock {
|
||||
color: rgb(var(--art-info));
|
||||
background-color: rgba(var(--art-info-rgb), 0.1);
|
||||
}
|
||||
|
||||
.low-stock {
|
||||
color: rgb(var(--art-warning));
|
||||
background-color: rgba(var(--art-warning-rgb), 0.1);
|
||||
}
|
||||
|
||||
.out-of-stock {
|
||||
color: rgb(var(--art-error));
|
||||
background-color: rgba(var(--art-error-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 11rem">
|
||||
<div class="card-header">
|
||||
<p class="title" style="font-size: 24px"
|
||||
>14.5k<i class="iconfont-sys text-success"></i></p
|
||||
>
|
||||
<p class="subtitle">销售量</p>
|
||||
</div>
|
||||
|
||||
<ArtBarChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 80, 50, 90, 60, 70, 50]"
|
||||
barWidth="16px"
|
||||
height="4rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<ArtTimelineListCard :list="timelineData" title="最近交易" subtitle="今日订单动态" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const timelineData = [
|
||||
{
|
||||
time: '上午 09:30',
|
||||
status: 'rgb(73, 190, 255)',
|
||||
content: '收到订单 #38291 支付 ¥385.90'
|
||||
},
|
||||
{
|
||||
time: '上午 10:00',
|
||||
status: 'rgb(54, 158, 255)',
|
||||
content: '新商品上架',
|
||||
code: 'SKU-3467'
|
||||
},
|
||||
{
|
||||
time: '上午 12:00',
|
||||
status: 'rgb(103, 232, 207)',
|
||||
content: '向供应商支付了 ¥6495.00'
|
||||
},
|
||||
{
|
||||
time: '下午 14:30',
|
||||
status: 'rgb(255, 193, 7)',
|
||||
content: '促销活动开始',
|
||||
code: 'PROMO-2023'
|
||||
},
|
||||
{
|
||||
time: '下午 15:45',
|
||||
status: 'rgb(255, 105, 105)',
|
||||
content: '订单取消提醒',
|
||||
code: 'ORD-9876'
|
||||
},
|
||||
{
|
||||
time: '下午 17:00',
|
||||
status: 'rgb(103, 232, 207)',
|
||||
content: '完成日销售报表'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="card art-custom-card sales-card" style="height: 26rem">
|
||||
<div class="card-header">
|
||||
<p class="title">销售分类</p>
|
||||
<p class="subtitle">按产品类别</p>
|
||||
</div>
|
||||
<ArtRingChart
|
||||
:data="[
|
||||
{ value: 30, name: '电子产品' },
|
||||
{ value: 55, name: '服装鞋包' },
|
||||
{ value: 36, name: '家居用品' }
|
||||
]"
|
||||
:color="['#4C87F3', '#EDF2FF', '#8BD8FC']"
|
||||
:radius="['70%', '80%']"
|
||||
height="16.5rem"
|
||||
:showLabel="false"
|
||||
:borderRadius="0"
|
||||
centerText="¥300,458"
|
||||
/>
|
||||
<div class="icon-text-widget">
|
||||
<div class="item">
|
||||
<div class="icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>¥500,458</p>
|
||||
<span>总收入</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>¥130,580</p>
|
||||
<span>净利润</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 11rem">
|
||||
<div class="card-header">
|
||||
<p class="title" style="font-size: 24px"
|
||||
>12%<i class="iconfont-sys text-success"></i></p
|
||||
>
|
||||
<p class="subtitle">增长</p>
|
||||
</div>
|
||||
|
||||
<ArtLineChart
|
||||
:showAreaColor="true"
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 85, 65, 95, 75, 130, 180]"
|
||||
barWidth="16px"
|
||||
height="4rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 26rem">
|
||||
<div class="card-header">
|
||||
<p class="title">销售趋势</p>
|
||||
<p class="subtitle">月度销售对比</p>
|
||||
</div>
|
||||
<ArtDualBarCompareChart
|
||||
:topData="[50, 80, 120, 90, 60]"
|
||||
:bottomData="[30, 60, 90, 70, 40]"
|
||||
:xAxisData="['一月', '二月', '三月', '四月', '五月']"
|
||||
height="18rem"
|
||||
:barWidth="16"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 13.3rem">
|
||||
<div class="card-header">
|
||||
<p class="title" style="font-size: 24px">205,216</p>
|
||||
<p class="subtitle">总订单量</p>
|
||||
</div>
|
||||
<ArtRingChart
|
||||
:data="[
|
||||
{ value: 30, name: '已完成' },
|
||||
{ value: 25, name: '处理中' },
|
||||
{ value: 45, name: '待发货' }
|
||||
]"
|
||||
:color="['#4C87F3', '#93F1B4', '#8BD8FC']"
|
||||
:radius="['56%', '76%']"
|
||||
height="7rem"
|
||||
:showLabel="false"
|
||||
:borderRadius="0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="card art-custom-card" style="height: 13.3rem">
|
||||
<div class="card-header">
|
||||
<p class="title" style="font-size: 24px">55,231</p>
|
||||
<p class="subtitle">商品总数</p>
|
||||
</div>
|
||||
<ArtBarChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 80, 40, 90, 60, 70]"
|
||||
height="7rem"
|
||||
barWidth="18px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<ArtDataListCard
|
||||
:maxCount="4"
|
||||
:list="dataList"
|
||||
title="最近活动"
|
||||
subtitle="订单处理状态"
|
||||
:showMoreButton="true"
|
||||
@more="handleMore"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const dataList = [
|
||||
{
|
||||
title: '新订单 #38291',
|
||||
status: '待处理',
|
||||
time: '5分钟',
|
||||
class: 'bg-primary',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
title: '退款申请 #12845',
|
||||
status: '处理中',
|
||||
time: '10分钟',
|
||||
class: 'bg-secondary',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
title: '客户投诉处理',
|
||||
status: '待处理',
|
||||
time: '15分钟',
|
||||
class: 'bg-warning',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
title: '库存不足提醒',
|
||||
status: '紧急',
|
||||
time: '20分钟',
|
||||
class: 'bg-danger',
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
title: '订单 #29384 已发货',
|
||||
status: '已完成',
|
||||
time: '20分钟',
|
||||
class: 'bg-success',
|
||||
icon: ''
|
||||
}
|
||||
]
|
||||
|
||||
const handleMore = () => {}
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<ArtException
|
||||
:data="{
|
||||
title: '403',
|
||||
desc: $t('exceptionPage.403'),
|
||||
btnText: $t('exceptionPage.gohome'),
|
||||
imgUrl
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import imgUrl from '@imgs/state/403.png'
|
||||
defineOptions({ name: 'Exception403' })
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<ArtException
|
||||
:data="{
|
||||
title: '404',
|
||||
desc: $t('exceptionPage.404'),
|
||||
btnText: $t('exceptionPage.gohome'),
|
||||
imgUrl
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import imgUrl from '@imgs/state/404.png'
|
||||
defineOptions({ name: 'Exception404' })
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<ArtException
|
||||
:data="{
|
||||
title: '500',
|
||||
desc: $t('exceptionPage.500'),
|
||||
btnText: $t('exceptionPage.gohome'),
|
||||
imgUrl
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import imgUrl from '@imgs/state/500.png'
|
||||
defineOptions({ name: 'Exception500' })
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<ArtLayouts>
|
||||
<!-- 顶栏、水平/混合菜单 -->
|
||||
<ArtHeaderBar />
|
||||
<!-- 左侧/双列菜单 -->
|
||||
<ArtSidebarMenu />
|
||||
<!-- 页面内容 -->
|
||||
<ArtPageContent />
|
||||
<!-- 设置面板 -->
|
||||
<ArtSettingsPanel />
|
||||
<!-- 全局搜索 -->
|
||||
<ArtGlobalSearch />
|
||||
<!-- 屏幕锁定 -->
|
||||
<ArtScreenLock />
|
||||
<!-- 聊天窗口 -->
|
||||
<ArtChatWindow />
|
||||
<!-- 礼花效果 -->
|
||||
<ArtFireworksEffect />
|
||||
<!-- 水印效果 -->
|
||||
<ArtWatermark />
|
||||
</ArtLayouts>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
@use '@/assets/styles/variables' as *;
|
||||
|
||||
.layouts {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--art-bg-color);
|
||||
transition: padding 0.3s ease-in-out;
|
||||
|
||||
.layout-content {
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 40px);
|
||||
margin: auto;
|
||||
|
||||
// 子页面默认style
|
||||
:deep(.page-content) {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
background: var(--art-main-bg-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad) {
|
||||
.layouts {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding-left: 0 !important;
|
||||
overflow-y: scroll;
|
||||
|
||||
.layout-content {
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-phone) {
|
||||
.layouts {
|
||||
.layout-content {
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="iframe-container" v-loading="isLoading">
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
:src="iframeUrl"
|
||||
frameborder="0"
|
||||
class="iframe-content"
|
||||
@load="handleIframeLoad"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getIframeRoutes } from '@/router/utils/menuToRouter'
|
||||
|
||||
const route = useRoute()
|
||||
const isLoading = ref(true)
|
||||
const iframeUrl = ref('')
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
const iframeRoute = getIframeRoutes().find((item: any) => item.path === route.path)
|
||||
|
||||
if (iframeRoute?.meta) {
|
||||
iframeUrl.value = iframeRoute.meta.link || ''
|
||||
}
|
||||
})
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.iframe-container {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.iframe-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - 120px);
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user