762 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed, ref, watch, h } from 'vue'
import {
NButton,
NTag,
NTabs,
NTabPane,
NSpin,
NImage,
NImageGroup,
NDataTable,
} from 'naive-ui'
import { marked } from 'marked'
import { formatDate } from '@/utils'
import TheIcon from '@/components/icon/TheIcon.vue'
import CertificateModal from './CertificateModal.vue'
import api from '@/api'
import { useMessage } from 'naive-ui'
import { getStatusConfig } from '../constants'
import {
formatAgeDistribution,
formatAmount,
formatHistoricalEvidence,
formatPercent,
formatPlatformAccounts,
formatPriceRange,
formatThreeYearIncome,
formatNumberValue,
formatEnumByKey,
} from '../utils'
import { mockReportMarkdown } from './mockData.js'
const props = defineProps({
loading: { type: Boolean, default: false },
detailData: { type: Object, default: null },
mode: { type: String, default: 'view' },
})
const emit = defineEmits(['back', 'approve', 'reject'])
const $message = useMessage()
const activeDetailTab = ref('audit')
const reportLoading = ref(false)
const reportContent = ref('')
const pickFilledValue = (...values) => values.find((val) => val !== undefined && val !== null && val !== '')
const formatEnumField = (key, ...values) => formatEnumByKey(pickFilledValue(...values), key)
// 证书弹窗相关状态
const certificateModalVisible = ref(false)
const certificateModalMode = ref('upload') // 'upload' 或 'view'
const certificateData = ref({})
watch(
() => props.detailData?.id,
() => {
activeDetailTab.value = 'audit'
reportContent.value = ''
}
)
// 监听 tab 切换,当切换到计算流程时加载报告
watch(activeDetailTab, async (newTab) => {
if (newTab === 'flow' && props.detailData?.id && !reportContent.value) {
await fetchReport()
}
})
// 获取报告内容
const fetchReport = async () => {
if (!props.detailData?.id) return
reportLoading.value = true
try {
const response = await api.getValuationReport({ valuation_id: props.detailData.id })
reportContent.value = response.data || response || ''
} catch (error) {
console.error('获取报告失败:', error)
$message.error('获取报告失败')
reportContent.value = '# 获取报告失败\n\n请稍后重试'
} finally {
reportLoading.value = false
}
}
const detailSections = computed(() => {
const detail = props.detailData
if (!detail) return []
const sections = [
{
key: 'basic',
title: '基础信息',
fields: [
{ label: '资产名称', type: 'text', value: detail.asset_name || '-' },
{ label: '所属机构/权利人', type: 'text', value: detail.institution || '-' },
{ label: '统一社会信用代码/身份证号', type: 'text', value: detail.credit_code_or_id || '-' },
{ label: '所属行业', type: 'text', value: detail.industry || '-' },
{ label: '业务/传承介绍', type: 'text', value: detail.biz_intro || '-' },
],
},
{
key: 'finance',
title: '财务状况',
fields: [
{ label: '近12个月机构营收/万元', type: 'text', value: formatNumberValue(detail.annual_revenue) },
{ label: '近12个月机构研发投入/万元', type: 'text', value: formatNumberValue(detail.rd_investment) },
{ label: '近三年机构收益/万元', type: 'list', value: formatThreeYearIncome(detail.three_year_income) },
{ label: '资产受资助情况', type: 'text', value: formatEnumField('fundingStatus', detail.funding_status) },
],
},
{
key: 'tech',
title: '非遗等级与技术',
fields: [
{ label: '非遗传承人等级', type: 'text', value: formatEnumField('inheritorLevel', detail.inheritor_level) },
{
label: '非遗传承人年龄水平及数量',
type: 'list',
value: formatAgeDistribution(detail.inheritor_age_count || detail.inheritor_ages),
},
{ label: '非遗传承人等级证书', type: 'images', value: detail.inheritor_certificates || [] },
{
label: '非遗等级',
type: 'text',
value: formatEnumField('heritageLevel', detail.heritage_level, detail.heritage_asset_level),
},
{ label: '非遗资产所用专利的申请号', type: 'text', value: detail.patent_application_no || '-' },
{ label: '非遗资产历史证明证据及数量', type: 'list', value: formatHistoricalEvidence(detail.historical_evidence) },
{
label: '非遗资产所用专利/纹样图片',
type: 'images',
value: [...(detail.patent_certificates || []), ...(detail.pattern_images || [])],
},
],
},
{
key: 'promotion',
title: '非遗应用与推广',
fields: [
{
label: '非遗资产应用成熟度',
type: 'text',
value: formatEnumField('applicationMaturity', detail.application_maturity, detail.implementation_stage),
},
{
label: '非遗资产应用覆盖范围',
type: 'text',
value: formatEnumField('applicationCoverage', detail.application_coverage, detail.coverage_area),
},
{
label: '非遗资产跨界合作深度',
type: 'text',
value: formatEnumField('cooperationDepth', detail.cooperation_depth, detail.collaboration_type),
},
{
label: '近12个月线下相关宣讲活动次数',
type: 'text',
value: formatNumberValue(detail.offline_activities ?? detail.offline_teaching_count),
},
{ label: '线上相关宣传账号信息', type: 'list', value: formatPlatformAccounts(detail.platform_accounts) },
],
},
{
key: 'products',
title: '非遗资产衍生商品信息',
fields: [
{ label: '代表产品近12个月销售数量', type: 'text', value: formatNumberValue(detail.sales_volume) },
{ label: '商品链接浏览量', type: 'text', value: formatNumberValue(detail.link_views) },
{ label: '发行量', type: 'text', value: formatEnumField('circulation', detail.circulation, detail.scarcity_level) },
{
label: '最近一次市场活动时间',
type: 'text',
value: formatEnumField('marketActivity', detail.last_market_activity, detail.market_activity_time),
},
{
label: '月交易额水平',
type: 'text',
value: formatEnumField('monthlyTransaction', detail.monthly_transaction, detail.monthly_transaction_amount),
},
{ label: '近30天价格区间', type: 'text', value: formatPriceRange(detail.price_fluctuation) },
],
},
]
// 为每个 section 生成 NDataTable 需要的 columns 和 data
return sections.map(section => {
const columns = [
{
title: '字段名',
key: 'fieldName',
width: 120,
align: 'center',
fixed: 'left',
},
...section.fields.map(field => ({
title: field.label,
key: field.label,
width: 200,
ellipsis: field.type === 'list' ? false : {
tooltip: {
style: { maxWidth: '600px', maxHeight: '400px', overflow: 'auto' }
},
},
render: (row) => {
const fieldData = row[field.label]
if (!fieldData) return '-'
if (fieldData.type === 'list') {
if (fieldData.value && fieldData.value.length) {
return h('div', { style: 'display: flex; flex-direction: column; gap: 4px;' },
fieldData.value.map(item => h('span', item))
)
}
return '-'
} else if (fieldData.type === 'images') {
if (fieldData.value && fieldData.value.length) {
return h(NImageGroup, {}, () =>
fieldData.value.map(img =>
h(NImage, {
src: img,
width: 72,
height: 48,
objectFit: 'cover',
style: 'margin-right: 8px;'
})
)
)
}
return '-'
} else {
return fieldData.value || '-'
}
}
})),
]
const data = [
{
fieldName: '用户输入',
...section.fields.reduce((acc, field) => {
acc[field.label] = field
return acc
}, {}),
},
]
return {
...section,
columns,
data,
}
})
})
const calcFlow = computed(() => props.detailData?.calculation_result?.flow || [])
const renderedFlowHtml = computed(() => {
return marked.parse(reportContent.value || mockReportMarkdown)
})
// 证书相关功能
const handleUploadCertificate = () => {
certificateModalMode.value = 'upload'
certificateData.value = {
detailData: props.detailData
}
certificateModalVisible.value = true
}
const handleViewCertificate = () => {
certificateModalMode.value = 'view'
const formatFiles = (urlData) => {
if (!urlData) return []
// Handle string (single or comma-separated)
const urls = typeof urlData === 'string' ? urlData.split(',') : (Array.isArray(urlData) ? urlData : [])
return urls.filter(u => u).map((url, index) => ({
id: String(index),
name: url.substring(url.lastIndexOf('/') + 1) || 'unknown',
status: 'finished',
url: url
}))
}
certificateData.value = {
reportFiles: formatFiles(props.detailData?.report_url),
certificateFiles: formatFiles(props.detailData?.certificate_url),
detailData: props.detailData
}
certificateModalVisible.value = true
}
const handleCertificateConfirm = async (data) => {
console.log('证书数据:', data)
try {
const certificateUrl = data.certificateFiles?.map(f => f.url).filter(Boolean) || []
const reportUrl = data.reportFiles?.map(f => f.url).filter(Boolean) || []
// 现在改为只能上传 1 张
const payload = {
...props.detailData,
certificate_url: certificateUrl?.[0],
report_url: reportUrl?.[0],
status: 'success'
}
console.log("🔥🔥🔥🔥🔥🔥🔥 ~ handleCertificateConfirm ~ payload:", payload);
await api.updateValuation(payload)
$message.success('上传并通知成功')
certificateModalVisible.value = false
emit('back') // 或者 emit('refresh') 取决于需求,这里假设返回列表或刷新
} catch (error) {
console.error('更新失败:', error)
$message.error('操作失败')
}
}
</script>
<template>
<div class="audit-detail">
<div class="detail-header">
<div>
<button type="button" class="back-btn" @click="emit('back')">
<TheIcon icon="mdi:arrow-left" :size="16" class="mr-4" />
返回审核列表
</button>
<!-- <div class="detail-title">
<h2>{{ detailData?.asset_name || '审核详情' }}</h2>
<NTag size="small" :type="getStatusConfig(detailData?.status).type">
{{ getStatusConfig(detailData?.status).text }}
</NTag>
</div>
<p class="detail-meta">
<span>手机号{{ detailData?.phone || '-' }}</span>
<span>微信号{{ detailData?.wechat || '-' }}</span>
<span>提交时间{{ formatDate(detailData?.created_at) }}</span>
<span>审核时间{{ detailData?.reviewed_at ? formatDate(detailData?.reviewed_at) : '-' }}</span>
</p> -->
</div>
</div>
<NTabs
v-model:value="activeDetailTab"
type="line"
size="large"
class="audit-tabs"
>
<NTabPane name="audit" tab="审核信息">
<NSpin :show="loading">
<div v-for="section in detailSections" :key="section.key" class="detail-section">
<div class="section-title">
<span class="dot" />
<span>{{ section.title }}</span>
</div>
<NDataTable
:columns="section.columns"
:data="section.data"
:bordered="true"
:single-line="false"
:scroll-x="section.fields.length * 200 + 120"
>
<template #empty>
<span>暂无数据</span>
</template>
</NDataTable>
</div>
</NSpin>
</NTabPane>
<NTabPane name="flow" tab="计算流程">
<NSpin :show="reportLoading">
<div class="markdown-body" v-html="renderedFlowHtml"></div>
</NSpin>
</NTabPane>
</NTabs>
<!-- 证书按钮 -->
<div class="certificate-actions">
<NButton
v-if="mode === 'approve'"
type="primary"
@click="handleUploadCertificate"
>
<TheIcon icon="mdi:upload" :size="16" class="mr-4" />
上传证书
</NButton>
<NButton
v-else
type="info"
@click="handleViewCertificate"
>
<TheIcon icon="mdi:eye" :size="16" class="mr-4" />
查看证书
</NButton>
</div>
<!-- 证书弹窗 -->
<CertificateModal
v-model:visible="certificateModalVisible"
:mode="certificateModalMode"
:certificate-data="certificateData"
@confirm="handleCertificateConfirm"
/>
</div>
</template>
<style scoped>
.audit-detail {
background: #fff;
border-radius: 12px;
padding: 24px;
.certificate-actions {
margin-top: 20px;
width: 100%;
display: flex;
justify-content: flex-end;
z-index: 100;
}
}
.detail-header {
display: flex;
justify-content: space-between;
gap: 16px;
margin: -16px 0px 10px ;
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
border: none;
background: transparent;
color: #409eff;
cursor: pointer;
}
.detail-title {
display: flex;
align-items: center;
gap: 12px;
margin: 8px 0;
}
.detail-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.detail-meta {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 16px;
color: #666;
font-size: 13px;
}
.detail-actions {
display: flex;
gap: 12px;
align-items: center;
}
.detail-section {
margin-bottom: 24px;
}
.audit-tabs :deep(.n-tabs-nav) {
margin-bottom: 16px;
}
.audit-tabs :deep(.n-tabs-tab) {
font-size: 16px;
font-weight: 500;
color: #1d2129;
padding: 0 24px;
}
.audit-tabs :deep(.n-tabs-tab.n-tabs-tab--active) {
color: #ff6f3b;
}
.audit-tabs :deep(.n-tabs-nav__line) {
background-color: #ff6f3b;
height: 3px;
border-radius: 999px;
}
.section-title {
display: flex;
align-items: center;
gap: 12px;
font-weight: 600;
margin-bottom: 12px;
font-size: 18px;
color: #1d2129;
}
.section-title .dot {
width: 10px;
height: 18px;
border-radius: 4px;
background: #3b82f6;
}
.detail-section :deep(.n-data-table) {
background: #f9fafe;
}
.detail-section :deep(.n-data-table-th) {
background: #f1f2f5;
font-weight: 600;
}
.detail-section :deep(.n-data-table-th:first-child) {
background: #f1f2f5;
text-align: center;
}
.detail-section :deep(.n-data-table-td:first-child) {
background: #f1f2f5;
text-align: center;
font-weight: 600;
}
.cell-multi {
display: flex;
flex-direction: column;
gap: 4px;
}
/* Markdown Styles */
.markdown-body {
line-height: 1.6;
color: #333;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3),
.markdown-body :deep(h4),
.markdown-body :deep(h5),
.markdown-body :deep(h6) {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-body :deep(h1) { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: .3em; }
.markdown-body :deep(h2) { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: .3em; }
.markdown-body :deep(h3) { font-size: 1.25em; }
.markdown-body :deep(p) { margin-top: 0; margin-bottom: 16px; }
.markdown-body :deep(blockquote) {
margin: 0 0 16px;
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
}
.markdown-body :deep(table) {
display: block;
width: 100%;
overflow: auto;
margin-bottom: 16px;
border-spacing: 0;
border-collapse: collapse;
}
.markdown-body :deep(table tr) {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
.markdown-body :deep(table tr:nth-child(2n)) {
background-color: #f6f8fa;
}
.markdown-body :deep(table th), .markdown-body :deep(table td) {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
.markdown-body :deep(table th) {
font-weight: 600;
}
.markdown-body :deep(code) {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: rgba(27,31,35,0.05);
border-radius: 3px;
}
.markdown-body :deep(pre) {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #f6f8fa;
border-radius: 3px;
}
.markdown-body :deep(pre code) {
display: inline;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: initial;
border: 0;
}
/* 计算流程容器 */
:deep(.calc-flow-container) {
display: flex;
gap: 24px;
min-height: 600px;
}
/* 左侧详细流程 */
:deep(.calc-flow-left) {
flex: 1;
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
overflow-y: auto;
max-height: 800px;
}
:deep(.calc-formula-header) {
font-size: 18px;
font-weight: 600;
color: #1d2129;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid #e5e7eb;
}
:deep(.calc-section) {
margin-bottom: 24px;
}
:deep(.calc-section-title) {
font-size: 16px;
font-weight: 600;
color: #1d2129;
margin-bottom: 16px;
line-height: 1.6;
}
:deep(.calc-subsection) {
margin-left: 20px;
margin-bottom: 20px;
}
:deep(.calc-subsection-title) {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
line-height: 1.6;
}
:deep(.calc-item) {
margin-left: 20px;
margin-bottom: 16px;
}
:deep(.calc-item-label) {
font-size: 14px;
color: #4b5563;
margin-bottom: 8px;
line-height: 1.6;
}
:deep(.calc-detail) {
margin-left: 20px;
margin-top: 8px;
}
:deep(.calc-detail-item) {
font-size: 13px;
color: #6b7280;
margin-bottom: 4px;
line-height: 1.6;
}
/* 右侧大纲 */
:deep(.calc-flow-right) {
width: 320px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
overflow-y: auto;
max-height: 800px;
}
:deep(.calc-outline-title) {
font-size: 16px;
font-weight: 600;
color: #1d2129;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid #e5e7eb;
}
:deep(.calc-outline) {
font-size: 13px;
}
:deep(.outline-section) {
margin-bottom: 16px;
}
:deep(.outline-title) {
font-size: 14px;
font-weight: 600;
color: #1d2129;
margin-bottom: 8px;
}
:deep(.outline-subsection) {
margin-left: 12px;
margin-bottom: 12px;
}
:deep(.outline-subtitle) {
font-size: 13px;
font-weight: 600;
color: #374151;
margin-bottom: 6px;
}
:deep(.outline-item) {
font-size: 12px;
color: #4b5563;
margin-bottom: 4px;
margin-left: 12px;
}
:deep(.outline-detail) {
font-size: 11px;
color: #6b7280;
margin-left: 24px;
margin-bottom: 2px;
line-height: 1.5;
}
.calc-empty {
text-align: center;
color: #999;
padding: 40px 0;
}
</style>