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
+251 -18
View File
@@ -1,39 +1,272 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
/**
* 快应用上下文 /app/:appCode/* 占位(T5)。
* 快应用上下文 /app/:appCode/(T8 落地,替换 T5 占位)。
*
* 形态(方案 §6.2 纯 A 形态、手册 §2.1):快应用没有独立前端,UserBase 应用行 pc_url 直接指向
* 本路由,中心按<b>那个应用</b>的模块树渲染轻导航/直落单表单——取树走 api/module/base.ts 的
* getUserMod(appCode)(参数名按 P2 定稿 appCode)。
* 形态(方案 §6.2 纯 A 形态 + 任务书 T8"快应用上下文"):
* 快应用 = UserBase 真实应用行 + 无自有后端 + pc_url → 本路由。
* - **模块树**:经 `/api/base/GetUserMod?appCode=<目标应用>` 取**那个应用**的树。H4 未上线时
* 后端返回空树 + warn(T1 已验收的兜底,P3 裁决)——本页照此显示空态,**绝不回落本应用菜单**。
* - **范围矩阵**:受限身份可见集**以后端为准**,本页不自行按角色过滤(任务书 T8 红线)——
* 树里有什么就是什么,页面只做"单表单直落 vs 应用聚合轻导航"的形态分派。
* - **单表单直落无导航**:树里只有一个可跳转填报行(`/f/{code}`)→ 直接渲染填报页,无轻导航。
* - **应用聚合带轻导航**:树里有多个可跳转行 → 顶部一排轻导航(按 path 分派),内容区渲染对应视图。
*
* P3 裁决的口径在本页成立:<b>H4 未上线时该请求返回空树,页面就显示空态,绝不回落本应用菜单</b>
* (回落会把「要 A 应用的菜单」伪装成「A 应用就这些」)。树渲染与范围矩阵在 T8 落地。
* V26 三半句的前端侧(模块把关):
* ① 无导出授权者不渲染导出按钮 → `canExport(tree, "app")`,其中 **tree 是本页拉到的那棵应用树**,
* 经 `modules` prop 下传给内嵌的 Data.vue(P76:中心会话树里没有快应用的"导出"锚点行,
* 拿它判锚点永远判不中——fail-closed 方向,有授权也不画按钮);
* ② 导航不渲染 path 空节点 → 树构建走 `buildNavTree`(menuNav 已跳过锚点行,本页复验);
* ③ 学生身份只见填报路由 → 树里只有 `/f/{code}` 行时,导航自然只有填报项(后端授权的直接结果,
* 前端不再自己按角色过滤)。
*
* path → 视图分派(快应用模块行的 path 形态,方案 §6.2 表):
* `/f/{code}` → 填报(渲染单表单,或跳中心填报页)
* `/app/{appCode}/data` → 数据管理(复用 Data.vue,codes 来自树里的填报行)
* `/app/{appCode}/stats`→ 统计(复用 Stats.vue)
* 其余(含导入这类 path 空锚点)→ 不渲染为可跳转项(menuNav 已过滤 path 空)
*/
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { baseApi } from "@/api/module/base";
import { buildNavTree, flattenNav, type NavNode } from "@/utils/menuNav";
import { resolveMenuPage } from "@/utils/menuRoute";
import { fillCodesOfModules } from "@/utils/modulePerms";
import Data from "@/views/admin/Data.vue";
import Stats from "@/views/admin/Stats.vue";
import type { IModule } from "@/types/Basics";
const route = useRoute();
const router = useRouter();
const appCode = computed(() => String(route.params.appCode ?? ""));
const view = computed(() => String(route.params.view ?? ""));
/** 子视图段(/app/:appCode/:view):空=由树决定单表单直落或落默认聚合视图 */
const viewParam = computed(() => String(route.params.view ?? ""));
const rawTree = ref<IModule[]>([]);
const navTree = ref<NavNode[]>([]);
const loading = ref(false);
const errorText = ref("");
const emptyTree = ref(false);
/** 树里的填报目标 code(数据/统计视图靠它 resolve 出 uuid——快应用不授权定义管理行,§3.4) */
const fillCodes = computed(() => fillCodesOfModules(rawTree.value));
/** 可跳转导航项(path 空锚点已被 menuNav 跳过——V26 第二半句的复验点) */
const navItems = computed(() => flattenNav(navTree.value));
const isSingleForm = computed(() => navItems.value.length === 1 && /^\/f\//.test(navItems.value[0]?.page || ""));
/** 当前内容视图:'fill' | 'data' | 'stats' | 'unknown' */
const activeView = computed<"fill" | "data" | "stats" | "unknown">(() => {
const v = viewParam.value.trim();
if (!v) return isSingleForm.value ? "fill" : navItems.value.length ? "data" : "unknown";
if (v === "data") return "data";
if (v === "stats") return "stats";
if (v === "fill") return "fill";
// 模块行里配置的完整 path 段(如 /app/honor/data 的 "data" 已覆盖;未知段按 unknown 呈现)
return "unknown";
});
const currentTitle = computed(() => {
if (activeView.value === "data") return "数据查看";
if (activeView.value === "stats") return "统计";
if (activeView.value === "fill") return "填报";
return "应用表单";
});
async function loadTree() {
if (!appCode.value) {
errorText.value = "地址里缺少 appCode";
return;
}
loading.value = true;
errorText.value = "";
emptyTree.value = false;
try {
// 目标应用的模块树:H4 未上线时后端返回空树(P3 口径),这里如实显示空态
const tree: IModule[] = (await baseApi.getUserMod(appCode.value).send()) || [];
rawTree.value = tree;
// 导航树:path 空节点(权限锚点)跳过(menuNav 规则①,V26 第二半句)
navTree.value = buildNavTree(tree, resolveMenuPage);
emptyTree.value = navTree.value.length === 0;
} catch (e: any) {
rawTree.value = [];
navTree.value = [];
errorText.value = e?.message || "目标应用的模块树取不到";
emptyTree.value = true;
} finally {
loading.value = false;
}
}
/** 轻导航点击:按模块行的 path 分派到本页的内容视图段 */
function gotoNav(item: { page: string }) {
const p = item.page;
if (/^\/f\//.test(p)) {
router.push(p);
return;
}
const m = /^\/app\/[^/]+\/(.+)$/.exec(p);
const seg = m ? m[1] : p.replace(/^\//, "");
router.push(`/app/${appCode.value}/${seg}`);
}
/** 树里第一个可跳转项(单表单直落/默认落点用) */
function firstNavPath(): string {
return navItems.value[0]?.page || "";
}
onMounted(async () => {
await loadTree();
// 单表单直落:树里只有一个填报行且当前没带子视图段 → 直接把内容区渲染成该表单的填报
// (不跳转,避免把用户从 /app/{appCode}/ 弹到别处;用户看着地址栏仍是应用入口)
if (!viewParam.value && isSingleForm.value) {
// 保持在本页渲染填报(activeView 已算成 fill),无需额外动作
}
});
watch(appCode, () => loadTree());
</script>
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<h4 class="subtitle">快应用上下文</h4>
<Alert show-icon type="info">
<code>/app/:appCode/*</code> 路由骨架占位;目标应用模块树渲染、单表单直落与应用聚合轻导航在 T8 落地。
<div class="app-head">
<span class="app-tag">快应用</span>
<code class="app-code">{{ appCode || "(空)" }}</code>
<span class="toolbar-note">模块树按目标应用取(GetUserMod?appCode=),范围以后端为准</span>
<Button icon="md-refresh" size="small" @click="loadTree">刷新</Button>
</div>
<Alert v-if="errorText" show-icon type="error" class="region-tip">{{ errorText }}</Alert>
<Alert v-else-if="emptyTree" show-icon type="warning" class="region-tip">
「{{ appCode }}」这个应用当前没有可进入的模块行。
<br />两种可能:① 你的身份没有被授权该应用的任何模块行(受限身份未见集由后端决定);
② 平台侧尚未上线目标应用的模块树查询(UserBase 的 appCode 参数,H4)——届时本接口返回空树,
<b>本页刻意不回落表单中心自己的菜单</b>(回落会把「要 A 应用的菜单」伪装成「A 应用就这些」)。
</Alert>
<p class="app-meta">
appCode = <b>{{ appCode || "(空)" }}</b>
<template v-if="view">|view = <b>{{ view }}</b></template>
<template v-else>
<!-- 应用聚合:多于一个可跳转行时给轻导航(单表单直落时不出导航) -->
<div v-if="!isSingleForm && navItems.length" class="light-nav">
<span class="nav-live">
<a v-for="item in navItems" :key="item.id" href="javascript:;"
:class="['nav-item', { active: route.path === item.page }]" @click="gotoNav(item)">
{{ item.name }}
</a>
</span>
<span class="toolbar-note">path 空的权限锚点行(如"导出")不进导航</span>
</div>
<h5 class="section-title">{{ currentTitle }}</h5>
<!-- 数据/统计视图复用中心管理端组件:定义管理权不在快应用,codes 只来自模块树的填报行;
embedded 让它只渲染本体,避免与本页的白卡/内边距叠成两层;
modules 把**本页刚拉到的那棵应用树**传进去——导出按钮的锚点判定吃它,不吃中心会话树(P76) -->
<Data v-if="activeView === 'data'" context="app" :app-code="appCode" :codes="fillCodes"
:modules="rawTree" embedded />
<Stats v-else-if="activeView === 'stats'" context="app" :app-code="appCode" :codes="fillCodes" embedded />
<template v-else-if="activeView === 'fill'">
<Alert show-icon type="info">
单表单直落:该应用只授权了一个填报行,内容区直接渲染填报,不再叠一层导航。
<br />填报目标:<code>{{ firstNavPath() || "(树里没有 /f/{code} 行)" }}</code>
</Alert>
<div class="fill-mount">
<div class="fill-hint">
直接进入填报页:
<a href="javascript:;" @click="firstNavPath() && router.push(firstNavPath())">
{{ firstNavPath() || "—" }}
</a>
(填报页本体是 T7 交付的 /f/:code,本页不复制一份)
</div>
</div>
</template>
<Alert v-else show-icon type="warning">
该应用模块行里的这一段路径没有对应视图(本页只认识 data / stats / fill 三种,
以及 path 形如 /f/{code} 的填报行)。请核对平台侧的模块行配置。
</Alert>
</template>
<p class="toolbar-note foot-note">
V26 前端侧三半句:①无导出授权不画导出按钮(快应用按 path 空的"导出"锚点行判)——
导出按钮在数据视图内按该规则显隐;②导航不渲染 path 空节点;③学生身份只见填报路由(树里授权的直接结果)。
接口侧的拒绝是 T4 已验的另一半,前端藏按钮不等于授权。
</p>
</div>
</div>
</template>
<style scoped>
.app-meta {
margin: 16px 0 0;
.app-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.app-tag {
display: inline-block;
padding: 1px 8px;
font-size: 12px;
color: #fff;
background: #2d8cf0;
border-radius: 3px;
}
.app-code {
color: #17233d;
}
.toolbar-note {
color: #808695;
font-size: 12px;
}
.region-tip {
margin-bottom: 12px;
}
.light-nav {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.nav-live {
display: inline-flex;
gap: 4px;
}
.nav-item {
display: inline-block;
padding: 3px 12px;
font-size: 13px;
color: #515a6e;
border-radius: 3px;
}
.nav-item:hover {
color: #2d8cf0;
background: #f0f6ff;
}
.nav-item.active {
color: #2d8cf0;
background: #f0f6ff;
font-weight: 600;
}
.section-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
color: #17233d;
border-left: 3px solid #2d8cf0;
padding-left: 8px;
}
.fill-mount {
padding: 16px 0;
}
.fill-hint {
font-size: 13px;
color: #515a6e;
}
.foot-note {
display: block;
margin-top: 20px;
line-height: 1.6;
}
</style>
+507 -20
View File
@@ -1,30 +1,394 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
/**
* 填报页 /f/:code 占位(T5)。
* 本体(code 寻址 + resolve、mode 闭集、控件渲染与校验、提交与幂等、ResizeObserver 高度上报)在 T7 落地。
* 本轮只固定两件事:①路由形态与参数名;②self != top 的剥壳态可被肉眼确认(壳由 App.vue 判,
* 这里只把它显示出来,方便冒烟时区分直航/嵌入两种形态——方案 §3.3 同一 URL 的两种形态)。
* 填报页 /f/:code(T7 落地,替换 T5 占位)。
*
* 契约纪律:
* 1. mode 闭集(page/embed),显式优先,self!=top 仅作未传时自动兜底(契约 §7);
* 2. 身份一律 CAS 会话自取,任何身份注入被禁止(V17);
* 3. 提交带客户端幂等键(uuid),命中返回 200+原 answerId(契约 §5);
* 4. 值渲染默认转义,全仓禁 v-html(方案 §2.3 红线);
* 5. 三态空值与已删字段降级(契约 §3)。
*/
import { computed, h, onMounted, onUnmounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { fieldWidget } from "@/components/fields";
import type { DesignField } from "@/types/formSchema";
import type { ResolvedForm, MyAnswerItem } from "@/types/answer";
import { resolveForm, submitAnswer, listMyAnswers, deleteMyAnswer, editMyAnswer } from "@/api/module/fill";
import { startHeightReporting, reportSubmitResult, reportNeedLogin } from "@/utils/embed";
import Utils from "@/utils";
import { Message, Modal } from "view-ui-plus";
const route = useRoute();
// ==================== mode 闭集(契约 §7) ====================
const MODE_WHITELIST = ["page", "embed"] as const;
type FillMode = typeof MODE_WHITELIST[number];
const explicitMode = computed(() => {
const raw = String(route.query.mode ?? "").trim().toLowerCase();
if (MODE_WHITELIST.includes(raw as any)) return raw as FillMode;
if (raw) console.warn(`[T7] mode 参数 "${raw}" 不在白名单 ${MODE_WHITELIST.join("/")},已拒绝(V36)`);
return null;
});
const isEmbedded = computed(() => {
if (explicitMode.value) return explicitMode.value === "embed";
return window.self !== window.top;
});
// ==================== 表单解析 ====================
const code = computed(() => String(route.params.code ?? ""));
const embedded = window.self !== window.top;
const appCode = computed(() => String(route.query.appCode ?? ""));
const form = ref<ResolvedForm | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
async function loadForm() {
loading.value = true;
error.value = null;
try {
form.value = await resolveForm(code.value, appCode.value || undefined);
} catch (e: any) {
error.value = e?.message || "表单加载失败";
if (e?.code === 4) {
if (isEmbedded.value) {
reportNeedLogin();
} else {
window.location.href = "/cas/login?service=" + encodeURIComponent(window.location.href);
}
}
} finally {
loading.value = false;
}
}
// ==================== 字段渲染与校验 ====================
const formValues = ref<Record<string, any>>({});
const formErrors = ref<Record<string, string>>({});
function getFieldWidget(type: string) {
return fieldWidget(type, "pc");
}
function getFieldValue(key: string, defaultValue?: any) {
const val = formValues.value[key];
if (val === undefined || val === null || val === "") return defaultValue ?? "";
return val;
}
function setFieldValue(key: string, value: any) {
formValues.value[key] = value;
delete formErrors.value[key];
}
function validateField(field: DesignField): string | null {
const value = formValues.value[field.key];
if (field.required && (value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0))) {
return `${field.label} 为必填项`;
}
if (field.type === "text" || field.type === "textarea" || field.type === "phone") {
if (typeof value === "string" && field.maxLength && value.length > field.maxLength) {
return `${field.label} 不能超过 ${field.maxLength} 个字符`;
}
if (field.pattern && typeof value === "string" && value && !new RegExp(field.pattern).test(value)) {
return `${field.label} 格式不正确`;
}
}
if (field.type === "number" && typeof value === "number") {
if (field.min !== undefined && value < field.min) return `${field.label} 不能小于 ${field.min}`;
if (field.max !== undefined && value > field.max) return `${field.label} 不能大于 ${field.max}`;
}
return null;
}
function validateAll(): boolean {
const errors: Record<string, string> = {};
if (!form.value?.groups) return true;
const groups = form.value.groups as any[];
for (const group of groups) {
for (const field of group.fields || []) {
const err = validateField(field);
if (err) errors[field.key] = err;
}
}
formErrors.value = errors;
return Object.keys(errors).length === 0;
}
// ==================== 提交 ====================
const submitting = ref(false);
const submitted = ref(false);
const submitResult = ref<{ answerId: string; answerNo: string | null } | null>(null);
async function handleSubmit() {
if (!form.value || submitting.value) return;
if (!validateAll()) {
Message.error("请检查表单填写");
return;
}
if (form.value.deadlineReached) {
Message.error("已过截止时间,无法提交");
return;
}
submitting.value = true;
try {
const idempotencyKey = Utils.uuid();
const result = await submitAnswer({
formCode: code.value,
appCode: appCode.value || undefined,
idempotencyKey,
values: formValues.value
});
submitResult.value = { answerId: result.answerId, answerNo: result.answerNo };
submitted.value = true;
if (isEmbedded.value) {
reportSubmitResult({ success: true, answerId: result.answerId, answerNo: result.answerNo || undefined });
} else {
const redirectUrl = String(route.query.redirect ?? "");
if (redirectUrl) {
const finalUrl = redirectUrl.replace("{answerId}", result.answerId);
window.location.href = finalUrl;
} else {
Message.success("提交成功" + (result.answerNo ? `,报名号:${result.answerNo}` : ""));
}
}
} catch (e: any) {
const msg = e?.message || "提交失败";
Message.error(msg);
if (isEmbedded.value) {
reportSubmitResult({ success: false, error: msg });
}
} finally {
submitting.value = false;
}
}
// ==================== 我的提交段 ====================
const myAnswers = ref<MyAnswerItem[]>([]);
const editingAnswer = ref<MyAnswerItem | null>(null);
async function loadMyAnswers() {
try {
myAnswers.value = await listMyAnswers(code.value, appCode.value || undefined);
} catch (e: any) {
console.error("加载我的提交失败:", e);
}
}
function handleEditAnswer(answer: MyAnswerItem) {
editingAnswer.value = answer;
formValues.value = { ...answer.values };
window.scrollTo(0, 0);
}
async function handleUpdateAnswer() {
if (!editingAnswer.value || submitting.value) return;
if (!validateAll()) {
Message.error("请检查表单填写");
return;
}
submitting.value = true;
try {
await editMyAnswer(editingAnswer.value.answerId, formValues.value);
Message.success("更新成功");
editingAnswer.value = null;
formValues.value = {};
await loadMyAnswers();
} catch (e: any) {
Message.error(e?.message || "更新失败");
} finally {
submitting.value = false;
}
}
function handleDeleteAnswer(answer: MyAnswerItem) {
Modal.confirm({
title: "确认删除",
content: "删除后可重新填写(软删释放限次名额),确定删除吗?",
onOk: async () => {
try {
await deleteMyAnswer(answer.answerId);
Message.success("删除成功");
await loadMyAnswers();
} catch (e: any) {
Message.error(e?.message || "删除失败");
}
}
});
}
function handleCancelEdit() {
editingAnswer.value = null;
formValues.value = {};
formErrors.value = {};
}
// ==================== 三态空值与已删字段降级(契约 §3) ====================
// Note: renderFieldValue is kept for future use in read-only display scenarios
// const renderFieldValue = (field: DesignField, values: Record<string, any>): string => { ... }
// ==================== ResizeObserver 高度上报 ====================
const containerRef = ref<HTMLElement | null>(null);
let stopHeightReporting: (() => void) | null = null;
onMounted(() => {
loadForm();
loadMyAnswers();
if (isEmbedded.value && containerRef.value) {
stopHeightReporting = startHeightReporting(containerRef.value);
}
});
onUnmounted(() => {
if (stopHeightReporting) stopHeightReporting();
});
watch(code, () => {
loadForm();
loadMyAnswers();
});
// ==================== 只读态判定 ====================
const isReadonly = computed(() => {
if (!form.value) return false;
if (form.value.deadlineReached) return true;
if (form.value.fillLimit === 1 && myAnswers.value.length > 0) return true;
return false;
});
const readonlyReason = computed(() => {
if (!form.value) return "";
if (form.value.deadlineReached) return `已过截止时间(${form.value.deadline})`;
if (form.value.fillLimit === 1 && myAnswers.value.length > 0) return "您已提交过该表单(限填一次)";
return "";
});
</script>
<template>
<!-- 嵌入态不套 page-wrapper 骨架:剥壳后由页面自己铺满(T7 接高度上报) -->
<div :class="embedded ? 'fill-plain' : 'page-wrapper yh-fill'">
<div ref="containerRef" :class="isEmbedded ? 'fill-plain' : 'page-wrapper yh-fill'">
<div class="main-container">
<h4 class="subtitle">填报</h4>
<Alert show-icon type="info">
<code>/f/:code</code> 路由骨架占位,表单寻址与答卷提交在 T7 落地。
</Alert>
<p class="fill-meta">
code = <b>{{ code || "(空)" }}</b>
|形态 = <b>{{ embedded ? "页面块(self != top,已剥壳)" : "整页(直航,带壳)" }}</b>
</p>
<!-- 加载中 -->
<div v-if="loading" class="loading-state">
<Spin fix>
<Icon type="ios-loading" size=18 class="spin-icon-load"></Icon>
<div>加载中...</div>
</Spin>
</div>
<!-- 错误态 -->
<div v-else-if="error" class="error-state">
<Alert show-icon type="error">
{{ error }}
<template v-if="!isEmbedded" slot desc>
<a href="/cas/login">重新登录</a>
</template>
</Alert>
</div>
<!-- 表单不存在/停用/从未发布 -->
<div v-else-if="!form" class="empty-state">
<Alert show-icon type="warning">
表单不存在、已停用或从未发布
</Alert>
</div>
<!-- 表单内容 -->
<div v-else class="form-content">
<h2 class="form-title">{{ form.name }}</h2>
<!-- 只读态提示 -->
<Alert v-if="isReadonly" show-icon type="info" class="readonly-tip">
{{ readonlyReason }},当前为只读模式
</Alert>
<!-- 提交成功态 -->
<div v-if="submitted && submitResult" class="success-state">
<Alert show-icon type="success">
提交成功!
<template v-if="submitResult.answerNo" slot desc>
报名号:<b>{{ submitResult.answerNo }}</b>
</template>
</Alert>
</div>
<!-- 编辑态提示 -->
<Alert v-if="editingAnswer" show-icon type="info" class="edit-tip">
正在编辑您的提交({{ editingAnswer.submitTime }})
<Button type="text" @click="handleCancelEdit">取消编辑</Button>
</Alert>
<!-- 表单字段 -->
<div v-if="!submitted && !isReadonly || editingAnswer" class="form-groups">
<div v-for="group in (form.groups as any[])" :key="group.key" class="form-group">
<h3 v-if="group.label" class="group-label">{{ group.label }}</h3>
<div v-for="field in (group.fields || [])" :key="field.key" class="form-item">
<label class="form-label">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<div class="form-control">
<component :is="getFieldWidget(field.type)" :field="field"
:options="field.options || []" :model-value="getFieldValue(field.key, field.defaultValue)"
:readonly="isReadonly && !editingAnswer"
@update:model-value="(val: any) => setFieldValue(field.key, val)" />
<div v-if="formErrors[field.key]" class="form-error">{{ formErrors[field.key] }}</div>
<div v-if="field.tip" class="form-tip">{{ field.tip }}</div>
</div>
</div>
</div>
<!-- 提交按钮 -->
<div v-if="!isReadonly || editingAnswer" class="form-actions">
<Button v-if="editingAnswer" type="primary" :loading="submitting" @click="handleUpdateAnswer">
更新提交
</Button>
<Button v-else type="primary" :loading="submitting" :disabled="isReadonly"
@click="handleSubmit">
提交
</Button>
</div>
</div>
<!-- 我的提交段 -->
<div v-if="!editingAnswer && myAnswers.length > 0" class="my-answers">
<h3 class="section-title">我的提交</h3>
<Table :columns="[{
title: '提交时间',
key: 'submitTime',
width: 180
}, {
title: '报名号',
key: 'answerNo'
}, {
title: '状态',
key: 'statusLabel'
}, {
title: '操作',
key: 'actions',
width: 150,
render: (_h: any, { row }: any) => {
return h('div', { class: 'action-buttons' }, [
h('Button', {
props: { type: 'text', size: 'small' },
on: { click: () => handleEditAnswer(row) }
}, '编辑'),
h('Button', {
props: { type: 'text', size: 'small', color: 'error' },
on: { click: () => handleDeleteAnswer(row) }
}, '删除')
]);
}
}]" :data="myAnswers" border></Table>
</div>
</div>
</div>
</div>
</template>
@@ -32,8 +396,131 @@ const embedded = window.self !== window.top;
<style scoped>
.fill-plain {
height: 100%;
padding: 16px;
}
.fill-meta {
margin: 16px 0 0;
.page-wrapper.yh-fill {
min-height: 100vh;
background: #f5f7f9;
}
.main-container {
max-width: 800px;
margin: 0 auto;
padding: 24px;
background: white;
border-radius: 4px;
}
.loading-state,
.error-state,
.empty-state {
padding: 40px 0;
}
.form-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
color: #17233d;
}
.readonly-tip,
.edit-tip {
margin-bottom: 16px;
}
.success-state {
margin-bottom: 24px;
}
.form-groups {
margin-bottom: 24px;
}
.form-group {
margin-bottom: 24px;
}
.group-label {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #17233d;
border-bottom: 1px solid #e8eaec;
padding-bottom: 8px;
}
.form-item {
margin-bottom: 16px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #515a6e;
}
.required {
color: #ed4014;
margin-left: 2px;
}
.form-control {
position: relative;
}
.form-error {
margin-top: 4px;
font-size: 12px;
color: #ed4014;
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: #808695;
}
.form-actions {
margin-top: 24px;
text-align: center;
}
.my-answers {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #e8eaec;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #17233d;
}
.action-buttons {
display: flex;
gap: 8px;
}
.spin-icon-load {
animation: ani-spin 1s linear infinite;
}
@keyframes ani-spin {
from {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
to {
transform: rotate(360deg);
}
}
</style>
+276 -2
View File
@@ -1,7 +1,281 @@
<script setup lang="ts">
import PageStub from "@/components/PageStub.vue";
/**
* 我的填写 /my(T8 落地,替换 T5 占位)。
*
* 两视图 = 两个**独立入口**,不做 query 参数分支(方案 §6.3 原文:"做成两个视图不做一个接口加参数
* (鉴权分支容易写漏)",V18):
* - `/my/submit` = 我提交的(submitter = 会话用户)→ `GET /api/form/my/submit-list`;
* - `/my/about` = 关于我的(subject = 会话用户,含管理员代填的那几条)→ `GET /api/form/my/about-list`。
* 两者是**两个端点、两个谓词**,页面按路由分派;顶部 Tab 只是 UI 导航(切路由),不是接口参数。
* 管理员代填后:这条答卷在**填表人的"我提交的"**里出现,同时在**被代填人的"关于我的"**里出现(V18)。
*
* 三条交互口径沿 T7 的"我的提交"段(不另造轮子):
* 1. **编辑入口只在"我提交的"**:编辑=填表人的动作;"关于我的"整列只读
* (被代填人能看不能改——改是填表人或管理员的动作,后端 myDetail 同口径)。
* 删除同理只在"我提交的"出现(删的是自己交的那条)。
* 2. **填表人无状态入口**:两个视图都不出现"改状态"(V14/V29 前端半句)——
* 后端 FormMyAnswerController 里根本没有状态端点,前端也不画这个按钮。
* 3. **编辑按钉住 version**:详情走 `GET /api/form/my/detail`(钉住版本 groups + 值文档),
* 复用 T7/T8 同一个编辑弹层(mode="my"),按同版本校验提交。
*
* 身份一律会话推导(V17):请求里不带任何用户 id 参数。数据面身份只用 uuid(V5)。
*/
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight";
import { deleteMyAnswer, getMyAnswerDetail, listAnswersAboutMe, listMySubmitted } from "@/api/module/fill";
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
import AnswerValueView from "@/components/answer/AnswerValueView.vue";
import { statusDisplay } from "@/utils/dataValue";
import { useDefaultStore } from "@/store";
import type { AnswerDetail, MyAnswerViewItem } from "@/types/data";
const route = useRoute();
const router = useRouter();
const store = useDefaultStore();
/** 当前视图:只由路由路径决定(两个独立入口,不看 query) */
const view = computed<"submit" | "about">(() => (String(route.path).endsWith("/about") ? "about" : "submit"));
const VIEW_META = {
submit: {
title: "我提交的",
endpoint: "GET /api/form/my/submit-list",
predicate: "谓词:submitter_id = 会话用户",
note: "这里是我交过的答卷(管理员代填的也在——提交人是我)。可以编辑/删除自己的答卷。"
},
about: {
title: "关于我的",
endpoint: "GET /api/form/my/about-list",
predicate: "谓词:subject_id = 会话用户",
note: "这些数据是关于我的(老师/管理员代填后指定的主体是我)。整列只读——我不是提交人,改/删是提交人或管理员的动作。"
}
} as const;
const meta = computed(() => VIEW_META[view.value]);
const regionRef = ref<HTMLElement | null>(null);
const regionH = useElementHeight(regionRef);
const rows = ref<MyAnswerViewItem[]>([]);
const loading = ref(false);
const loadedOnce = ref(false);
const errorText = ref("");
/** 会话用户 id:判"这条是不是我提交的"(编辑/删除入口显隐),身份来源仍是会话(不传后端) */
const sessionUserId = computed(() => store.sessionUserId);
function isMine(row: MyAnswerViewItem): boolean {
return sessionUserId.value !== null && row.submitterId === sessionUserId.value;
}
async function load() {
loading.value = true;
errorText.value = "";
try {
rows.value = view.value === "about" ? await listAnswersAboutMe() : await listMySubmitted();
loadedOnce.value = true;
} catch (e: any) {
rows.value = [];
errorText.value = e?.message || "加载失败";
Message.error(errorText.value);
} finally {
loading.value = false;
}
}
function switchView(target: "submit" | "about") {
if (target === view.value) return;
router.push({ path: target === "about" ? "/my/about" : "/my/submit" });
}
// ==================== 详情 / 编辑 / 删除 ====================
const detailOpen = ref(false);
const detail = ref<AnswerDetail | null>(null);
async function openDetail(row: MyAnswerViewItem) {
try {
detail.value = await getMyAnswerDetail(row.answerId);
detailOpen.value = true;
} catch (e: any) {
Message.error(e?.message || "答卷详情取不到");
}
}
const editDetail = ref<AnswerDetail | null>(null);
/** 编辑入口:只在"我提交的"视图 + 这条确实是我提交的(两条同时成立才画按钮) */
async function openEdit(row: MyAnswerViewItem) {
try {
editDetail.value = await getMyAnswerDetail(row.answerId);
} catch (e: any) {
Message.error(e?.message || "答卷详情取不到");
}
}
function softDelete(row: MyAnswerViewItem) {
Modal.confirm({
title: "确认删除这条答卷?",
content:
"删除是软删(无物理删除):四条读出路径同时不可见,索引行同步删," +
"并**释放限填名额**——删掉后你可以重新填一次(契约 §5)。",
onOk: async () => {
try {
await deleteMyAnswer(row.answerId);
Message.success("已删除");
await load();
} catch (e: any) {
Message.error(e?.message || "删除失败");
}
}
});
}
const tableColumns = computed(() => {
const cols: any[] = [
{
title: "表单",
key: "formName",
minWidth: 200,
render: (h2: any, p: any) =>
h2("span", [
p.row.formName || p.row.code,
h2("span", { class: "form-code" }, `(${p.row.appCode}/${p.row.code})`)
])
},
{ title: "答卷号/报名号", key: "answerNo", width: 180, render: (h2: any, p: any) => h2("span", p.row.answerNo || "—") },
{
title: "状态",
key: "status",
width: 130,
render: (h2: any, p: any) => h2("span", statusDisplay(p.row.status, p.row.statusLabel))
},
{ title: "钉住版本", key: "version", width: 100, render: (h2: any, p: any) => h2("span", `v${p.row.version}`) },
{ title: "提交时间", key: "submitTime", width: 170 },
{
title: "提交人 / 被代填人",
key: "identity",
width: 190,
render: (h2: any, p: any) =>
h2(
"span",
`提交人 ${p.row.submitterId}${p.row.subjectId ? `|主体 ${p.row.subjectId}` : "(本人自填)"}`
)
}
];
// 操作列沿 P51 的恒定件:只在"我提交的"给编辑/删除;"关于我的"只给"详情"。
// 两个视图都不出现"改状态"(填表人无此权限——V14,后端也无该端点)。
const items: any[] = [{ key: "detail", label: "详情", on: (row: MyAnswerViewItem) => openDetail(row) }];
if (view.value === "submit") {
items.push({ key: "edit", label: "编辑", when: (row: MyAnswerViewItem) => isMine(row), on: (row: MyAnswerViewItem) => openEdit(row) });
items.push({
key: "delete",
label: "删除",
danger: true,
when: (row: MyAnswerViewItem) => isMine(row),
on: (row: MyAnswerViewItem) => softDelete(row)
});
}
cols.push(actionColumn(items, { mode: "text", maxInline: 2 }));
return cols;
});
watch(view, () => {
loadedOnce.value = false;
load();
});
onMounted(load);
</script>
<template>
<PageStub title="我的填写" stage="T8" note="全员授权行(手册 §2.2):我提交的/关于我的两视图在 T8 落地,两个视图不做参数分支。" />
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="view-head">
<RadioGroup :model-value="view" type="button" @on-change="(v: string | number) => switchView(String(v) as 'submit' | 'about')">
<Radio label="submit">我提交的</Radio>
<Radio label="about">关于我的</Radio>
</RadioGroup>
<Button icon="md-refresh" @click="load">刷新</Button>
<span class="toolbar-note">
{{ meta.endpoint }} | {{ meta.predicate }}(两个端点两个谓词,不做参数分支)
</span>
</div>
<Alert show-icon type="info" class="region-tip">
<b>{{ meta.title }}</b>:{{ meta.note }}
<template v-if="view === 'about'">
<br />本视图不渲染编辑/删除入口(填表人才能改自己提交的那条);也不渲染改状态(填表人在接口层即被拒)。
</template>
</Alert>
<div class="table-region" ref="regionRef">
<Table :columns="tableColumns" :data="rows" :height="regionH || undefined" :loading="loading"
no-data-text="这里还没有数据" class="link-table" />
</div>
<p class="toolbar-note foot-note">
共 {{ rows.length }} 条(跨表单,不带值文档——要看值走详情,值文档与钉住版本的 schema 一起下发的,
渲染与校验同源)。
</p>
</div>
<Modal v-model="detailOpen" :title="`答卷详情(${detail?.formName || detail?.code || ''})`" :width="720"
@on-cancel="detailOpen = false">
<template v-if="detail">
<div class="meta-line">
<span>答卷 <code>{{ detail.answerId }}</code></span>
<span>钉住版本 v{{ detail.version }}</span>
<span>状态 {{ statusDisplay(detail.status, detail.statusLabel) }}</span>
</div>
<AnswerValueView :groups="detail.groups" :values="detail.values" />
</template>
<template #footer>
<Button @click="detailOpen = false">关闭</Button>
</template>
</Modal>
<AnswerEditModal v-if="editDetail" :detail="editDetail" mode="my" @close="editDetail = null" @saved="load" />
</div>
</template>
<style scoped>
.view-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar-note {
color: #808695;
font-size: 12px;
}
.region-tip {
margin-bottom: 12px;
}
.form-code {
color: #808695;
font-size: 12px;
}
.meta-line {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 12px;
color: #808695;
font-size: 12px;
}
.meta-line code {
color: #17233d;
}
.foot-note {
display: block;
margin-top: 12px;
line-height: 1.6;
}
</style>
+246
View File
@@ -0,0 +1,246 @@
<script setup lang="ts">
/**
* 答卷详情(T8,任务书"详情页"):中心上下文 `/admin/data/detail?answerId=&lt;uuid&gt;`,
* 快应用上下文 `/app/{appCode}/data/detail?answerId=&lt;uuid&gt;`(P77——同一个组件、同一条 uuid 链,
* 返回按地址段认路,不把用户弹出应用壳)。
*
* 三条纪律:
* 1. **uuid 寻址**(V5):参数与页面文案里出现的答卷身份只有 32 位 uuid,库内自增 id 不在这条链上,
* 也不在任何响应里(后端 ReadModels 的注释是同一条);
* 2. **按钉住版本呈现**:`groups` 是这条答卷提交时那个版本的 schema(后端 detail 直接下发),
* 所以升版后回看历史答卷不会看到"当年没有的字段",三态空值与已删字段由同一份呈现函数降级显示(V8);
* 3. **改状态/编辑入口按管理权显隐**(V29 前端半句):判据是"授权模块集里有没有 `/admin/data` 这一行",
* 与后端守卫同一棵树(`FormAdminGuard`),前端藏按钮不等于授权,接口侧仍会拒。
* ——这一条在两种上下文里都按**中心会话树**判:后端 detail/list/export 走的仍是中心 `/admin/data` 行,
* 快应用自己的"数据查看"行不参与接口把关(该口径缺口登记执行手册 §5,见 T9 报告)。
*/
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { dataApi } from "@/api/module/data";
import { resolveForm } from "@/api/module/fill";
import AnswerValueView from "@/components/answer/AnswerValueView.vue";
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
import { useDefaultStore } from "@/store";
import { hasPage } from "@/utils/modulePerms";
import { statusDisplay } from "@/utils/dataValue";
import type { AnswerDetail } from "@/types/data";
import type { StatusDefView } from "@/types/form";
const route = useRoute();
const router = useRouter();
const store = useDefaultStore();
const answerId = computed(() => String(route.query.answerId || "").trim());
const detail = ref<AnswerDetail | null>(null);
const loading = ref(false);
const error = ref("");
/** 数据管理权(授权模块行判定;填表人身份进来时这里就是 false——V29 前端半句) */
const canManage = computed(() => hasPage(store.modules, "/admin/data"));
const liveStatusDefs = ref<StatusDefView[]>([]);
const statusModal = ref<{ visible: boolean; target: string }>({ visible: false, target: "" });
const editOpen = ref(false);
async function load() {
if (!answerId.value) {
error.value = "缺少 answerId(答卷 uuid)——数据面只认 uuid,地址形如 ?answerId=&lt;32 位 uuid&gt;";
return;
}
loading.value = true;
error.value = "";
try {
detail.value = await dataApi.detail(answerId.value).send();
} catch (e: any) {
// 不在范围内=FORBIDDEN(与列表同一构造器判:列表看不见的详情也打不开)
error.value = e?.message || "答卷加载失败";
detail.value = null;
} finally {
loading.value = false;
}
}
/**
* 状态候选来自该表单的<b>当前发布版本</b>定义(detail 响应不含 statusDefs,这是 T4 已验收的形状)。
* 取数走 resolve——它是契约 §1 里读接口的唯一入口,且不要求"表单设计"模块行,
* 所以只授权数据管理的身份也能拿到;表单已停用/从未发布时 resolve 会拒,
* 那时就不给改状态入口(宁可少一个动作,也不编一份假的状态表)。
*/
async function loadStatusDefs() {
if (!detail.value) return;
try {
const resolved = await resolveForm(detail.value.code, detail.value.appCode);
liveStatusDefs.value = (resolved.statusDefs || []).filter((d) => !d.is_deleted);
} catch (e: any) {
console.warn("[T8] 状态定义取不到(表单可能已停用/未发布):", e?.message || e);
liveStatusDefs.value = [];
}
}
function back() {
// 归属上下文由地址段给出(P77):/app/{appCode}/data/detail → 回该应用的数据视图;否则回中心数据管理
const app = String(route.params.appCode || "");
if (app) router.push({ path: `/app/${app}/data`, query: { form: detail.value?.formUuid } });
else router.push({ path: "/admin/data", query: { form: detail.value?.formUuid } });
}
function openStatus() {
if (!liveStatusDefs.value.length) {
Message.warning("这张表的状态定义取不到(可能表单定义已被清理),本轮不给出改状态入口");
return;
}
statusModal.value = { visible: true, target: liveStatusDefs.value[0].code };
}
async function submitStatus() {
statusModal.value.visible = false;
try {
await dataApi.changeStatus(answerId.value, statusModal.value.target).send();
Message.success("已改状态并写 status_log");
await load();
} catch (e: any) {
Message.error(e?.message || "改状态失败");
}
}
function softDelete() {
Modal.confirm({
title: "确认软删这条答卷?",
content: "软删是唯一的删除形态(无物理删除接口):四条读出路径同时不可见,索引行同步删,限填名额释放。",
onOk: async () => {
try {
await dataApi.softDelete(answerId.value).send();
Message.success("已软删");
back();
} catch (e: any) {
Message.error(e?.message || "软删失败");
}
}
});
}
onMounted(async () => {
await load();
await loadStatusDefs();
});
</script>
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="head-bar">
<Button icon="ios-arrow-back" @click="back">返回列表</Button>
<span v-if="detail" class="crumb">{{ detail.formName || detail.code }}</span>
</div>
<Alert v-if="loading" show-icon type="info">加载中…</Alert>
<Alert v-else-if="error" show-icon type="error">{{ error }}</Alert>
<template v-else-if="detail">
<div class="meta-card">
<div class="meta-row"><span class="meta-key">答卷 uuid</span><code>{{ detail.answerId }}</code></div>
<div class="meta-row"><span class="meta-key">表单</span>
<span>{{ detail.formName }}({{ detail.appCode }}/{{ detail.code }})</span>
</div>
<div class="meta-row"><span class="meta-key">钉住版本</span>
<span>v{{ detail.version }}(按这一版渲染,与当前发布版无关)</span>
</div>
<div class="meta-row"><span class="meta-key">状态</span>
<span>{{ statusDisplay(detail.status, detail.statusLabel) }}</span>
</div>
<div class="meta-row"><span class="meta-key">报名号</span><span>{{ detail.answerNo || "—" }}</span></div>
<div class="meta-row"><span class="meta-key">提交时间</span><span>{{ detail.submitTime }}</span></div>
<div class="meta-row"><span class="meta-key">提交人 / 被代填人</span>
<span>{{ detail.submitterId }} / {{ detail.subjectId ?? "—(本人自填)" }}</span>
</div>
<div class="meta-row"><span class="meta-key">表单 uuid</span><code>{{ detail.formUuid }}</code></div>
</div>
<div class="actions" v-if="canManage">
<Button type="primary" @click="editOpen = true">编辑值</Button>
<Button @click="openStatus">改状态</Button>
<Button type="error" ghost @click="softDelete">软删</Button>
<span class="tip-note">填表人身份看不到这一排入口(V29 前端半句);接口侧的拒绝是另一道,不冲突。</span>
</div>
<Alert v-else show-icon type="info" class="actions">
当前身份没有数据管理模块行,本页只读(改状态/编辑/删除入口不渲染)。
</Alert>
<h5 class="section-title">答卷值(钉住版本 v{{ detail.version }})</h5>
<AnswerValueView :groups="detail.groups" :values="detail.values" />
</template>
<Modal v-model="statusModal.visible" title="改状态" :width="420">
<Form :label-width="90">
<FormItem label="当前状态">
<span>{{ detail ? statusDisplay(detail.status, detail.statusLabel) : "—" }}</span>
</FormItem>
<FormItem label="目标状态">
<Select v-model="statusModal.target">
<Option v-for="def in liveStatusDefs" :key="def.code" :value="def.code">{{ def.label }}</Option>
</Select>
</FormItem>
</Form>
<p class="tip-note">
状态是标记不是流转:不校验从哪个状态到哪个状态、无条件分支(方案 §5.3 红线、V15);改动写 status_log。
</p>
<template #footer>
<Button @click="statusModal.visible = false">取消</Button>
<Button type="primary" @click="submitStatus">确认</Button>
</template>
</Modal>
<AnswerEditModal v-if="detail && editOpen" :detail="detail" mode="admin" @close="editOpen = false"
@saved="load" />
</div>
</div>
</template>
<style scoped>
.head-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.crumb {
color: #808695;
font-size: 13px;
}
.meta-card {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 6px 24px;
padding: 12px 16px;
background: #f8f8f9;
border-radius: 4px;
margin-bottom: 12px;
}
.meta-row {
display: flex;
gap: 10px;
font-size: 13px;
align-items: baseline;
}
.meta-key {
color: #808695;
min-width: 130px;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.tip-note {
color: #808695;
font-size: 12px;
}
.section-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
color: #17233d;
}
</style>
+770 -6
View File
@@ -1,11 +1,775 @@
<script setup lang="ts">
import PageStub from "@/components/PageStub.vue";
/**
* 数据列表 /admin/data(T8 落地,替换 T5 占位)。
*
* 四处分寸,改动前请先读:
* 1. **翻页只有游标**:后端 keyset 响应没有 total、没有页码(方案 §5.5 铁律②),
* 所以这里只有"触底加载下一页",`cursor`=上一页末行 answerId(uuid)(P39),不能跳页;
* 2. **列 = 最新已发布 schema ∪ 值文档里出现过的历史 key**(方案 §5.2):
* 删字段不清历史索引行,历史 key 在值里还在,列上降级显示"(已移除字段)xxx"(V8 前端半句)。
* 筛选候选则**只认后端 `filterFields`**(当前发布版本 searchable 且有算子的字段)——
* 历史已删字段的 key 发给后端会被 `rejectUnknownKeys` 拒(缺口记执行手册 §5 问题单,前端不私造筛选项);
* 3. **可见列按 (归属应用, code) 记忆**(V40),localStorage 键见 `utils/dataValue.columnMemoryKey`;
* 4. **数据范围以后端为准**:响应里的 `scopes` 只用来把"你现在看到的是哪个范围"讲清楚,
* 前端不据此再过滤一遍(任务书 T8 红线)。
*
* 快应用上下文(AppContext)复用本组件:传 `context="app"` + `app-code` + `codes`
* (候选来自该应用模块树的 `/f/{code}` 行,经 resolve 拿 uuid——定义管理权不在快应用上下文里,§3.4)
* + `modules`(**同一棵原始模块树**,按钮级权限按它判,P76)。
*/
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message, Modal, Tooltip, Checkbox } from "view-ui-plus";
import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight";
import { useFormCandidates } from "@/utils/hook/useFormCandidates";
import { dataApi, downloadBlob, exportBlob } from "@/api/module/data";
import { useDefaultStore } from "@/store";
import { canExport } from "@/utils/modulePerms";
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
import {
cellText,
clipText,
columnMemoryKey,
deriveColumns,
loadColumnMemory,
reconcileVisibleKeys,
saveColumnMemory,
statusDisplay,
type DataCol
} from "@/utils/dataValue";
import { scopeLabel, type AnswerDetail, type AnswerRow, type FilterField, type FilterItem, type ListView } from "@/types/data";
import type { StatusDefView } from "@/types/form";
import type { DesignField } from "@/types/formSchema";
import type { IModule } from "@/types/Basics";
/** 行视图 + 勾选态(`_checked` 是 view-ui-plus Table 的行内选中字段,见 withSelectionState) */
type RowVM = AnswerRow & { _checked?: boolean };
const props = withDefaults(
defineProps<{
context?: "center" | "app";
/** app 上下文的归属应用(center 留空=form-center) */
appCode?: string;
/** app 上下文的候选 code(来自该应用模块树的 /f/{code} 行) */
codes?: string[];
/**
* 按钮级权限判定的**那棵树**(P76):app 上下文必须由宿主传入目标应用的模块树
* (GetUserMod?appCode= 的结果)——中心会话树里没有快应用的"导出"锚点行,
* 拿它判锚点等于永远判不中(fail-closed 方向:有授权也不画按钮)。
* center 上下文不传,仍按 `store.modules`(会话自己的树)判页面行。
*/
modules?: IModule[] | null;
/**
* 被宿主页面内嵌(快应用上下文 /app/:appCode/)时置 true:只渲染内容本体,
* 不再套一层 `.page-wrapper > .main-container`——否则会出现双层白卡与双份内边距。
*/
embedded?: boolean;
}>(),
{ context: "center", appCode: "", codes: () => [], modules: null, embedded: false }
);
const route = useRoute();
const router = useRouter();
const store = useDefaultStore();
const regionRef = ref<HTMLElement | null>(null);
const regionH = useElementHeight(regionRef);
const PAGE_SIZE = 20;
const BATCH_LIMIT = 200;
// ==================== 表单候选与当前版本(与统计页共用 useFormCandidates) ====================
const {
candidates,
candidateError,
current,
formUuid,
loadCandidates,
selectForm: chooseForm,
pickByCode
} = useFormCandidates(() => props.context, () => props.appCode, () => props.codes);
const manualCode = ref("");
/** 定义清单不可用时的直达入口:仍然只经 resolve(契约 §1"resolve 是读接口唯一入口") */
async function useManualCode() {
const code = manualCode.value.trim();
if (!code) {
Message.warning("请先输入表单 code");
return;
}
const uuid = await pickByCode(code);
if (!uuid) {
Message.error(candidateError.value || "按 code 解析失败");
return;
}
manualCode.value = "";
await selectForm(uuid);
}
// ==================== 筛选 / 排序 / 游标 ====================
const filterFields = ref<FilterField[]>([]);
const filterRows = ref<FilterItem[]>([]);
const statusFilter = ref("");
const order = ref<"desc" | "asc">("desc");
const rows = ref<RowVM[]>([]);
const nextCursor = ref<string | null>(null);
const scopes = ref<string[]>([]);
const loading = ref(false);
const loadedOnce = ref(false);
/** 已选行(跨已加载页累积;换表单/换筛选即清空) */
const selected = ref<string[]>([]);
const liveStatusDefs = computed<StatusDefView[]>(() =>
(current.value?.statusDefs || []).filter((d: StatusDefView) => !d.is_deleted)
);
/** 生效范围文案(只展示,不过滤) */
const scopeText = computed(() => (scopes.value.length ? scopes.value.map(scopeLabel).join("、") : "后端未回传"));
async function loadFilterFields(uuid: string): Promise<void> {
try {
filterFields.value = (await dataApi.filterFields(uuid).send()) || [];
} catch (e: any) {
filterFields.value = [];
console.warn("[T8] 筛选候选取不到:", e?.message || e);
}
}
function queryBody() {
const filters = filterRows.value
.filter((f) => f.key)
.map((f) => ({
key: f.key,
value: f.value || undefined,
from: f.from || undefined,
to: f.to || undefined
}));
return {
formUuid: formUuid.value,
status: statusFilter.value || undefined,
filters: filters.length ? filters : undefined
};
}
/** 首屏/条件变更:重置游标从头取(keyset 没有"跳到第 N 页"这回事) */
async function reload() {
if (!formUuid.value) return;
loading.value = true;
try {
const res: ListView = await dataApi
.list({ ...queryBody(), cursor: null, order: order.value, size: PAGE_SIZE })
.send();
selected.value = [];
rows.value = withSelectionState(res?.rows || []);
nextCursor.value = res?.nextCursor ?? null;
scopes.value = res?.scopes || [];
loadedOnce.value = true;
} catch (e: any) {
Message.error(e?.message || "数据加载失败");
} finally {
loading.value = false;
}
}
/** 触底加载:cursor=上一页末行 uuid,服务端按 uk 换库内 id 并同样过范围(P39:不可见即拒) */
async function loadMore() {
if (!nextCursor.value || loading.value) return;
loading.value = true;
try {
const res: ListView = await dataApi
.list({ ...queryBody(), cursor: nextCursor.value, order: order.value, size: PAGE_SIZE })
.send();
rows.value = withSelectionState([...rows.value, ...(res?.rows || [])]);
nextCursor.value = res?.nextCursor ?? null;
if (res?.scopes) scopes.value = res.scopes;
} catch (e: any) {
Message.error(e?.message || "下一页加载失败");
} finally {
loading.value = false;
}
}
/**
* 行级勾选态回填(T9 观察项①落实):view-ui-plus 的 `data` 监听器会用 `makeObjBaseData` 重建
* `objData`,而那里的 `_isChecked` **只认行上的 `_checked`**(点选只改 objData,不回写行),
* 所以整组替换后已勾的行会弹回未选,而"已选 N 条"仍按 selected 累计——两处口径打架。
* 判据统一放这一处:`selected`(onSelectionChange 维护)才是唯一事实源,rows 每次都据它带 `_checked`。
*/
function withSelectionState(list: AnswerRow[]): RowVM[] {
return list.map((row) => ({ ...row, _checked: selected.value.includes(row.answerId) } as RowVM));
}
// ==================== 可见列(V40) ====================
const allColumns = computed<DataCol[]>(() => deriveColumns(current.value?.fields || [], rows.value));
const visibleKeys = ref<string[]>([]);
function applyMemory() {
if (!current.value) {
visibleKeys.value = [];
return;
}
const key = columnMemoryKey(current.value.appCode, current.value.code);
visibleKeys.value = reconcileVisibleKeys(allColumns.value, loadColumnMemory(key));
}
const colPicker = ref(false);
function toggleColumn(colKey: string) {
const next = visibleKeys.value.includes(colKey)
? visibleKeys.value.filter((k) => k !== colKey)
: [...visibleKeys.value, colKey];
visibleKeys.value = next;
if (current.value) saveColumnMemory(columnMemoryKey(current.value.appCode, current.value.code), next);
}
watch(allColumns, applyMemory);
// ==================== 表格列定义 ====================
function renderCell(col: DataCol, values: Record<string, unknown>) {
const text = cellText(col.field, col.key, values);
const { shown, truncated, full } = clipText(text);
const span = h("span", { class: col.removed ? "cell-removed" : "" }, shown);
if (!truncated) return span;
return h(Tooltip, { content: full, transfer: true }, { default: () => [span] });
}
const tableColumns = computed(() => {
const cols: any[] = [{ type: "selection", width: 44 }];
cols.push({ title: "提交时间", key: "submitTime", width: 160 });
cols.push({
title: "状态",
key: "status",
width: 130,
render: (h2: any, p: any) => h2("span", statusDisplay(p.row.status, p.row.statusLabel))
});
for (const col of allColumns.value) {
if (!visibleKeys.value.includes(col.key)) continue;
cols.push({
title: col.label,
key: col.key,
width: 180,
render: (_h2: any, p: any) => renderCell(col, p.row.values)
});
}
cols.push({
title: "报名号",
key: "answerNo",
width: 150,
render: (h2: any, p: any) => h2("span", p.row.answerNo || "—")
});
cols.push({
title: "身份",
key: "identity",
width: 170,
render: (h2: any, p: any) =>
h2(Tooltip, {
content: `submitter_id=${p.row.submitterId}${p.row.subjectId ? `,subject_id=${p.row.subjectId}(他人代填)` : ",无代填主体"}`,
transfer: true
}, {
default: () => [h2("span", `提交人 ${p.row.submitterId}${p.row.subjectId ? `|主体 ${p.row.subjectId}` : ""}`)]
})
});
cols.push(
actionColumn(
[
{ key: "detail", label: "详情", on: (row: AnswerRow) => goDetail(row) },
{ key: "edit", label: "编辑", on: (row: AnswerRow) => openEdit(row) },
{ key: "status", label: "改状态", on: (row: AnswerRow) => openStatus(row) },
{ key: "delete", label: "软删", danger: true, on: (row: AnswerRow) => softDeleteOne(row) }
],
{ mode: "text", maxInline: 2 }
)
);
return cols;
});
/** 附件列的"N 个文件"由 cellText 统一给,这里不再另写一份(同一张表里两套口径迟早漂移) */
// ==================== 行操作 ====================
function goDetail(row: AnswerRow) {
// P77:快应用上下文留在应用壳里看详情(/app/{appCode}/data/detail),中心上下文走 /admin/data/detail;
// 两边都只带 uuid(V5),返回由详情页按 route.params.appCode 认路。
const path = props.context === "app" ? `/app/${props.appCode}/data/detail` : "/admin/data/detail";
router.push({ path, query: { answerId: row.answerId } });
}
const editDetail = ref<AnswerDetail | null>(null);
async function openEdit(row: AnswerRow) {
try {
// 编辑弹层吃的是详情接口——它带的是该答卷**钉住版本**的 groups,不是当前发布版本
editDetail.value = await dataApi.detail(row.answerId).send();
} catch (e: any) {
Message.error(e?.message || "答卷详情取不到");
}
}
/** 等值筛选项的候选值:选项类字段(select/radio/checkbox/multiselect)的已展开 options(契约 §2.1 内联) */
function eqOptionsFor(key: string): Array<{ label: string; value: string }> {
const field = (current.value?.fields || []).find((f: DesignField) => f.key === key);
const options = field?.options;
return Array.isArray(options) ? options : [];
}
const statusModal = ref<{ visible: boolean; answerId: string; target: string; current: string }>({
visible: false,
answerId: "",
target: "",
current: ""
});
function openStatus(row: AnswerRow) {
if (!liveStatusDefs.value.length) {
Message.warning("该表单没有可用状态定义");
return;
}
statusModal.value = {
visible: true,
answerId: row.answerId,
target: row.status || liveStatusDefs.value[0].code,
current: statusDisplay(row.status, row.statusLabel)
};
}
async function submitStatus() {
const { answerId, target } = statusModal.value;
statusModal.value.visible = false;
try {
await dataApi.changeStatus(answerId, target).send();
Message.success("已改状态并写 status_log");
await reload();
} catch (e: any) {
Message.error(e?.message || "改状态失败");
}
}
function softDeleteOne(row: AnswerRow) {
Modal.confirm({
title: "确认软删这条答卷?",
content: "软删是唯一的删除形态(无物理删除接口):列表/详情/统计/导出都不再可见,索引行同步删,限填名额释放。",
onOk: async () => {
try {
await dataApi.softDelete(row.answerId).send();
Message.success("已软删");
await reload();
} catch (e: any) {
Message.error(e?.message || "软删失败");
}
}
});
}
function onSelectionChange(selection: RowVM[]) {
selected.value = selection.map((r) => r.answerId);
}
// ==================== 批量 ====================
function reportBatch(results: { answerId: string; success: boolean; msg: string | null }[], actionText: string) {
const failed = results.filter((r) => !r.success);
const okCount = results.length - failed.length;
if (!failed.length) {
Message.success(`${actionText} ${okCount} 条全部成功(后端逐条校验、逐条留痕)`);
return;
}
Modal.warning({
title: `${actionText}:成功 ${okCount} 条,失败 ${failed.length} 条`,
content: failed
.slice(0, 10)
.map((f) => `${f.answerId.slice(0, 8)}…:${f.msg || "未知原因"}`)
.join("\n") + (failed.length > 10 ? `\n…另有 ${failed.length - 10} 条` : ""),
okText: "知道了"
});
}
function checkedBatch(ids: string[], actionText: string, run: () => Promise<any>) {
if (!ids.length) {
Message.warning("先勾选要处理的答卷");
return;
}
if (ids.length > BATCH_LIMIT) {
Message.warning(`批量单次上限 ${BATCH_LIMIT} 条(后端 P29),当前选了 ${ids.length} 条,请减少选择`);
return;
}
Modal.confirm({
title: `确认${actionText} ${ids.length} 条?`,
content: `处理范围只覆盖${actionText}能覆盖到的行:不在你数据范围(当前=${scopeText.value})里的答卷会逐条被拒并点名原因,成功的部分照常留痕。`,
onOk: async () => {
try {
const results = await run();
reportBatch(results || [], actionText);
await reload();
} catch (e: any) {
Message.error(e?.message || `${actionText}失败`);
}
}
});
}
function batchDelete() {
checkedBatch(selected.value, "批量软删", () => dataApi.batchSoftDelete(selected.value).send());
}
const batchStatusModal = ref(false);
const batchStatusTarget = ref("");
function batchChangeStatus() {
if (!selected.value.length) {
Message.warning("先勾选要改状态的答卷");
return;
}
if (!liveStatusDefs.value.length) {
Message.warning("该表单没有可用状态定义");
return;
}
batchStatusTarget.value = liveStatusDefs.value[0].code;
batchStatusModal.value = true;
}
async function submitBatchStatus() {
const ids = selected.value;
batchStatusModal.value = false;
checkedBatch(ids, `批量改状态为「${liveStatusDefs.value.find((d) => d.code === batchStatusTarget.value)?.label}」`, () =>
dataApi.batchChangeStatus(ids, batchStatusTarget.value).send()
);
}
// ==================== 导出 ====================
/**
* 权限判定用的树(P76):app 上下文=宿主编进来的**目标应用树**(快应用的"导出"锚点行在这里),
* center 上下文=会话自己的树(按 `/admin/data` 页面行判,手册 §2.2 中心表没有导出行)。
* 两种上下文各自吃自己的树,`canExport` 的纯函数判据不变。
*/
const permsTree = computed(() => (props.context === "app" ? props.modules : store.modules));
const exportable = computed(() => canExport(permsTree.value, props.context === "app" ? "app" : "center"));
const exporting = ref(false);
function askExport() {
if (!formUuid.value) {
Message.warning("先选择表单");
return;
}
Modal.confirm({
title: "导出当前筛选结果",
content:
`范围=单表单 + 当前筛选(表单:${current.value?.name || formUuid.value})。` +
"行数口径是你这个数据范围(当前=" + scopeText.value + ")下符合筛选条件的**总数**,不是本页已加载的行数。" +
"导出是同步流式下载,不排任务、不给假进度。",
onOk: async () => {
exporting.value = true;
try {
const blob = await exportBlob(queryBody());
downloadBlob(blob, `${current.value?.code || "form"}-answers.xlsx`);
Message.success("已开始下载");
} catch (e: any) {
Message.error(e?.message || "导出失败");
} finally {
exporting.value = false;
}
}
});
}
// ==================== 触底加载(keyset 的 UI 形态) ====================
let scrollTarget: HTMLElement | null = null;
function onScroll() {
if (!scrollTarget || loading.value || !nextCursor.value) return;
const { scrollTop, clientHeight, scrollHeight } = scrollTarget;
if (scrollTop + clientHeight >= scrollHeight - 40) loadMore();
}
/** view-ui 定了 :height 后内部滚动在 .ivu-table-body,触底事件挂它而不是挂窗口 */
async function bindBottomScroll() {
await nextTick();
scrollTarget?.removeEventListener("scroll", onScroll);
scrollTarget = regionRef.value?.querySelector(".ivu-table-body") ?? null;
scrollTarget?.addEventListener("scroll", onScroll);
}
watch(rows, bindBottomScroll);
onMounted(bindBottomScroll);
onBeforeUnmount(() => scrollTarget?.removeEventListener("scroll", onScroll));
// ==================== 选择与初始化 ====================
async function selectForm(uuid: string) {
formUuid.value = uuid;
filterRows.value = [];
statusFilter.value = "";
rows.value = [];
nextCursor.value = null;
loadedOnce.value = false;
await Promise.all([chooseForm(uuid), loadFilterFields(uuid)]);
applyMemory();
await reload();
}
function addFilterRow() {
filterRows.value = [...filterRows.value, { key: "", value: "", from: "", to: "" }];
}
function removeFilterRow(index: number) {
filterRows.value = filterRows.value.filter((_, i) => i !== index);
}
function filterFieldOf(key: string): FilterField | undefined {
return filterFields.value.find((f) => f.key === key);
}
async function init() {
await loadCandidates();
const fromQuery = String(route.query.form || "");
const picked = candidates.value.find((c: { formUuid: string }) => c.formUuid === fromQuery) || candidates.value[0];
if (picked) await selectForm(picked.formUuid);
}
watch(
() => [props.context, props.appCode, props.codes.join(",")],
() => init()
);
onMounted(init);
</script>
<template>
<PageStub
title="数据管理"
stage="T8"
note="两段式列表 + keyset(提交时间序)、可见列选择记忆、批量软删/改状态、按钉住版本编辑在 T8 落地;数据范围(scope)由后端裁决,前端只消费。"
/>
<div :class="embedded ? 'data-bare' : 'page-wrapper yh-fill'">
<div :class="embedded ? '' : 'main-container'">
<div class="toolbar">
<Select v-model="formUuid" style="width: 260px" placeholder="选择表单" @on-change="selectForm">
<Option v-for="c in candidates" :key="c.formUuid" :value="c.formUuid"
:label="`${c.name}(${c.appCode}/${c.code})`" />
</Select>
<Input v-model="manualCode" search placeholder="按 code 直达" style="width: 200px" clearable
@on-search="useManualCode" />
<Select v-model="statusFilter" style="width: 140px" placeholder="状态" clearable @on-change="reload">
<Option value="">全部状态</Option>
<Option v-for="def in liveStatusDefs" :key="def.code" :value="def.code">{{ def.label }}</Option>
</Select>
<Select v-model="order" style="width: 150px" @on-change="reload">
<Option value="desc">最新在前</Option>
<Option value="asc">最早在前</Option>
</Select>
<Button icon="md-refresh" @click="reload">刷新</Button>
<Button icon="md-settings" @click="colPicker = true">列设置</Button>
<span class="toolbar-note">
已加载 {{ rows.length }} 行{{ nextCursor ? "(还有下一页,触底继续取)" : "(已到末尾)" }}
</span>
</div>
<div v-if="filterFields.length || filterRows.length" class="filter-region">
<div v-for="(row, index) in filterRows" :key="`f_${index}`" class="filter-row">
<Select v-model="row.key" style="width: 190px" placeholder="筛选字段">
<Option v-for="f in filterFields" :key="f.key" :value="f.key"
:label="`${f.label}(${f.operator === 'EQ' ? '等值' : f.operator === 'RANGE' ? '区间' : '前缀'})`" />
</Select>
<template v-if="filterFieldOf(row.key)?.operator === 'EQ'">
<Select v-model="row.value" style="width: 200px" placeholder="选择值" clearable>
<Option v-for="opt in eqOptionsFor(row.key)" :key="opt.value" :value="opt.value"
:label="opt.label" />
</Select>
</template>
<template v-else-if="filterFieldOf(row.key)?.operator === 'RANGE'">
<Input v-model="row.from" style="width: 160px"
:placeholder="filterFieldOf(row.key)?.type === 'date' ? '起始日 YYYY-MM-DD' : '下界(含)'" />
<Input v-model="row.to" style="width: 160px"
:placeholder="filterFieldOf(row.key)?.type === 'date' ? '截止日 YYYY-MM-DD' : '上界(含)'" />
</template>
<template v-else>
<Input v-model="row.value" style="width: 200px" placeholder="前缀文本" clearable />
</template>
<Button icon="md-close" @click="removeFilterRow(index)">删</Button>
</div>
<div class="filter-actions">
<Button size="small" icon="md-add" @click="addFilterRow">加筛选条件</Button>
<Button size="small" type="primary" :disabled="!formUuid" @click="reload">应用筛选</Button>
<span class="toolbar-note">
候选=当前发布版本里 searchable 且有算子的字段(附件类不出现,P28);算子按字段类型定,不给自由表达式
</span>
</div>
</div>
<Alert v-if="candidateError" show-icon type="warning" class="region-tip">
表单清单:{{ candidateError }}(这通常是账号只授权了"数据管理"、没有"表单设计"模块行——
用上面"按 code 直达"仍可打开已知表单的数据)
</Alert>
<Alert v-else-if="loadedOnce" show-icon type="info" class="region-tip">
数据范围(后端契约 §8 矩阵 + customId 解析,前端只展示不再过滤):<b>{{ scopeText }}</b>
</Alert>
<div class="table-region" ref="regionRef">
<Table :columns="tableColumns" :data="rows" :height="regionH || undefined" :loading="loading"
no-data-text="没有符合条件的答卷(或还没选表单)" class="link-table"
@on-selection-change="onSelectionChange" />
</div>
<div class="page-panel">
<div class="fl ml-15 align-middle batch-bar">
<span class="toolbar-note">已选 {{ selected.length }} 条</span>
<Button size="small" :disabled="!selected.length" @click="batchDelete">批量软删</Button>
<Button size="small" :disabled="!selected.length" @click="batchChangeStatus">批量改状态</Button>
<Button v-if="exportable" size="small" icon="md-download" :loading="exporting"
@click="askExport">导出</Button>
<Tooltip v-else content="当前身份的授权模块集里没有导出权限位(V26:接口侧的后端拒是 T4 已验的另一半)" transfer>
<span class="toolbar-note">导出(无权限)</span>
</Tooltip>
</div>
<div class="fr page-hint">
<span class="toolbar-note">{{ loading ? "取下一页…" : nextCursor ? "向下滚动继续加载" : "已到最后一页" }}</span>
</div>
</div>
</div>
<Modal v-model="colPicker" title="可见列(按表单记忆)" :width="520" @on-cancel="colPicker = false">
<p class="toolbar-note">
勾选即刻写入本地记忆(键=归属应用 + 表单 code,V40);新出现的列会自动补进可见集,
历史已删字段列默认不铺满——需要看历史残留时再勾。
</p>
<div class="col-list">
<label v-for="col in allColumns" :key="col.key" class="col-item">
<Checkbox :model-value="visibleKeys.includes(col.key)" @on-change="toggleColumn(col.key)">
<span :class="col.removed ? 'cell-removed' : ''">{{ col.label }}</span>
<span class="col-filled">已填 {{ Math.round(col.filledRatio * 100) }}%</span>
</Checkbox>
</label>
</div>
<template #footer>
<Button @click="colPicker = false">关闭</Button>
</template>
</Modal>
<Modal v-model="batchStatusModal" title="批量改状态" :width="420">
<Form :label-width="90">
<FormItem label="目标状态">
<Select v-model="batchStatusTarget">
<Option v-for="def in liveStatusDefs" :key="def.code" :value="def.code">{{ def.label }}</Option>
</Select>
</FormItem>
</Form>
<p class="toolbar-note">
逐条走单条校验与留痕(V39):{{ selected.length }} 条里不在你数据范围内的会被逐条拒。
</p>
<template #footer>
<Button @click="batchStatusModal = false">取消</Button>
<Button type="primary" @click="submitBatchStatus">确认</Button>
</template>
</Modal>
<Modal v-model="statusModal.visible" title="改状态" :width="420">
<Form :label-width="90">
<FormItem label="当前状态">
<span>{{ statusModal.current }}</span>
</FormItem>
<FormItem label="目标状态">
<Select v-model="statusModal.target">
<Option v-for="def in liveStatusDefs" :key="def.code" :value="def.code">{{ def.label }}</Option>
</Select>
</FormItem>
</Form>
<p class="toolbar-note">
状态是标记不是流转:不校验从哪个状态到哪个状态、无条件分支(方案 §5.3 红线、V15);改动写 status_log。
</p>
<template #footer>
<Button @click="statusModal.visible = false">取消</Button>
<Button type="primary" @click="submitStatus">确认</Button>
</template>
</Modal>
<AnswerEditModal v-if="editDetail" :detail="editDetail" mode="admin" @close="editDetail = null"
@saved="reload" />
</div>
</template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar-note {
color: #808695;
font-size: 12px;
}
.filter-region {
margin-bottom: 12px;
padding: 10px 12px;
background: #f8f8f9;
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.filter-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.region-tip {
margin-bottom: 12px;
}
.batch-bar {
display: flex;
align-items: center;
gap: 8px;
}
.page-hint {
display: flex;
align-items: center;
}
.col-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 46vh;
overflow-y: auto;
}
.col-item {
display: flex;
align-items: center;
}
.col-filled {
color: #c5c8ce;
font-size: 12px;
margin-left: 6px;
}
</style>
<style>
.cell-removed {
color: #ff9900;
}
.status-options {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.status-option {
display: flex;
align-items: center;
gap: 6px;
}
.status-note {
color: #808695;
font-size: 12px;
}
</style>
+417 -6
View File
@@ -1,11 +1,422 @@
<script setup lang="ts">
import PageStub from "@/components/PageStub.vue";
/**
* 选项字典管理(/admin/dicts,契约 §2.1 / 方案 §4.3③b)。
*
* 两级的落点:
* - **本校字典**:`/dict/list` 只出本层,可增可删可改;
* - **平台级字典**:`sch_id=0` 的行只有超管(UserBase type==1)上下文能写,
* 校级用户这边只从 `/dict/selectable` 读到它们,**可见、可用、不可改**——
* 所以列表给平台级行挂「平台级」标签且不给操作按钮,域切换按钮也只对超管显示;
* 接口侧的拒绝仍由后端 FormAdminGuard 做,前端藏按钮不等于授权(契约 §7 身份行)。
*
* 条目的三条纪律:value 建一次即不可变(它是答案里存的值,也是 uk(dict_id,value) 的定位键)、
* label 随便改、废弃是软删(旧答卷仍能原样重存,只是设计器不再给选)。
*/
import { computed, onMounted, reactive, ref } from "vue";
import { Message, Modal } from "view-ui-plus";
import { dictApi } from "@/api/module/dict";
import { useDefaultStore } from "@/store";
import type { DictItemView, DictView } from "@/types/form";
import { MAX_LABEL_LEN, MAX_NAME_LEN, CODE_PATTERN } from "@/types/formSchema";
import { nextOptionValue } from "@/utils/designer/schema";
const store = useDefaultStore();
const loading = ref(false);
const own = ref<DictView[]>([]);
const selectable = ref<DictView[]>([]);
/** 超管的当前维护域:school=本校,platform=平台级(schId=0) */
const layer = ref<"school" | "platform">("school");
const dictModal = reactive({ visible: false, creating: false, code: "", name: "", origin: null as DictView | null });
const itemModal = reactive({ visible: false, dict: null as DictView | null, newLabel: "", items: [] as DictItemView[] });
const busy = ref(false);
/** render() 产出的节点拿不到 scoped 样式,样式走内联 */
const CODE_CHIP_STYLE = {
color: "#515a6e",
background: "#f3f4f6",
borderRadius: "3px",
padding: "2px 6px",
fontSize: "12px"
};
const scope = computed(() => (layer.value === "platform" ? { schId: 0 } : {}));
/** 列表数据源:超管在平台级上下文时只出平台级行;校级 = 本校行 + 平台级只读行(同 code 本校遮蔽平台) */
const rows = computed<DictView[]>(() => {
if (layer.value === "platform") return own.value;
const platformVisible = selectable.value.filter(
(d) => d.platformLevel && !own.value.some((o) => o.code === d.code)
);
return [...own.value, ...platformVisible];
});
const columns = computed(() => [
{ title: "字典名称", key: "name", minWidth: 180 },
{ title: "code", key: "code", minWidth: 140, render: (h: any, p: any) => h("code", { style: CODE_CHIP_STYLE }, p.row.code) },
{
title: "层级",
key: "platformLevel",
width: 130,
render: (h: any, p: any) =>
h(
"Tag",
{ color: p.row.platformLevel ? "blue" : "green" },
() => (p.row.platformLevel ? "平台级(不可改)" : "本校")
)
},
{
title: "可用条目",
key: "items",
width: 110,
render: (h: any, p: any) => h("span", `${(p.row.items || []).length}`)
},
{
title: "引用方式",
key: "source",
minWidth: 180,
render: (h: any, p: any) => h("code", { style: CODE_CHIP_STYLE }, `dict:${p.row.code}`)
},
{
title: "操作",
key: "action",
width: 190,
align: "center",
render: (h: any, p: any) =>
p.row.platformLevel && !canEdit(p.row)
? h("span", { style: { color: "#c5c8ce", fontSize: "12px" } }, "可见可用不可改")
: h("div", { class: "op-cell" }, [
h("div", { class: "operating-link", onClick: () => openItems(p.row) }, "条目维护"),
h("div", { class: "operating-link", onClick: () => openRename(p.row) }, "改名"),
h("div", { class: "operating-link text-error", onClick: () => removeDict(p.row) }, "删除")
])
}
]);
function canEdit(dict: DictView): boolean {
// 平台级行必须处在超管的平台级上下文里才可写;本校行本校管理员可写
return dict.platformLevel ? layer.value === "platform" && store.isSuperAdmin : !dict.platformLevel;
}
async function load() {
loading.value = true;
try {
own.value = (await dictApi.list(scope.value).send()) || [];
selectable.value = (await dictApi.selectable().send()) || [];
} catch (e: any) {
Message.error(e?.message || "字典列表加载失败");
} finally {
loading.value = false;
}
}
function switchLayer(next: "school" | "platform") {
layer.value = next;
load();
}
function openCreate() {
dictModal.visible = true;
dictModal.creating = true;
dictModal.origin = null;
dictModal.code = "";
dictModal.name = "";
}
function openRename(dict: DictView) {
dictModal.visible = true;
dictModal.creating = false;
dictModal.origin = dict;
// code 是 schema 里的引用键,建后不可改:这里只给名字
dictModal.code = dict.code;
dictModal.name = dict.name;
}
async function submitDict() {
const code = dictModal.code.trim();
const name = dictModal.name.trim();
if (!CODE_PATTERN.test(code)) {
Message.error("字典 code 仅限字母/数字/下划线(≤64 位)");
return;
}
if (!name) {
Message.error("字典名称不能为空");
return;
}
busy.value = true;
try {
await dictApi.save({ ...scope.value, code, name }).send();
Message.success(dictModal.creating ? `已创建字典 ${code}` : "名称已更新(code 不变,引用不受影响)");
dictModal.visible = false;
await load();
} catch (e: any) {
Message.error(e?.message || "保存失败");
} finally {
busy.value = false;
}
}
function removeDict(dict: DictView) {
Modal.confirm({
title: `删除字典 ${dict.name}`,
content: "软删字典。引用它的表单再次发布会因「字典无法解析」被拒;历史答卷的取值仍可原样重存。",
onOk: async () => {
try {
await dictApi.remove({ ...scope.value, code: dict.code, name: dict.name }).send();
Message.success("已删除");
await load();
} catch (e: any) {
Message.error(e?.message || "删除失败");
}
}
});
}
async function openItems(dict: DictView) {
// 编辑目标取最新一份(列表里的对象可能是 selectable 的平台级副本)
const target = own.value.find((d) => d.code === dict.code) || dict;
itemModal.visible = true;
itemModal.dict = target;
itemModal.newLabel = "";
itemModal.items = (target.items || []).map((i) => ({ ...i }));
}
function nextValue(): string {
return nextOptionValue(itemModal.items.map((i) => i.value), "val");
}
async function addItem() {
const label = itemModal.newLabel.trim();
const dict = itemModal.dict;
if (!dict) return;
if (!label) {
Message.error("条目名称不能为空");
return;
}
busy.value = true;
try {
const value = nextValue();
const created = await dictApi
.saveItem({ ...scope.value, dictCode: dict.code, value, label, sort: itemModal.items.length + 1 })
.send();
itemModal.items.push({ ...created });
itemModal.newLabel = "";
Message.success(`已新增条目(value=${created.value},生成后不可变)`);
await load();
} catch (e: any) {
Message.error(e?.message || "新增失败");
} finally {
busy.value = false;
}
}
async function saveItem(item: DictItemView) {
const dict = itemModal.dict;
if (!dict) return;
if (!item.label.trim()) {
Message.error("条目名称不能为空");
return;
}
busy.value = true;
try {
// 只传 label/sort:value 原样带回当定位键,接口没有改 value 的通道
await dictApi.saveItem({ ...scope.value, dictCode: dict.code, value: item.value, label: item.label.trim(), sort: item.sort }).send();
Message.success(`条目 ${item.value} 已更新(value 未变)`);
await load();
} catch (e: any) {
Message.error(e?.message || "保存失败");
} finally {
busy.value = false;
}
}
async function toggleItem(item: DictItemView) {
const dict = itemModal.dict;
if (!dict) return;
busy.value = true;
try {
if (item.deleted) {
await dictApi
.saveItem({ ...scope.value, dictCode: dict.code, value: item.value, label: item.label, sort: item.sort })
.send();
Message.success("已恢复为可选条目");
} else {
await dictApi.removeItem({ ...scope.value, dictCode: dict.code, value: item.value, label: item.label }).send();
Message.success("已废弃:设计器不再给选,旧答卷该 value 仍可原样重存");
}
await load();
const fresh = own.value.find((d) => d.code === dict.code);
itemModal.items = (fresh?.items || []).map((i) => ({ ...i }));
} catch (e: any) {
Message.error(e?.message || "操作失败");
} finally {
busy.value = false;
}
}
onMounted(load);
</script>
<template>
<PageStub
title="字典管理"
stage="T6"
note="form_dict / form_dict_item 两级维护、条目 value 不可变、options.source=dict:{code} 引用在 T6 落地;平台级字典(sch_id=0)只在超管上下文维护(方案 §6.5)。"
/>
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="toolbar">
<Button type="primary" icon="md-add" :disabled="layer === 'platform' && !store.isSuperAdmin"
@click="openCreate">新建字典</Button>
<RadioGroup v-if="store.isSuperAdmin" :model-value="layer" type="button" size="small"
@on-change="switchLayer">
<Radio label="school">本校字典</Radio>
<Radio label="platform">平台级(sch_id=0)</Radio>
</RadioGroup>
<Alert v-else type="info" class="layer-note">
当前维护域:本校。平台级字典由学校可见、可用、<b>不可改</b>(方案 §4.3③b)
</Alert>
<Button icon="md-refresh" @click="load">刷新</Button>
</div>
<div class="table-region">
<Table :columns="columns" :data="rows" :loading="loading" no-data-text="还没有字典" class="link-table" />
</div>
<p class="foot-note">
设计器里字段写 <code>options.source = "dict:{code}"</code> 引用字典;引用是活引用、
不进版本快照,读接口下发时内联展开(契约 §2.1)。
</p>
</div>
<Modal v-model="dictModal.visible" :title="dictModal.creating ? '新建字典' : '字典改名'" :mask-closable="false"
:width="480">
<Form :label-width="90">
<FormItem label="code" required>
<Input v-model="dictModal.code" :disabled="!dictModal.creating"
placeholder="字母/数字/下划线,≤64,建后不可改" />
<p class="tip">code 是 schema 里的引用键,改 code 等于换字典</p>
</FormItem>
<FormItem label="名称" required>
<Input v-model="dictModal.name" :maxlength="MAX_NAME_LEN" placeholder="如:民族" />
</FormItem>
</Form>
<template #footer>
<Button @click="dictModal.visible = false">取消</Button>
<Button type="primary" :loading="busy" @click="submitDict">保存</Button>
</template>
</Modal>
<Modal v-model="itemModal.visible" :title="`条目维护|${itemModal.dict?.name || ''}`" :mask-closable="false"
:width="720">
<Alert type="info" show-icon class="mb10">
value 生成即不可变(答案存它);只有名称与排序可改。废弃=软删,旧答卷照旧可读可重存。
</Alert>
<div class="item-head">
<span class="col-value">value</span>
<span class="col-label">名称(可改)</span>
<span class="col-sort">排序</span>
<span class="col-op">操作</span>
</div>
<div v-for="item in itemModal.items" :key="item.value" class="item-row" :class="{ dropped: item.deleted }">
<code class="col-value">{{ item.value }}</code>
<Input v-model="item.label" size="small" :maxlength="MAX_LABEL_LEN" class="col-label" />
<InputNumber v-model="item.sort" size="small" :min="0" class="col-sort" controls-out />
<span class="col-op">
<Button size="small" @click="saveItem(item)">保存</Button>
<Button size="small" type="text" :class="item.deleted ? '' : 'text-error'"
@click="toggleItem(item)">
{{ item.deleted ? "恢复" : "废弃" }}
</Button>
<Tag v-if="item.deleted" color="default" size="small">已废弃</Tag>
</span>
</div>
<div class="item-row add-row">
<code class="col-value">{{ nextValue() }}</code>
<Input v-model="itemModal.newLabel" size="small" placeholder="新条目名称,如 维吾尔族" class="col-label"
@on-enter="addItem" />
<span class="col-sort"></span>
<span class="col-op">
<Button size="small" type="primary" :loading="busy" icon="md-add" @click="addItem">新增条目</Button>
</span>
</div>
<p class="tip">
上一行的 value 是新增时将生成的值({{ itemModal.dict?.code }} 现有
{{ itemModal.items.length }} 条,含已废弃);生成后它就固定了。
</p>
<template #footer>
<Button @click="itemModal.visible = false">关闭</Button>
</template>
</Modal>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.layer-note {
margin: 0;
}
.table-region {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.foot-note {
color: #808695;
font-size: 12px;
margin-top: 10px;
line-height: 1.7;
}
.item-head,
.item-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.item-head {
color: #808695;
font-size: 12px;
padding-bottom: 6px;
border-bottom: 1px solid #eff0f3;
}
.col-value {
width: 120px;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
background: #f3f4f6;
border-radius: 3px;
padding: 3px 6px;
flex-shrink: 0;
}
.col-label {
flex: 1;
}
.col-sort {
width: 110px;
flex-shrink: 0;
}
.col-op {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.item-row.dropped .col-value {
color: #c5c8ce;
text-decoration: line-through;
}
.add-row {
border-top: 1px dashed #eff0f3;
padding-top: 10px;
}
.tip {
color: #808695;
font-size: 12px;
line-height: 1.7;
}
.mb10 {
margin-bottom: 10px;
}
</style>
+479
View File
@@ -0,0 +1,479 @@
<script setup lang="ts">
/**
* 表单设计器(全屏三栏:控件面板 / 画布 / 属性面板 + 版本与发布流程)。
*
* 版本语义按 P20 落到 UI 上,这是本页最容易做错的地方:
* - **保存 ≠ 发布**。保存只在草稿行上直改,或为已发布表单开一条新的草稿行,
* 头表的 status/current_version 一律不动——升版窗口期旧版仍可填(契约 §5 钉版)。
* - 所以头部必须同时说清「当前发布 v几」和「待发布草稿 v几」,
* 并且版本下拉里选到已发布行时整页转只读(行不可变,V7)。
* - 发布才把草稿行钉成版本、推指针,并留下 change_log。
*
* 编辑权边界:已发布过的字段 key 与 type 锁死(改 key=换字段、同 key 不换 type 是后端红线),
* 面板里只给「删旧增新」这条路;code 发布后同样不可改(改 code=换表单)。
*/
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import ComponentLibrary from "@/components/designer/ComponentLibrary.vue";
import DesignerCanvas from "@/components/designer/DesignerCanvas.vue";
import FieldPropPanel from "@/components/designer/FieldPropPanel.vue";
import FormConfigModal from "@/components/form/FormConfigModal.vue";
import { formApi } from "@/api/module/form";
import { dictApi } from "@/api/module/dict";
import type { DictView, FormView, VersionView } from "@/types/form";
import type { DesignField, DesignGroup } from "@/types/formSchema";
import { MAX_CHANGE_LOG_LEN, MAX_NAME_LEN } from "@/types/formSchema";
import {
buildSchemaJson,
cloneField,
collectUsedKeys,
countFields,
defaultGroup,
makeNextKey,
restoreSchema,
typesByKeys,
validateDesign
} from "@/utils/designer/schema";
const route = useRoute();
const router = useRouter();
const uuid = computed(() => String(route.query.uuid || "").trim());
const form = ref<FormView | null>(null);
const versions = ref<VersionView[]>([]);
const groups = ref<DesignGroup[]>([]);
const dicts = ref<DictView[]>([]);
const selected = ref<{ gi: number; fi: number } | null>(null);
/** 当前画布装载的版本行号 */
const viewing = ref<number | null>(null);
const viewingPublished = ref(false);
/** 当前发布版本行原文(锁 key/type + 跨版本身份校验都按它判) */
const publishedSchemaJson = ref<string | null>(null);
const nameDraft = ref("");
const codeDraft = ref("");
const saving = ref(false);
const publishing = ref(false);
const showErrors = ref(false);
/** 装载时的 schema 原文基线:dirty 就是「与它不一致」,不用深 watcher 追 */
const baseline = ref("");
const configRef = ref<InstanceType<typeof FormConfigModal> | null>(null);
const publishModal = reactive({ visible: false, changeLog: "" });
const locked = computed(() => viewingPublished.value);
const usedKeys = computed(() => collectUsedKeys(groups.value));
const publishedTypes = computed(() => typesByKeys(publishedSchemaJson.value));
/** 画布要知道哪些 key 是发布过的(卡片上的身份标记) */
const publishedKeySet = computed(() => new Set(publishedTypes.value.keys()));
const codeEditable = computed(() => !!form.value && form.value.currentVersion === null);
const selectedField = computed<DesignField | null>(() => {
const s = selected.value;
if (!s) return null;
return groups.value[s.gi]?.fields[s.fi] ?? null;
});
const errors = computed(() =>
validateDesign(groups.value, {
name: nameDraft.value,
code: codeEditable.value ? codeDraft.value : undefined,
publishedSchemaJson: publishedSchemaJson.value
})
);
const visibleErrors = computed(() => (showErrors.value ? errors.value : []));
const versionOptions = computed(() =>
versions.value.map((v) => ({
value: v.version,
label: `v${v.version} ${v.published ? "已发布" : "待发布草稿"}`
}))
);
const dirty = computed(() => baseline.value !== buildSchemaJson(groups.value));
async function reloadHead() {
if (!uuid.value) return;
form.value = await formApi.get(uuid.value).send();
versions.value = (await formApi.versions(uuid.value).send()) || [];
nameDraft.value = form.value?.name || "";
codeDraft.value = form.value?.code || "";
if (form.value?.currentVersion != null) {
const row = await formApi.version(uuid.value, form.value.currentVersion).send();
publishedSchemaJson.value = row?.schemaJson || null;
} else {
publishedSchemaJson.value = null;
}
}
/** 装载某个版本行:已发布行只读(V7 逐字节冻结),草稿行可编辑 */
async function loadVersion(version: number) {
if (!uuid.value || typeof version !== "number" || !Number.isFinite(version)) return;
if (loadingRow || version === viewing.value) return;
loadingRow = true;
try {
const row = await formApi.version(uuid.value, version).send();
const restored = restoreSchema(row?.schemaJson);
groups.value = restored.length ? restored : [defaultGroup(new Set<string>(), 0)];
// 基线取「用同一个序列化函数再算一遍」,不取库里的原文:
// 原文的属性顺序可能不同,直接比会把刚装载的版本误标成「未保存」
baseline.value = buildSchemaJson(groups.value);
viewing.value = version;
viewingPublished.value = !!row?.published;
selected.value = null;
showErrors.value = false;
} finally {
loadingRow = false;
}
}
/**
* 版本下拉的选择入口。
* iView 的 Select 在挂载/modelValue 变化时可能回吐一次 on-change(值还不一定是合法版本),
* 不加这道闸就是「渲染→emit→请求→渲染」的死循环(浏览器标签会被自己打满)。
*/
function onVersionPick(next: unknown) {
const version = Number(next);
if (!Number.isInteger(version) || version === viewing.value) return;
loadVersion(version);
}
let loadingRow = false;
function defaultTargetVersion(f: FormView | null): number | null {
if (!f) return null;
// 草稿优先:有草稿就编辑草稿,没有草稿则装载当前发布版(第一次保存时开新草稿行)
return f.draftVersion ?? f.currentVersion ?? (versions.value[versions.value.length - 1]?.version ?? null);
}
function addGroup() {
groups.value.push(defaultGroup(usedKeys.value, groups.value.length));
}
function removeGroup(gi: number) {
const g = groups.value[gi];
if (!g) return;
const content = g.fields.length
? `分组「${g.label || g.key}」内还有 ${g.fields.length} 个字段,删除后字段一并移除(已发布字段的历史答卷仍可按 key 回退检索)。`
: "确定删除该分组吗?";
Modal.confirm({
title: "删除分组确认",
content,
onOk: () => {
groups.value.splice(gi, 1);
selected.value = null;
}
});
}
function addField(field: DesignField) {
if (!groups.value.length) addGroup();
const gi = selected.value?.gi ?? groups.value.length - 1;
const group = groups.value[gi];
group.fields.push(field);
select(gi, group.fields.length - 1);
}
function select(gi: number, fi: number) {
const g = groups.value[gi];
if (!g || !g.fields[fi]) {
selected.value = null;
return;
}
selected.value = { gi, fi };
}
function copyField(gi: number, fi: number) {
const src = groups.value[gi]?.fields[fi];
if (!src) return;
const copy = cloneField(src);
copy.key = makeNextKey(usedKeys.value)(src.type);
groups.value[gi].fields.splice(fi + 1, 0, copy);
select(gi, fi + 1);
}
function removeField(gi: number, fi: number) {
const f = groups.value[gi]?.fields[fi];
if (!f) return;
const history = publishedTypes.value.has(f.key)
? `「${f.key}」已发布过:删除后历史答卷该字段仍可读、按它筛选仍有效(V8),但新表单不再有它。`
: "确定删除该字段吗?";
Modal.confirm({ title: "删除字段确认", content: history, onOk: () => {
groups.value[gi].fields.splice(fi, 1);
selected.value = null;
} });
}
async function save(silent = false): Promise<boolean> {
if (!uuid.value || !form.value) return false;
if (locked.value) {
Message.warning("已发布版本行只读;要改版请在版本下拉切回待发布草稿");
return false;
}
showErrors.value = true;
if (errors.value.length) {
if (!silent) Message.error(errors.value[0]);
return false;
}
saving.value = true;
try {
const res = await formApi
.save({
uuid: uuid.value,
name: nameDraft.value.trim(),
code: codeEditable.value ? codeDraft.value.trim() : undefined,
schemaJson: buildSchemaJson(groups.value)
})
.send();
form.value = res;
await reloadHead();
baseline.value = buildSchemaJson(groups.value);
if (!silent) {
Message.success(
res.currentVersion === null
? "已保存为草稿 v" + res.draftVersion + "(发布后填报入口才生效)"
: `已保存为待发布草稿 v${res.draftVersion};当前发布仍是 v${res.currentVersion},旧版仍可填`
);
}
return true;
} catch (e: any) {
Message.error(e?.message || "保存失败");
return false;
} finally {
saving.value = false;
}
}
function openPublish() {
if (!form.value) return;
if (!form.value.draftVersion && !dirty.value) {
Message.warning("没有待发布的草稿版本:已发布表单要先保存改动才会开新草稿");
return;
}
showErrors.value = true;
if (errors.value.length) {
Message.error(errors.value[0]);
return;
}
publishModal.changeLog = "";
publishModal.visible = true;
}
async function confirmPublish() {
if (!uuid.value) return;
publishing.value = true;
try {
// 发布走的是库里那条草稿行,先把画布上的改动落盘,避免「发的是旧草稿」
if (dirty.value && !(await save(true))) return;
if (!form.value?.draftVersion) {
Message.warning("库里没有待发布的草稿版本,请先保存改动");
return;
}
const log = publishModal.changeLog.trim().slice(0, MAX_CHANGE_LOG_LEN);
const res = await formApi.publish(uuid.value, log || undefined).send();
form.value = res;
await reloadHead();
publishModal.visible = false;
Message.success(`已发布 v${res.currentVersion},填报入口按该版本寻址`);
// 发布后草稿行被钉成版本,画布切到新发布版(继续改就再开一条草稿)
await loadVersion(res.currentVersion ?? viewing.value ?? 1);
} catch (e: any) {
Message.error(e?.message || "发布失败");
} finally {
publishing.value = false;
}
}
async function copyAs() {
const f = form.value;
if (!f) return;
Modal.confirm({
title: "另存为副本",
content: `基于当前装载的 v${viewing.value} 复制一张新表单(新 code、新 uuid、version 从 1 起,字典引用随行),原表单与其历史数据零改动。`,
onOk: async () => {
const code = `${f.code}_copy`;
try {
const res = await formApi.copy({ uuid: f.formUuid, code, name: `${f.name}(副本)` }).send();
Message.success(`副本已创建:${res.code}`);
router.push({ path: "/admin/forms/designer", query: { uuid: res.formUuid } });
} catch (e: any) {
Message.error(e?.message || "副本创建失败");
}
}
});
}
function onConfigSaved(res: FormView) {
form.value = res;
}
async function boot() {
if (!uuid.value) {
Message.warning("请从表单列表进入设计器");
router.replace("/admin/forms");
return;
}
try {
dicts.value = (await dictApi.selectable().send()) || [];
await reloadHead();
const target = defaultTargetVersion(form.value);
if (target != null) await loadVersion(target);
} catch (e: any) {
Message.error(e?.message || "表单加载失败");
router.replace("/admin/forms");
}
}
onMounted(boot);
</script>
<template>
<div class="designer-page">
<div class="designer-head">
<div class="head-left">
<Button icon="ios-arrow-back" @click="router.push('/admin/forms')">返回列表</Button>
<Input v-model="nameDraft" placeholder="表单名称" :maxlength="MAX_NAME_LEN" style="width: 200px" />
<Tooltip max-width="260" placement="bottom"
content="code 发布后不可改(改 code=换表单);仅限字母/数字/下划线/中划线 ≤64">
<Input v-model="codeDraft" :disabled="!codeEditable" placeholder="表单 code" style="width: 170px" />
</Tooltip>
<Select :model-value="viewing" style="width: 190px" @on-change="onVersionPick">
<Option v-for="v in versionOptions" :key="v.value" :value="v.value" :label="v.label" />
</Select>
<Tag v-if="form && form.currentVersion !== null" color="success">当前发布 v{{ form.currentVersion }}</Tag>
<Tag v-if="form && form.draftVersion" color="warning">待发布草稿 v{{ form.draftVersion }}</Tag>
<Tag v-if="form && form.currentVersion === null && form.draftVersion" color="default">从未发布</Tag>
<span v-if="form" class="head-meta">
{{ form.code }}|{{ form.appCode }}|{{ countFields(groups) }} 个字段
<span v-if="dirty" class="head-dirty">未保存</span>
</span>
</div>
<div class="head-right">
<Button @click="configRef?.open()">表单配置</Button>
<Button @click="copyAs">另存为副本</Button>
<Button :loading="saving" :disabled="locked" @click="save()">保存草稿</Button>
<Button type="primary" :disabled="locked" @click="openPublish">发布</Button>
</div>
</div>
<Alert v-if="visibleErrors.length" type="error" show-icon class="head-errors">
<div v-for="(err, i) in visibleErrors" :key="i">{{ err }}</div>
</Alert>
<Alert v-if="locked" type="warning" show-icon class="head-errors">
正在查看已发布版本 v{{ viewing }}:行内容逐字节冻结(V7),画布只读。要改版请切到待发布草稿。
</Alert>
<div class="designer-body">
<ComponentLibrary :next-key="makeNextKey(usedKeys)" :disabled="locked" @add="addField" />
<DesignerCanvas
:groups="groups"
:selected="selected"
:disabled="locked"
:dicts="dicts"
:published-keys="publishedKeySet"
@select="select"
@copy="copyField"
@remove="removeField"
@add="addField"
@add-group="addGroup"
@remove-group="removeGroup"
/>
<FieldPropPanel
v-if="selectedField"
:key="selectedField._uid"
:field="selectedField"
:dicts="dicts"
:used-keys="usedKeys"
:published-types="publishedTypes"
:disabled="locked"
/>
<aside v-else class="d-prop-empty">
<Icon type="ios-settings" />
<p>选中画布中的字段后在这里改属性</p>
<p class="d-prop-sub">
选项类控件改名称不会影响已生成的 value(value 只读),<br />
引用字典的字段候选项由字典侧决定,不进版本快照
</p>
</aside>
</div>
<FormConfigModal ref="configRef" :form="form" @saved="onConfigSaved" />
<Modal v-model="publishModal.visible" title="发布新版本" :mask-closable="false">
<p class="publish-tip">
发布把待发布草稿 v{{ form?.draftVersion ?? "?" }} 钉成不可变版本行并推当前发布指针;<br />
历史答卷仍按各自钉住的版本读取,升版窗口期的在途提交按旧版安全落地。
</p>
<Input v-model="publishModal.changeLog" type="textarea" :rows="3" :maxlength="MAX_CHANGE_LOG_LEN"
placeholder="变更说明(可空,写入版本行 change_log)" />
<template #footer>
<Button @click="publishModal.visible = false">取消</Button>
<Button type="primary" :loading="publishing" @click="confirmPublish">确认发布</Button>
</template>
</Modal>
</div>
</template>
<style scoped>
.designer-page {
display: flex;
flex-direction: column;
height: 100%;
background: #f2f4f8;
}
.designer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: #fff;
border-bottom: 1px solid #e8eaec;
flex-shrink: 0;
}
.head-left,
.head-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.head-meta {
color: #808695;
font-size: 12px;
}
.head-dirty {
color: #ff9900;
margin-left: 6px;
}
.head-errors {
border-radius: 0;
margin: 0;
}
.designer-body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.d-prop-empty {
width: 320px;
flex-shrink: 0;
border-left: 1px solid #e8eaec;
background: #fff;
color: #808695;
text-align: center;
padding: 40px 18px;
}
.d-prop-empty :deep(.ivu-icon) {
font-size: 28px;
color: #c5c8ce;
}
.d-prop-sub {
font-size: 12px;
line-height: 1.8;
margin-top: 12px;
}
.publish-tip {
color: #515a6e;
font-size: 13px;
line-height: 1.8;
margin-bottom: 10px;
}
</style>
+368 -6
View File
@@ -1,11 +1,373 @@
<script setup lang="ts">
import PageStub from "@/components/PageStub.vue";
/**
* 表单列表(/admin/forms,替换 T5 占位页)。
*
* 两条口径写明白,避免接手的人以为是「后端分页列表」:
* - `/api/form/admin/list` 返回本校(+ 目标域)全量表单头表,**没有服务端分页参数**,
* 所以这里用 `.table-region` 实测高度喂固定表头、关键字/状态在前端过滤当页数据;
* 答卷数据面的两段式列表与 keyset 分页是 T8 的事,两者不是一回事。
* - 每行同时呈现**两根指针**:`当前发布 v{n}` 与 `待发布草稿 v{m}`(P20)——
* 只写 status 会让人以为「保存了就是改了线上」。
*
* 操作列形态按 P51 定 text 文字链(同族 center 应用一致),超出 2 个自动收进「更多」。
*/
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { formApi } from "@/api/module/form";
import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight";
import FormConfigModal from "@/components/form/FormConfigModal.vue";
import type { FormView } from "@/types/form";
import { AUDIENCE, FORM_STATUS } from "@/types/form";
import { CODE_PATTERN, MAX_NAME_LEN } from "@/types/formSchema";
const router = useRouter();
const regionRef = ref<HTMLElement | null>(null);
const regionH = useElementHeight(regionRef);
const list = ref<FormView[]>([]);
const loading = ref(false);
const keyword = ref("");
const statusFilter = ref<number | string>("");
const createModal = ref(false);
const createDraft = ref({ code: "", name: "", appCode: "form-center" });
const creating = ref(false);
const copyModal = ref({ visible: false, source: null as FormView | null, code: "", name: "" });
const copying = ref(false);
/** 配置面板就地开在列表页(不进设计器也能改表单级配置,它不进版本快照) */
const configRef = ref<InstanceType<typeof FormConfigModal> | null>(null);
const activeForm = ref<FormView | null>(null);
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase();
return list.value
.filter((f) => statusFilter.value === "" || f.status === Number(statusFilter.value))
.filter((f) => !kw || f.name.toLowerCase().includes(kw) || f.code.toLowerCase().includes(kw));
});
const columns = computed(() => [
{
title: "表单名称",
key: "name",
minWidth: 180,
render: (h2: any, p: any) =>
h2("div", { class: "cell-name" }, [
h2("span", p.row.name),
p.row.appCode && p.row.appCode !== "form-center"
? h2("span", { class: "cell-app" }, `快应用 ${p.row.appCode}`)
: null
])
},
{ title: "code", key: "code", minWidth: 150 },
{
title: "状态",
key: "status",
width: 200,
render: (h2: any, p: any) => {
const f: FormView = p.row;
const tag =
f.status === FORM_STATUS.DRAFT
? h2("Tag", { color: "default" }, () => "草稿")
: f.status === FORM_STATUS.PUBLISHED
? h2("Tag", { color: "success" }, () => `已发布 v${f.currentVersion}`)
: h2("Tag", { color: "error" }, () => `已停用 v${f.currentVersion}`);
return h2("div", { class: "cell-status" }, [
tag,
f.draftVersion ? h2("Tag", { color: "warning" }, () => `待发布草稿 v${f.draftVersion}`) : null
]);
}
},
{
title: "面向",
key: "audience",
width: 90,
render: (h2: any, p: any) => h2("span", p.row.audience === AUDIENCE.TEACHER ? "面向教师" : "面向学生")
},
{
title: "截止",
key: "deadline",
width: 130,
render: (h2: any, p: any) => h2("span", p.row.deadline ? String(p.row.deadline).slice(0, 10) : "不限")
},
{
title: "限次/报名号",
key: "fillLimit",
width: 130,
render: (h2: any, p: any) =>
h2("span", `${p.row.fillLimit ? "限一次" : "不限次"}|${p.row.answerNoEnabled ? "有报名号" : "无报名号"}`)
},
actionColumn(
[
{ key: "design", label: "设计", on: (row: FormView) => goDesigner(row) },
{ key: "config", label: "配置", on: (row: FormView) => openConfig(row) },
{
key: "publish",
label: "发布",
when: (row: FormView) => !!row.draftVersion,
on: (row: FormView) => publish(row)
},
{
key: "disable",
label: "停用",
when: (row: FormView) => row.status === FORM_STATUS.PUBLISHED,
on: (row: FormView) => lifecycle(row, "disable")
},
{
key: "enable",
label: "恢复",
when: (row: FormView) => row.status === FORM_STATUS.DISABLED,
on: (row: FormView) => lifecycle(row, "enable")
},
{ key: "copy", label: "另存为副本", on: (row: FormView) => openCopy(row) },
{ key: "delete", label: "删除", danger: true, on: (row: FormView) => softDelete(row) }
],
{ mode: "text", maxInline: 2 }
)
]);
function goDesigner(row: FormView) {
router.push({ path: "/admin/forms/designer", query: { uuid: row.formUuid } });
}
function openConfig(row: FormView) {
activeForm.value = row;
configRef.value?.open();
}
function onConfigSaved(res: FormView) {
activeForm.value = res;
load();
}
async function load() {
loading.value = true;
try {
list.value = (await formApi.list().send()) || [];
} catch (e: any) {
Message.error(e?.message || "表单列表加载失败");
} finally {
loading.value = false;
}
}
const createError = computed(() => {
const c = createDraft.value.code.trim();
if (!c) return "code 不能为空";
if (!CODE_PATTERN.test(c)) return "code 仅限字母/数字/下划线/中划线,≤64 位";
if (!createDraft.value.name.trim()) return "名称不能为空";
return "";
});
async function submitCreate() {
if (createError.value) {
Message.error(createError.value);
return;
}
creating.value = true;
try {
const res = await formApi
.create({
code: createDraft.value.code.trim(),
name: createDraft.value.name.trim(),
appCode: createDraft.value.appCode.trim() || "form-center"
})
.send();
Message.success(`已创建草稿:${res.code}`);
createModal.value = false;
createDraft.value = { code: "", name: "", appCode: "form-center" };
goDesigner(res);
await load();
} catch (e: any) {
Message.error(e?.message || "创建失败");
} finally {
creating.value = false;
}
}
function publish(row: FormView) {
Modal.confirm({
title: `发布 ${row.name}`,
content: `把待发布草稿 v${row.draftVersion} 钉成不可变版本并推当前发布指针;当前发布 v${row.currentVersion ?? "无"} 在升版窗口期仍可被在途提交按旧版落地。`,
onOk: async () => {
try {
const res = await formApi.publish(row.formUuid).send();
Message.success(`已发布 v${res.currentVersion}`);
await load();
} catch (e: any) {
Message.error(e?.message || "发布失败");
}
}
});
}
function lifecycle(row: FormView, action: "disable" | "enable") {
Modal.confirm({
title: action === "disable" ? `停用 ${row.name}` : `恢复 ${row.name}`,
content:
action === "disable"
? "停用只断填报入口,数据管理仍可读可导(方案 §5.4)。"
: "恢复=重新发布当前版本,不产生新版本行;草稿请走发布。",
onOk: async () => {
try {
const res = action === "disable" ? await formApi.disable(row.formUuid).send() : await formApi.enable(row.formUuid).send();
Message.success(action === "disable" ? "已停用" : `已恢复 v${res.currentVersion}`);
await load();
} catch (e: any) {
Message.error(e?.message || "操作失败");
}
}
});
}
function openCopy(row: FormView) {
copyModal.value = { visible: true, source: row, code: `${row.code}_copy`, name: `${row.name}(副本)` };
}
async function submitCopy() {
const m = copyModal.value;
if (!m.source) return;
if (!CODE_PATTERN.test(m.code.trim())) {
Message.error("副本 code 仅限字母/数字/下划线/中划线,≤64 位");
return;
}
copying.value = true;
try {
const res = await formApi
.copy({ uuid: m.source.formUuid, code: m.code.trim(), name: m.name.trim() })
.send();
Message.success(`副本已创建:${res.code}(v${res.draftVersion},源表单零改动)`);
m.visible = false;
await load();
} catch (e: any) {
Message.error(e?.message || "副本创建失败");
} finally {
copying.value = false;
}
}
function softDelete(row: FormView) {
Modal.confirm({
title: `删除 ${row.name}`,
content: "只做软删:填报入口消失,历史答卷与索引行保留,且系统没有物理删除接口(方案 §5.4)。",
onOk: async () => {
try {
await formApi.softDelete(row.formUuid).send();
Message.success("已删除(软删)");
await load();
} catch (e: any) {
Message.error(e?.message || "删除失败");
}
}
});
}
onMounted(load);
</script>
<template>
<PageStub
title="表单设计"
stage="T6"
note="设计器三件套(14 类控件面板 + 属性面板 + 表单级配置)、发布/升版流程、另存为副本在 T6 落地。"
/>
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="toolbar">
<Button type="primary" icon="md-add" @click="createModal = true">新建表单</Button>
<Input v-model="keyword" search placeholder="按名称或 code 搜索" style="width: 220px" clearable />
<Select v-model="statusFilter" style="width: 150px" placeholder="状态">
<Option value="">全部状态</Option>
<Option :value="0">草稿</Option>
<Option :value="1">已发布</Option>
<Option :value="2">停用</Option>
</Select>
<Button icon="md-refresh" @click="load">刷新</Button>
<span class="toolbar-note">
列表显示 {{ filtered.length }} / {{ list.length }} 条(后端一次给全量头表,筛选在前端)
</span>
</div>
<div class="table-region" ref="regionRef">
<Table :columns="columns" :data="filtered" :height="regionH || undefined" :loading="loading"
no-data-text="还没有表单,点「新建表单」开始" class="link-table" />
</div>
</div>
<Modal v-model="createModal" title="新建表单" :mask-closable="false" :width="520">
<Form :label-width="90">
<FormItem label="表单名称" required>
<Input v-model="createDraft.name" :maxlength="MAX_NAME_LEN" placeholder="如:三好学生申报" />
</FormItem>
<FormItem label="表单 code" required>
<Input v-model="createDraft.code" placeholder="字母/数字/下划线/中划线,≤64" />
<div class="form-tip">
入口与分享地址按 code 寻址(/form-center/f/{code});发布后不可改,改 code=换表单
</div>
</FormItem>
<FormItem label="归属应用">
<Input v-model="createDraft.appCode" placeholder="form-center" />
<div class="form-tip">中心表单留 form-center;快应用表单挂该快应用的 app_code</div>
</FormItem>
</Form>
<Alert v-if="createError" type="warning" show-icon class="form-tip">{{ createError }}</Alert>
<template #footer>
<Button @click="createModal = false">取消</Button>
<Button type="primary" :loading="creating" :disabled="!!createError" @click="submitCreate">
创建并进设计器
</Button>
</template>
</Modal>
<Modal v-model="copyModal.visible" title="另存为副本" :mask-closable="false" :width="520">
<p class="form-tip">
副本=新 code + 新 uuid + version 从 1 起,schema 深拷贝、字典引用随行;<br />
源表单与其历史答卷零改动(V41)。表单级配置随副本,但**不继承旧截止时间**。
</p>
<Form :label-width="90">
<FormItem label="副本名称" required>
<Input v-model="copyModal.name" :maxlength="MAX_NAME_LEN" />
</FormItem>
<FormItem label="副本 code" required>
<Input v-model="copyModal.code" placeholder="字母/数字/下划线/中划线,≤64" />
</FormItem>
</Form>
<template #footer>
<Button @click="copyModal.visible = false">取消</Button>
<Button type="primary" :loading="copying" @click="submitCopy">创建副本</Button>
</template>
</Modal>
<FormConfigModal ref="configRef" :form="activeForm" @saved="onConfigSaved" />
</div>
</template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar-note {
color: #808695;
font-size: 12px;
}
.form-tip {
color: #808695;
font-size: 12px;
line-height: 1.7;
margin-top: 4px;
}
.cell-name {
display: flex;
flex-direction: column;
}
.cell-app {
color: #808695;
font-size: 12px;
}
.cell-status {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
</style>
+293 -6
View File
@@ -1,11 +1,298 @@
<script setup lang="ts">
import PageStub from "@/components/PageStub.vue";
/**
* 统计页 /admin/stats(T8 落地,替换 T5 占位)。
*
* 三条纪律:
* 1. **前端零聚合**(V11 前端半句):总数 / 按状态 / 按字段分组三个数都直接来自
* `POST /api/form/admin/data/stats`,本页不含任何 `reduce`/`filter(...).length`/`groupBy`。
* 页面只负责把后端给的桶画成图——一旦前端自己数一遍,就会出现"列表和统计对不上"的第二套口径。
* 2. **不缓存、不限定 scope**:短 TTL 缓存由后端管(键含范围签名,仅升版失效——铁律④);
* scope 也由后端按契约 §8 矩阵解析。响应里的 `cached` 只用来把"这是缓存值"讲清楚,
* 前端既不自己缓存,也不传范围参数(传了就是越权面)。
* 3. **分组候选 = 筛选候选取 searchable 字段**(同一个 `filterFields` 端点形状,后端已排除附件类,P28)——
* 分组阈值与筛选阈值是同一份"有算子的 searchable 字段",不另造一份候选。
*
* 快应用上下文复用本组件:传 `context="app"` + `app-code` + `codes`(同 Data.vue 的取 uuid 路径)。
*/
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { Message } from "view-ui-plus";
import { useFormCandidates } from "@/utils/hook/useFormCandidates";
import { dataApi } from "@/api/module/data";
import { scopeLabel, type CountItem, type FieldGroups, type FilterField, type StatsView } from "@/types/data";
const props = withDefaults(
defineProps<{
context?: "center" | "app";
appCode?: string;
codes?: string[];
/** 被宿主页面内嵌(快应用上下文)时置 true:不套 `.page-wrapper > .main-container` */
embedded?: boolean;
}>(),
{ context: "center", appCode: "", codes: () => [], embedded: false }
);
const route = useRoute();
const { candidates, candidateError, formUuid, loadCandidates, selectForm } = useFormCandidates(
() => props.context,
() => props.appCode,
() => props.codes
);
/** 分组候选:与筛选候选同一个端点(searchable 且有算子;附件类不出现) */
const groupFields = ref<FilterField[]>([]);
const groupByField = ref("");
const stats = ref<StatsView | null>(null);
const loading = ref(false);
const loadedOnce = ref(false);
/** 生效范围文案(只展示,不过滤——任务书 T8 红线) */
const scopes = ref<string[]>([]);
const scopeText = computed(() => (scopes.value.length ? scopes.value.map(scopeLabel).join("、") : "后端未回传"));
/**
* 只按"哪些字段能分组"这一件事取候选。scopes 从统计响应里拿(列表接口会回传,统计接口不带——
* 首个响应回来后从列表接口补一次范围签名不合适:那会把统计页变成列表页的依赖)。
* 这里统一从 `filterFields` 拉不到范围,就把范围文案留空,不编一个假的。
*/
async function loadGroupFields(uuid: string) {
try {
groupFields.value = (await dataApi.filterFields(uuid).send()) || [];
} catch (e: any) {
groupFields.value = [];
console.warn("[T8] 统计分组候选取不到:", e?.message || e);
}
}
async function loadStats() {
if (!formUuid.value) return;
loading.value = true;
try {
stats.value = await dataApi
.stats({
formUuid: formUuid.value,
groupByField: groupByField.value || undefined
})
.send();
loadedOnce.value = true;
} catch (e: any) {
Message.error(e?.message || "统计加载失败");
} finally {
loading.value = false;
}
}
/** 按状态 / 按字段的桶宽度:以桶内最大值归一,纯视觉映射(不是聚合) */
function barPercent(items: CountItem[]): (item: CountItem) => number {
const max = items.reduce((m, it) => Math.max(m, it.count), 0);
return (item: CountItem) => (max > 0 ? Math.max(2, Math.round((item.count / max) * 100)) : 0);
}
const statusPercent = computed(() => barPercent(stats.value?.byStatus || []));
const fieldPercent = computed(() => (groups: FieldGroups) => barPercent(groups.items || []));
async function selectFormAndLoad(uuid: string) {
groupByField.value = "";
stats.value = null;
loadedOnce.value = false;
await selectForm(uuid);
await loadGroupFields(uuid);
await loadStats();
}
async function init() {
await loadCandidates();
const fromQuery = String(route.query.form || "");
const picked = candidates.value.find((c: { formUuid: string }) => c.formUuid === fromQuery) || candidates.value[0];
if (picked) await selectFormAndLoad(picked.formUuid);
}
watch(
() => [props.context, props.appCode, props.codes.join(",")],
() => init()
);
onMounted(init);
</script>
<template>
<PageStub
title="统计"
stage="T8"
note="总数/按状态/按字段分组走接口聚合(铁律④:前端不自己聚合),缓存失效仅升版;UserBase 侧「统计」行可并入数据管理,二选一。"
/>
<div :class="embedded ? 'stats-bare' : 'page-wrapper yh-fill'">
<div :class="embedded ? '' : 'main-container'">
<div class="toolbar">
<Select v-model="formUuid" style="width: 260px" placeholder="选择表单" @on-change="selectFormAndLoad">
<Option v-for="c in candidates" :key="c.formUuid" :value="c.formUuid"
:label="`${c.name}(${c.appCode}/${c.code})`" />
</Select>
<Select v-model="groupByField" style="width: 240px" placeholder="按字段分组(可不选)" clearable
@on-change="loadStats">
<Option value="">不分组(只出总数与按状态)</Option>
<Option v-for="f in groupFields" :key="f.key" :value="f.key" :label="f.label" />
</Select>
<Button icon="md-refresh" :disabled="!formUuid" @click="loadStats">刷新</Button>
<span class="toolbar-note">
三个数(总数 / 按状态 / 按字段)全部来自接口聚合,本页不做任何二次统计
</span>
</div>
<Alert v-if="candidateError" show-icon type="warning" class="region-tip">
表单清单:{{ candidateError }}
</Alert>
<Spin v-if="loading && !stats" fix></Spin>
<template v-if="stats">
<div class="stat-cards">
<div class="stat-card">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">总数</div>
<div class="stat-sub">范围:{{ scopeText }}</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.byStatus.length }}</div>
<div class="stat-label">状态桶数</div>
<div class="stat-sub">含"已废弃"状态的行会带 null label</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.byField.length }}</div>
<div class="stat-label">分组字段数</div>
<div class="stat-sub">
{{ stats.cached ? "命中后端短 TTL 缓存(容忍 N 分钟延迟)" : "本次数值直接来自接口聚合" }}
</div>
</div>
</div>
<h5 class="section-title">按状态</h5>
<div v-if="!stats.byStatus.length" class="empty-tip">没有可统计的答卷(或后端未回传状态桶)</div>
<div v-else class="bar-list">
<div v-for="item in stats.byStatus" :key="item.value" class="bar-row">
<span class="bar-label">{{ item.label || item.value }}</span>
<div class="bar-track">
<div class="bar-fill" :style="{ width: statusPercent(item) + '%' }"></div>
</div>
<span class="bar-count">{{ item.count }}</span>
</div>
</div>
<template v-for="groups in stats.byField" :key="groups.key">
<h5 class="section-title">按字段分组:{{ groups.label || groups.key }}</h5>
<div v-if="!groups.items.length" class="empty-tip">该字段没有可统计的分组值</div>
<div v-else class="bar-list">
<div v-for="item in groups.items" :key="item.value" class="bar-row">
<span class="bar-label">{{ item.label || item.value }}</span>
<div class="bar-track">
<div class="bar-fill" :style="{ width: fieldPercent(groups)(item) + '%' }"></div>
</div>
<span class="bar-count">{{ item.count }}</span>
</div>
</div>
</template>
<p class="toolbar-note foot-note">
统计只覆盖你数据范围内的答卷(契约 §8 矩阵,后端四读出路径共用同一构造器);
新提交的答卷在短 TTL 内不计入是<b>预期行为</b>(铁律④:提交不清缓存)。
</p>
</template>
<Alert v-else-if="loadedOnce === false && !loading && !candidateError" show-icon type="info" class="region-tip">
先在上面选一张表单。
</Alert>
</div>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar-note {
color: #808695;
font-size: 12px;
}
.region-tip {
margin-bottom: 12px;
}
.stat-cards {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.stat-card {
flex: 1 1 200px;
min-width: 180px;
padding: 14px 16px;
background: #f8f8f9;
border-radius: 4px;
}
.stat-value {
font-size: 26px;
font-weight: 600;
color: #2d8cf0;
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: #515a6e;
margin-top: 4px;
}
.stat-sub {
font-size: 12px;
color: #808695;
margin-top: 6px;
}
.section-title {
margin: 18px 0 10px;
font-size: 14px;
font-weight: 600;
color: #17233d;
border-left: 3px solid #2d8cf0;
padding-left: 8px;
}
.bar-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.bar-row {
display: grid;
grid-template-columns: 180px 1fr 64px;
align-items: center;
gap: 12px;
}
.bar-label {
font-size: 13px;
color: #515a6e;
word-break: break-all;
}
.bar-track {
height: 14px;
background: #eef0f3;
border-radius: 7px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: #2d8cf0;
border-radius: 7px;
transition: width 0.2s ease;
}
.bar-count {
font-size: 13px;
color: #17233d;
text-align: right;
}
.empty-tip {
color: #808695;
font-size: 13px;
padding: 8px 0;
}
.foot-note {
display: block;
margin-top: 20px;
line-height: 1.6;
}
</style>