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
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
/**
* upload 附件(值 [{id,fileName,key}];单文件 ≤50MB、每字段 ≤9 个是契约 §3 的硬上限,
* 字段级 accept/maxSize/maxFiles 只能往更严收,不能往宽放)。
* accept 不含 .svg 由设计器把门(P28),这里只如实呈现配置。
*/
import { computed, ref } from "vue";
import type { FieldWidgetProps } from "../types";
import type { FileValue } from "@/types/formSchema";
import { Upload } from "view-ui-plus";
import { alovaInstance } from "@/utils/alova/alova-iview";
const props = withDefaults(defineProps<FieldWidgetProps & {
modelValue?: FileValue[];
readonly?: boolean;
}>(), {
modelValue: () => [],
readonly: true
});
const emit = defineEmits<{
(e: "update:modelValue", value: FileValue[]): void;
}>();
const fileList = computed(() => props.modelValue || []);
const uploading = ref(false);
const hints = computed(() => {
const f = props.field;
return [
f.accept ? `格式 ${f.accept}` : "",
f.maxSize ? `单个 ≤${f.maxSize}MB` : "",
f.maxFiles ? `最多 ${f.maxFiles} 个` : ""
].filter(Boolean);
});
const accept = computed(() => props.field.accept || "");
async function beforeUpload(file: File) {
const maxSize = (props.field.maxSize || 50) * 1024 * 1024;
if (file.size > maxSize) {
return false;
}
const maxFiles = props.field.maxFiles || 9;
if (fileList.value.length >= maxFiles) {
return false;
}
return true;
}
async function handleUpload(file: File) {
uploading.value = true;
try {
const formData = new FormData();
formData.append("file", file);
const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>(
"/api/form/upload",
formData,
{ headers: { "Content-Type": "multipart/form-data" } }
).send();
const newValue = [...fileList.value, { id: result.id, fileName: result.fileName, key: result.key }];
emit("update:modelValue", newValue);
} catch (e) {
console.error("Upload failed:", e);
} finally {
uploading.value = false;
}
}
function handleRemove(index: number) {
const newValue = fileList.value.filter((_, i) => i !== index);
emit("update:modelValue", newValue);
}
</script>
<template>
<div class="attach">
<div class="file-list">
<div v-for="(item, idx) in fileList" :key="item.id" class="file-item">
<Icon type="ios-document" class="file-icon" />
<span class="file-name">{{ item.fileName }}</span>
<Icon v-if="!readonly" type="ios-close-circle" class="file-remove" @click="handleRemove(idx)" />
</div>
</div>
<Upload v-if="!readonly" :before-upload="beforeUpload" :handle-upload="handleUpload" :accept="accept"
:disabled="uploading">
<Button icon="ios-cloud-upload-outline" :loading="uploading">上传附件</Button>
</Upload>
<div v-if="hints.length" class="attach-hint">{{ hints.join("|") }}</div>
</div>
</template>
<style scoped>
.attach {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.file-list {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
background: #f7f7f7;
border-radius: 4px;
}
.file-icon {
font-size: 16px;
color: #515a6e;
}
.file-name {
flex: 1;
font-size: 13px;
color: #515a6e;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-remove {
font-size: 16px;
color: #ed4014;
cursor: pointer;
}
.attach-hint {
color: #808695;
font-size: 12px;
}
</style>