Files
form-center-front/src/components/answer/AnswerValueView.vue
T
chenxing e5555efb79
build / build (push) Successful in 1m47s
fix(p93): 附件/图片回显 URL 用错挂载点——/static/* 404,应为 {ctx}/upload/*
- 160 演练实测(honor_student 证书图片上传后):/static/form/** 404(146B)、
  /form-center/upload/form/** 200 且字节数一致——/static 是平台主题通道保留前缀,
  附件实际挂载在 {ctx}/upload/(Caddy 静态路由 + 后端 upload 映射)。
- 修复两处 URL 构建:fields/image/pc.vue 回显 img src → `${serviceHost}/upload/{key}`;
  AnswerValueView.vue fileLinks 前缀 → `${serviceHost}/upload/`(serviceHost 空=dev
  同源相对,vite 代理补 /upload → localhost:8086)。
- 附带发现记 P92:字段级 maxSize 仅前端 beforeUpload 拦截,/api/form/upload 对
  5.7MB 文件返回成功(上传端点不感知表单 schema 的字段约束)——裁决口径见报告。

验证:vue-tsc --noEmit 干净;vite build 通过。160 端回显修复随下次部署生效。
2026-10-16 00:04:02 +08:00

182 lines
6.1 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 lang="ts">
/**
* 答卷值的只读呈现(数据详情页 / 我的填写详情 / 编辑弹层的"原值"对照共用)。
*
* 三条纪律:
* 1. **三态空值与已删字段降级**(契约 §3、V8):值在 schema 且为空 → "—";
* values 无此 key(提交时字段还不存在)→ "—";values 有 key 但传入的 schema 已删该字段 →
* "(已移除字段)原始key: 原始值",并在表尾单列一节,不静默丢弃;
* 2. **默认转义**(方案 §2.3 红线):全部走文本插值与 CSS(textarea 用 pre-wrap 保换行),
* <b>本组件永不含 v-html</b>,冒烟脚本按全仓 grep 断言零命中;
* 3. **只读态才脱敏**(契约 §3 phone):手机号在只读呈现打码,编辑态由弹层自己传原值。
*
* 附件链接的 URL 前缀与 T7 的图片控件同口径(值里只有相对 key,绝对地址渲染期拼——契约 §3、V24);
* "经 FILE_WEB_PATH 真能取到文件"属部署环境断言,按 T3/T9 的分工留到 160 复验。
*/
import { computed } from "vue";
import { EMPTY_TEXT, cellText, flattenFields, isFileArray } from "@/utils/dataValue";
import type { DesignField, FileValue } from "@/types/formSchema";
import conf from "@/assets/config/host.config";
const props = withDefaults(
defineProps<{
/** 钉住版本(或当前发布版本)内联展开后的 groups——后端读接口下发的形状 */
groups?: any;
/** 答卷值文档 */
values?: Record<string, unknown> | null;
/** 手机号是否脱敏(只读呈现默认脱敏) */
maskPhone?: boolean;
}>(),
{ groups: () => [], values: null, maskPhone: true }
);
interface FileLink {
name: string;
url: string;
}
interface ValueRow {
key: string;
label: string;
text: string;
multiline: boolean;
files: FileLink[];
}
interface GroupView {
key: string;
label: string;
rows: ValueRow[];
}
const schemaFields = computed<DesignField[]>(() => flattenFields(props.groups));
/** 附件地址由渲染期拼(值里只存相对 key,绝对 URL 不进答卷——契约 §3、方案 §4.3②)。
* P93:挂载点是 {ctx}/upload/*(Caddy 静态路由 + 后端 upload 映射),不是 /static/*
* ——/static 是平台主题通道的保留前缀,160 实测 /static/form/** 404、/{ctx}/upload/** 200。 */
function fileLinks(value: unknown): FileLink[] {
if (!isFileArray(value)) return [];
const prefix = `${conf.serviceHost || ""}/upload/`;
return (value as FileValue[]).map((f) => ({
name: String(f.fileName ?? f.key ?? ""),
url: prefix + String(f.key ?? "")
}));
}
const views = computed<GroupView[]>(() => {
const groups = Array.isArray(props.groups) ? (props.groups as any[]) : [];
return groups.map((group) => {
const fields = Array.isArray(group?.fields) ? (group.fields as DesignField[]) : [];
return {
key: String(group?.key ?? ""),
label: String(group?.label ?? ""),
rows: fields.map((field) => ({
key: field.key,
label: field.label || field.key,
text: cellText(field, field.key, props.values, { maskPhone: props.maskPhone }),
multiline: field.type === "textarea",
files: fileLinks(props.values?.[field.key])
}))
};
});
});
/** schema 已删、值文档里还留着的 key(V8 的降级半边) */
const removedRows = computed<ValueRow[]>(() => {
const known = new Set(schemaFields.value.map((f) => f.key));
const values = props.values || {};
return Object.keys(values)
.filter((key) => !known.has(key))
.map((key) => {
const raw = values[key];
const blank = raw === null || raw === undefined || (typeof raw === "string" && raw.trim() === "");
return {
key,
label: key,
text: blank ? EMPTY_TEXT : cellText(null, key, values),
multiline: false,
files: []
};
});
});
</script>
<template>
<div class="answer-values">
<section v-for="group in views" :key="group.key" class="value-group">
<h5 v-if="group.label" class="group-title">{{ group.label }}</h5>
<div v-for="row in group.rows" :key="row.key" class="value-row">
<span class="value-label">{{ row.label }}</span>
<span :class="['value-text', row.multiline ? 'pre-wrap' : '']">{{ row.text }}</span>
<span v-if="row.files.length" class="value-files">
<a v-for="file in row.files" :key="file.url" :href="file.url" target="_blank" rel="noopener">
{{ file.name }}
</a>
</span>
</div>
</section>
<section v-if="removedRows.length" class="value-group removed-group">
<h5 class="group-title">历史遗留字段(当前版本 schema 已删除)</h5>
<div v-for="row in removedRows" :key="row.key" class="value-row">
<span class="value-label">{{ row.label }}</span>
<span class="value-text pre-wrap">{{ row.text }}</span>
</div>
</section>
</div>
</template>
<style scoped>
.answer-values {
display: flex;
flex-direction: column;
gap: 16px;
}
.value-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.group-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #17233d;
border-left: 3px solid #2d8cf0;
padding-left: 8px;
}
.value-row {
display: grid;
grid-template-columns: 180px 1fr;
gap: 12px;
align-items: start;
padding: 6px 0;
border-bottom: 1px dashed #e8eaec;
}
.value-label {
color: #808695;
font-size: 13px;
}
.value-text {
color: #17233d;
font-size: 14px;
word-break: break-all;
}
.value-text.pre-wrap {
white-space: pre-wrap;
}
.value-files {
grid-column: 2;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.value-files a {
font-size: 12px;
color: #2d8cf0;
}
.removed-group .group-title {
border-left-color: #ff9900;
}
</style>