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
+321
View File
@@ -0,0 +1,321 @@
<script setup lang="ts">
/**
* 表单级配置面板(方案 §5.1:配置与状态定义**不进版本快照**,改它们不升版)。
*
* 三处口径按后端原样,不自己发明:
* - **限次只有 fillLimit 一个开关**:`limitScope` 不是表单可配项,它是提交时按 subject_id
* 推导的语义(subject 非空按被代填人限、否则按提交人限——契约 §5)。所以这里给的是解释文案,
* 不是一个假的单选框(本轮问题单 P53)。
* - 截止时间只到日期时后端补 23:59:59(方案 §5.4),空=不限。
* - 状态定义:code 建后不改(历史答卷按 code 存状态,改 code=改写历史),能改的只有 label
* 与「废弃」标记;废弃是软删(V13 历史仍显示其名),且至少留一个可用状态。
*/
import { computed, reactive, ref, watch } from "vue";
import { Message, Modal } from "view-ui-plus";
import { formApi } from "@/api/module/form";
import type { FormView, StatusDefView } from "@/types/form";
import { AUDIENCE_OPTIONS, FORM_STATUS } from "@/types/form";
import { MAX_NAME_LEN, CODE_PATTERN } from "@/types/formSchema";
const props = defineProps<{ form: FormView | null }>();
const emit = defineEmits<{ (e: "saved", form: FormView): void }>();
const visible = ref(false);
const saving = ref(false);
const model = reactive<{
audience: number;
fillLimit: number;
deadline: string;
answerNoEnabled: number;
groupTag: string;
}>({
audience: AUDIENCE_OPTIONS[0].value,
fillLimit: 0,
deadline: "",
answerNoEnabled: 0,
groupTag: ""
});
const statusDefs = ref<StatusDefView[]>([]);
const newStatus = reactive({ code: "", label: "" });
watch(
() => [visible.value, props.form?.formUuid] as const,
() => {
const f = props.form;
if (!visible.value || !f) return;
model.audience = f.audience ?? AUDIENCE_OPTIONS[0].value;
model.fillLimit = f.fillLimit ?? 0;
model.deadline = f.deadline ? String(f.deadline).slice(0, 10) : "";
model.answerNoEnabled = f.answerNoEnabled ?? 0;
model.groupTag = f.groupTag || "";
statusDefs.value = (f.statusDefs || []).map((d) => ({ ...d }));
newStatus.code = "";
newStatus.label = "";
}
);
const isPlatform = computed(() => props.form?.schId === 0);
const statusText = computed(() => {
const f = props.form;
if (!f) return "";
if (f.status === FORM_STATUS.DRAFT) return "草稿(从未发布)";
if (f.status === FORM_STATUS.PUBLISHED) return `已发布 v${f.currentVersion}`;
return `已停用(当前发布 v${f.currentVersion})`;
});
function open() {
visible.value = true;
}
function addStatus() {
const code = newStatus.code.trim();
const label = newStatus.label.trim();
if (!code || !CODE_PATTERN.test(code)) {
Message.error("状态 code 仅限字母/数字/下划线(≤64 位)");
return;
}
if (!label) {
Message.error("状态名称不能为空");
return;
}
if (statusDefs.value.some((d) => d.code === code)) {
// 含已废弃的 code:复用它会改写历史答卷的状态名(后端同判)
Message.error(`状态 code 已存在(含已废弃的):${code}`);
return;
}
statusDefs.value.push({ code, label, is_deleted: false });
newStatus.code = "";
newStatus.label = "";
}
function moveStatus(index: number, dir: -1 | 1) {
const target = index + dir;
if (target < 0 || target >= statusDefs.value.length) return;
const list = statusDefs.value;
[list[index], list[target]] = [list[target], list[index]];
}
function liveCount(defs: StatusDefView[]): number {
return defs.filter((d) => !d.is_deleted).length;
}
async function submit() {
const f = props.form;
if (!f) return;
if (statusDefs.value.length && liveCount(statusDefs.value) === 0) {
Message.error("至少要保留一个可用状态(全部废弃后新数据无状态可给)");
return;
}
if (model.answerNoEnabled === 1 && f.code.length > 49) {
Message.error(`开启报名号要求 code ≤49 位(当前 ${f.code.length})`);
return;
}
saving.value = true;
try {
await formApi
.config({
uuid: f.formUuid,
audience: model.audience,
fillLimit: model.fillLimit,
deadline: model.deadline,
answerNoEnabled: model.answerNoEnabled,
groupTag: model.groupTag
})
.send();
// 状态定义为空列表时不能传空数组(后端拒),传 null 才是「回默认待处理/已处理」
const defs = statusDefs.value;
const latest = await formApi
.statusDefs({ uuid: f.formUuid, statusDefs: defs.length ? defs : null })
.send();
emit("saved", latest);
Message.success("表单配置已保存(不进版本快照,无需升版)");
visible.value = false;
} catch (e: any) {
Message.error(e?.message || "保存失败");
} finally {
saving.value = false;
}
}
async function toggleLifecycle() {
const f = props.form;
if (!f) return;
try {
const res =
f.status === FORM_STATUS.PUBLISHED
? await formApi.disable(f.formUuid).send()
: await formApi.enable(f.formUuid).send();
emit("saved", res);
Message.success(
f.status === FORM_STATUS.PUBLISHED
? "已停用:填报入口失效,数据仍可读可导"
: "已恢复当前发布版本"
);
} catch (e: any) {
Message.error(e?.message || "操作失败");
}
}
defineExpose({ open });
</script>
<template>
<Modal v-model="visible" :title="`表单配置|${form?.name || ''}`" :width="640" :mask-closable="false">
<div v-if="form" class="cfg">
<Alert v-if="isPlatform" type="warning" show-icon class="mb10">
平台级表单(sch_id=0):跨校资产,只有超管上下文可维护。
</Alert>
<div class="cfg-row">
<span class="cfg-label">生命周期</span>
<span class="cfg-static">
{{ statusText }}
<Tag v-if="form.draftVersion" color="warning" size="small">
待发布草稿 v{{ form.draftVersion }}
</Tag>
</span>
<span class="cfg-actions">
<Button v-if="form.status === FORM_STATUS.PUBLISHED || form.status === FORM_STATUS.DISABLED"
size="small" @click="toggleLifecycle">
{{ form.status === FORM_STATUS.PUBLISHED ? "停用" : "恢复" }}
</Button>
</span>
</div>
<div class="cfg-row">
<span class="cfg-label">面向人群</span>
<RadioGroup v-model="model.audience">
<Radio v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Radio>
</RadioGroup>
<span class="cfg-note">决定数据范围矩阵落在哪一列(契约 §8)</span>
</div>
<div class="cfg-row">
<span class="cfg-label">限次</span>
<RadioGroup v-model="model.fillLimit">
<Radio :label="0">不限次数</Radio>
<Radio :label="1">每人限填一次</Radio>
</RadioGroup>
<span class="cfg-note">
「每人」按提交主体判:管理员代填指定了被代填人时按那个人限,否则按提交人限;
软删答卷会释放名额(契约 §5)
</span>
</div>
<div class="cfg-row">
<span class="cfg-label">截止时间</span>
<DatePicker v-model="model.deadline" type="date" format="yyyy-MM-dd" placeholder="不限"
clearable style="width: 180px" />
<span class="cfg-note">只选日期时后端补 23:59:59;过期后填报页只读、提交接口拒绝</span>
</div>
<div class="cfg-row">
<span class="cfg-label">报名号</span>
<i-switch v-model="model.answerNoEnabled" :true-value="1" :false-value="0" />
<span class="cfg-note">
开启后提交生成 <code>{code}-{YYYYMMDD}-{当日序号}</code>(要求 code ≤49 位)
</span>
</div>
<div class="cfg-row">
<span class="cfg-label">分组标签</span>
<Input v-model="model.groupTag" :maxlength="64" placeholder="可空,用于列表筛选归类" style="width: 220px" />
</div>
<Divider />
<div class="cfg-status">
<div class="cfg-row">
<span class="cfg-label">状态定义</span>
<span class="cfg-note">
状态是标记不是流转(无流转规则、无会签);默认「待处理/已处理」,
<Button size="small" type="text" @click="statusDefs = []">回默认</Button>
</span>
</div>
<div v-for="(d, i) in statusDefs" :key="d.code" class="status-row"
:class="{ dropped: d.is_deleted }">
<code class="status-code">{{ d.code }}</code>
<Input v-model="d.label" size="small" :maxlength="128" style="width: 160px" />
<span class="status-order">
<Button size="small" type="text" icon="ios-arrow-up" :disabled="i === 0"
@click="moveStatus(i, -1)" />
<Button size="small" type="text" icon="ios-arrow-down"
:disabled="i === statusDefs.length - 1" @click="moveStatus(i, 1)" />
</span>
<Checkbox v-model="d.is_deleted">废弃</Checkbox>
</div>
<div class="status-row">
<Input v-model="newStatus.code" size="small" placeholder="code" style="width: 120px"
:maxlength="64" />
<Input v-model="newStatus.label" size="small" placeholder="名称" style="width: 160px"
:maxlength="128" />
<Button size="small" icon="md-add" @click="addStatus">新增状态</Button>
</div>
<div class="cfg-note">
已废弃的状态历史答卷仍显示其名(V13),只是新数据不再给;code 一经使用不改,
改 code 等于改写历史答卷的状态名。名称长度 ≤{{ MAX_NAME_LEN }} 与后端同口径。
</div>
</div>
</div>
<template #footer>
<Button @click="visible = false">取消</Button>
<Button type="primary" :loading="saving" @click="submit">保存配置</Button>
</template>
</Modal>
</template>
<style scoped>
.cfg-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.cfg-label {
width: 78px;
color: #515a6e;
flex-shrink: 0;
}
.cfg-static {
display: flex;
align-items: center;
gap: 8px;
}
.cfg-actions {
margin-left: auto;
}
.cfg-note {
color: #808695;
font-size: 12px;
line-height: 1.6;
}
.cfg-status {
margin-top: 4px;
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.status-row.dropped .status-code,
.status-row.dropped input {
color: #c5c8ce;
}
.status-code {
font-family: Menlo, Consolas, monospace;
font-size: 12px;
background: #f3f4f6;
border-radius: 3px;
padding: 2px 6px;
min-width: 90px;
text-align: center;
}
.status-order {
display: flex;
}
.mb10 {
margin-bottom: 10px;
}
</style>