build / build (push) Successful in 1m47s
- 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 端回显修复随下次部署生效。
182 lines
6.1 KiB
Vue
182 lines
6.1 KiB
Vue
<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>
|