feat(t9): P76 导出锚点判树修正 + P77 应用内详情形态 + 观察项①勾选回填 + 演练桩
build / build (push) Successful in 3m17s

P76(T8 验收遗留):快应用上下文的导出按钮此前恒按中心会话树判"导出"锚点
(Data.vue exportable 读 store.modules),锚点判定不生效(fail-closed 方向,
有授权也不画按钮)。本轮把 AppContext 拉到的**目标应用树**经 modules prop 下传,
Data.vue 的权限判据改为 permsTree(app=应用树 / center=会话树),canExport 纯函数不动。
Stats.vue 未接:本页无锚点判定也无 store.modules 引用,加 prop 是死代码(差异记 T9 报告)。

P77(T8 验收遗留):应用上下文内的"详情"不再跳回中心路由 /admin/data/detail。
形态二选一取**子路由 /app/:appCode/data/detail**(同一个 AnswerDetail.vue):
地址保留 uuid 可深链/刷新、详情页只有一份实现、back() 本就按 app 回应用数据视图,
写 parentPath 反而造假(menuRoute 的 ROUTE_PATHS 只收静态 path,应用段导航在页内轻导航)。
goDetail 按 props.context 分派;back() 改读 route.params.appCode,撤掉 query.app 双轨。

观察项①(T8 验收记档):确认跨触底页勾选**会被清空**——view-ui-plus 的 data 监听器
用 makeObjBaseData 重建 objData,_isChecked 只认行上的 _checked(点选不回写行),
整组替换后勾选视觉与 getSelection 归零而"已选 N 条"仍累计。新增 withSelectionState()
在 reload/loadMore 两条装载路径按 selected 回填 _checked,selected 为唯一事实源。

演练桩(本地件,dev:mock 生效):baseapi 增 fc-drill 快应用模块树(方案 §6.2 五行含
导入/导出两个 path 空锚点),dataapi 增 /api/form/resolve 桩(快应用数据视图唯一的
合规取数路径是 code→resolve→uuid,契约 §1)。其余外指 appCode 仍旧返回空树(P3 口径)。

门禁:npm run build ✓ / npm run type-check 0 错 ✓;
t1~t9 全量复跑:t5 13/0/1、t6 22/0/2、t7 12/0/0、t8 21/0/1、t9 12/0/1
(skip 均为 headless Edge --dump-dom 吐 0 字节的 P73 环境事实,不假通过)。
本提交含 T6~T8 三轮堆在工作区的既有改动(沿 T8 验收 §5 推送分层)。
This commit is contained in:
chenxing
2026-10-15 12:57:10 +08:00
parent fdd9aaaa3b
commit 14d45f7e3c
61 changed files with 9236 additions and 72 deletions
+179
View File
@@ -0,0 +1,179 @@
<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②) */
function fileLinks(value: unknown): FileLink[] {
if (!isFileArray(value)) return [];
const prefix = `${conf.serviceHost || ""}/static/`;
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>