feat(报告): 实现评估报告生成与展示功能

- 新增报告模板文件及生成工具模块
- 在拦截器中添加 isRaw 配置支持原始响应
- 实现报告内容获取接口及前端展示逻辑
- 完善发票模块的附件处理功能
- 优化用户管理界面的默认值设置
- 移除用户列表中的备注列显示
This commit is contained in:
邹方成 2025-11-27 16:34:37 +08:00
commit 97b872aa9b
10 changed files with 318 additions and 65 deletions

Binary file not shown.

View File

@ -68,5 +68,6 @@ export default {
request.post(`/valuations/${data.valuation_id || data.id}/reject`, { admin_notes: data.admin_notes }),
updateValuationNotes: (data = {}) =>
request.put(`/valuations/${data.valuation_id || data.id}/admin-notes`, { admin_notes: data.admin_notes }),
getValuationReport: (params = {}) => request.get(`/valuations/${params.valuation_id || params.id}/report`, { isRaw: true }),
sendSmsReport: (data = {}) => request.post('/sms/send-report', data),
}

View File

@ -21,7 +21,10 @@ export function reqReject(error) {
}
export function resResolve(response) {
const { data, status, statusText } = response
const { data, status, statusText, config } = response
if (config?.isRaw) {
return Promise.resolve(data)
}
if (data?.code !== 200) {
const code = data?.code ?? status
/** 根据code处理对应的操作并返回处理后的message */

View File

@ -1,3 +1,13 @@
/**
* 报告生成工具模块
*
* 功能说明
* 1. 基于 Word 模板生成非遗资产评估报告
* 2. 使用 Docxtemplater 进行模板变量替换
* 3. 自动格式化数值枚举日期等字段
* 4. 支持复杂数据结构数组对象的格式化
*/
import PizZip from 'pizzip'
import Docxtemplater from 'docxtemplater'
import { saveAs } from 'file-saver'
@ -7,29 +17,59 @@ import {
formatAgeDistribution,
formatHistoricalEvidence,
formatPlatformAccounts,
formatPriceRange
formatPriceRange,
formatEnumByKey
} from '@/views/valuation/audit/utils'
/**
* 生成评估报告
*
* @param {Object} detailData - 评估详情数据对象
* @returns {Promise<void>} - 生成并下载报告文件
*
* 数据结构说明
* - detailData: 包含所有评估基础信息
* - detailData.calculation_input: 计算输入数据
* - model_data: 模型数据
* - economic_data: 经济价值数据
* - cultural_data: 文化价值数据
* - risky_data: 风险数据
* - market_data: 市场数据
* - detailData.calculation_result: 计算结果数据
*
* 模板变量命名规则
* - 使用 ${变量名} 格式
* - 如果数据不存在保持原始占位符不变
* - 支持数组索引访问 ${inheritor_age_count[0]}
*/
export const generateReport = async (detailData) => {
try {
// Validate input
// ========== 1. 数据验证 ==========
if (!detailData || typeof detailData !== 'object') {
throw new Error('无效的详情数据')
}
// Load the template
// ========== 2. 加载 Word 模板文件 ==========
const response = await fetch('/report_template.docx')
if (!response.ok) {
throw new Error('Failed to load report template')
}
const content = await response.arrayBuffer()
// ========== 3. 初始化 Docxtemplater ==========
const zip = new PizZip(content)
const doc = new Docxtemplater(zip, {
paragraphLoop: true,
linebreaks: true,
delimiters: { start: '${', end: '}' },
// 当字段不存在时,保持原始的 ${字段名} 格式,不替换
paragraphLoop: true, // 启用段落循环
linebreaks: true, // 支持换行符
delimiters: { start: '${', end: '}' }, // 模板变量分隔符
/**
* nullGetter: 自定义空值处理器
* 当模板中的变量在数据对象中不存在时的处理逻辑
*
* 策略保持原始占位符格式 ${字段名}不进行替换
* 这样可以在后续手动填写或调试时识别缺失的字段
*/
nullGetter: (part) => {
if (!part.module) {
return '${' + part.value + '}'
@ -38,40 +78,66 @@ export const generateReport = async (detailData) => {
},
})
// Helper function to safely add field only if it exists
// ========== 4. 辅助函数定义 ==========
/**
* 安全添加字段已定义但未使用保留供未来扩展
* @param {Object} obj - 目标对象
* @param {string} key - 字段名
* @param {*} value - 字段值
*/
const addIfExists = (obj, key, value) => {
if (value !== undefined && value !== null) {
obj[key] = value
}
}
// Extract calculation data
// ========== 5. 提取计算相关数据 ==========
// 计算输入数据
const calcInput = detailData.calculation_input || {}
// 计算结果数据
const calcResult = detailData.calculation_result || {}
// 模型数据
const modelData = calcInput.model_data || {}
// 经济价值数据B1 相关)
const ecoData = modelData.economic_data || {}
// 文化价值数据B2 相关)
const cultData = modelData.cultural_data || {}
// 风险数据B3 相关)
const riskData = modelData.risky_data || {}
// 市场数据C 相关)
const marketData = calcInput.market_data || {}
// Prepare data - only include fields that actually exist
// ========== 6. 构建报告数据对象 ==========
const data = {
// Spread all existing detailData fields
// 展开所有原始字段(基础信息、财务信息等)
...detailData,
// Date fields (always generated)
yyyy: new Date().getFullYear(),
mm: String(new Date().getMonth() + 1).padStart(2, '0'),
dd: String(new Date().getDate()).padStart(2, '0'),
yyyymmdd: `${new Date().getFullYear()}${String(new Date().getMonth() + 1).padStart(2, '0')}${String(new Date().getDate()).padStart(2, '0')}`,
// ========== 6.1 日期字段(始终生成当前日期) ==========
yyyy: new Date().getFullYear(), // 年份,如 2025
mm: String(new Date().getMonth() + 1).padStart(2, '0'), // 月份,如 11
dd: String(new Date().getDate()).padStart(2, '0'), // 日期,如 27
yyyymmdd: `${new Date().getFullYear()}${String(new Date().getMonth() + 1).padStart(2, '0')}${String(new Date().getDate()).padStart(2, '0')}`, // 完整日期,如 20251127
}
// Handle inheritor_age_count array - format each item
// ========== 6.2 处理非遗传承人年龄分布数据 ==========
/**
* inheritor_age_count: 数组格式 [50岁人数, 50-70岁人数, 70岁人数]
*
* 生成的字段
* - inheritor_age_count_text: 格式化后的完整文本换行分隔
* - inheritor_age_count[0]: 50岁人数
* - inheritor_age_count[1]: 50-70岁人数
* - inheritor_age_count[2]: 70岁人数
*/
if (detailData.inheritor_age_count && Array.isArray(detailData.inheritor_age_count)) {
const ageData = formatAgeDistribution(detailData.inheritor_age_count)
// 提供格式化后的完整文本
// 格式化后的完整文本,如 "≤50岁10\n50-70岁20\n≥70岁5"
data.inheritor_age_count_text = ageData.join('\n')
// 提供单独的数值,支持 ${inheritor_age_count[0]} 这样的用法
// 提供单独的数值访问,支持模板中使用 ${inheritor_age_count[0]} 等
if (detailData.inheritor_age_count[0] !== undefined) {
data['inheritor_age_count[0]'] = detailData.inheritor_age_count[0]
}
@ -83,60 +149,121 @@ export const generateReport = async (detailData) => {
}
}
// Handle historical_evidence object - format as text
// ========== 6.3 处理历史证明证据数据 ==========
/**
* historical_evidence: 对象格式
* {
* artifacts: 出土实物数量,
* ancient_literature: 古代文献数量,
* inheritor_testimony: 传承人佐证数量,
* modern_research: 现代研究数量
* }
*
* 生成的字段
* - historical_evidence: 格式化后的文本换行分隔
* "出土实物5\n古代文献10\n传承人佐证3\n现代研究8"
*/
if (detailData.historical_evidence && typeof detailData.historical_evidence === 'object') {
const evidenceData = formatHistoricalEvidence(detailData.historical_evidence)
// 提供格式化后的完整文本(换行分隔)
data.historical_evidence = evidenceData.join('\n')
}
// Handle platform_accounts object - format as text
// ========== 6.4 处理平台账号信息数据 ==========
/**
* platform_accounts: 对象格式
* {
* bilibili: { account: 'xxx', likes: 1000, comments: 200, shares: 50 },
* douyin: { account: 'yyy', likes: 5000, comments: 800, shares: 300 },
* ...
* }
*
* 生成的字段
* - platform_accounts: 格式化后的文本换行分隔
* "B站账号xxx赞1,000 / 评200 / 转50\n抖音账号yyy赞5,000 / 评800 / 转300"
*/
if (detailData.platform_accounts && typeof detailData.platform_accounts === 'object') {
const accountsData = formatPlatformAccounts(detailData.platform_accounts)
// 提供格式化后的完整文本(换行分隔)
data.platform_accounts = accountsData.join('\n')
}
// Handle price_fluctuation array
// ========== 6.5 处理价格波动区间数据 ==========
/**
* price_fluctuation: 数组格式 [最低价, 最高价]
*
* 生成的字段
* - price_fluctuation[0]: 最低价
* - price_min: 最低价别名
* - price_fluctuation[1]: 最高价
* - price_max: 最高价别名
* - price_range: 简单范围 "100-500"
* - price_range_yuan: 带单位范围 "100-500元"
* - price_fluctuation_range: 带货币符号范围 "¥100.00 - ¥500.00"
* - price_fluctuation[0]-price_fluctuation[1]: 组合字段名 "100-500"
*/
if (detailData.price_fluctuation && Array.isArray(detailData.price_fluctuation)) {
const min = detailData.price_fluctuation[0]
const max = detailData.price_fluctuation[1]
// 提供单独的数值,支持 ${price_fluctuation[0]} 这样的用法
// 提供单独的最低价访问
if (min !== undefined) {
data['price_fluctuation[0]'] = min
data.price_min = min
}
// 提供单独的最高价访问
if (max !== undefined) {
data['price_fluctuation[1]'] = max
data.price_max = max
}
// 提供多种格式的范围文本
// 提供多种格式的价格区间文本
if (min !== undefined && max !== undefined) {
// 简单范围(数字-数字)
data.price_range = `${min}-${max}`
// 带"元"的范围
data.price_range_yuan = `${min}-${max}`
// 带货币符号的范围
data.price_fluctuation_range = formatPriceRange(detailData.price_fluctuation)
// 精确匹配模板中的字段名(作为一个整体的字段名)
data['price_fluctuation[0]-price_fluctuation[1]'] = `${min}-${max}`
data.price_range = `${min}-${max}` // 简单范围
data.price_range_yuan = `${min}-${max}` // 带"元"
data.price_fluctuation_range = formatPriceRange(detailData.price_fluctuation) // 带货币符号
data['price_fluctuation[0]-price_fluctuation[1]'] = `${min}-${max}` // 组合字段名
}
}
// Add calculation results if they exist
// ========== 6.6 添加计算结果字段 ==========
/**
* 主要计算结果顶层
* - B: 模型价值
* - B1: 经济价值
* - B2: 文化价值
* - B3: 风险调整系数
* - C: 市场价值
*/
if (calcResult.model_value_b !== undefined) data.B = formatNumberValue(calcResult.model_value_b)
if (calcResult.economic_value_b1 !== undefined) data.B1 = formatNumberValue(calcResult.economic_value_b1)
if (calcResult.cultural_value_b2 !== undefined) data.B2 = formatNumberValue(calcResult.cultural_value_b2)
if (calcResult.risk_adjustment_b3 !== undefined) data.B3 = calcResult.risk_adjustment_b3
if (calcResult.market_value_c !== undefined) data.C = formatNumberValue(calcResult.market_value_c)
// Add DPR if exists
// ========== 6.7 添加动态质押率DPR ==========
/**
* DPR (Dynamic Pledge Rate): 动态质押率
* 优先从 drp_result.dynamic_pledge_rate 获取其次从 dynamic_pledge_rate 获取
*/
const dpr = detailData.drp_result?.dynamic_pledge_rate || detailData.dynamic_pledge_rate
if (dpr !== undefined) data.DPR = dpr
// Add economic data fields if they exist
// ========== 6.8 添加经济价值相关字段B1 细分) ==========
/**
* 经济价值 B1 = B11 × B12 × B13
*
* B11: 基础价值 = F × L × D
* - F: 财务价值
* - L: 法律强度
* - D: 发展潜力
*
* B12: 流量因子
* - search_index_ratio (S1): 搜索指数比
* - social_media_spread_s3 (S3): 社交媒体传播度
*
* B13: 政策乘数
* - policy_fit_score: 政策契合度评分
*/
if (ecoData.basic_value_b11 !== undefined) data.B11 = formatNumberValue(ecoData.basic_value_b11)
if (ecoData.financial_value !== undefined) data.F = formatNumberValue(ecoData.financial_value)
if (ecoData.legal_strength !== undefined) data.L = ecoData.legal_strength
@ -147,7 +274,20 @@ export const generateReport = async (detailData) => {
if (ecoData.policy_multiplier_b13 !== undefined) data.B13 = ecoData.policy_multiplier_b13
if (ecoData.policy_compatibility_score !== undefined) data.policy_fit_score = ecoData.policy_compatibility_score
// Add cultural data fields if they exist
// ========== 6.9 添加文化价值相关字段B2 细分) ==========
/**
* 文化价值 B2 = B21 × B22
*
* B21: 活态传承价值
* - inheritor_level_score: 传承人等级系数
* - teaching_frequency_score: 授课频次
* - cooperation_depth_score: 跨界合作深度
*
* B22: 纹样熵值
* - SC: 结构复杂度
* - H: 归一化熵
* - HI: 历史传承度
*/
if (cultData.living_inheritance_b21 !== undefined) data.B21 = cultData.living_inheritance_b21
if (cultData.inheritor_level_coefficient !== undefined) data.inheritor_level_score = cultData.inheritor_level_coefficient
if (cultData.teaching_frequency !== undefined) data.teaching_frequency_score = cultData.teaching_frequency
@ -157,25 +297,83 @@ export const generateReport = async (detailData) => {
if (cultData.normalized_entropy !== undefined) data.H = cultData.normalized_entropy
if (cultData.historical_inheritance !== undefined) data.HI = cultData.historical_inheritance
// Add risk data fields if they exist
// ========== 6.10 添加风险数据字段B3 相关) ==========
/**
* 风险调整系数 B3 综合考虑
* - risk_market: 市场风险
* - risk_legal: 法律风险
* - risk_inheritance: 传承风险
*/
if (riskData.risk_market !== undefined) data.risk_market = riskData.risk_market
if (riskData.risk_legal !== undefined) data.risk_legal = riskData.risk_legal
if (riskData.risk_inheritance !== undefined) data.risk_inheritance = riskData.risk_inheritance
// Add market data fields if they exist
// ========== 6.11 添加市场数据字段C 细分) ==========
/**
* 市场价值 C = C1 × C2 × C3 × C4
*
* - C1: 市场竞价基准
* - C2: 热度系数
* - C3: 稀缺性乘数
* - C4: 时间衰减系数
*/
if (marketData.market_bidding_c1 !== undefined) data.C1 = formatNumberValue(marketData.market_bidding_c1)
if (marketData.heat_coefficient_c2 !== undefined) data.C2 = marketData.heat_coefficient_c2
if (marketData.scarcity_multiplier_c3 !== undefined) data.C3 = marketData.scarcity_multiplier_c3
if (marketData.time_decay_c4 !== undefined) data.C4 = marketData.time_decay_c4
// ========== 6.12 格式化枚举字段 ==========
/**
* 枚举字段需要从数字代码转换为中文描述
*
* 辅助函数
* - pickValue: 从多个可能的字段中选择第一个有效值兼容不同版本的字段名
* - formatEnum: 将枚举值格式化为中文描述
*
* 枚举字段列表
* 1. funding_status: 资助情况国家级/省级/
* 2. inheritor_level: 传承人等级国家级/省级/市级
* 3. heritage_level: 非遗等级国家级/省级/国家文化数字化清单/
* 4. application_maturity: 应用成熟度成熟应用/推广阶段/试点阶段
* 5. application_coverage: 应用覆盖范围全球/全国/区域
* 6. cooperation_depth: 跨界合作深度/品牌联名/科技载体/国家外交礼品
* 7. circulation: 发行量/稀缺性孤品/限量/稀有/流通
* 8. last_market_activity: 最近市场活动时间近一周/近一月/近一年/其他
* 9. monthly_transaction: 月交易额水平<100/100-500/500
*/
const pickValue = (...args) => args.find(v => v !== undefined && v !== null && v !== '')
const formatEnum = (key, ...values) => formatEnumByKey(pickValue(...values), key)
data.funding_status = formatEnum('fundingStatus', detailData.funding_status)
data.inheritor_level = formatEnum('inheritorLevel', detailData.inheritor_level)
data.heritage_level = formatEnum('heritageLevel', detailData.heritage_level, detailData.heritage_asset_level)
data.application_maturity = formatEnum('applicationMaturity', detailData.application_maturity, detailData.implementation_stage)
data.application_coverage = formatEnum('applicationCoverage', detailData.application_coverage, detailData.coverage_area)
data.cooperation_depth = formatEnum('cooperationDepth', detailData.cooperation_depth, detailData.collaboration_type)
data.circulation = formatEnum('circulation', detailData.circulation, detailData.scarcity_level)
data.last_market_activity = formatEnum('marketActivity', detailData.last_market_activity, detailData.market_activity_time)
data.monthly_transaction = formatEnum('monthlyTransaction', detailData.monthly_transaction, detailData.monthly_transaction_amount)
// ========== 7. 渲染模板 ==========
/**
* 使用准备好的数据对象渲染 Word 模板
* Docxtemplater 会将模板中的 ${变量名} 替换为对应的值
*/
doc.render(data)
// ========== 8. 生成并下载文件 ==========
/**
* 将渲染后的文档生成为 Blob 对象
* 使用 file-saver 库触发浏览器下载
*/
const out = doc.getZip().generate({
type: 'blob',
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
// 下载文件,文件名使用资产名称,默认为"评估报告"
saveAs(out, `${detailData.asset_name || '评估报告'}.docx`)
} catch (error) {
console.error('Report generation failed:', error)
throw error

View File

@ -40,12 +40,34 @@ watch(
() => props.visible,
(val) => {
if (val) {
formData.value = {
email: props.invoiceData?.email || '',
content: '',
attachments: [],
// extra extra
const extra = props.invoiceData?.extra
if (props.mode === 'view' && extra) {
formData.value = {
email: extra.email || props.invoiceData?.email || '',
content: extra.body || '',
attachments: extra.file_urls || [],
}
//
if (extra.file_urls && Array.isArray(extra.file_urls)) {
fileList.value = extra.file_urls.map((url, index) => ({
id: `file-${index}`,
name: url.split('/').pop() || `附件${index + 1}`,
url: url,
status: 'finished',
}))
} else {
fileList.value = []
}
} else {
// 使
formData.value = {
email: props.invoiceData?.email || '',
content: '',
attachments: [],
}
fileList.value = []
}
fileList.value = []
}
}
)

View File

@ -295,6 +295,7 @@ async function handleInvoiceConfirm(formData) {
email: formData.email,
subject: formData.email, // subject email
body: formData.content, // content -> body
file_urls: formData.attachments, // attachments -> file_url
file_url: formData.attachments, // attachments -> file_url
status:'success'
}

View File

@ -36,8 +36,8 @@ const currentRemaining = computed(() => props.userData?.remaining_count ?? 0)
watch(() => props.userData, (newData) => {
if (newData && Object.keys(newData).length > 0) {
limitForm.value = {
targetCount: newData.remaining_count || 0,
quotaType: newData.user_type || '免费体验',
targetCount: 0,
quotaType: '免费体验',
remark: ''
}
}

View File

@ -108,16 +108,16 @@ const columns = [
return row.created_at ? formatDate(row.created_at) : '-'
},
},
{
title: '备注',
key: 'notes',
align: 'center',
width: 120,
ellipsis: { tooltip: true },
render(row) {
return row.notes || '-'
},
},
// {
// title: '',
// key: 'notes',
// align: 'center',
// width: 120,
// ellipsis: { tooltip: true },
// render(row) {
// return row.notes || '-'
// },
// },
{
title: '剩余体验次数',
key: 'remaining_count',

View File

@ -42,6 +42,8 @@ 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)
@ -55,9 +57,34 @@ 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 []
@ -232,10 +259,8 @@ const calcFlow = computed(() => props.detailData?.calculation_result?.flow || []
const mockFlowHtml = ref(mockReportMarkdown)
const renderedFlowHtml = computed(() => {
return marked.parse(mockFlowHtml.value)
return marked.parse(reportContent.value || mockReportMarkdown)
})
@ -351,7 +376,7 @@ const handleCertificateConfirm = async (data) => {
</NSpin>
</NTabPane>
<NTabPane name="flow" tab="计算流程">
<NSpin :show="loading">
<NSpin :show="reportLoading">
<div class="markdown-body" v-html="renderedFlowHtml"></div>
</NSpin>
</NTabPane>

View File

@ -1534,9 +1534,12 @@ const submit = () => {
api.valuations(data).then((res) => {
loading.value = false
getHistoryList()
setTimeout(() => {
window.location.reload()
}, 1000)
message.success('评估完成 将于7个工作日内生成报告与证书 以短信形式通知')
router.push('/user-center')
// status.value = 'success'
// setTimeout(() => {
// window.location.reload()
// }, 1000)
})
}
const getHistoryList = () => {