diff --git a/web/public/report_template.docx b/web/public/report_template.docx index 29b5d44..551c211 100644 Binary files a/web/public/report_template.docx and b/web/public/report_template.docx differ diff --git a/web/src/api/index.js b/web/src/api/index.js index 1b92614..ec1734d 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -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), } diff --git a/web/src/utils/http/interceptors.js b/web/src/utils/http/interceptors.js index e782eba..aa2ab19 100644 --- a/web/src/utils/http/interceptors.js +++ b/web/src/utils/http/interceptors.js @@ -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 */ diff --git a/web/src/utils/report.js b/web/src/utils/report.js index 51e58d8..471eb1a 100644 --- a/web/src/utils/report.js +++ b/web/src/utils/report.js @@ -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} - 生成并下载报告文件 + * + * 数据结构说明: + * - 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 diff --git a/web/src/views/transaction/invoice/InvoiceModal.vue b/web/src/views/transaction/invoice/InvoiceModal.vue index ce01ab2..3c68709 100644 --- a/web/src/views/transaction/invoice/InvoiceModal.vue +++ b/web/src/views/transaction/invoice/InvoiceModal.vue @@ -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 = [] } } ) diff --git a/web/src/views/transaction/invoice/index.vue b/web/src/views/transaction/invoice/index.vue index 7657198..88fad20 100644 --- a/web/src/views/transaction/invoice/index.vue +++ b/web/src/views/transaction/invoice/index.vue @@ -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' } diff --git a/web/src/views/user-management/user-list/LimitSettingModal.vue b/web/src/views/user-management/user-list/LimitSettingModal.vue index bb312ef..13627e0 100644 --- a/web/src/views/user-management/user-list/LimitSettingModal.vue +++ b/web/src/views/user-management/user-list/LimitSettingModal.vue @@ -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: '' } } diff --git a/web/src/views/user-management/user-list/index.vue b/web/src/views/user-management/user-list/index.vue index 33adf9e..a6d8f2d 100644 --- a/web/src/views/user-management/user-list/index.vue +++ b/web/src/views/user-management/user-list/index.vue @@ -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', diff --git a/web/src/views/valuation/audit/components/AuditDetail.vue b/web/src/views/valuation/audit/components/AuditDetail.vue index fec3c02..61887f6 100644 --- a/web/src/views/valuation/audit/components/AuditDetail.vue +++ b/web/src/views/valuation/audit/components/AuditDetail.vue @@ -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) => { - +
diff --git a/web1/src/views/pages/index.vue b/web1/src/views/pages/index.vue index 419db01..525051d 100644 --- a/web1/src/views/pages/index.vue +++ b/web1/src/views/pages/index.vue @@ -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 = () => {