Compare commits

...
7 Commits
Author SHA1 Message Date
chenxing 2e406a88b1 S8收口:匿名通道三端点与app注册表CRUD的本地mock入册(dev:mock面)
build / build (push) Failing after 2m2s
2026-10-20 22:37:40 +08:00
chenxing d1177a741c t18:前端代录与导入——管理员视图两个新动作接进同一条 submit 管线
- Fill.vue 加代录态:?proxy=1 且该表单在 managed 清单里才画「被记录人」选择器(复用 UserCheckModal +
  GetSchoolAll 本校组织树),提交体带 subjectId。三条口径写在注释里:proxy 只是界面开关、判权在后端
  requireSubject(非 ALL 档带字段即 403);subjectId 是被写入数据的字段而不是操作者身份(V17 不变);
  代录态不取也不显示"我的提交"段(那是本人视角的历史,混进来会让人以为已经给这个人记过)。
- AppContext 管理员视图每行加「添加(代录)」与「导入」;导入弹窗组件 components/admin/ImportPanel.vue:
  下载模板 → 选文件 → 逐行结果表(行号/结果/答卷ID/原因)+ 成功失败计数。整批拒(不是这张表的模板、
  label 重复、超行数上限)与坏行原因一律按后端 msg 原样亮出,前端不复制规则(同 AdminListPanel 口径)。
- api/module/data.ts 加 import(multipart,formUuid 走 query)与 importTemplateBlob(原生 fetch:
  文件流不能过 alova 的 response.json() 拦截器,理由同导出那条)。types/data.ts 加 ImportResult/ImportRowResult。
- api/module/anon.ts 加开发期会话开关:仅 conf.isMock(build 产物恒 false)时,地址带 mockSession=0
  即判"无会话"——否则 mock 的 GetUserInfo 永远返回 code=0,匿名通道在 mock 面演不出来(V67 前半句要实拍)。

门禁:npx vue-tsc --noEmit 0 错(s7/tsc-2.log);npm run build ✓ built in 20.99s(s7/build-1.log)。
2026-10-20 17:40:30 +08:00
chenxing 531f7641fb t16:快应用注册表入口——系统设置第三个 tab(注册/改名/复制入口链接/删除)
- api/module/apps.ts:C17 四端点,寻址一律按 appCode(库内 id 不外露,V5)。
- types/data.ts 增 AppRow(四业务字段 + entryUrl);无角色/授权字段(V59 的界面侧表达)。
- views/admin/Apps.vue:注册(C10 格式与保留字做即时预检,但保存失败以后端 msg 为准并留在弹窗里)、
  改名(code 不随改,输入框禁用)、复制应用入口(后端给站内路径,前端补 origin,生产/本地都能点开)、
  删除(在用禁删与"编码不复用"两类拒绝一律原样提示,前端不复制规则,同 AdminListPanel 口径)。
  页面上没有任何"给这个应用配谁能用"的入口——那是有意的。
- AppSettings.vue:tab 值改闭集判定(roles/apps/dicts),?tab=apps 可深链;
  入口本身仍由 capabilities.schoolAdmin 决定(S3 第三支路),不做"进了页再藏 tab"。

门禁:npx vue-tsc --noEmit exit 0;npm run build ✓ built in 12.16s。
2026-10-20 14:30:28 +08:00
chenxing 60083517c7 t15:前端双探针引导——/f/ 页通道选择走匿名面,顺修 F1 未登录引导与 F2 desc 插槽
- 新 api/module/anon.ts:两把探针走原生 fetch(alova 的 302 拦截对全部 /api/* 生效且没有
  per-request 豁免位,用它问"有没有会话"会当场整页跳 CAS,C15 的代码事实);带 yschapirequest
  头让墙内未登录回 JSON 的 code=302 信封而不是 HTML 跳转。提交回落 alova(/anon 面无 302 语义)。
- Fill.vue 引导改双探针:probeSession → 有会话走登录通道并取"我的提交"段;无会话再 probeAnonForm
  → 命中即匿名渲染(匿名人无任何查询能力,编辑/删除段整条不出现);两跳都落空才 guideToLogin。
- F1 顺修(取"改判通道"那一支,不动全局 errorHandle):旧判据 e?.code===4 是死分支——alova 抛的是
  不带 code 的 Error,embed 的 needLogin 从来没发出去过;现在未登录引导只由探针结论触发。
- F2 顺修:slot desc → #desc 两处(Vue 3 下旧写法静默不进 Alert 描述插槽,报名号那行从未渲染)。
- App.vue:/f/ 前缀路由先以原生探针判会话,无会话则裸渲染且一个鉴权请求都不发(P124——否则无会话者
  还没走到填报页的探针就被带去 CAS,"匿名表单 page=直接可填"根本兑现不了);探到会话时结果直接复用,
  登录面不多发一次请求。其余路由引导一字未动。
- upload/image 两个附件控件的上传端点改 inject("fcUploadUrl", 默认 /api/form/upload),由 Fill.vue
  按通道 provide;设计器预览等调用点行为不变。vite.config.ts 代理加 /anon → 8086。

门禁:npx vue-tsc --noEmit exit 0;npm run build ✓ built in 11.61s。
2026-10-20 14:05:05 +08:00
chenxing 6401a69c18 t14:前端模块行退役——左导航三支路自解析、AppContext 重构为通用壳两视图
- App.vue:引导只剩 GetUserInfo,之后并行取 fillable/managed/capabilities 三支构造导航
  (取不到按空集/false 走,fail-closed,不造兜底菜单树);摘 GetUserMod 调用与 setModules
- store:modules/IModule 状态与 setModules 删除,改存三支路数据 + managesForm() 判"这张表我管不管得动"
- AppContext.vue:重写为通用壳两视图(§5.1)——管理员视图=数据列表+复制填报路径
  (添加/导入 T18 接进这一档),填报者视图=仅填报 tab;判定只看 managed 是否非空,
  壳零自有权限逻辑;GetUserMod?appCode= 拉目标应用树那一路与 :modules prop 链整条拆除
- Data.vue:导出按钮改"该表单 ALL 档(managed 成员)",中心与快应用两种上下文从此同一条判据;
  AnswerDetail.vue 的管理动作区同判据;utils/modulePerms.ts(canExport/hasPage)删除
- utils/menuNav.ts:buildNavTree 与"从树渲染菜单"三条规则删除,只留导航数据结构与
  高亮/面包屑/默认跳转;menuRoute 的 /f/{code} 形态识别按任务书保留(分享直链/历史链接)
- mock:baseapi 去 GetUserMod 与两棵模块树;dataapi 补 /my/managed 与 /my/capabilities 桩
2026-10-20 13:21:23 +08:00
chenxing 4a7db3d22c t13:前端换形——audience 数组多选 + 冻结/匿名窗口开关,fillable 行去 canFill/myTier
- types/form.ts:AUDIENCE 改字符串码闭集(含 ANONYMOUS)+ audienceText();
  FormView/FormConfigRequest 加 audience 数组、frozen、anonCookieMinutes
- types/data.ts:FillableForm 去 canFill/myTier(C2,入集即可填);
  DATA_SCOPE_LABELS 由四枚举改两档(ALL/SELF,§19 作废-1)
- FormConfigModal:面向人群改复选组(多选共集)+ 冻结开关(文案指路"喂外部业务应用的表单请开启")
  + 匿名防重窗口;含 ANONYMOUS 时限次置灰并在提交前挡(与后端同判据)
- Forms.vue 面向列、My.vue「可填写」卡集、fill.ts/routes.ts 注记同步换形
- AppContext.vue:填报 tab 直接吃入集行;数据/统计两档的驱动位改由 T14 的 managed 端点接,
  本批不留"按 myTier 猜"的兼容分支(壳零自有权限逻辑)
- mock:formapi/dataapi 的 audience 数组形态 + 匿名×限次跨字段约束
2026-10-20 12:54:26 +08:00
chenxing 25dc3eb2e5 fix(post-p95): 编辑态按钉住版渲染,自填/代填主体展示改判 submitter
- 跨版本编辑必挂:编辑态此前吃 resolve 返回的当前发布版渲染,服务端却按答卷钉住
  的旧版校验,升版后编辑永远 400。改为拉 /my/detail 取钉住版 groups,渲染与校验
  同源(renderGroups)。
- 自填行 subject 归一为本人后,"本人自填"与"他人代填"的判据从"subject 是否为空"
  改为"subject 是否等于 submitter",同步修正 My.vue / Data.vue / AnswerDetail.vue
  三处展示与 tooltip。

验证:type-check + build 双绿。
2026-10-16 15:04:52 +08:00
31 changed files with 1516 additions and 591 deletions
+66 -26
View File
@@ -4,10 +4,10 @@ import layout from "./components/layout.vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import config from "@/assets/config/host.config"; import config from "@/assets/config/host.config";
import { baseApi } from "@/api/module/base"; import { baseApi } from "@/api/module/base";
import { resolveMenuPage } from "@/utils/menuRoute"; import { probeSession } from "@/api/module/anon";
import { buildNavTree, firstNavPage, isSingleNav, type NavNode } from "@/utils/menuNav"; import { getMyCapabilities, listFillableForms, listManagedForms } from "@/api/module/fill";
import { firstNavPage, isSingleNav, type NavNode } from "@/utils/menuNav";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import type { IModule } from "@/types/Basics";
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
const router = useRouter(); const router = useRouter();
@@ -17,7 +17,7 @@ const isReady = ref(false);
/** /**
* 页面即组件(方案 §3.3):被 iframe 嵌入时自动剥壳——不渲染顶栏/左导航/面包屑, * 页面即组件(方案 §3.3):被 iframe 嵌入时自动剥壳——不渲染顶栏/左导航/面包屑,
* 也不发两个鉴权请求(嵌入上下文里本应用 /api/base/* 取不到会话会弹错误提示并画出带壳页面)。 * 也不发会话请求(嵌入上下文里本应用 /api/base/* 取不到会话会弹错误提示并画出带壳页面)。
* 显式 mode 参数(?mode=embed|page)的闭集判定在 T7 填报页落地,壳这层只兜 self != top 这一条。 * 显式 mode 参数(?mode=embed|page)的闭集判定在 T7 填报页落地,壳这层只兜 self != top 这一条。
*/ */
const isEmbedded = window.self !== window.top; const isEmbedded = window.self !== window.top;
@@ -37,11 +37,36 @@ const layoutData = reactive({
baseHost: config.baseHost, baseHost: config.baseHost,
ssoHost: config.ssoHost, ssoHost: config.ssoHost,
userInfo: null as any, userInfo: null as any,
/** 已按「path 空=权限锚点不进导航」规则过滤的可渲染菜单树(见 utils/menuNav.ts) */ /** 自解析出来的可渲染导航(契约 v2 §2.4 三支路;不再由 UserBase 模块树生成) */
navList: [] as NavNode[], navList: [] as NavNode[],
isAlone: false, isAlone: false,
}); });
/** 导航节点 id:layout 只做高亮/面包屑对照,不参与任何判定,序号稳定即可 */
let navSeq = 1000;
function navItem(name: string, page: string, icon: string): NavNode {
return { id: navSeq++, modId: page, name, icon, page, external: false, raw: page, group: false, children: [] };
}
/**
* 三支路自解析(D5):可填的表单 / 我管理的表单 / 校管理员系统设置。
*
* <b>前端不自行判权</b>——每一支的画不画只看后端那三把接口给了什么:
* `fillable`(入集即可填)、`managed`(创建人 ∪ 表单管理员)、`capabilities.schoolAdmin`。
*/
function buildSelfResolvedNav(): NavNode[] {
const nav: NavNode[] = [navItem("我的填写", "/my", "ios-person"), navItem("可填的表单", "/my/fillable", "ios-create")];
if (store.managed.length) {
nav.push(navItem("我管理的表单", "/admin/data", "ios-list"));
nav.push(navItem("统计", "/admin/stats", "ios-pie"));
}
if (store.capabilities.schoolAdmin) {
nav.push(navItem("表单设计", "/admin/forms", "md-build"));
nav.push(navItem("系统设置", "/admin/app", "md-settings"));
}
return nav;
}
onMounted(async () => { onMounted(async () => {
// 首次导航异步完成后再判默认跳转:mount 时 currentRoute 可能仍是初始 "/" // 首次导航异步完成后再判默认跳转:mount 时 currentRoute 可能仍是初始 "/"
await router.isReady(); await router.isReady();
@@ -49,39 +74,54 @@ onMounted(async () => {
isReady.value = true; isReady.value = true;
if (isEmbedded) return; if (isEmbedded) return;
// 免墙直达面(契约 v2 §3.2/C15):/f/{code} 的通道选择归填报页自己的双探针,壳必须先以
// 「不整页跳转的原生探针」问一次有没有会话。直接发 getUserInfo 的话,无会话者还没走到探针
// 就被 alova 的 302 拦截带去 CAS,匿名表单在 page 模式下永远打不开(V67 要的那条)。
// 探到会话时顺手把用户带下来,登录面因此不多发一次请求。
const probed = router.currentRoute.value.path.startsWith("/f/") ? await probeSession() : null;
if (probed && !probed.logged) {
// 没有会话就没有导航可画:裸渲染,页面自己决定是匿名可填还是引导登录
layoutData.isAlone = true;
return;
}
try { try {
// 1. 当前会话用户 // 会话用户(GetUserMod 已随模块行退役删除,引导只剩这一把 + 三支路自解析)
const user: any = await baseApi.getUserInfo().send(); const user: any = probed?.user ?? (await baseApi.getUserInfo().send());
const { id, name, profilePicture } = user || {}; const { id, name, profilePicture } = user || {};
// 存一份给页面用(如字典页判平台级维护权);权限判定仍在后端,这里只是不重复发一次请求 // 存一份给页面用(如字典页判平台级维护权);权限判定仍在后端,这里只是不重复发一次请求
store.setUser(user || null); store.setUser(user || null);
// 2. 当前用户已授权的模块树(左导航数据源;path 空的权限锚点行也在内,由导航层跳过) // 三支路并行取(互相独立,串行只会让首屏多等两个 RTT)
const modList: IModule[] = (await baseApi.getUserMod().send()) || []; const [fillable, managed, capabilities] = await Promise.all([
listFillableForms().catch((e: any) => {
console.warn("[自解析] 可填清单取不到,按空集处理:", e?.message);
return [];
}),
listManagedForms().catch((e: any) => {
console.warn("[自解析] 管理清单取不到,按空集处理:", e?.message);
return [];
}),
getMyCapabilities().catch((e: any) => {
console.warn("[自解析] 能力位取不到,按无入口处理(fail-closed):", e?.message);
return { schoolAdmin: false };
})
]);
store.setFillable(fillable || []);
store.setManaged(managed || []);
store.setCapabilities(capabilities || null);
layoutData.userInfo = { id, name: name || "用户", avatar: profilePicture || "" }; layoutData.userInfo = { id, name: name || "用户", avatar: profilePicture || "" };
layoutData.navList = buildNavTree(modList, resolveMenuPage); layoutData.navList = buildSelfResolvedNav();
layoutData.isAlone = isSingleNav(layoutData.navList); layoutData.isAlone = isSingleNav(layoutData.navList);
// 原始树留一份:按钮级权限(快应用的"导出"锚点)按它判,导航树已把锚点行过滤掉了
store.setModules(modList);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
// 冒烟取证:原始树 vs 可渲染导航(path 空的权限锚点应当只出现在前者)
console.table( console.table(
(modList || []).map((m: any) => ({ layoutData.navList.map((n) => ({ 渲染项: n.name, page: n.page }))
name: m?.name,
id: m?.id,
modId: m?.modId,
page: m?.page,
icon: m?.icon,
children: (m?.children || []).length
}))
);
console.table(
layoutData.navList.map((n) => ({ 渲染项: n.name, page: n.page, 分组头: n.group }))
); );
} }
// 入口页:跳第一个可跳转菜单项(无授权用户即「我的填写」) // 入口页:跳第一个可跳转菜单项
if (router.currentRoute.value.path === "/") { if (router.currentRoute.value.path === "/") {
const first = firstNavPage(layoutData.navList); const first = firstNavPage(layoutData.navList);
if (first) router.replace(first); if (first) router.replace(first);
@@ -91,7 +131,7 @@ onMounted(async () => {
if (error?.message !== "redirect") { if (error?.message !== "redirect") {
Message.error("载入用户信息失败,请检查后端服务是否正常!"); Message.error("载入用户信息失败,请检查后端服务是否正常!");
} }
// 降级只降「导航为空」,不造兜底菜单树——静默回落会把「平台未配模块/未授权」伪装成正常 // 降级只降「导航为空」,不造兜底菜单树——静默回落会把"接口没通"伪装成正常
store.setUser(null); store.setUser(null);
layoutData.userInfo = { name: "用户", avatar: "" }; layoutData.userInfo = { name: "用户", avatar: "" };
layoutData.navList = []; layoutData.navList = [];
+89
View File
@@ -0,0 +1,89 @@
/**
* 匿名通道(契约 v2 §3.2/C6/C15,T15)。
*
* <p><b>两把探针必须走原生 fetch,不走 alova</b>:alova 实例的 302 拦截对全部 `/api/*` 生效且
* 没有 per-request 豁免位(C15 的代码事实,alova-core.ts:58-65),而引导期要问的问题恰恰是
* "这个人有没有会话"——用 alova 去问,无会话者当场被整页带去 CAS,双探针就没了意义。
* 探针只回数据,不跳转。</p>
*
* <p>提交回落 alova:`/anon` 面不存在 302 语义(无会话墙),业务错误提示链与登录面同一条;
* 上传端点由本模块给出 URL 串(附件控件按注入的 URL 走,见 Fill.vue 的 provide)。</p>
*/
import conf from "@/assets/config/host.config";
import Utils from "@/utils";
import { alovaInstance } from "@/utils/alova/alova-iview";
import type { ResolvedForm, SubmitRequest, SubmitView } from "@/types/answer";
/** 平台统一响应信封(探针只看 code,不套 alova 的错误处理) */
interface Envelope<T = any> {
code: number;
msg?: string;
data?: T;
}
/** 会话探针的结果:有没有会话 + 有会话时顺手把用户带回来(省一次重复请求) */
export interface SessionProbe {
logged: boolean;
user: any | null;
}
/**
* 探针请求。带 `yschapirequest: 1` 是给 CAS 过滤器认"这是 AJAX"的记号——不带它,墙内未登录会回
* HTML 跳转而不是 JSON 的 code=302 信封,fetch 会跟着跳到 CAS 域名然后撞 CORS,探针就变成了一次
* 无声的失败。`yschreferer` 同 alova 实例的头,保持两条通道对后端看起来是同一个前端。
*/
async function probe<T>(path: string): Promise<Envelope<T>> {
try {
const res = await fetch(`${conf.serviceHost}${path}`, {
credentials: "include",
headers: {
yschapirequest: "1",
yschreferer: Utils.strToHexCharCode(window.location.href)
}
});
if (!res.ok) return { code: -1, msg: `HTTP ${res.status}` };
return (await res.json()) as Envelope<T>;
} catch (e: any) {
return { code: -1, msg: e?.message || "探针请求失败" };
}
}
/**
* 探针 1(C15 第一跳):当前浏览器有没有 CAS 会话。非 0(含 302/网络错)一律按"无会话"回答。
*
* mock 面的开发期开关(`conf.isMock` 在 build 产物里恒 false,生产链路一个字都不走这支):
* 地址带 `mockSession=0` 即视为无会话——否则 mock 的 GetUserInfo 永远返回 code=0,
* 匿名通道在 mock 面就演不出来(V67 要的"匿名表单 page=直接可填"需要能在本机实拍)。
*/
export async function probeSession(): Promise<SessionProbe> {
if (conf.isMock && window.location.href.includes("mockSession=0")) {
return { logged: false, user: null };
}
const envelope = await probe("/api/base/GetUserInfo");
return { logged: envelope.code === 0, user: envelope.code === 0 ? envelope.data ?? null : null };
}
/**
* 探针 2(C15 第二跳):这张表是不是匿名表单。
* 后端对"非匿名表单"和"压根不存在"给同一条 PARAM 表单不存在(C6/V54 防探测),
* 所以这里只需要"是不是 0"一个判据,msg 不进 UI。
*/
export async function probeAnonForm(code: string, appCode?: string): Promise<ResolvedForm | null> {
const query = appCode ? `&appCode=${encodeURIComponent(appCode)}` : "";
const envelope = await probe<ResolvedForm>(`/anon/form/resolve?code=${encodeURIComponent(code)}${query}`);
return envelope.code === 0 && envelope.data ? envelope.data : null;
}
/** 匿名提交(响应形状与登录面同一条:{answerId, answerNo, replayed},C7 幂等命中 200+原 answerId) */
export function submitAnonAnswer(body: SubmitRequest) {
return alovaInstance.Post<SubmitView>("/anon/form/submit", body).send();
}
/**
* 匿名上传的端点寻址(`code` 必带:后端只有表单能定 sch_id,落错校提交时的附件归属就查不到这行)。
* 返回的是给附件控件用的完整请求路径,appCode 以 query 参数交出去。
*/
export function anonUploadPath(code: string, appCode?: string): string {
const query = appCode ? `&appCode=${encodeURIComponent(appCode)}` : "";
return `/anon/form/upload?code=${encodeURIComponent(code)}${query}`;
}
+36
View File
@@ -0,0 +1,36 @@
/**
* 快应用注册表 CRUD(契约 v2 C17/§5.2,T16,挂应用设置 · 快应用 tab)。
*
* 三处与直觉不同:
* 1. **寻址按 `appCode`,不是库内 id**(V5:自增 id 绝不外露)——所以改名/删除的 URL 段是 slug 本身;
* 2. **`schId` 只有超管切目标域时才带**,校管理员传别的值即被 `FormAdminGuard` 拒(同两表端点口径);
* 3. **入口链接由后端生成**(`entryUrl` 是站内路径,按后端自己的 context-path 组段),
* 前端只补 `origin`——生产得到 `/form-center/front/#/app/{code}/`,本地 dev 没有 context 段,
* 得到 `/#/app/{code}/`,两处复制出来的链接都能直接点开。
*/
import { alovaInstance } from "@/utils/alova/alova-iview";
import type { AppRow } from "@/types/data";
const BASE = "/api/form/admin/apps";
/** 清单:本校 ∪ 平台级(软删行不在内) */
export function listApps(schId?: number) {
const params: Record<string, string | number> = {};
if (schId !== undefined && schId !== null) params.schId = schId;
return alovaInstance.Get<AppRow[]>(BASE, { params }).send();
}
/** 建(app_code + name;无审核,D14) */
export function createApp(body: { appCode: string; name: string }) {
return alovaInstance.Post<AppRow>(BASE, body).send();
}
/** 改名——code 不随改(它是入口链接与 oapi/MQ 的域段) */
export function renameApp(appCode: string, name: string) {
return alovaInstance.Put<AppRow>(`${BASE}/${encodeURIComponent(appCode)}`, { name }).send();
}
/** 软删(名下有未删除表单时后端按 CUSTOM(5) 拒,msg 指点先迁先删) */
export function deleteApp(appCode: string) {
return alovaInstance.Delete<AppRow>(`${BASE}/${encodeURIComponent(appCode)}`).send();
}
+8 -19
View File
@@ -1,14 +1,16 @@
import { alovaInstance } from "@/utils/alova/alova-iview"; import { alovaInstance } from "@/utils/alova/alova-iview";
import type { IModule } from "@/types/Basics";
import type { SchoolTreeNode } from "@/types/admin"; import type { SchoolTreeNode } from "@/types/admin";
/** /**
* 基础平台接口(notice/front 的 api/module 形态 + flow-center base.ts 同构): * 基础平台接口(notice/front 的 api/module 形态 + flow-center base.ts 同构)。
* 用户信息与已授权模块列表(左导航数据源)。
* *
* 模块行在 UserBase 按本应用(app_code=form-center)配置,path 须逐字对上 * 契约 v2 §2.4 之后只剩两把:
* router/routes.ts(手册 §2.2 表 v1.5 五行版)。数据范围升级位不再挂在模块行上: * - `getUserInfo`:会话用户本体,App.vue 引导的唯一请求;
* T10 起由后端管理员两表(school_admin / form_admin)承载,前端只在 /admin/app 的角色设置 tab 维护它。 * - `getSchoolAll`:本校组织树(教师版),管理员两表选人弹窗的数据源。
*
* **`getUserMod` 已随 UserBase 模块行整条退役而删除**(§19 作废-10):左导航改由三支路自解析
* (`/api/form/my/fillable`、`/api/form/my/managed`、`/api/form/my/capabilities`),
* B14 缺口与跨项目触点 H4 一并消失。
* *
* 写法与 Notice 的一处差异:Notice 用 `new Method<T>(...)`,而 alova 3.5 的 Method 第一个 * 写法与 Notice 的一处差异:Notice 用 `new Method<T>(...)`,而 alova 3.5 的 Method 第一个
* 泛型位是 AlovaGenerics(不是响应类型),strict vue-tsc 下报 TS2344;本端改用实例方法 * 泛型位是 AlovaGenerics(不是响应类型),strict vue-tsc 下报 TS2344;本端改用实例方法
@@ -22,19 +24,6 @@ class BaseApi {
return alovaInstance.Get(`${BASE}/GetUserInfo`); return alovaInstance.Get(`${BASE}/GetUserInfo`);
} }
/**
* 当前用户已授权的模块树(左侧菜单数据源)。
*
* @param appCode 可选目标应用(快应用上下文 /app/{appCode}/ 要的是<b>那个应用</b>的树,
* 参数名按执行手册 §5 P2 定稿 appCode)。UserBase 侧 H4 未上线时后端
* 返回空树并打 warn——前端据此显示空导航,<b>不得回落本应用树</b>(P3 裁决)。
*/
getUserMod(appCode?: string) {
return alovaInstance.Get<IModule[]>(`${BASE}/GetUserMod`, {
params: appCode ? { appCode } : undefined
});
}
/** /**
* 本校组织树(教师版)——选人弹窗 UserCheckModal 的 loader,形态同 notice。 * 本校组织树(教师版)——选人弹窗 UserCheckModal 的 loader,形态同 notice。
* 没有 schId 入参:域只从会话推导,因此候选天然限在本校树里(跨校的人在接口层就给不出来)。 * 没有 schId 入参:域只从会话推导,因此候选天然限在本校树里(跨校的人在接口层就给不出来)。
+44
View File
@@ -18,6 +18,7 @@ import type {
AnswerDetail, AnswerDetail,
BatchItemResult, BatchItemResult,
FilterField, FilterField,
ImportResult,
ListRequest, ListRequest,
ListView, ListView,
QueryRequest, QueryRequest,
@@ -93,10 +94,53 @@ class DataApi {
params: paramsOf({ ...scope }) params: paramsOf({ ...scope })
}); });
} }
/**
* 批量导入(契约 v2 C14,T18):multipart xlsx,逐行走提交管线,响应给逐行结果(坏行不静默)。
* 守卫与导出同一把(该表单 ALL 档),`formUuid` 走 query 而不是 body——它是请求的寻址件不是数据。
*/
import(formUuid: string, file: File, scope: Scope = {}) {
const formData = new FormData();
formData.append("file", file);
return alovaInstance.Post<ImportResult>(`${BASE}/import`, formData, {
params: paramsOf({ ...scope, formUuid }),
headers: { "Content-Type": "multipart/form-data" }
});
}
} }
export const dataApi = new DataApi(); export const dataApi = new DataApi();
/**
* 导入模板下载(C14,T18)。与 {@link exportBlob} 同一支原生 fetch:这两个端点都直接写
* OutputStream、不套 JSON 信封,本仓的响应拦截器对每个响应都 `response.json()`,文件流过它必报错。
*/
export async function importTemplateBlob(formUuid: string, scope: Scope = {}): Promise<Blob> {
const query = new URLSearchParams({ formUuid });
if (scope.schId !== undefined && scope.schId !== null) query.set("schId", String(scope.schId));
const url = `${conf.serviceHost}${BASE}/import-template?${query.toString()}`;
const headers: Record<string, string> = {
yschapirequest: "1",
yschreferer: Utils.strToHexCharCode(window.location.href)
};
const currentRole = localStorage.getItem("currentRole");
if (currentRole) headers.currentRole = currentRole;
const response = await fetch(url, { method: "GET", headers, credentials: "include" });
const type = response.headers.get("content-type") || "";
if (!response.ok || type.includes("application/json")) {
let msg = "模板下载失败";
try {
const env = await response.json();
msg = env?.msg || msg;
} catch {
/* 响应体不是 JSON 时保持默认文案 */
}
throw new Error(msg);
}
return await response.blob();
}
/** /**
* 导出(xlsx 流式同步下载,方案 §5.5 铁律④:不设行数上限、不做异步任务与下载中心)。 * 导出(xlsx 流式同步下载,方案 §5.5 铁律④:不设行数上限、不做异步任务与下载中心)。
* *
+20 -4
View File
@@ -6,7 +6,7 @@
*/ */
import { alovaInstance } from "@/utils/alova/alova-iview"; import { alovaInstance } from "@/utils/alova/alova-iview";
import type { ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer"; import type { ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer";
import type { AnswerDetail, FillableForm, MyAnswerViewItem } from "@/types/data"; import type { AnswerDetail, Capabilities, FillableForm, ManagedForm, MyAnswerViewItem } from "@/types/data";
/** 解析当前发布版本(契约 §1:入口寻址) */ /** 解析当前发布版本(契约 §1:入口寻址) */
export function resolveForm(code: string, appCode?: string) { export function resolveForm(code: string, appCode?: string) {
@@ -56,13 +56,29 @@ export function getMyAnswerDetail(answerId: string) {
return alovaInstance.Get<AnswerDetail>("/api/form/my/detail", { params: { answerId } }).send(); return alovaInstance.Get<AnswerDetail>("/api/form/my/detail", { params: { answerId } }).send();
} }
// ==================== 可填写列表(P95 W3/W4,/my/fillable) ==================== // ==================== 可填写列表(契约 v2 C2,/my/fillable) ====================
// 数据源=后端按 P95 三层模型解析的**填报入口**清单,不再是会话菜单树里挑 /f/ 行(P91 的形态)。 // 数据源=后端按 **audience 门**(会话身份类型 ∈ 表单面向人群)解析的填报入口清单,
// 不再是会话菜单树里挑 /f/ 行(P91 的形态),也不再有"入口开了但不能自填"的中间态。
// appCode 可选:缺省=全部应用(中心 + 快应用),快应用上下文传自身 appCode 收窄。 // appCode 可选:缺省=全部应用(中心 + 快应用),快应用上下文传自身 appCode 收窄。
/** 我可填的表单(入口层非 NONE 的都下发;本人能不能填看 canFill) */ /** 我可填的表单(入集即等价于可填;资格判定在后端,与提交时同一个公式) */
export function listFillableForms(appCode?: string) { export function listFillableForms(appCode?: string) {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (appCode) params.appCode = appCode; if (appCode) params.appCode = appCode;
return alovaInstance.Get<FillableForm[]>("/api/form/my/fillable", { params }).send(); return alovaInstance.Get<FillableForm[]>("/api/form/my/fillable", { params }).send();
} }
// ==================== 菜单自解析另两支路(契约 v2 §2.4/D5,T14) ====================
// 三支路的后两支:我管理的表单(C3)与能力位(校管理员)。前端不自行判权,只消费这三把接口。
/** 我管理的表单(创建人 ∪ 表单管理员;role 标出来源) */
export function listManagedForms(appCode?: string) {
const params: Record<string, string> = {};
if (appCode) params.appCode = appCode;
return alovaInstance.Get<ManagedForm[]>("/api/form/my/managed", { params }).send();
}
/** 能力位:只回答"要不要给这个人画系统设置入口",不是权限 */
export function getMyCapabilities() {
return alovaInstance.Get<Capabilities>("/api/form/my/capabilities").send();
}
+115
View File
@@ -0,0 +1,115 @@
/**
* 匿名通道三端点的本地 Mock(契约 v2 §3.2/C6/C7,vite-plugin-mock,仅 `npm run dev:mock` 生效)。
*
* 用途:本机没有 CAS 会话时,`/f/:code` 的**匿名通道**(双探针的第二跳 + 匿名 resolve/submit/upload)
* 在 mock 面要可渲染、可提交、可实拍"窗口内二发被拒"。配合 `api/module/anon.ts` 里那条
* "mock 模式地址带 `mockSession=0` 即视为无会话"的开发期开关使用——真环境的会话判定仍完全由
* `probeSession()` 探 `/api/base/GetUserInfo` 决定,本文件不参与生产链路。
*
* 三处形状与后端逐字段对齐(前端取证只看对接形状,语义权威在 Java 侧的 AnonFillIT):
* ① resolve 出参与登录面 `/api/form/resolve` 同构(audience 是数组、options 已内联);
* ② 非匿名 code 一律回"表单不存在"(PARAM_ERROR=2 家族里的防探测文案,与不存在同一条);
* ③ submit 命中防重回 CUSTOM(5) 且 msg 含剩余分钟;成功回 `{answerId, answerNo, replayed}`。
*/
import type { ResolvedForm } from "@/types/answer";
const ok = (data: unknown) => ({ code: 0, msg: "", data });
const bizErr = (code: number, msg: string) => ({ code, msg, data: null });
/** 唯一一张"允许匿名"的种子表单;其余 code 都按不存在回答(防探测口径的 mock 面复演) */
const ANON_CODE = "school_survey";
const WINDOW_MINUTES = 10;
const GROUPS = [
{
key: "base",
label: "基本信息",
fields: [
{ key: "nick", label: "称呼", type: "text", required: true, maxLength: 20 },
{
key: "grade", label: "年级", type: "select", required: true,
options: [
{ label: "高一", value: "g1" },
{ label: "高二", value: "g2" },
{ label: "高三", value: "g3" }
]
},
{ key: "suggestion", label: "建议", type: "textarea", required: false }
]
}
];
/** 防重窗口:code → 上次成功提交的毫秒时间戳(后端是 HMAC 签名的 cookie,mock 面用内存表复演) */
const lastSubmitAt: Record<string, number> = {};
const submitted: any[] = [];
let seq = 1;
function queryValue(query: any, key: string): string {
if (!query) return "";
if (typeof query.get === "function") return query.get(key) || "";
return String(query[key] ?? "");
}
function resolveAnon(code: string): ResolvedForm {
return {
formUuid: "mock0000000000000000000000000an1",
code,
appCode: "form-center",
name: "校园意见征集(匿名)",
version: 1,
audience: ["ANONYMOUS"],
fillLimit: 0,
answerNoEnabled: 1,
deadline: null,
deadlineReached: false,
statusDefs: [
{ code: "pending", label: "待处理", is_deleted: false },
{ code: "processed", label: "已处理", is_deleted: false }
],
groups: GROUPS as any
} as ResolvedForm;
}
export default [
{
url: "/anon/form/resolve",
method: "get",
response: ({ query }: any) => {
const code = queryValue(query, "code");
if (code !== ANON_CODE) return bizErr(2, `表单不存在:form-center/${code}`);
return ok(resolveAnon(code));
}
},
{
url: "/anon/form/submit",
method: "post",
response: ({ body }: any) => {
const code = String(body?.formCode ?? "");
if (code !== ANON_CODE) return bizErr(2, `表单不存在:form-center/${code}`);
const elapsed = Date.now() - (lastSubmitAt[code] ?? 0);
if (lastSubmitAt[code] && elapsed < WINDOW_MINUTES * 60_000) {
const left = Math.max(1, Math.ceil((WINDOW_MINUTES * 60_000 - elapsed) / 60_000));
return bizErr(5, `该表单 ${WINDOW_MINUTES} 分钟内只能提交一次,请约 ${left} 分钟后再提交:${code}`);
}
lastSubmitAt[code] = Date.now();
submitted.push(body);
const no = seq++;
return ok({
// 与真 answer_uuid 同长(32)——mock 面的 id 串一旦被别的调试脚本硬编码引用,长度漂移就是 P72 那类假失败
answerId: `mockanon${String(no).padStart(4, "0")}${"0".repeat(20)}`,
answerNo: `${code}-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-000${no}`,
replayed: false
});
}
},
{
url: "/anon/form/upload",
method: "post",
response: ({ query }: any) => {
const code = queryValue(query, "code");
if (code !== ANON_CODE) return bizErr(2, `表单不存在:form-center/${code}`);
// 与登录面同一份返回三件套(无 fileUrl,V24);id 是 mock 自增,真环境是 form_file 主键
return ok({ id: seq, fileName: "匿名附件.jpg", key: `form/2026/10/mockanon${seq++}.jpg` });
}
}
];
+110
View File
@@ -0,0 +1,110 @@
/**
* app 注册表(快应用)的本地 Mock(契约 v2 §5.2/C10/C17,vite-plugin-mock,仅 `npm run dev:mock` 生效)。
*
* 桩把后端四条规则也演一遍,让「应用设置 · 快应用」tab 在 mock 面能实拍到"前端没有把规则写丢":
* ① C10 格式与保留字(小写 slug 2~64、`form-center` 不可注册);② code 全局唯一;
* ③ 软删后编码不复用(`uk_app_code` 不含 is_deleted,问题单 P125 的那条口径);
* ④ 在用禁删(名下有表单即 CUSTOM(5),msg 指点先迁先删)。
* 零角色列(V59)在这里的形状证据就是 `AppRow` 里根本没有权限字段。
*/
import type { AppRow } from "@/types/data";
const ok = (data: unknown) => ({ code: 0, msg: "", data });
const fail = (msg: string) => ({ code: 5, msg, data: null });
const bad = (msg: string) => ({ code: 2, msg, data: null });
const CODE_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$/;
const RESERVED = "form-center";
/** 名下还有未删表单的 app_code(后端由 form 表统计,这里用一张内存表复演) */
const formsInUse = new Set<string>([RESERVED, "honor"]);
interface Row extends AppRow {
deleted: boolean;
}
const rows: Row[] = [
{ appCode: "honor", name: "荣誉管理", schId: 3260, entryUrl: "/#/app/honor/", deleted: false },
{ appCode: "affair-lib", name: "事项库", schId: 0, entryUrl: "/#/app/affair-lib/", deleted: false }
];
function view(row: Row): AppRow {
return { appCode: row.appCode, name: row.name, schId: row.schId, entryUrl: row.entryUrl };
}
function live(): AppRow[] {
return rows.filter((r) => !r.deleted).map(view);
}
function pathParam(url: string): string {
const tail = String(url ?? "").split("?")[0].split("/").pop() ?? "";
try {
return decodeURIComponent(tail);
} catch {
return tail;
}
}
function bodyOf(ctx: any): any {
if (ctx?.body && Object.keys(ctx.body).length) return ctx.body;
try {
return JSON.parse(ctx?.bodyText || "{}");
} catch {
return {};
}
}
export default [
{ url: "/api/form/admin/apps", method: "get", response: () => ok(live()) },
{
url: "/api/form/admin/apps",
method: "post",
response: ({ body }: any) => {
const req = bodyOf({ body });
const code = String(req?.appCode ?? "").trim();
const name = String(req?.name ?? "").trim();
if (!code) return bad("appCode 必填");
if (!name) return bad("应用名称 name 必填");
if (code.toLowerCase() === RESERVED) return bad(`${RESERVED} 是中心保留的 app_code,不可注册`);
if (!CODE_RE.test(code)) {
return bad(`appCode 需为 2~64 位小写 slug(^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$):${code}`);
}
const existing = rows.find((r) => r.appCode === code);
if (existing) {
return fail(existing.deleted
? `app_code 曾被注册且已删除,编码不复用(uk_app_code 全库唯一,请换 code):${code}`
: `app_code 已存在:${code}`);
}
const row: Row = { appCode: code, name, schId: 3260, entryUrl: `/#/app/${code}/`, deleted: false };
rows.push(row);
return ok(view(row));
}
},
{
url: "/api/form/admin/apps/:appCode",
method: "put",
response: ({ url, body }: any) => {
const code = pathParam(url);
const row = rows.find((r) => r.appCode === code && !r.deleted);
if (!row) return bad(`app 不存在:${code}`);
const name = String(bodyOf({ body })?.name ?? "").trim();
if (!name) return bad("应用名称 name 必填");
row.name = name;
return ok(view(row));
}
},
{
url: "/api/form/admin/apps/:appCode",
method: "delete",
response: ({ url }: any) => {
const code = pathParam(url);
const row = rows.find((r) => r.appCode === code && !r.deleted);
if (!row) return bad(`app 不存在:${code}`);
if (formsInUse.has(code)) {
return fail(`app_code「${code}」名下还有未删除表单,先把表单迁走或删除再删注册行`);
}
row.deleted = true;
return ok(view(row));
}
}
];
+5 -151
View File
@@ -2,150 +2,16 @@
* 表单中心本地 Mock(vite-plugin-mock,仅 `npm run dev:mock` 即 --mode mock 生效)。 * 表单中心本地 Mock(vite-plugin-mock,仅 `npm run dev:mock` 即 --mode mock 生效)。
* *
* 背景与授权口径:执行手册 §5 P1 的降级取证先例——本地 CAS/UserBase 不可用时, * 背景与授权口径:执行手册 §5 P1 的降级取证先例——本地 CAS/UserBase 不可用时,
* 以本文件的模块树验证「壳渲染 + 左导航 + path 空节点跳过」,并把结论记入问题单。 * 用本文件的桩验证「壳渲染 + 自解析左导航」,并把结论记入问题单。
* *
* 形状严格对齐后端 FormBaseController.toFrontendTree 的下发字段 * 契约 v2 §2.4(T14)之后本桩只剩两把基础端点:`GetUserInfo` 与 `GetSchoolAll`。
* (id/modId/name/parentId/page/level/icon/orderNumber/usable/children,信封 {code,msg,data}), * 原 `GetUserMod` 与两棵模块树桩(中心五行 / 快应用演练行)随模块行整条退役删除——
* 模块行取执行手册 §2.2 的 v1.5 现行表(中心管理员四行 + 我的填写,共五行)。 * 左导航改由三支路(`my/fillable`、`my/managed`、`my/capabilities`)自解析,
* 那三支的桩在 `dataapi.ts` 里。
*/ */
import type { IModule } from "@/types/Basics";
const ok = (data: any) => ({ code: 0, msg: "", data }); const ok = (data: any) => ({ code: 0, msg: "", data });
/** 中心应用(form-center)自身的模块树 */
const OWN_MODULES: IModule[] = [
{
id: 9101,
modId: 9101,
name: "表单设计",
parentId: 0,
page: "/admin/forms",
level: 1,
icon: "word",
orderNumber: 1,
usable: true,
children: []
},
{
id: 9102,
modId: 9102,
name: "数据管理",
parentId: 0,
page: "/admin/data",
level: 1,
icon: "category",
orderNumber: 2,
usable: true,
children: []
},
{
id: 9103,
modId: 9103,
name: "统计",
parentId: 0,
page: "/admin/stats",
level: 1,
icon: "tongji",
orderNumber: 3,
usable: true,
children: []
},
{
id: 9104,
modId: 9104,
name: "应用设置",
parentId: 0,
page: "/admin/app",
level: 1,
icon: "zidingyi",
orderNumber: 4,
usable: true,
children: []
},
{
id: 9105,
modId: 9105,
name: "我的填写",
parentId: 0,
page: "/my",
level: 1,
// 平台未配置图标 → layout 兜底 icon-list(规范 §4 / Notice 13afb22)
icon: "",
orderNumber: 5,
usable: true,
children: []
}
// v1.5(T10)起中心应用就这五行:字典管理行撤(迁入 /admin/app 的页内 tab)、
// "数据全量"path 空锚点行撤(UserBase 侧 customId 权限锚点协议退役,数据范围升级改由后端管理员两表承载)。
// 本地要复现旧六行版的登录态(V46 的"不再出现字典管理导航项")请用 t10 冒烟脚本里的临时树覆盖,
// 不要把遗留行写回这份 mock——它是现行配置的镜像。
];
/**
* 第一个快应用的模块树(T9 演练桩,P95 W6 改版为终态形态)。
*
* 只在 `appCode=fc-drill` 时下发,用来在本机把 **P76(导出锚点按应用树判)/ P77(应用内详情形态)**
* 跑到可实拍的程度——真会话取证要等 H2/H4(执行手册 §2 人工项)。
* 其余外指 appCode 仍旧返回空树:那是 H4 未上线时的既定口径(P3),桩不能替平台把没上线的能力假装成有。
*
* <b>P95 起这棵树里没有「填报」行了</b>:`/f/{code}` 模块行不再承担填报授权
* (入口由中心按三层模型解析,见 `/api/form/my/fillable` 桩),本页的树只剩两种职责——
* 数据查看/统计两个页面行,和 path 空的导入/导出按钮锚点(P76 判的就是这两行)。
* 与 `service/sql/h2_userbase_app_and_modules.sql` 的快应用段同一份终态。
*/
const DRILL_APP_MODULES: IModule[] = [
{
id: 9202,
modId: 9202,
name: "数据查看",
parentId: 0,
page: "/app/fc-drill/data",
level: 1,
icon: "category",
orderNumber: 2,
usable: true,
children: []
},
{
id: 9203,
modId: 9203,
name: "统计",
parentId: 0,
page: "/app/fc-drill/stats",
level: 1,
icon: "tongji",
orderNumber: 3,
usable: true,
children: []
},
{
// 1.5 预留位:path 空锚点(方案 §6.2 表第四行)——不进导航
id: 9204,
modId: 9204,
name: "导入",
parentId: 0,
page: "",
level: 1,
icon: "",
orderNumber: 4,
usable: true,
children: []
},
{
// 按钮级权限锚点:导出按钮显隐按它判(V26 第一半句,P76 修的就是"判的是哪棵树")
id: 9205,
modId: 9205,
name: "导出",
parentId: 0,
page: "",
level: 1,
icon: "",
orderNumber: 5,
usable: true,
children: []
}
];
export default [ export default [
{ {
url: "/api/base/GetUserInfo", url: "/api/base/GetUserInfo",
@@ -163,18 +29,6 @@ export default [
school: { id: 3260, name: "云海附属中学" } school: { id: 3260, name: "云海附属中学" }
}) })
}, },
{
url: "/api/base/GetUserMod",
method: "get",
// 与后端同一条判定:appCode 指向别的应用 → 空树(H4 未上线口径,P3:前端不得回落本应用树)。
// 唯一例外 fc-drill=T9 快应用演练桩(模块树见 DRILL_APP_MODULES),用于本机实拍 P76/P77/V19
response: ({ query }: any) => {
const target = String(query?.appCode ?? "").trim();
if (target === "fc-drill") return ok(DRILL_APP_MODULES);
if (target && target.toLowerCase() !== "form-center") return ok([]);
return ok(OWN_MODULES);
}
},
{ {
// 选人弹窗的组织树桩(T10):形状=后端 SchoolModels.Node(isParent=true 是组、false 是人员叶子)。 // 选人弹窗的组织树桩(T10):形状=后端 SchoolModels.Node(isParent=true 是组、false 是人员叶子)。
// id 即 UserBase 用户主键,提交给 /school-admins 与 /forms/{uuid}/admins 的就是它。 // id 即 UserBase 用户主键,提交给 /school-admins 与 /forms/{uuid}/admins 的就是它。
+44 -18
View File
@@ -18,7 +18,7 @@
* 游标(keyset):`cursor`=上一页末行 answerId(uuid),服务端按它定位后取下一页—— * 游标(keyset):`cursor`=上一页末行 answerId(uuid),服务端按它定位后取下一页——
* 这里用 uuid 在数组里的下标模拟,形状与后端一致(前端不感知差异)。 * 这里用 uuid 在数组里的下标模拟,形状与后端一致(前端不感知差异)。
*/ */
import type { AnswerDetail, AnswerRow, BatchItemResult, FilterField, FillableForm, ListView, MyAnswerViewItem, StatsView } from "@/types/data"; import type { AnswerDetail, AnswerRow, BatchItemResult, FilterField, FillableForm, ListView, ManagedForm, MyAnswerViewItem, StatsView } from "@/types/data";
const ok = (data: any) => ({ code: 0, msg: "", data }); const ok = (data: any) => ({ code: 0, msg: "", data });
const bizErr = (code: number, msg: string) => ({ code, msg, data: null }); const bizErr = (code: number, msg: string) => ({ code, msg, data: null });
@@ -206,7 +206,7 @@ export default [
appCode: queryValue(query, "appCode") || APP, appCode: queryValue(query, "appCode") || APP,
name: "三好学生申报", name: "三好学生申报",
version: 2, version: 2,
audience: 1, audience: ["STUDENT"],
fillLimit: 0, fillLimit: 0,
answerNoEnabled: 0, answerNoEnabled: 0,
deadline: null, deadline: null,
@@ -317,27 +317,26 @@ export default [
return ok(results); return ok(results);
} }, } },
// ==================== 可填写入口清单(P95 W3/W4,/my/fillable) ==================== // ==================== 可填写入口清单(契约 v2 C2,/my/fillable) ====================
/** /**
* 三行种子刻意覆盖三层模型的可观测差异(冒烟脚本 p95-fill-scope-smoke.sh 就断言这三件事): * 行形状按 C2:无 canFill/myTier,`audience` 是数组。<b>入集即等价于可填</b>
* ① FILL:学生面向、canFill=true → 「可填写」画卡; * (后端 audience 门筛过一次,不再有"进了列表但不能自填、只给代填"的中间态)。
* ② MANAGE:学生面向、canFill=false(教师那种"只能代填")→ 「可填写」<b>不</b>画卡, * 种子刻意覆盖三种人群形态:
* 但快应用上下文的数据管理 tab 要靠它出现; * ① 纯学生面向;② 学生+教师共集(C1 多值,一张表两类人都能填);③ 纯教师面向。
* ③ ADMIN:教师面向、canFill=true + 最高层 → 统计 tab 只由它决定。
* appCode 传值时按该应用收窄(缺省=全部应用),与后端 FillableFormService 同一口径。 * appCode 传值时按该应用收窄(缺省=全部应用),与后端 FillableFormService 同一口径。
*/ */
{ url: "/api/form/my/fillable", method: "get", response: ({ query }: any) => { { url: "/api/form/my/fillable", method: "get", response: ({ query }: any) => {
const rows: FillableForm[] = [ const rows: FillableForm[] = [
{ code: "honor_apply", name: "三好学生申报", appCode: APP, audience: 1, { code: "honor_apply", name: "三好学生申报", appCode: APP, audience: ["STUDENT"],
deadline: "2026-12-31 23:59:59", fillLimit: 1, groupTag: null, canFill: true, myTier: "FILL" }, deadline: "2026-12-31 23:59:59", fillLimit: 1, groupTag: null },
{ code: "home_visit", name: "家访登记(教师代填)", appCode: APP, audience: 1, { code: "home_visit", name: "家访登记(师生共集)", appCode: APP, audience: ["STUDENT", "TEACHER"],
deadline: null, fillLimit: 0, groupTag: "五年级", canFill: false, myTier: "MANAGE" }, deadline: null, fillLimit: 0, groupTag: "五年级" },
{ code: "teacher_survey", name: "教师问卷", appCode: APP, audience: 2, { code: "teacher_survey", name: "教师问卷", appCode: APP, audience: ["TEACHER"],
deadline: "2026-11-30 23:59:59", fillLimit: 0, groupTag: null, canFill: true, myTier: "ADMIN" }, deadline: "2026-11-30 23:59:59", fillLimit: 0, groupTag: null },
{ code: "drill-honour", name: "荣誉申报(演练应用)", appCode: "fc-drill", audience: 1, { code: "drill-honour", name: "荣誉申报(演练应用)", appCode: "fc-drill", audience: ["STUDENT"],
deadline: "2026-10-31 23:59:59", fillLimit: 0, groupTag: null, canFill: true, myTier: "FILL" }, deadline: "2026-10-31 23:59:59", fillLimit: 0, groupTag: null },
{ code: "drill_roster", name: "花名册(演练应用,教师代填)", appCode: "fc-drill", audience: 1, { code: "drill_roster", name: "花名册(演练应用,师生共集)", appCode: "fc-drill",
deadline: null, fillLimit: 0, groupTag: null, canFill: false, myTier: "MANAGE" } audience: ["STUDENT", "TEACHER"], deadline: null, fillLimit: 0, groupTag: null }
]; ];
// 排序沿后端口径:deadline 升序、不限截止在后、同档按 code // 排序沿后端口径:deadline 升序、不限截止在后、同档按 code
const app = String(query?.appCode || "").trim(); const app = String(query?.appCode || "").trim();
@@ -350,6 +349,33 @@ export default [
return ok(sorted); return ok(sorted);
} }, } },
// ==================== 菜单自解析另两支路(契约 v2 §2.4/C3,T14) ====================
/**
* 我管理的表单(C3):行形状=fillable 行 + `role` 来源标记(creator/form_admin),
* 另带 `formUuid`/`status`(管理与定义动作都按 uuid 寻址,问题单 P120)。
* mock 里把上面三行里的两张给成"我管的":一张创建人、一张表单管理员、一张两者都是。
*/
{ url: "/api/form/my/managed", method: "get", response: ({ query }: any) => {
const rows: ManagedForm[] = [
{ formUuid: FORM_UUID, status: 1, code: "honor_apply", name: "三好学生申报", appCode: APP,
audience: ["STUDENT"], deadline: "2026-12-31 23:59:59", fillLimit: 1, groupTag: null,
role: ["creator", "form_admin"] },
{ formUuid: FORM_UUID.replace(/.$/, "2"), status: 1, code: "teacher_survey", name: "教师问卷",
appCode: APP, audience: ["TEACHER"], deadline: "2026-11-30 23:59:59", fillLimit: 0,
groupTag: null, role: ["form_admin"] },
{ formUuid: FORM_UUID.replace(/.$/, "3"), status: 0, code: "home_visit", name: "家访登记(草稿)",
appCode: "fc-drill", audience: ["STUDENT", "TEACHER"], deadline: null, fillLimit: 0,
groupTag: "五年级", role: ["creator"] }
];
const app = String(query?.appCode || "").trim();
return ok(app ? rows.filter((r) => r.appCode === app) : rows);
} },
/**
* 能力位:mock 固定给"是校管理员",好让"系统设置/表单设计"两支在本地实拍得出来。
* 真实取值来自后端 `FormAccessPolicy.isSchoolAdmin`(超管恒 true)。
*/
{ url: "/api/form/my/capabilities", method: "get", response: () => ok({ schoolAdmin: true }) },
// ==================== 我的填写两视图(V18) ==================== // ==================== 我的填写两视图(V18) ====================
{ url: "/api/form/my/submit-list", method: "get", response: () => { { url: "/api/form/my/submit-list", method: "get", response: () => {
const items: MyAnswerViewItem[] = seed const items: MyAnswerViewItem[] = seed
+29 -5
View File
@@ -95,10 +95,12 @@ const forms: MockForm[] = [
statusName: "PUBLISHED", statusName: "PUBLISHED",
currentVersion: 1, currentVersion: 1,
draftVersion: 2, draftVersion: 2,
audience: AUDIENCE.STUDENT, audience: [AUDIENCE.STUDENT],
fillLimit: 1, fillLimit: 1,
deadline: null, deadline: null,
answerNoEnabled: 0, answerNoEnabled: 0,
frozen: 0,
anonCookieMinutes: 10,
groupTag: null, groupTag: null,
statusDefs: DEFAULT_STATUS_DEFS, statusDefs: DEFAULT_STATUS_DEFS,
createTime: now(), createTime: now(),
@@ -161,10 +163,12 @@ function view(f: MockForm): FormView {
statusName: f.statusName, statusName: f.statusName,
currentVersion: f.currentVersion, currentVersion: f.currentVersion,
draftVersion: f.draftVersion, draftVersion: f.draftVersion,
audience: f.audience, audience: [...f.audience],
fillLimit: f.fillLimit, fillLimit: f.fillLimit,
deadline: f.deadline, deadline: f.deadline,
answerNoEnabled: f.answerNoEnabled, answerNoEnabled: f.answerNoEnabled,
frozen: f.frozen,
anonCookieMinutes: f.anonCookieMinutes,
groupTag: f.groupTag, groupTag: f.groupTag,
statusDefs: f.statusDefs ? JSON.parse(JSON.stringify(f.statusDefs)) : null, statusDefs: f.statusDefs ? JSON.parse(JSON.stringify(f.statusDefs)) : null,
createTime: f.createTime, createTime: f.createTime,
@@ -332,10 +336,12 @@ export default [
statusName: "DRAFT", statusName: "DRAFT",
currentVersion: null, currentVersion: null,
draftVersion: 1, draftVersion: 1,
audience: AUDIENCE.STUDENT, audience: [AUDIENCE.STUDENT],
fillLimit: 0, fillLimit: 0,
deadline: null, deadline: null,
answerNoEnabled: 0, answerNoEnabled: 0,
frozen: 0,
anonCookieMinutes: 10,
groupTag: null, groupTag: null,
statusDefs: DEFAULT_STATUS_DEFS, statusDefs: DEFAULT_STATUS_DEFS,
createTime: now(), createTime: now(),
@@ -443,13 +449,31 @@ export default [
const f = findForm(body.uuid); const f = findForm(body.uuid);
if (!f) return bizErr(2, "表单未找到"); if (!f) return bizErr(2, "表单未找到");
if (body.audience !== undefined) { if (body.audience !== undefined) {
if (body.audience !== AUDIENCE.STUDENT && body.audience !== AUDIENCE.TEACHER) return bizErr(2, "audience 只能是 1 或 2"); const codes = Array.isArray(body.audience) ? body.audience : [];
f.audience = body.audience; if (!codes.length) return bizErr(2, "audience 不能是空数组");
for (const c of codes) {
if (c !== AUDIENCE.STUDENT && c !== AUDIENCE.TEACHER && c !== AUDIENCE.ANONYMOUS)
return bizErr(2, "audience 只能是 STUDENT/TEACHER/ANONYMOUS");
}
f.audience = codes;
} }
if (body.fillLimit !== undefined) { if (body.fillLimit !== undefined) {
if (body.fillLimit !== 0 && body.fillLimit !== 1) return bizErr(2, "fillLimit 只能是 0 或 1"); if (body.fillLimit !== 0 && body.fillLimit !== 1) return bizErr(2, "fillLimit 只能是 0 或 1");
f.fillLimit = body.fillLimit; f.fillLimit = body.fillLimit;
} }
if (body.frozen !== undefined) {
if (body.frozen !== 0 && body.frozen !== 1) return bizErr(2, "frozen 只能是 0 或 1");
f.frozen = body.frozen;
}
if (body.anonCookieMinutes !== undefined) {
const minutes = Number(body.anonCookieMinutes);
if (!(minutes >= 1 && minutes <= 1440)) return bizErr(2, "anonCookieMinutes 需在 1~1440 之间");
f.anonCookieMinutes = minutes;
}
// 与后端同一条跨字段约束(§3.2/作废-11):匿名表单无 limit_key,限次只能关着
if (f.audience.includes(AUDIENCE.ANONYMOUS) && f.fillLimit === 1) {
return bizErr(5, "面向人群含匿名时不支持限次:fillLimit 必须为 0");
}
if (body.answerNoEnabled !== undefined) { if (body.answerNoEnabled !== undefined) {
if (body.answerNoEnabled !== 0 && body.answerNoEnabled !== 1) return bizErr(2, "answerNoEnabled 只能是 0 或 1"); if (body.answerNoEnabled !== 0 && body.answerNoEnabled !== 1) return bizErr(2, "answerNoEnabled 只能是 0 或 1");
if (body.answerNoEnabled === 1 && f.code.length > 49) return bizErr(2, "开启报名号要求 code ≤49 位"); if (body.answerNoEnabled === 1 && f.code.length > 49) return bizErr(2, "开启报名号要求 code ≤49 位");
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
/**
* 批量导入面板(契约 v2 §5.1/C14,T18):通用壳管理员视图「导入」按钮背后的弹窗。
*
* 三件事:下载模板 → 选文件 → 逐行结果。四处分明:
* 1. **导入入口只在管理员视图**(§5.1):能不能进来由该表单 ALL 档决定,守卫在后端
* (`requireData`),这里只负责不把这个按钮画给填报者;
* 2. **坏行不静默**:响应是逐行结果,表格里 ok/error 全摊开;整体"成功 N 行失败 M 行"
* 不等于"文件里有 N+M 行"这件事也由行号列自证;
* 3. **重复导入同一个文件是逐行 200+原 answerId**(C13 服务端确定性幂等键),
* 所以"手抖点两次导入"不会产生重复数据,也不会报错——提示语里写清楚,否则用户以为没执行;
* 4. 模板与解析共用后端那一份映射(`AnswerImportService`),前端不重述字段规则;
* 行上限 1000 超限是整批拒(不是截断),文案按后端 msg 原样给。
*/
import { computed, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { dataApi, downloadBlob, importTemplateBlob } from "@/api/module/data";
import type { ImportRowResult } from "@/types/data";
const props = defineProps<{
modelValue: boolean;
/** 导入目标:数据面按 uuid 寻址(code 只作填报入口,契约 §1) */
form: { formUuid: string; code: string; name: string; appCode?: string | null };
}>();
const emit = defineEmits<{ (e: "update:modelValue", value: boolean): void }>();
const file = ref<File | null>(null);
const inputRef = ref<HTMLInputElement | null>(null);
const busy = ref(false);
const rows = ref<ImportRowResult[]>([]);
const counts = ref<{ ok: number; fail: number } | null>(null);
const visible = computed({
get: () => props.modelValue,
set: (next: boolean) => emit("update:modelValue", next)
});
const columns = computed(() => [
{ title: "行号", key: "rowNo", width: 70 },
{
title: "结果",
key: "ok",
width: 80,
render: (h: any, p: any) =>
h("Tag", { color: p.row.ok ? "green" : "red" }, p.row.ok ? "已写入" : "未写入")
},
{ title: "答卷ID", key: "answerId", minWidth: 150, ellipsis: true, tooltip: true },
{ title: "原因", key: "error", minWidth: 220 }
]);
function pick(next: Event) {
const picked = (next.target as HTMLInputElement).files?.[0] || null;
file.value = picked;
rows.value = [];
counts.value = null;
}
async function downloadTemplate() {
busy.value = true;
try {
const blob = await importTemplateBlob(props.form.formUuid);
downloadBlob(blob, `import-template-${props.form.code}.xlsx`);
} catch (e: any) {
Message.error(e?.message || "模板下载失败");
} finally {
busy.value = false;
}
}
async function runImport() {
if (!file.value) {
Message.warning("先选择按模板填好的 xlsx 文件");
return;
}
busy.value = true;
try {
const result = await dataApi.import(props.form.formUuid, file.value).send();
rows.value = result?.rows || [];
counts.value = { ok: result?.okCount ?? 0, fail: result?.failCount ?? 0 };
if (counts.value.fail === 0) {
Message.success(`导入完成:${counts.value.ok} 行已写入`);
} else {
Message.warning(`导入完成:${counts.value.ok} 行已写入,${counts.value.fail} 行未写入(见下表原因)`);
}
} catch (e: any) {
// 整批被拒的情形都在这里(不是这张表的模板、label 重复、行数超上限、解析失败):
// 后端给的是可诊断文案,原样亮出来,不在前端改写
Message.error(e?.message || "导入失败");
} finally {
busy.value = false;
}
}
watch(visible, (open) => {
if (!open) return;
file.value = null;
rows.value = [];
counts.value = null;
if (inputRef.value) inputRef.value.value = "";
});
</script>
<template>
<Modal v-model="visible" :title="`批量导入|${form.name}`" :width="760" :mask-closable="false">
<div class="import-bar">
<Button :loading="busy" icon="md-download" @click="downloadTemplate">下载模板</Button>
<input ref="inputRef" type="file" accept=".xlsx" @change="pick" />
<Button type="primary" :loading="busy" :disabled="!file" icon="md-cloud-upload" @click="runImport">
导入
</Button>
</div>
<Alert show-icon type="info" class="import-tip">
每一行都走与手工填报<b>同一条提交管线</b>(值校验、限次、截止、幂等、索引),所以导入的数据与
手工填的完全可比;重复导入同一个文件不会落重复行,而是逐行返回原答卷ID。
第一列「被记录人」留空即以你本人为被记录人;填他人 ID 需要该表单的 ALL 档。
</Alert>
<p v-if="counts" class="import-count">
共 {{ rows.length }} 行:已写入 {{ counts.ok }},未写入 {{ counts.fail }}
</p>
<Table :columns="columns" :data="rows" size="small" :border="false" max-height="320" />
<template #footer>
<Button @click="visible = false">关闭</Button>
</template>
</Modal>
</template>
<style scoped>
.import-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.import-tip {
margin-bottom: 10px;
}
.import-count {
margin: 0 0 8px;
font-size: 13px;
color: #515a6e;
}
</style>
+6 -2
View File
@@ -3,13 +3,17 @@
* image 图片(值 [{id,fileName,key}],契约 §3——中心上传接口直接返回这三件, * image 图片(值 [{id,fileName,key}],契约 §3——中心上传接口直接返回这三件,
* 前端只存返回值,绝不把 fileUrl/绝对 URL 写进答卷值,否则换域名历史附件全断)。 * 前端只存返回值,绝不把 fileUrl/绝对 URL 写进答卷值,否则换域名历史附件全断)。
*/ */
import { computed, ref } from "vue"; import { computed, inject, ref } from "vue";
import type { ComputedRef } from "vue";
import type { FieldWidgetProps } from "../types"; import type { FieldWidgetProps } from "../types";
import type { FileValue } from "@/types/formSchema"; import type { FileValue } from "@/types/formSchema";
import { Upload } from "view-ui-plus"; import { Upload } from "view-ui-plus";
import { alovaInstance } from "@/utils/alova/alova-iview"; import { alovaInstance } from "@/utils/alova/alova-iview";
import conf from "@/assets/config/host.config"; import conf from "@/assets/config/host.config";
/** 上传端点按通道注入(匿名面走 /anon/form/upload,契约 v2 C6);无注入=登录面默认 */
const uploadUrl = inject<ComputedRef<string>>("fcUploadUrl", computed(() => "/api/form/upload"));
/** 附件回显基址:{ctx}/upload/(P93);serviceHost 空(dev 同源)时走 /upload 相对路径 */ /** 附件回显基址:{ctx}/upload/(P93);serviceHost 空(dev 同源)时走 /upload 相对路径 */
const fileBase = `${conf.serviceHost || ""}/upload`; const fileBase = `${conf.serviceHost || ""}/upload`;
@@ -57,7 +61,7 @@ async function handleUpload(file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>( const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>(
"/api/form/upload", uploadUrl.value,
formData, formData,
{ headers: { "Content-Type": "multipart/form-data" } } { headers: { "Content-Type": "multipart/form-data" } }
).send(); ).send();
+9 -2
View File
@@ -4,12 +4,19 @@
* 字段级 accept/maxSize/maxFiles 只能往更严收,不能往宽放)。 * 字段级 accept/maxSize/maxFiles 只能往更严收,不能往宽放)。
* accept 不含 .svg 由设计器把门(P28),这里只如实呈现配置。 * accept 不含 .svg 由设计器把门(P28),这里只如实呈现配置。
*/ */
import { computed, ref } from "vue"; import { computed, inject, ref } from "vue";
import type { ComputedRef } from "vue";
import type { FieldWidgetProps } from "../types"; import type { FieldWidgetProps } from "../types";
import type { FileValue } from "@/types/formSchema"; import type { FileValue } from "@/types/formSchema";
import { Upload } from "view-ui-plus"; import { Upload } from "view-ui-plus";
import { alovaInstance } from "@/utils/alova/alova-iview"; import { alovaInstance } from "@/utils/alova/alova-iview";
/**
* 上传端点由填报页按通道注入(匿名面必须走 `/anon/form/upload?code=`,契约 v2 C6:
* 只有表单能定 form_file.sch_id=表单归属校)。没有注入=登录面默认,设计器预览等调用点行为不变。
*/
const uploadUrl = inject<ComputedRef<string>>("fcUploadUrl", computed(() => "/api/form/upload"));
const props = withDefaults(defineProps<FieldWidgetProps & { const props = withDefaults(defineProps<FieldWidgetProps & {
modelValue?: FileValue[]; modelValue?: FileValue[];
readonly?: boolean; readonly?: boolean;
@@ -54,7 +61,7 @@ async function handleUpload(file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>( const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>(
"/api/form/upload", uploadUrl.value,
formData, formData,
{ headers: { "Content-Type": "multipart/form-data" } } { headers: { "Content-Type": "multipart/form-data" } }
).send(); ).send();
+50 -13
View File
@@ -13,8 +13,8 @@
import { computed, reactive, ref, watch } from "vue"; import { computed, reactive, ref, watch } from "vue";
import { Message, Modal } from "view-ui-plus"; import { Message, Modal } from "view-ui-plus";
import { formApi } from "@/api/module/form"; import { formApi } from "@/api/module/form";
import type { FormView, StatusDefView } from "@/types/form"; import type { AudienceCode, FormView, StatusDefView } from "@/types/form";
import { AUDIENCE_OPTIONS, FORM_STATUS } from "@/types/form"; import { AUDIENCE, AUDIENCE_OPTIONS, FORM_STATUS } from "@/types/form";
import { MAX_NAME_LEN, CODE_PATTERN } from "@/types/formSchema"; import { MAX_NAME_LEN, CODE_PATTERN } from "@/types/formSchema";
const props = defineProps<{ form: FormView | null }>(); const props = defineProps<{ form: FormView | null }>();
@@ -23,30 +23,39 @@ const emit = defineEmits<{ (e: "saved", form: FormView): void }>();
const visible = ref(false); const visible = ref(false);
const saving = ref(false); const saving = ref(false);
const model = reactive<{ const model = reactive<{
audience: number; audience: string[];
fillLimit: number; fillLimit: number;
deadline: string; deadline: string;
answerNoEnabled: number; answerNoEnabled: number;
frozen: number;
anonCookieMinutes: number;
groupTag: string; groupTag: string;
}>({ }>({
audience: AUDIENCE_OPTIONS[0].value, audience: [AUDIENCE.STUDENT],
fillLimit: 0, fillLimit: 0,
deadline: "", deadline: "",
answerNoEnabled: 0, answerNoEnabled: 0,
frozen: 0,
anonCookieMinutes: 10,
groupTag: "" groupTag: ""
}); });
const statusDefs = ref<StatusDefView[]>([]); const statusDefs = ref<StatusDefView[]>([]);
const newStatus = reactive({ code: "", label: "" }); const newStatus = reactive({ code: "", label: "" });
/** 含匿名时后端强制 fill_limit=0(§3.2/作废-11:匿名无 limit_key)——前端同判据提前挡,不给点了才报错 */
const anonymousChosen = computed(() => model.audience.includes(AUDIENCE.ANONYMOUS));
watch( watch(
() => [visible.value, props.form?.formUuid] as const, () => [visible.value, props.form?.formUuid] as const,
() => { () => {
const f = props.form; const f = props.form;
if (!visible.value || !f) return; if (!visible.value || !f) return;
model.audience = f.audience ?? AUDIENCE_OPTIONS[0].value; model.audience = f.audience?.length ? [...f.audience] : [AUDIENCE.STUDENT];
model.fillLimit = f.fillLimit ?? 0; model.fillLimit = f.fillLimit ?? 0;
model.deadline = f.deadline ? String(f.deadline).slice(0, 10) : ""; model.deadline = f.deadline ? String(f.deadline).slice(0, 10) : "";
model.answerNoEnabled = f.answerNoEnabled ?? 0; model.answerNoEnabled = f.answerNoEnabled ?? 0;
model.frozen = f.frozen ?? 0;
model.anonCookieMinutes = f.anonCookieMinutes ?? 10;
model.groupTag = f.groupTag || ""; model.groupTag = f.groupTag || "";
statusDefs.value = (f.statusDefs || []).map((d) => ({ ...d })); statusDefs.value = (f.statusDefs || []).map((d) => ({ ...d }));
newStatus.code = ""; newStatus.code = "";
@@ -110,15 +119,25 @@ async function submit() {
Message.error(`开启报名号要求 code ≤49 位(当前 ${f.code.length})`); Message.error(`开启报名号要求 code ≤49 位(当前 ${f.code.length})`);
return; return;
} }
if (!model.audience.length) {
Message.error("面向人群至少要选一项(空集合等于谁都不能填)");
return;
}
if (anonymousChosen.value && model.fillLimit === 1) {
Message.error("含「匿名可填」的表单不支持限次(匿名没有可比对的身份),请把限次改回不限");
return;
}
saving.value = true; saving.value = true;
try { try {
await formApi await formApi
.config({ .config({
uuid: f.formUuid, uuid: f.formUuid,
audience: model.audience, audience: model.audience as AudienceCode[],
fillLimit: model.fillLimit, fillLimit: model.fillLimit,
deadline: model.deadline, deadline: model.deadline,
answerNoEnabled: model.answerNoEnabled, answerNoEnabled: model.answerNoEnabled,
frozen: model.frozen,
anonCookieMinutes: model.anonCookieMinutes,
groupTag: model.groupTag groupTag: model.groupTag
}) })
.send(); .send();
@@ -184,21 +203,24 @@ defineExpose({ open });
<div class="cfg-row"> <div class="cfg-row">
<span class="cfg-label">面向人群</span> <span class="cfg-label">面向人群</span>
<RadioGroup v-model="model.audience"> <CheckboxGroup v-model="model.audience">
<Radio v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Radio> <Checkbox v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Checkbox>
</RadioGroup> </CheckboxGroup>
<span class="cfg-note">决定数据范围矩阵落在哪一列(契约 §8)</span> <span class="cfg-note">
可多选(契约 v2 C1)。填报资格=登录身份类型落在集合内;「匿名可填」只对无会话的
<code>/anon</code> 通道生效,登录用户不会因此多出入口
</span>
</div> </div>
<div class="cfg-row"> <div class="cfg-row">
<span class="cfg-label">限次</span> <span class="cfg-label">限次</span>
<RadioGroup v-model="model.fillLimit"> <RadioGroup v-model="model.fillLimit">
<Radio :label="0">不限次数</Radio> <Radio :label="0">不限次数</Radio>
<Radio :label="1">每人限填一次</Radio> <Radio :label="1" :disabled="anonymousChosen">每人限填一次</Radio>
</RadioGroup> </RadioGroup>
<span class="cfg-note"> <span class="cfg-note">
「每人」按提交主体判:管理员代填指定了被代填人时按那个人限,否则按提交人限; 「每人」按提交主体判:管理员代录指定了被记录人时按那个人限,否则按提交人限;软删答卷会释放名额。
软删答卷会释放名额(契约 §5) <template v-if="anonymousChosen">含匿名人群时不可限次(匿名无 limit_key,防重只有 cookie)。</template>
</span> </span>
</div> </div>
@@ -217,6 +239,21 @@ defineExpose({ open });
</span> </span>
</div> </div>
<div class="cfg-row">
<span class="cfg-label">提交后禁编辑</span>
<i-switch v-model="model.frozen" :true-value="1" :false-value="0" />
<span class="cfg-note">
开启后提交人与管理员的编辑通道同时关闭,修正只能「撤回(软删)+ 重新提交」——
<b>喂外部业务应用的表单请开启</b>(原件是档案,业务对象归应用)
</span>
</div>
<div class="cfg-row">
<span class="cfg-label">匿名防重窗口</span>
<InputNumber v-model="model.anonCookieMinutes" :min="1" :max="1440" style="width: 140px" />
<span class="cfg-note">分钟。仅对面向人群含「匿名可填」的表单有意义(默认 10 分钟)</span>
</div>
<div class="cfg-row"> <div class="cfg-row">
<span class="cfg-label">分组标签</span> <span class="cfg-label">分组标签</span>
<Input v-model="model.groupTag" :maxlength="64" placeholder="可空,用于列表筛选归类" style="width: 220px" /> <Input v-model="model.groupTag" :maxlength="64" placeholder="可空,用于列表筛选归类" style="width: 220px" />
+3 -2
View File
@@ -130,8 +130,9 @@ const routes: RouteRecordRaw[] = [
meta: { title: "关于我的", parentPath: "/my" }, meta: { title: "关于我的", parentPath: "/my" },
}, },
{ {
// P91 起第三视图;P95 W4 换数据源=GET /api/form/my/fillable(中心按三层模型解析的填报入口, // P91 起第三视图;P95 W4 换数据源=GET /api/form/my/fillable,契约 v2 C2 定行形状
// 不再从会话菜单树挑 /f/ 行)。子页面路由不占模块行、不进导航(手册 §2.2 注记),入口在 My 容器顶部 Tab。 // (中心按 audience 门解析填报入口,不再从会话菜单树挑 /f/ 行)。
// 子页面路由不占模块行、不进导航(手册 §2.2 注记),入口在 My 容器顶部 Tab。
path: "/my/fillable", path: "/my/fillable",
name: "fc_my_fillable", name: "fc_my_fillable",
component: () => import("@/views/My.vue"), component: () => import("@/views/My.vue"),
+46 -14
View File
@@ -1,25 +1,34 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import type { IModule } from "@/types/Basics"; import type { Capabilities, FillableForm, ManagedForm } from "@/types/data";
/** /**
* 全局 store(notice/front 基线形态)。 * 全局 store(notice/front 基线形态)。
* T6 起开第一个业务切片:会话用户本体——字典管理页要按它判「能不能进平台级上下文维护」 *
* (UserBase 侧只有 type==1 超管可写 sch_id=0 的行,方案 §6.5;后端 FormAdminGuard 同判)。 * 存两类东西:
* 这里存的是**展示与门禁提示用的会话身份**,不是权限来源:接口侧一律会话自取, * 1. **会话用户本体**——展示与门禁提示用(字典页判"能不能进平台级上下文维护")。
* 前端藏掉按钮不等于授权(契约 §7 身份行)。 * 这里存的是展示位,**不是权限来源**:接口侧一律会话自取,前端藏掉按钮不等于授权(契约 §7 身份行);
* 2. **菜单自解析的三支路数据**(契约 v2 §2.4/D5)——可填清单、我管理的清单、能力位。
* 原第四项"GetUserMod 原始模块树"随模块行整条退役删除(§19 作废-10):中心不再有任何
* 按授权树判定的地方,"两棵树"的缺口(B13/P78)因此不复存在。
*/ */
/** 中心表单的归属应用(快应用行的 appCode 不是这个值) */
export const CENTER_APP_CODE = "form-center";
/** 列表行的唯一键:同 code 在不同 app_code 下是两张表(契约 §1 的 uk=(sch_id, app_code, code)) */
const rowKey = (appCode: string, code: string) => `${appCode}#${code}`;
export const useDefaultStore = defineStore("defaultStore", { export const useDefaultStore = defineStore("defaultStore", {
state: () => ({ state: () => ({
userRole: "school" as string, userRole: "school" as string,
/** /api/base/GetUserInfo 原样下发的会话用户(未拿到时为 null) */ /** /api/base/GetUserInfo 原样下发的会话用户(未拿到时为 null) */
user: null as Record<string, any> | null, user: null as Record<string, any> | null,
/** /** 三支路之一:可填清单(后端按 audience 门筛过,入集即可填) */
* /api/base/GetUserMod 的<b>原始模块树</b>(T8 起留一份给按钮级判定用)。 fillable: [] as FillableForm[],
* 必须是原始树而不是 `utils/menuNav.ts` 过滤后的导航树——导航按规则跳过了 /** 三支路之二:我管理的清单(创建人 ∪ 表单管理员) */
* path 空的权限锚点行,而按钮位(快应用的"导出")正是这类行(方案 §6.2)。 managed: [] as ManagedForm[],
* 判定函数在 `utils/modulePerms.ts`;这里只是避免同一棵树重复发一次请求。 /** 三支路之三:能力位("系统设置"入口的唯一数据源) */
*/ capabilities: { schoolAdmin: false } as Capabilities
modules: [] as IModule[]
}), }),
getters: { getters: {
/** 超管(UserBase UserTypeEnum.SUPER_ADMIN=1):唯一能操作平台级行的身份 */ /** 超管(UserBase UserTypeEnum.SUPER_ADMIN=1):唯一能操作平台级行的身份 */
@@ -33,6 +42,10 @@ export const useDefaultStore = defineStore("defaultStore", {
/** 会话用户 id:判断"这条答卷是不是我提交的"(编辑/删除入口显隐)用 */ /** 会话用户 id:判断"这条答卷是不是我提交的"(编辑/删除入口显隐)用 */
sessionUserId(state): number | null { sessionUserId(state): number | null {
return state.user?.id ?? null; return state.user?.id ?? null;
},
/** "我管理的表单"键集:导出/详情管理按钮的显隐判据(ALL 档的清单形态) */
managedKeys(state): Set<string> {
return new Set(state.managed.map((r) => rowKey(r.appCode, r.code)));
} }
}, },
actions: { actions: {
@@ -42,8 +55,27 @@ export const useDefaultStore = defineStore("defaultStore", {
setUser(user: Record<string, any> | null) { setUser(user: Record<string, any> | null) {
this.user = user || null; this.user = user || null;
}, },
setModules(modules: IModule[] | null) { setFillable(rows: FillableForm[] | null) {
this.modules = modules || []; this.fillable = rows || [];
},
setManaged(rows: ManagedForm[] | null) {
this.managed = rows || [];
},
setCapabilities(caps: Capabilities | null) {
this.capabilities = { schoolAdmin: !!caps?.schoolAdmin };
} }
} }
}); });
/**
* "这张表我管不管得动"的判定(managed 清单成员 = 该表 ALL 档持有者,契约 v2 C3/C4②)。
*
* <p>前端只用它决定**画不画按钮**(导出、详情里的管理动作、快应用的数据/统计 tab);
* 真正的把关在后端(C4 三段门 + 行级范围构造器),藏按钮不等于授权。
* 刻意不做"拿不到清单就当有权限"的宽定——fail-closed 与原模块行判定同向。</p>
*/
export function managesForm(managed: ManagedForm[], appCode: string | null | undefined, code: string | null | undefined): boolean {
if (!code) return false;
const key = rowKey(appCode || CENTER_APP_CODE, code);
return managed.some((r) => rowKey(r.appCode, r.code) === key);
}
+2 -1
View File
@@ -18,7 +18,8 @@ export interface ResolvedForm {
appCode: string; appCode: string;
name: string; name: string;
version: number; version: number;
audience: number; /** 面向人群(契约 v2 C1 数组形态;填报页只用于展示与预提示,资格判定在后端 audience 门) */
audience: string[];
fillLimit: number; fillLimit: number;
answerNoEnabled: number; answerNoEnabled: number;
deadline: string | null; deadline: string | null;
+64 -14
View File
@@ -127,38 +127,88 @@ export interface MyAnswerViewItem {
} }
/** /**
* 「可填写」列表的行(P95 W3/W4,`GET /api/form/my/fillable`)。 * 「可填写」列表的行(契约 v2 C2,`GET /api/form/my/fillable`)。
* *
* <p>这不是答卷行,是**表单入口**行:后端按 P95 三层模型逐张表算出"这个人在这张表上算哪一层", * <p>这不是答卷行,是**表单入口**行:后端按 audience 门逐张表判"这个人的身份类型在不在面向人群里",
* NONE 的行不下发。`canFill` 与 `myTier` 是两件事——`canFill=false` 且 `myTier="MANAGE"` * 不在就不下发。<b>入集即等价于可填</b>——原 P95 的 `canFill`/`myTier` 两字段随三层模型出清
* 就是"教师×面向学生"那种只能代填的表:「可填写」列表把它筛掉,但快应用上下文的 * (§19 作废-3),不再有"进了列表但不能自填、只给代填"的中间态。</p>
* 数据管理 tab 仍要它(AppContext.vue 按 myTier 决定 tab 显隐)。</p>
*/ */
export interface FillableForm { export interface FillableForm {
code: string; code: string;
name: string; name: string;
appCode: string; appCode: string;
/** 面向人群:1 学生 / 2 教师(与定义域 FormView 同一个数字口径) */ /** 面向人群(C1 数组形态) */
audience: number; audience: string[];
/** yyyy-MM-dd HH:mm:ss;null=不限截止 */ /** yyyy-MM-dd HH:mm:ss;null=不限截止 */
deadline: string | null; deadline: string | null;
/** 限填次数:0 不限 / 1 限一次 */ /** 限填次数:0 不限 / 1 限一次 */
fillLimit: number; fillLimit: number;
groupTag: string | null; groupTag: string | null;
canFill: boolean;
/** 并集最高层:ADMIN > MANAGE > FILL */
myTier: "ADMIN" | "MANAGE" | "FILL";
} }
/** 数据范围闭集四枚举的展示名(契约 §8;与后端 FormEnums.DataScope 的 label 同源) */ /** 数据范围两档的展示名(契约 v2 §2.3;与后端 FormEnums.DataScope 的 label 同源) */
export const DATA_SCOPE_LABELS: Record<string, string> = { export const DATA_SCOPE_LABELS: Record<string, string> = {
ALL: "全部", ALL: "全部",
TEACHING_CLASS: "任教班级", SELF: "我相关"
SELF_SUBMITTED: "仅本人提交",
ABOUT_ME: "关于我"
}; };
/**
* 「我管理的表单」的行(契约 v2 C3,`GET /api/form/my/managed`)= fillable 行 + `role` 来源标记。
*
* `role` 闭集:`creator`(创建人,C16)/ `form_admin`(表单管理员表命中),
* 两个来源同时命中时数组有两项——界面要能说出"你为什么管得动这张表"。
*/
export interface ManagedForm extends FillableForm {
/** 管理与定义动作都按 uuid 寻址(code 只作填报入口;问题单 P120) */
formUuid: string;
/** 生命周期:0 草稿 / 1 已发布 / 2 停用(管理支路要标出"这张还没发布") */
status: number;
role: string[];
}
/** 能力位(契约 v2 §2.4 第三支路,`GET /api/form/my/capabilities`):前端"系统设置"入口的唯一数据源 */
export interface Capabilities {
schoolAdmin: boolean;
}
/**
* 快应用注册行(契约 v2 C17/§5.2,`GET /api/form/admin/apps`,T16)。
*
* 四个业务字段 + 中心生成的入口链接;**没有角色/授权列**(V59 红线:谁能用哪个应用一律由
* 会话在该应用表单上的 ALL/SELF 档现算,注册表不存授权痕迹)。
*/
export interface AppRow {
/** 全局唯一人读 slug(C10 格式;保留字 form-center 不可注册),发布后不随改 */
appCode: string;
name: string;
/** 归属租户域;0=平台级(仅超管可建) */
schId: number;
/** 应用入口链接(站内路径,§5.2"由中心生成")——复制后贴去 UserBase 建应用 */
entryUrl: string;
}
/** 范围名 → 展示文案;未知名原样显示(后端加枚举值不破坏前端) */ /** 范围名 → 展示文案;未知名原样显示(后端加枚举值不破坏前端) */
export function scopeLabel(name: string): string { export function scopeLabel(name: string): string {
return DATA_SCOPE_LABELS[name] ?? name; return DATA_SCOPE_LABELS[name] ?? name;
} }
/**
* 批量导入的逐行结果(契约 v2 C14,T18,`POST /api/form/admin/data/import`)。
*
* 失败原因直接是提交管线那句话(导入侧不改写),所以"这一行为什么没进去"在界面上与手工填报报错同文;
* 重复导入同一个文件时该行仍是 ok 且带<b>原</b> answerId(C13 的确定性幂等键)。
*/
export interface ImportRowResult {
/** xlsx 里的物理行号(与用户在 Excel 里看到的行号一致,可直接指到那一格) */
rowNo: number;
ok: boolean;
answerId?: string | null;
error?: string | null;
}
/** 导入响应(C14 的闭集:rows + 两个计数;行上限 1000 由服务端整批拒) */
export interface ImportResult {
rows: ImportRowResult[];
okCount: number;
failCount: number;
}
+37 -7
View File
@@ -11,17 +11,37 @@ export const FORM_STATUS = {
DISABLED: 2 DISABLED: 2
} as const; } as const;
/** 面向人群(后端 FormEnums.Audience):1 面向学生 / 2 面向教师 */ /**
* 面向人群(契约 v2 C1 多选):后端列值=逗号分隔枚举码,JSON 面一律字符串数组。
* 闭集三值;ANONYMOUS 只对匿名通道生效(登录会话不会因它拿到填报入口)。
*/
export const AUDIENCE = { export const AUDIENCE = {
STUDENT: 1, STUDENT: "STUDENT",
TEACHER: 2 TEACHER: "TEACHER",
ANONYMOUS: "ANONYMOUS"
} as const; } as const;
export const AUDIENCE_OPTIONS = [ export type AudienceCode = (typeof AUDIENCE)[keyof typeof AUDIENCE];
export const AUDIENCE_OPTIONS: { value: AudienceCode; label: string }[] = [
{ value: AUDIENCE.STUDENT, label: "面向学生" }, { value: AUDIENCE.STUDENT, label: "面向学生" },
{ value: AUDIENCE.TEACHER, label: "面向教师" } { value: AUDIENCE.TEACHER, label: "面向教师" },
{ value: AUDIENCE.ANONYMOUS, label: "匿名可填" }
]; ];
/** 码 → 展示名(列表/详情文案用) */
export const AUDIENCE_LABELS: Record<string, string> = {
STUDENT: "学生",
TEACHER: "教师",
ANONYMOUS: "匿名"
};
/** 数组人群 → "学生/教师" 形式的短文案 */
export function audienceText(audience: string[] | null | undefined): string {
const list = audience || [];
return list.length ? list.map((a) => AUDIENCE_LABELS[a] || a).join("/") : "—";
}
/** 表单项(头表 + 版本指针,不含 schema 正文) */ /** 表单项(头表 + 版本指针,不含 schema 正文) */
export interface FormView { export interface FormView {
formUuid: string; formUuid: string;
@@ -36,11 +56,16 @@ export interface FormView {
currentVersion: number | null; currentVersion: number | null;
/** 待发布的草稿版本号,null=无草稿(P20:UI 必须区分这两根指针) */ /** 待发布的草稿版本号,null=无草稿(P20:UI 必须区分这两根指针) */
draftVersion: number | null; draftVersion: number | null;
audience: number; /** 面向人群(C1 数组形态) */
audience: string[];
fillLimit: number; fillLimit: number;
/** yyyy-MM-dd HH:mm:ss,null=不限 */ /** yyyy-MM-dd HH:mm:ss,null=不限 */
deadline: string | null; deadline: string | null;
answerNoEnabled: number; answerNoEnabled: number;
/** 提交后禁编辑(契约 v2 §4.2):1=本人编辑与管理员编辑同关 */
frozen: number;
/** 匿名 cookie 防重窗口分钟数(C7) */
anonCookieMinutes: number;
groupTag: string | null; groupTag: string | null;
statusDefs: StatusDefView[] | null; statusDefs: StatusDefView[] | null;
createTime: string; createTime: string;
@@ -104,11 +129,16 @@ export interface FormSaveRequest {
export interface FormConfigRequest { export interface FormConfigRequest {
uuid: string; uuid: string;
audience?: number; /** 面向人群(C1 数组;缺省=不改这一项) */
audience?: AudienceCode[];
fillLimit?: number; fillLimit?: number;
/** yyyy-MM-dd(后端补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串=不限 */ /** yyyy-MM-dd(后端补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串=不限 */
deadline?: string | null; deadline?: string | null;
answerNoEnabled?: number; answerNoEnabled?: number;
/** 提交后禁编辑(契约 v2 §4.2) */
frozen?: number;
/** 匿名 cookie 防重窗口分钟数(C7,1~1440) */
anonCookieMinutes?: number;
groupTag?: string | null; groupTag?: string | null;
} }
+10 -70
View File
@@ -1,39 +1,24 @@
/** /**
* 左导航渲染模型(GetUserMod 模块树 → 可渲染菜单树)。 * 左导航的渲染模型(契约 v2 §2.4/D5 之后的形态)。
* *
* 纯函数、零运行时依赖(只 import type),解析器由调用方注入(resolveMenuPage)—— * <p>原 `buildNavTree(模块树 → 导航树)` 与它的三条规则(path 空=权限锚点跳过、分组头保留、
* 便于在冒烟脚本里直接跑断言,不必起浏览器。 * 配错照常显示)随 **UserBase 模块行整条退役** 一起删除(§19 作废-10)——导航不再从平台树生成,
* * 而由 `App.vue` 按后端三支路(可填 / 我管理的 / 能力位)直接构造 `NavNode`。
* 三条渲染规则(执行手册 §2.2「path 空行=纯权限锚点」的前端一侧): * 本文件因此只留"导航数据结构 + 高亮/面包屑/默认跳转"这三件与权限无关的事。</p>
* ① <b>跳过判据取平台原始 path 是否为空</b>,不取解析结果:权限锚点(快应用面的「导入/导出」
* 按钮行)本就不进导航,靠名字/modId 猜出路由再显示反而是假象——中心应用自手册 §2.2 v1.5
* 起没有 path 空行,这条规则留着是给快应用面用的;
* ② 有子节点的分组行即使自身 path 为空也保留为<b>分组头</b>(头本身不可点),
* 子节点按 ① 过滤后全空则整个分组不渲染;
* ③ 原始 path 非空但解析不出已注册路由的行<b>照常显示</b>,点击时提示配置错误——
* 配置问题要暴露成可见告警,不能静默少一条菜单(与 utils/menuRoute.ts 的告警口径一致)。
*/ */
import type { IModule } from "@/types/Basics";
/** 菜单项目标解析器(utils/menuRoute.ts 的 resolveMenuPage 同签名) */
export type MenuResolver = (
page?: string | null,
modId?: number | string | null,
name?: string | null
) => { path: string; external: boolean };
/** 可渲染的导航节点 */ /** 可渲染的导航节点 */
export interface NavNode { export interface NavNode {
id: number; id: number;
/** 自解析形态下 modId 就是目标 path(layout 只用它做高亮对照,不参与任何判定) */
modId: number | string; modId: number | string;
name: string; name: string;
/** 平台配置的图标名(如 list),空串由 layout 兜底 icon-list */ /** 平台配置的图标名,空由 layout 兜底 icon-list */
icon: string; icon: string;
/** 解析后的内部路由 path(外链为完整 URL);解析不出时为空串 */ /** 内部路由 path(外链为完整 URL);为空表示这一项没有可跳转页面 */
page: string; page: string;
external: boolean; external: boolean;
/** 平台原始配置串,仅排障用 */ /** 原始配置串(自解析形态下与 page 同值,仅排障对照用) */
raw: string; raw: string;
/** 分组头(有可渲染子节点)为 true,本身不参与高亮/跳转 */ /** 分组头(有可渲染子节点)为 true,本身不参与高亮/跳转 */
group: boolean; group: boolean;
@@ -50,51 +35,6 @@ export interface FlatNav {
pid: number; pid: number;
} }
/** 原始 path 是否为空(= 纯权限锚点,不进导航) */
function isAnchorRow(node: IModule): boolean {
return String(node?.page ?? "").trim() === "";
}
export function buildNavTree(modList: IModule[] | null | undefined, resolve: MenuResolver): NavNode[] {
const tree: NavNode[] = [];
for (const node of modList ?? []) {
if (!node) continue;
const children = buildNavTree(node.children, resolve);
const raw = String(node.page ?? "").trim();
if (children.length) {
// 规则②:分组头保留(自身 path 是否为空无关),子节点已按规则①过滤。
// 分组头不作为链接渲染,page 只留作排障对照
tree.push({
id: node.id,
modId: node.modId,
name: node.name,
icon: node.icon ?? "",
page: resolve(raw, node.modId, node.name).path,
external: false,
raw,
group: true,
children
});
continue;
}
// 规则①:叶子且原始 path 为空 = 权限锚点,跳过渲染
if (isAnchorRow(node)) continue;
const resolved = resolve(raw, node.modId, node.name);
tree.push({
id: node.id,
modId: node.modId,
name: node.name,
icon: node.icon ?? "",
page: resolved.path,
external: resolved.external,
raw,
group: false,
children: []
});
}
return tree;
}
/** 深度优先展平(分组头不入表:它没有可跳转页面,高亮/面包屑只对照可跳转项) */ /** 深度优先展平(分组头不入表:它没有可跳转页面,高亮/面包屑只对照可跳转项) */
export function flattenNav(tree: NavNode[], pid = 0): FlatNav[] { export function flattenNav(tree: NavNode[], pid = 0): FlatNav[] {
const out: FlatNav[] = []; const out: FlatNav[] = [];
@@ -126,7 +66,7 @@ export function countNavItems(tree: NavNode[]): number {
/** /**
* 「单模块独占」= 只有一个可跳转菜单项且无分组:隐藏左导航、内容区铺满。 * 「单模块独占」= 只有一个可跳转菜单项且无分组:隐藏左导航、内容区铺满。
* 无授权用户只余「我的填写」一行时即为此形态。 * 三支路都空的人(例如只被授了一张表当填报人、`/my` 一项)即为此形态。
*/ */
export function isSingleNav(tree: NavNode[]): boolean { export function isSingleNav(tree: NavNode[]): boolean {
const hasGroup = tree.some((node) => node.group); const hasGroup = tree.some((node) => node.group);
-75
View File
@@ -1,75 +0,0 @@
/**
* 按钮级/页面级权限的**前端呈现**判定(方案 §6.1 第②层、§6.2 权限锚点、V26 的前端半句)。
*
* 输入永远是 `GetUserMod` 返回的<b>原始模块树</b>(含 path 空的权限锚点行)——
* 不是 `utils/menuNav.ts` 过滤后的导航树:导航按规则跳过了 path 空节点,
* 拿过滤后的树来判按钮,锚点行永远不在里面,判据就成了猜。
*
* 三条纪律:
* 1. **这里只决定"按钮画不画"**,接口侧一律会话自取(契约 §7 身份行)——藏按钮不等于授权;
* 2. **中心与快应用两种上下文的导出载体不同形**(手册 §2.2 中心表没有导出行、方案 §6.2
* 快应用表的导出行是 path 空锚点),所以按上下文分派,不硬造一个 `fc:export:*` 协议值
* (中心面原来只有数据范围升级位那一个锚点值,T10 已把它退役、换成后端管理员两表;
* 前端这套按钮锚点判定与此无关,消费的仍是快应用自己那两行 path 空行);
* 3. **快应用上下文拿不到锚点就是不给**(fail-closed),不做"没配就当有"的宽定。
*/
import type { IModule } from "@/types/Basics";
/** 深度优先展平(顺序不承诺,只做集合判定) */
export function flattenModules(tree: IModule[] | null | undefined): IModule[] {
const out: IModule[] = [];
for (const node of tree ?? []) {
if (!node) continue;
out.push(node);
out.push(...flattenModules(node.children));
}
return out;
}
/** 授权页面行 path 集合(path 空的权限锚点不在内)——与后端 FormModuleKeys.paths 同一判据 */
export function authorizedPaths(tree: IModule[] | null | undefined): string[] {
return flattenModules(tree)
.map((m) => String(m.page ?? "").trim())
.filter((p) => p !== "");
}
/** 权限锚点行(path 为空的节点)——契约 §8"锚点行形态"注记 / P47:只认 path 空节点 */
export function anchorRows(tree: IModule[] | null | undefined): IModule[] {
return flattenModules(tree).filter((m) => String(m.page ?? "").trim() === "");
}
/** 是否被授权了某个页面行(path 精确匹配) */
export function hasPage(tree: IModule[] | null | undefined, path: string): boolean {
return authorizedPaths(tree).includes(path);
}
/** 是否存在某个权限锚点行(按行名称匹配——快应用"导出"行的形态就是 path 空 + 名称"导出") */
export function hasAnchorNamed(tree: IModule[] | null | undefined, name: string): boolean {
const want = name.trim();
return anchorRows(tree).some((m) => String(m.name ?? "").trim() === want);
}
export type DataExportContext = "center" | "app";
/**
* 导出按钮显隐(V26 前端半句:"无导出授权者前端无按钮";接口拒是 T4 已验的另一半)。
*
* - `center`:中心管理端。手册 §2.2 的中心模块行表里<b>没有导出行</b>,后端 T4 的导出端点
* 按 `/admin/data` 页面行把关,所以前端同口径按页面行判——不自造一个中心侧没有的锚点。
* - `app`:快应用上下文。方案 §6.2 的快应用典型配法里"导出"是 <b>path 空的按钮级锚点行</b>,
* 授权了才在树里出现;拿不到该锚点就不画按钮(fail-closed)。
*/
export function canExport(tree: IModule[] | null | undefined, context: DataExportContext): boolean {
if (context === "app") return hasAnchorNamed(tree, "导出");
return hasPage(tree, "/admin/data");
}
/**
* 受限身份可见集的来源(V26 第三半句的前端侧口径):
* 学生身份只见"填报"路由 = 导航树里只有填报行——这是模块授权的直接结果,
* 前端<b>不再自己按角色过滤一遍</b>(任务书 T8:范围以后端为准,前端不自行过滤)。
* 本函数只做"树里有几项可跳转"的取证辅助,供断言用。
*/
export function navigableCount(tree: IModule[] | null | undefined): number {
return authorizedPaths(tree).length;
}
+156 -97
View File
@@ -1,80 +1,58 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 快应用上下文 /app/:appCode/(T8 落地,P95 W5 改版:导航壳按身份写死)。 * 快应用上下文 `#/app/:appCode/` = <b>通用壳</b>(契约 v2 §5.1/D15,T14 重构)。
* *
* 形态(方案 §6.2 纯 A 形态 + P95 三层模型): * 一个壳承载全部形态,**壳零自有权限逻辑**——两视图的判定只看后端两把清单:
* 快应用 = UserBase 真实应用行 + 无自有后端 + pc_url → 本路由。 * · `GET /api/form/my/managed?appCode=`(创建人 ∪ 表单管理员=该 app 域内的 ALL 档)
* - **清单来源**:`GET /api/form/my/fillable?appCode=<目标应用>`——中心按 * · `GET /api/form/my/fillable?appCode=`(audience 门筛过的可填清单,入集即可填)
* `audience × 身份 × 管理员两表` 解析出的填报入口。**不再吃该应用的模块树**, *
* 发一张表单不需要超管加一条 `/f/{code}` 模块行(P95 §0 病根第 1 条就此拆掉)。 * 两视图(§5.1 原文):
* - **导航壳按身份渲染**(三档写死,不再由"树里有什么"决定): * · <b>管理员视图</b>:数据列表 / 添加(代录,T18 接)/ 导入(T18 接)/ 删除 / 复制填报路径;
* · 填报 tab <b>恒显</b>:`canFill=true` 的行;0 行=空态;恰 1 行且地址没带子视图段= * · <b>填报者视图</b>:只有「填报」tab。
* <b>单表单直落</b>形态保留(内容区直接给这张表的填报入口,不叠导航); * 判定即"对这张 app 域表单是不是 ALL 档持有者"——原 P95 的 `myTier` 三档写死分组随三层模型出清
* · 数据管理 tab:存在 `myTier∈{MANAGE, ADMIN}` 的行时显,`:codes` 喂接口结果; * (§19 作废-9),原 `GetUserMod?appCode=` 拉目标应用树那一路也整条摘掉(它唯一剩余用途=导出锚点判定,
* · 统计 tab:存在 `myTier=ADMIN` 的行时显,codes 同上。 * 现改 managed 成员判定,B13/P78 遗迹清零)。
* - **"范围以后端为准"这条红线不动**:tab 只决定画不画;进去能看哪些行仍由后端 *
* (`DataScopeService` + `FormAdminGuard.requireDataView`)把关,前端不按角色再筛一遍(任务书 T8 红线)。 * 仍保留的既有形态:只有一个可填项且地址没带子视图段时<b>单表单直落</b>(T8/P95 的入口语义,不叠导航)。
* - **模块树仍然拉一次**,唯一用途=把原始树经 `modules` prop 下传给内嵌 Data.vue 判"导出"锚点
* (P76:中心会话树里没有快应用的锚点行,拿错树永远判不中)。空树/取树失败=fail-closed 不画导出按钮,
* <b>不再整页报"这个应用进不来"</b>——原"空树"告警与"树里没有 /f/ 行"的分支随这条依赖收敛而退役。
*/ */
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { baseApi } from "@/api/module/base"; import { listFillableForms, listManagedForms } from "@/api/module/fill";
import { listFillableForms } from "@/api/module/fill";
import { fillRouteOf } from "@/utils/fillLink"; import { fillRouteOf } from "@/utils/fillLink";
import Data from "@/views/admin/Data.vue"; import Data from "@/views/admin/Data.vue";
import Stats from "@/views/admin/Stats.vue"; import ImportPanel from "@/components/admin/ImportPanel.vue";
import type { FillableForm } from "@/types/data"; import type { FillableForm, ManagedForm } from "@/types/data";
import type { IModule } from "@/types/Basics"; import { Message } from "view-ui-plus";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const appCode = computed(() => String(route.params.appCode ?? "")); const appCode = computed(() => String(route.params.appCode ?? ""));
/** 子视图段(/app/:appCode/:view):空=填报(单表单直落或多表单列表) */ /** 子视图段(/app/:appCode/:view):空=按视图默认档(管理员→数据,填报者→填报) */
const viewParam = computed(() => String(route.params.view ?? "")); const viewParam = computed(() => String(route.params.view ?? ""));
const fillRows = ref<FillableForm[]>([]); const fillRows = ref<FillableForm[]>([]);
/** 目标应用的原始模块树:本页只把它交给内嵌 Data.vue 判导出锚点(P76),不再拿它算导航 */ const managedRows = ref<ManagedForm[]>([]);
const rawTree = ref<IModule[]>([]);
const loading = ref(false); const loading = ref(false);
const errorText = ref(""); const errorText = ref("");
/** 填报 tab 的行:后端 canFill 判定(层②"只能代填"的行 canFill=false,不进这张列表) */ /** ALL 档持有=该 app 域里有 managed 行——这是两视图唯一的分岔判据 */
const fillCards = computed(() => fillRows.value.filter((r) => r.canFill)); const isAdminView = computed(() => managedRows.value.length > 0);
/** 数据管理候选=层②∪层③;统计候选=层③(与后端 FillAccessService 的三个谓词一一对应) */ const fillCards = computed(() => fillRows.value);
const manageRows = computed(() => fillRows.value.filter((r) => r.myTier === "MANAGE" || r.myTier === "ADMIN")); const adminCodes = computed(() => managedRows.value.map((r) => r.code));
const adminRows = computed(() => fillRows.value.filter((r) => r.myTier === "ADMIN"));
function codesOf(rows: FillableForm[]): string[] { const activeView = computed<"data" | "fill" | "unknown">(() => {
const codes: string[] = [];
for (const row of rows) if (!codes.includes(row.code)) codes.push(row.code);
return codes;
}
const dataCodes = computed(() => codesOf(manageRows.value));
const statsCodes = computed(() => codesOf(adminRows.value));
const showDataTab = computed(() => dataCodes.value.length > 0);
const showStatsTab = computed(() => statsCodes.value.length > 0);
/** 单表单直落:可填项恰好一个且地址没带子视图段(P95 保留 T8 的这条形态,只换数据源) */
const isSingleForm = computed(() => !viewParam.value.trim() && fillCards.value.length === 1);
const activeView = computed<"fill" | "data" | "stats" | "unknown">(() => {
const v = viewParam.value.trim(); const v = viewParam.value.trim();
if (!v || v === "fill") return "fill"; if (v === "data") return isAdminView.value ? "data" : "unknown";
if (v === "data") return showDataTab.value ? "data" : "unknown"; if (v === "fill") return "fill";
if (v === "stats") return showStatsTab.value ? "stats" : "unknown"; if (!v) return isAdminView.value ? "data" : "fill";
return "unknown"; return "unknown";
}); });
const currentTitle = computed(() => { /** 单表单直落:填报者视图下只有一个可填项且地址没带子视图段 */
if (activeView.value === "data") return "数据查看"; const isSingleForm = computed(() => !isAdminView.value && !viewParam.value.trim() && fillCards.value.length === 1);
if (activeView.value === "stats") return "统计";
if (activeView.value === "fill") return "填报"; const currentTitle = computed(() => (activeView.value === "data" ? "数据查看" : "填报"));
return "应用表单";
});
async function loadAll() { async function loadAll() {
if (!appCode.value) { if (!appCode.value) {
@@ -84,24 +62,23 @@ async function loadAll() {
loading.value = true; loading.value = true;
errorText.value = ""; errorText.value = "";
try { try {
fillRows.value = (await listFillableForms(appCode.value)) || []; const [fillable, managed] = await Promise.all([
listFillableForms(appCode.value),
listManagedForms(appCode.value)
]);
fillRows.value = fillable || [];
managedRows.value = managed || [];
} catch (e: any) { } catch (e: any) {
fillRows.value = []; fillRows.value = [];
errorText.value = e?.message || "可填清单取不到"; managedRows.value = [];
errorText.value = e?.message || "该应用的清单取不到";
} finally { } finally {
loading.value = false; loading.value = false;
} }
// 导出锚点那棵树:失败或空都按"判不出锚点=不给导出按钮"处理(fail-closed),
// 刻意不并进 errorText——它取不到不影响填报/数据/统计三档壳的成立
try {
rawTree.value = (await baseApi.getUserMod(appCode.value).send()) || [];
} catch {
rawTree.value = [];
}
} }
/** tab 点击:填报段回到应用入口(保留单表单直落判定),其余段按名分派 */ /** tab 点击:空段=按视图默认档,其余段按名分派 */
function gotoView(seg: "" | "data" | "stats") { function gotoView(seg: "" | "data" | "fill") {
router.push(seg ? `/app/${appCode.value}/${seg}` : `/app/${appCode.value}/`); router.push(seg ? `/app/${appCode.value}/${seg}` : `/app/${appCode.value}/`);
} }
@@ -109,9 +86,40 @@ function goFill(row: FillableForm) {
router.push(fillRouteOf(row)); router.push(fillRouteOf(row));
} }
/**
* 代录入口(T18):进同一张填报页,带 {@code proxy=1} 让它把"被记录人"选择器画出来。
* 这个 query 只是界面开关——<b>能不能真的挂到别人名下完全由后端判</b>(该表单 ALL 档,
* {@code AnswerService#requireSubject}),非档者即使手搓请求体也是 403。
*/
function goProxy(row: ManagedForm) {
const target = fillRouteOf(row);
router.push({ path: target.path, query: { ...target.query, proxy: "1" } });
}
/** 导入弹窗的目标行(§5.1:导入入口只存在于管理员视图) */
const importTarget = ref<ManagedForm | null>(null);
const importVisible = ref(false);
function openImport(row: ManagedForm) {
importTarget.value = row;
importVisible.value = true;
}
function fillPathText(row: FillableForm): string { function fillPathText(row: FillableForm): string {
const target = fillRouteOf(row); const target = fillRouteOf(row);
return `#${target.path}${target.query.appCode ? `?appCode=${target.query.appCode}` : ""}`; return `${location.origin}${location.pathname}#${target.path}${target.query.appCode ? `?appCode=${target.query.appCode}` : ""}`;
}
/** 复制填报路径(§5.1 管理员视图动作):单表单直落地址即分享单元(code 寻址 + 会话) */
async function copyFillPath(row: FillableForm) {
const text = fillPathText(row);
try {
await navigator.clipboard.writeText(text);
Message.success("填报路径已复制:" + text);
} catch {
// 非 https/无剪贴板权限时 clipboard 会拒——降级成把地址摊在提示里让人手抄,不静默失败
Message.warning(`当前环境不允许自动复制,请手动复制:${text}`);
}
} }
onMounted(loadAll); onMounted(loadAll);
@@ -124,33 +132,57 @@ watch(appCode, () => loadAll());
<div class="app-head"> <div class="app-head">
<span class="app-tag">快应用</span> <span class="app-tag">快应用</span>
<code class="app-code">{{ appCode || "(空)" }}</code> <code class="app-code">{{ appCode || "(空)" }}</code>
<span class="toolbar-note">清单按目标应用取(GET /api/form/my/fillable?appCode=),导航壳按身份渲染,范围以后端为准</span> <span class="toolbar-note">
清单按目标应用取(managed + fillable 两支),视图分岔只看 ALL 档,壳不自行判权
</span>
<Button icon="md-refresh" size="small" :loading="loading" @click="loadAll">刷新</Button> <Button icon="md-refresh" size="small" :loading="loading" @click="loadAll">刷新</Button>
</div> </div>
<Alert v-if="errorText" show-icon type="error" class="region-tip">{{ errorText }}</Alert> <Alert v-if="errorText" show-icon type="error" class="region-tip">{{ errorText }}</Alert>
<!-- 导航壳:三档由身份决定(不是由模块树里有什么决定) --> <!-- 两视图导航:管理员才有「数据查看」档;填报档两种视图都在 -->
<div class="light-nav"> <div class="light-nav">
<span class="nav-live"> <span class="nav-live">
<a v-if="isAdminView" :class="['nav-item', { active: activeView === 'data' }]" href="javascript:;"
@click="gotoView('data')">数据查看</a>
<a :class="['nav-item', { active: activeView === 'fill' }]" href="javascript:;" <a :class="['nav-item', { active: activeView === 'fill' }]" href="javascript:;"
@click="gotoView('')">填报({{ fillCards.length }})</a> @click="gotoView('fill')">填报({{ fillCards.length }})</a>
<a v-if="showDataTab" :class="['nav-item', { active: activeView === 'data' }]" href="javascript:;" </span>
@click="gotoView('data')">数据管理</a> <span class="toolbar-note">
<a v-if="showStatsTab" :class="['nav-item', { active: activeView === 'stats' }]" href="javascript:;" {{ isAdminView ? "你在该应用下有 ALL 档的表单(创建人或表单管理员)" : "你在这里只是填报人——没有可管理的数据" }}
@click="gotoView('stats')">统计</a>
</span> </span>
<span class="toolbar-note">数据管理/统计两档按你在这些表单上的层级出现;导出按钮仍按该应用的模块树判</span>
</div> </div>
<h5 class="section-title">{{ currentTitle }}</h5> <h5 class="section-title">{{ currentTitle }}</h5>
<!-- 数据/统计视图复用中心管理端组件:定义管理权不在快应用,codes 来自可填清单(接口驱动,P95); <!-- 管理员视图:数据列表(内嵌 Data,候选=managed 的 code)+ 复制填报路径
embedded 让它只渲染本体,避免与本页白卡/内边距叠成两层; + 添加(代录)+ 导入(T18 接进这一档:同一条 submit 管线、同一 ALL 档守卫)。 -->
modules 把**本页拉到的那棵应用树**传进去——导出按钮的锚点判定吃它,不吃中心会话树(P76) --> <template v-if="activeView === 'data'">
<Data v-if="activeView === 'data'" context="app" :app-code="appCode" :codes="dataCodes" <div class="manage-rows">
:modules="rawTree" embedded /> <div v-for="row in managedRows" :key="row.code" class="manage-row">
<Stats v-else-if="activeView === 'stats'" context="app" :app-code="appCode" :codes="statsCodes" embedded /> <div class="manage-main">
<div class="manage-name">
{{ row.name }}
<Tag v-if="row.status !== 1" color="warning" size="small">
{{ row.status === 0 ? "草稿" : "已停用" }}
</Tag>
</div>
<code class="manage-path">{{ fillPathText(row) }}</code>
<span class="toolbar-note">
来源:{{ (row.role || []).map((r) => (r === 'creator' ? '创建人' : '表单管理员')).join('、') }}
</span>
</div>
<div class="manage-ops">
<Button size="small" icon="md-copy" @click="copyFillPath(row)">复制填报路径</Button>
<Button size="small" icon="md-person-add" @click="goProxy(row)">添加(代录)</Button>
<Button size="small" icon="md-cloud-upload" :disabled="row.status !== 1"
@click="openImport(row)">导入</Button>
</div>
</div>
</div>
<Data v-if="adminCodes.length" context="app" :app-code="appCode" :codes="adminCodes" embedded />
<ImportPanel v-if="importTarget" v-model="importVisible" :form="importTarget" />
</template>
<template v-else-if="activeView === 'fill'"> <template v-else-if="activeView === 'fill'">
<!-- 单表单直落:只有一个可填项时不叠列表,内容区直接给这张表的填报入口 --> <!-- 单表单直落:只有一个可填项时不叠列表,内容区直接给这张表的填报入口 -->
<template v-if="isSingleForm"> <template v-if="isSingleForm">
@@ -158,12 +190,10 @@ watch(appCode, () => loadAll());
单表单直落:该应用在你名下只有一个可填入口,内容区直接给它,不再叠一层导航。 单表单直落:该应用在你名下只有一个可填入口,内容区直接给它,不再叠一层导航。
<br />填报目标:<code>{{ fillPathText(fillCards[0]) }}</code> <br />填报目标:<code>{{ fillPathText(fillCards[0]) }}</code>
</Alert> </Alert>
<div class="fill-mount"> <div class="fill-hint">
<div class="fill-hint"> 直接进入填报页:
直接进入填报页: <a href="javascript:;" @click="goFill(fillCards[0])">{{ fillCards[0].name }}</a>
<a href="javascript:;" @click="goFill(fillCards[0])">{{ fillCards[0].name }}</a> (填报页本体是 T7 交付的 /f/:code,本页不复制一份)
(填报页本体是 T7 交付的 /f/:code,本页不复制一份)
</div>
</div> </div>
</template> </template>
<div v-else-if="fillCards.length" class="fill-cards"> <div v-else-if="fillCards.length" class="fill-cards">
@@ -177,22 +207,20 @@ watch(appCode, () => loadAll());
</div> </div>
<Alert v-else show-icon type="warning" class="region-tip"> <Alert v-else show-icon type="warning" class="region-tip">
「{{ appCode }}」这个应用在你名下当前没有可填报的表单。 「{{ appCode }}」这个应用在你名下当前没有可填报的表单。
<br />表单发布后会按它的<b>面向人群</b>自动出现在这里(面向学生的表出现在学生账号下, <br />表单发布后按它的<b>面向人群</b>自动出现在这里(面向学生的表出现在学生账号下,
面向教师的表出现在教师账号下)——与基础平台的模块行授权无关; 面向教师的表出现在教师账号下)——与基础平台的模块行授权无关;匿名表单(ANONYMOUS)
如果你是教师且这里为空,说明该应用没有面向学生的表单可代填。 不下发给登录会话,持 URL 直达即可。
</Alert> </Alert>
</template> </template>
<Alert v-else show-icon type="warning"> <Alert v-else show-icon type="warning">
这一段路径对你不可见(本页只有 填报 / 数据管理 / 统计 三档,后两档按你在这些表单上的层级出现)。 这一段路径对你不可见(本页只有 数据查看 / 填报 两档,前者要求该应用下有 ALL 档的表单)。
<br />能直接地址进来但看不到档,说明层级不够——数据面与统计的把关在后端,不在这里。 <br />能直接地址进来但看不到档,说明不是这张表的 ALL 档持有者——把关在后端守卫,不在这里。
</Alert> </Alert>
<p class="toolbar-note foot-note"> <p class="toolbar-note foot-note">
V26 前端侧三半句(P95 改版):①无导出授权不画导出按钮——快应用按该应用树里 path 空的"导出" V72 前端侧三半句:①导出/管理按钮按「该表单 ALL 档」显隐(内嵌 Data.vue 自己读 managed 清单,
锚点行判(本页把那棵树原样交给内嵌 Data.vue,判不中就不画,fail-closed); 宿主不再传权限树);②导航不再由模块树生成,按两支清单构造,path 空的权限锚点形态从此不存在;
②导航不再由模块树生成,按身份三档写死,path 空节点自然不进导航; ③接口侧的 403 是另一半,<b>前端藏按钮/藏档不等于授权</b>。
③学生身份只见填报档=层①只给 canFill、不给数据/统计档的直接结果。
接口侧的拒绝是 T4 已验的另一半,<b>前端藏按钮/藏档不等于授权</b>。
</p> </p>
</div> </div>
</div> </div>
@@ -259,10 +287,41 @@ watch(appCode, () => loadAll());
border-left: 3px solid #2d8cf0; border-left: 3px solid #2d8cf0;
padding-left: 8px; padding-left: 8px;
} }
.fill-mount { .manage-rows {
padding: 16px 0; display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.manage-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 14px;
border: 1px solid #e8eaec;
border-radius: 4px;
background: #fff;
}
.manage-name {
font-size: 13px;
font-weight: 600;
color: #17233d;
}
.manage-ops {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.manage-path {
display: inline-block;
margin: 2px 8px 2px 0;
color: #808695;
font-size: 12px;
} }
.fill-hint { .fill-hint {
padding: 12px 0;
font-size: 13px; font-size: 13px;
color: #515a6e; color: #515a6e;
} }
+151 -25
View File
@@ -1,25 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 填报页 /f/:code(T7 落地,替换 T5 占位)。 * 填报页 /f/:code(T7 落地,替换 T5 占位;T15 换双探针通道引导)。
* *
* 契约纪律: * 契约纪律:
* 1. mode 闭集(page/embed),显式优先,self!=top 仅作未传时自动兜底(契约 §7); * 1. mode 闭集(page/embed),显式优先,self!=top 仅作未传时自动兜底(契约 §7);
* 2. 身份一律 CAS 会话自取,任何身份注入被禁止(V17); * 2. 登录面身份一律 CAS 会话自取,任何身份注入被禁止(V17);
* 3. 提交带客户端幂等键(uuid),命中返回 200+原 answerId(契约 §5); * 3. 提交带客户端幂等键(uuid),命中返回 200+原 answerId(契约 §5);
* 4. 值渲染默认转义,全仓禁 v-html(方案 §2.3 红线); * 4. 值渲染默认转义,全仓禁 v-html(方案 §2.3 红线);
* 5. 三态空值与已删字段降级(契约 §3)。 * 5. 三态空值与已删字段降级(契约 §3);
* 6. **通道选择=双探针**(契约 v2 C15,T15):先用原生 fetch 探有没有会话,无会话再探这张表
* 是不是匿名表单;有会话→登录通道(/api/form/*),无会话且含 ANONYMOUS→匿名通道
* (/anon/form/*),无会话且不含→page 跳 CAS / embed 发 needLogin。ANONYMOUS 不算登录身份。
*/ */
import { computed, h, onMounted, onUnmounted, ref, watch } from "vue"; import { computed, h, onMounted, onUnmounted, provide, ref, watch } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { fieldWidget } from "@/components/fields"; import { fieldWidget } from "@/components/fields";
import type { DesignField } from "@/types/formSchema"; import type { DesignField } from "@/types/formSchema";
import type { ResolvedForm, MyAnswerItem } from "@/types/answer"; import type { ResolvedForm, MyAnswerItem, SubmitRequest } from "@/types/answer";
import { resolveForm, submitAnswer, listMyAnswers, deleteMyAnswer, editMyAnswer } from "@/api/module/fill"; import { resolveForm, submitAnswer, listMyAnswers, deleteMyAnswer, editMyAnswer, getMyAnswerDetail } from "@/api/module/fill";
import { probeSession, probeAnonForm, submitAnonAnswer, anonUploadPath } from "@/api/module/anon";
import { baseApi } from "@/api/module/base";
import UserCheckModal from "@/components/UserCheckModal.vue";
import { CENTER_APP_CODE, managesForm, useDefaultStore } from "@/store";
import { startHeightReporting, reportSubmitResult, reportNeedLogin } from "@/utils/embed"; import { startHeightReporting, reportSubmitResult, reportNeedLogin } from "@/utils/embed";
import Utils from "@/utils"; import Utils from "@/utils";
import { Message, Modal } from "view-ui-plus"; import { Message, Modal } from "view-ui-plus";
const route = useRoute(); const route = useRoute();
const store = useDefaultStore();
// ==================== mode 闭集(契约 §7) ==================== // ==================== mode 闭集(契约 §7) ====================
@@ -46,25 +54,96 @@ const form = ref<ResolvedForm | null>(null);
const loading = ref(true); const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
/** 本次填报走的通道(null=还没判出来);提交、上传、"我的提交"段都按它分派 */
type FillChannel = "session" | "anon";
const channel = ref<FillChannel | null>(null);
/**
* 附件控件的上传端点(upload/image 控件 inject 它,缺省=登录面那条)。
* 匿名必须走 `/anon/form/upload?code=`:后端只有表单能定 {@code form_file.sch_id}=表单归属校,
* 落错校的话提交时的附件归属校验永远查不到这一行(C6/C8)。
*/
provide(
"fcUploadUrl",
computed(() =>
channel.value === "anon"
? anonUploadPath(code.value, appCode.value || undefined)
: "/api/form/upload"
)
);
/**
* 引导(C15 双探针)。两跳都用原生 fetch:alova 的 302 拦截对全部 `/api/*` 生效且没有豁免位,
* 用会话请求本身去问"有没有会话"会当场把无会话者整页带去 CAS,第二跳就没机会跑了。
*/
async function loadForm() { async function loadForm() {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
const queryAppCode = appCode.value || undefined;
try { try {
form.value = await resolveForm(code.value, appCode.value || undefined); if ((await probeSession()).logged) {
channel.value = "session";
form.value = await resolveForm(code.value, queryAppCode);
// 代录态不取"我的提交":那一段是本人视角的历史,代录的是别人的数据,混在一起会让人以为
// 自己已经给这个人记过一次(后端限次键按 coalesce(subject, submitter) 判,也不能靠界面猜)
if (String(route.query.proxy ?? "") === "1") myAnswers.value = [];
else await loadMyAnswers();
return;
}
const anonymous = await probeAnonForm(code.value, queryAppCode);
if (anonymous) {
channel.value = "anon";
form.value = anonymous;
// 匿名填报人没有任何查询能力(C8/§2.1):编辑/删除/我的提交段整条不存在
myAnswers.value = [];
return;
}
guideToLogin();
} catch (e: any) { } catch (e: any) {
error.value = e?.message || "表单加载失败"; error.value = e?.message || "表单加载失败";
if (e?.code === 4) {
if (isEmbedded.value) {
reportNeedLogin();
} else {
window.location.href = "/cas/login?service=" + encodeURIComponent(window.location.href);
}
}
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
/**
* 未登录引导(**F1 顺修**)。旧判据 `e?.code === 4` 是个死分支——alova 的 errorHandle 抛的是
* 不带 code 的普通 Error,所以 embed 形态的 needLogin 从来没发出去过(契约 §7 要求 embed 未登录
* 走引导协议而不是白屏/iframe 内跳登录页)。通道既然由探针决定,这里就只在
* "无会话 ∧ 这又不是匿名表单"时被调用:embed 发 postMessage,page 整页跳 CAS。
*/
function guideToLogin() {
if (isEmbedded.value) {
error.value = "请先登录后填写";
reportNeedLogin();
return;
}
window.location.href = "/cas/login?service=" + encodeURIComponent(window.location.href);
}
// ==================== 代录(T18,§5.1 管理员视图「添加」的落点) ====================
/**
* {@code ?proxy=1} 只是<b>界面开关</b>:它让这一页画出"被记录人"选择器,并在提交体里带 {@code subjectId}。
* 真正的把关完全在后端——{@code AnswerService#requireSubject} 按"该表单 ALL 档"判,非档者带了也是 403;
* 而 {@code subjectId} 从来不是"操作者身份"(操作者身份仍由会话推导,V17 不变),它是被写入数据的一个字段。
*/
const proxyMode = computed(() => String(route.query.proxy ?? "") === "1" && channel.value === "session");
const canProxy = computed(() =>
proxyMode.value && managesForm(store.managed, appCode.value || CENTER_APP_CODE, code.value)
);
const subject = ref<{ id: number; name: string } | null>(null);
const checkRef = ref<InstanceType<typeof UserCheckModal> | null>(null);
/** 选人数据源=本校组织树(与管理名单同一件,跨校的人根本选不出来,P33 同一条) */
const subjectSources = [{ name: "本校组织", loader: () => baseApi.getSchoolAll().send() }];
function onSubjectPicked(users: any[]) {
const first = (users || []).find((u) => u && u.id != null);
if (!first) return;
subject.value = { id: Number(first.id), name: String(first.name || "").trim() || `用户 ${first.id}` };
}
// ==================== 字段渲染与校验 ==================== // ==================== 字段渲染与校验 ====================
const formValues = ref<Record<string, any>>({}); const formValues = ref<Record<string, any>>({});
@@ -107,9 +186,8 @@ function validateField(field: DesignField): string | null {
function validateAll(): boolean { function validateAll(): boolean {
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
if (!form.value?.groups) return true; // 校验与渲染同源:编辑态吃钉住版 groups(renderGroups),否则按当前发布版(Bug#1)
const groups = form.value.groups as any[]; for (const group of renderGroups.value) {
for (const group of groups) {
for (const field of group.fields || []) { for (const field of group.fields || []) {
const err = validateField(field); const err = validateField(field);
if (err) errors[field.key] = err; if (err) errors[field.key] = err;
@@ -138,12 +216,17 @@ async function handleSubmit() {
submitting.value = true; submitting.value = true;
try { try {
const idempotencyKey = Utils.uuid(); const idempotencyKey = Utils.uuid();
const result = await submitAnswer({ const payload: SubmitRequest = {
formCode: code.value, formCode: code.value,
appCode: appCode.value || undefined, appCode: appCode.value || undefined,
idempotencyKey, idempotencyKey,
// 代录:subject=被记录人(后端按该表单 ALL 档把关,非档者带这个字段就是 403);
// 没选人就不带——"以我自己为被记录人"与手工填报同一条路径
subjectId: canProxy.value ? subject.value?.id : undefined,
values: formValues.value values: formValues.value
}); };
// 两条通道同一条请求形状(匿名体里没有 subjectId,服务端也不读身份字段——C8/V17)
const result = channel.value === "anon" ? await submitAnonAnswer(payload) : await submitAnswer(payload);
submitResult.value = { answerId: result.answerId, answerNo: result.answerNo }; submitResult.value = { answerId: result.answerId, answerNo: result.answerNo };
submitted.value = true; submitted.value = true;
if (isEmbedded.value) { if (isEmbedded.value) {
@@ -172,6 +255,14 @@ async function handleSubmit() {
const myAnswers = ref<MyAnswerItem[]>([]); const myAnswers = ref<MyAnswerItem[]>([]);
const editingAnswer = ref<MyAnswerItem | null>(null); const editingAnswer = ref<MyAnswerItem | null>(null);
/** 编辑态渲染用的 groups = 答卷钉住的那一版(/my/detail);null/空=未进入编辑 */
const editingGroups = ref<any[] | null>(null);
/** 渲染与校验同源的 groups:编辑态吃钉住版,否则吃当前发布版 */
const renderGroups = computed<any[]>(() => {
if (editingAnswer.value) return editingGroups.value || [];
return (form.value?.groups as any[]) || [];
});
async function loadMyAnswers() { async function loadMyAnswers() {
try { try {
@@ -181,9 +272,21 @@ async function loadMyAnswers() {
} }
} }
function handleEditAnswer(answer: MyAnswerItem) { async function handleEditAnswer(answer: MyAnswerItem) {
if (submitting.value) return;
try {
// Bug#1:编辑必须按答卷**钉住的版本**渲染(/my/detail 返回该版本的 groups)。
// 用当前发布版渲染,客户端会带上/漏掉升版后新增或删除的字段,而服务端按钉住旧版校验,
// 结果必然是"含未知字段"或"缺必填"——表单升过一次版,编辑就永久 400。
const detail = await getMyAnswerDetail(answer.answerId);
editingGroups.value = (detail.groups as any[]) || [];
} catch (e: any) {
Message.error(e?.message || "加载答卷失败,无法编辑");
return;
}
editingAnswer.value = answer; editingAnswer.value = answer;
formValues.value = { ...answer.values }; formValues.value = { ...answer.values };
formErrors.value = {};
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@@ -198,6 +301,7 @@ async function handleUpdateAnswer() {
await editMyAnswer(editingAnswer.value.answerId, formValues.value); await editMyAnswer(editingAnswer.value.answerId, formValues.value);
Message.success("更新成功"); Message.success("更新成功");
editingAnswer.value = null; editingAnswer.value = null;
editingGroups.value = null;
formValues.value = {}; formValues.value = {};
await loadMyAnswers(); await loadMyAnswers();
} catch (e: any) { } catch (e: any) {
@@ -225,6 +329,7 @@ function handleDeleteAnswer(answer: MyAnswerItem) {
function handleCancelEdit() { function handleCancelEdit() {
editingAnswer.value = null; editingAnswer.value = null;
editingGroups.value = null;
formValues.value = {}; formValues.value = {};
formErrors.value = {}; formErrors.value = {};
} }
@@ -239,8 +344,8 @@ const containerRef = ref<HTMLElement | null>(null);
let stopHeightReporting: (() => void) | null = null; let stopHeightReporting: (() => void) | null = null;
onMounted(() => { onMounted(() => {
// 引导里含"我的提交"段(只有登录通道会取),不再并行走两遍鉴权
loadForm(); loadForm();
loadMyAnswers();
if (isEmbedded.value && containerRef.value) { if (isEmbedded.value && containerRef.value) {
stopHeightReporting = startHeightReporting(containerRef.value); stopHeightReporting = startHeightReporting(containerRef.value);
} }
@@ -252,7 +357,6 @@ onUnmounted(() => {
watch(code, () => { watch(code, () => {
loadForm(); loadForm();
loadMyAnswers();
}); });
// ==================== 只读态判定 ==================== // ==================== 只读态判定 ====================
@@ -287,7 +391,8 @@ const readonlyReason = computed(() => {
<div v-else-if="error" class="error-state"> <div v-else-if="error" class="error-state">
<Alert show-icon type="error"> <Alert show-icon type="error">
{{ error }} {{ error }}
<template v-if="!isEmbedded" slot desc> <!-- F2 顺修:Vue 2 的 slot="desc" 写法在 Vue 3 下不成立(静默不进插槽),改 #desc -->
<template v-if="!isEmbedded" #desc>
<a href="/cas/login">重新登录</a> <a href="/cas/login">重新登录</a>
</template> </template>
</Alert> </Alert>
@@ -304,6 +409,20 @@ const readonlyReason = computed(() => {
<div v-else class="form-content"> <div v-else class="form-content">
<h2 class="form-title">{{ form.name }}</h2> <h2 class="form-title">{{ form.name }}</h2>
<!-- 代录条(?proxy=1 且这张表在你名下是 ALL 档时才画;判权在后端,这里只是界面开关) -->
<Alert v-if="canProxy" show-icon type="warning" class="readonly-tip">
代录模式:这条记录挂到「被记录人」名下,提交人仍是你本人(不选人即以你自己为被记录人)。
<Button size="small" type="primary" ghost @click="checkRef?.show()">
{{ subject ? "重新选人" : "选择被记录人" }}
</Button>
<span v-if="subject" class="proxy-subject">
{{ subject.name }}
<a href="javascript:;" @click="subject = null">清除</a>
</span>
</Alert>
<UserCheckModal ref="checkRef" :sources="subjectSources" :userids="subject ? [subject.id] : []"
@on-submit="onSubjectPicked" />
<!-- 只读态提示 --> <!-- 只读态提示 -->
<Alert v-if="isReadonly" show-icon type="info" class="readonly-tip"> <Alert v-if="isReadonly" show-icon type="info" class="readonly-tip">
{{ readonlyReason }},当前为只读模式 {{ readonlyReason }},当前为只读模式
@@ -313,7 +432,8 @@ const readonlyReason = computed(() => {
<div v-if="submitted && submitResult" class="success-state"> <div v-if="submitted && submitResult" class="success-state">
<Alert show-icon type="success"> <Alert show-icon type="success">
提交成功! 提交成功!
<template v-if="submitResult.answerNo" slot desc> <!-- F2 顺修:同上,#desc 才进 Alert 的描述插槽(报名号那条真渲染得出来) -->
<template v-if="submitResult.answerNo" #desc>
报名号:<b>{{ submitResult.answerNo }}</b> 报名号:<b>{{ submitResult.answerNo }}</b>
</template> </template>
</Alert> </Alert>
@@ -327,7 +447,7 @@ const readonlyReason = computed(() => {
<!-- 表单字段 --> <!-- 表单字段 -->
<div v-if="!submitted && !isReadonly || editingAnswer" class="form-groups"> <div v-if="!submitted && !isReadonly || editingAnswer" class="form-groups">
<div v-for="group in (form.groups as any[])" :key="group.key" class="form-group"> <div v-for="group in renderGroups" :key="group.key" class="form-group">
<h3 v-if="group.label" class="group-label">{{ group.label }}</h3> <h3 v-if="group.label" class="group-label">{{ group.label }}</h3>
<div v-for="field in (group.fields || [])" :key="field.key" class="form-item"> <div v-for="field in (group.fields || [])" :key="field.key" class="form-item">
<label class="form-label"> <label class="form-label">
@@ -423,6 +543,12 @@ const readonlyReason = computed(() => {
margin-bottom: 16px; margin-bottom: 16px;
} }
.proxy-subject {
margin-left: 10px;
font-weight: 600;
color: #17233d;
}
.success-state { .success-state {
margin-bottom: 24px; margin-bottom: 24px;
} }
+11 -10
View File
@@ -9,11 +9,11 @@
* 两者是**两个端点、两个谓词**,页面按路由分派;顶部 Tab 只是 UI 导航(切路由),不是接口参数。 * 两者是**两个端点、两个谓词**,页面按路由分派;顶部 Tab 只是 UI 导航(切路由),不是接口参数。
* 管理员代填后:这条答卷在**填表人的"我提交的"**里出现,同时在**被代填人的"关于我的"**里出现(V18)。 * 管理员代填后:这条答卷在**填表人的"我提交的"**里出现,同时在**被代填人的"关于我的"**里出现(V18)。
* *
* P91 起第三视图「可填写」(/my/fillable),P95 W4 换数据源:这不再是答卷视图,而是**填报入口列表**。 * P91 起第三视图「可填写」(/my/fillable),P95 W4 换数据源、契约 v2 C2 定行形状:这不再是答卷视图,
* 数据源从"会话菜单树里挑 path 形如 /f/{code} 的行"改成 `GET /api/form/my/fillable`—— * 而是**填报入口列表**。数据源从"会话菜单树里挑 path 形如 /f/{code} 的行"改成 `GET /api/form/my/fillable`——
* 谁能填哪张表由中心按三层模型(audience × 身份 × 管理员两表)自解析, * 谁能填哪张表由中心按 <b>audience 门</b>(会话身份类型 ∈ 表单面向人群)自解析,
* UserBase 侧不再需要逐表单配 `/f/` 模块行(任务书 P95 §0.1/§0.2 红线 6:分享链接与直链解析不动)。 * UserBase 侧不再需要逐表单配 `/f/` 模块行(任务书 P95 §0.1/§0.2 红线 6:分享链接与直链解析不动)。
* 前端照旧**不做本地角色过滤**:接口下发 canFill=true 的才画卡(canFill 是后端算的,不是我筛的)。 * 前端照旧**不做本地角色过滤**:接口下发什么就画什么(入集即等价于可填,行里不再有 canFill)。
* *
* 三条交互口径沿 T7 的"我的提交"段(不另造轮子): * 三条交互口径沿 T7 的"我的提交"段(不另造轮子):
* 1. **编辑入口只在"我提交的"**:编辑=填表人的动作;"关于我的"整列只读 * 1. **编辑入口只在"我提交的"**:编辑=填表人的动作;"关于我的"整列只读
@@ -67,7 +67,7 @@ const VIEW_META = {
fillable: { fillable: {
title: "可填写", title: "可填写",
endpoint: "GET /api/form/my/fillable", endpoint: "GET /api/form/my/fillable",
predicate: "谓词:后端按三层模型解析出的填报入口(canFill=true 才画卡)", predicate: "谓词:会话身份类型 ∈ form.audience(audience 门,与提交时同一公式)",
note: "这里是当前账号能填报的表单。表单发布后按面向人群自动出现在这里——不需要有人在基础平台逐张表配模块行。" note: "这里是当前账号能填报的表单。表单发布后按面向人群自动出现在这里——不需要有人在基础平台逐张表配模块行。"
} }
} as const; } as const;
@@ -84,12 +84,13 @@ const loadedOnce = ref(false);
const errorText = ref(""); const errorText = ref("");
/** /**
* 「可填写」的卡片集=接口里 `canFill=true` 的行。 * 「可填写」的卡片集=接口下发的全部行。
* *
* <p>过滤条件只有这一条,且判定<b>来自后端</b>:`canFill=false` 的行(教师×面向学生那种"只能代填") * <p>契约 v2 C2 之后行里没有 `canFill`:<b>入集即等价于可填</b>(后端按 audience 门筛过一次,
* 由后端按三层模型算出,前端只是不把它画成"进入填报"的卡——前端不重新按角色判一遍(T8 红线:范围以后端为准)。</p> * 不再有"进了列表但不能自填、只给代填"的中间态)。前端依旧不做任何本地身份过滤
* (T8 红线:范围与资格以后端为准,这里只画后端给的东西)。</p>
*/ */
const fillableCards = computed<FillableForm[]>(() => fillableRows.value.filter((r) => r.canFill)); const fillableCards = computed<FillableForm[]>(() => fillableRows.value);
/** 会话用户 id:判"这条是不是我提交的"(编辑/删除入口显隐),身份来源仍是会话(不传后端) */ /** 会话用户 id:判"这条是不是我提交的"(编辑/删除入口显隐),身份来源仍是会话(不传后端) */
const sessionUserId = computed(() => store.sessionUserId); const sessionUserId = computed(() => store.sessionUserId);
@@ -208,7 +209,7 @@ const tableColumns = computed(() => {
render: (h2: any, p: any) => render: (h2: any, p: any) =>
h2( h2(
"span", "span",
`提交人 ${p.row.submitterId}${p.row.subjectId ? `|主体 ${p.row.subjectId}` : "(本人自填)"}` `提交人 ${p.row.submitterId}${p.row.subjectId && p.row.subjectId !== p.row.submitterId ? `|主体 ${p.row.subjectId}` : "(本人自填)"}`
) )
} }
]; ];
+10 -4
View File
@@ -22,7 +22,7 @@ import { resolveForm } from "@/api/module/fill";
import AnswerValueView from "@/components/answer/AnswerValueView.vue"; import AnswerValueView from "@/components/answer/AnswerValueView.vue";
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue"; import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
import { hasPage } from "@/utils/modulePerms"; import { managesForm } from "@/store";
import { statusDisplay } from "@/utils/dataValue"; import { statusDisplay } from "@/utils/dataValue";
import type { AnswerDetail } from "@/types/data"; import type { AnswerDetail } from "@/types/data";
import type { StatusDefView } from "@/types/form"; import type { StatusDefView } from "@/types/form";
@@ -36,8 +36,14 @@ const detail = ref<AnswerDetail | null>(null);
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
/** 数据管理权(授权模块行判定;填表人身份进来时这里就是 false——V29 前端半句) */ /**
const canManage = computed(() => hasPage(store.modules, "/admin/data")); * 数据管理权(契约 v2 C4②:该表单 ALL 档;`modulePerms.hasPage` 随模块行退役删除)。
* 判据=这张表在 `my/managed` 清单里;填表人身份进来时就是 false(V29 前端半句不变),
* 接口侧的拒绝仍由后端守卫兜底——藏按钮不等于授权。
*/
const canManage = computed(() =>
managesForm(store.managed, detail.value?.appCode, detail.value?.code)
);
const liveStatusDefs = ref<StatusDefView[]>([]); const liveStatusDefs = ref<StatusDefView[]>([]);
const statusModal = ref<{ visible: boolean; target: string }>({ visible: false, target: "" }); const statusModal = ref<{ visible: boolean; target: string }>({ visible: false, target: "" });
const editOpen = ref(false); const editOpen = ref(false);
@@ -151,7 +157,7 @@ onMounted(async () => {
<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.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.submitTime }}</span></div>
<div class="meta-row"><span class="meta-key">提交人 / 被代填人</span> <div class="meta-row"><span class="meta-key">提交人 / 被代填人</span>
<span>{{ detail.submitterId }} / {{ detail.subjectId ?? "—(本人自填)" }}</span> <span>{{ detail.submitterId }} / {{ detail.subjectId && detail.subjectId !== detail.submitterId ? detail.subjectId : "(本人自填)" }}</span>
</div> </div>
<div class="meta-row"><span class="meta-key">表单 uuid</span><code>{{ detail.formUuid }}</code></div> <div class="meta-row"><span class="meta-key">表单 uuid</span><code>{{ detail.formUuid }}</code></div>
</div> </div>
+18 -7
View File
@@ -1,28 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 应用设置(`/admin/app`,T10 方案 §3/§6.1):UserBase 侧一行模块行,页内两个 tab。 * 应用设置(`/admin/app`,T10 方案 §3/§6.1):一行模块位置,页内三个 tab。
* *
* - **角色设置** = 校级表单管理员名单(`school_admin`); * - **角色设置** = 校级表单管理员名单(`school_admin`);
* - **快应用** = app 注册表(契约 v2 §5.2/C17,T16;零角色列,入口链接由中心生成);
* - **字典管理** = 原 `/admin/dicts` 整页迁入(`Dicts.vue` 原样复用,守卫行随本行换到 `/admin/app`)。 * - **字典管理** = 原 `/admin/dicts` 整页迁入(`Dicts.vue` 原样复用,守卫行随本行换到 `/admin/app`)。
* *
* 两条口径: * 三条口径:
* 1. **tab 不占模块行、不进导航**(同设计器子页),所以高亮/面包屑挂本行;`?tab=dicts` 可深链, * 1. **tab 不占模块行、不进导航**(同设计器子页),所以高亮/面包屑挂本行;`?tab=dicts|apps` 可深链,
* 旧地址 `/admin/dicts` 由路由重定向到这里的字典 tab; * 旧地址 `/admin/dicts` 由路由重定向到这里的字典 tab;
* ——深链落在模板的 {@code :model-value} 上:view-ui-plus 的 Tabs 受控 prop 是 modelValue, * ——深链落在模板的 {@code :model-value} 上:view-ui-plus 的 Tabs 受控 prop 是 modelValue,
* 写 {@code :value} 会落进 attrs 不被消费(T10 验收轮实拍证伪过一次),勿"顺手改回"; * 写 {@code :value} 会落进 attrs 不被消费(T10 验收轮实拍证伪过一次),勿"顺手改回";
* 2. **一行把关两个 tab 与字典 API**:能不能进来由 UserBase 的 `/admin/app` 决定, * 2. 不在闭集里的 tab 值一律回落缺省(角色设置),与 {@code Fill.vue} 的 mode 白名单同一条写法;
* 前端不做"进了页再按身份藏 tab"——藏按钮不等于授权(契约 §7 身份行)。 * 3. **一行把关三个 tab 与全部维护 API**:能不能进来由 capabilities.schoolAdmin(契约 v2 §2.4 第三支路)
* 决定,前端不做"进了页再按身份藏 tab"——藏按钮不等于授权(契约 §7 身份行)。
*/ */
import { computed } from "vue"; import { computed } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import AdminListPanel from "@/components/admin/AdminListPanel.vue"; import AdminListPanel from "@/components/admin/AdminListPanel.vue";
import Apps from "./Apps.vue";
import Dicts from "./Dicts.vue"; import Dicts from "./Dicts.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
/** 缺省落角色设置(本页新增的主用途);只认 dicts 这一个显式值,其它 query 一律按缺省 */ const TABS = ["roles", "apps", "dicts"] as const;
const tab = computed(() => (route.query.tab === "dicts" ? "dicts" : "roles"));
/** 缺省落角色设置;只认闭集内的值,其它 query 一律按缺省 */
const tab = computed(() => {
const raw = String(route.query.tab ?? "");
return (TABS as readonly string[]).includes(raw) ? raw : "roles";
});
function change(next: string) { function change(next: string) {
router.replace({ path: "/admin/app", query: { ...route.query, tab: next } }); router.replace({ path: "/admin/app", query: { ...route.query, tab: next } });
@@ -36,6 +44,9 @@ function change(next: string) {
<TabPane label="角色设置" name="roles"> <TabPane label="角色设置" name="roles">
<AdminListPanel mode="school" /> <AdminListPanel mode="school" />
</TabPane> </TabPane>
<TabPane label="快应用" name="apps">
<Apps />
</TabPane>
<TabPane label="字典管理" name="dicts"> <TabPane label="字典管理" name="dicts">
<Dicts /> <Dicts />
</TabPane> </TabPane>
+209
View File
@@ -0,0 +1,209 @@
<script setup lang="ts">
/**
* 快应用注册表(契约 v2 §5.2/C17,T16):应用设置页的第三个 tab,守同一行(capabilities.schoolAdmin)。
*
* 页面上只有四件事:建(app_code + 名称,无审核)、改名、复制入口链接、删(在用禁删)。
* **没有任何"给这个应用配谁能用"的入口**——那是有意的(V59 零角色红线):应用内每张表单的可见范围
* 由会话在中心侧现算 ALL/SELF 档,注册表只给 slug 上户口。
*
* 两条拒绝都在后端,这里按信封 msg 原样提示,不在前端复制规则(同 AdminListPanel 的口径):
* ① app_code 格式与保留字(C10:小写 slug 2~64 位,`form-center` 不可注册);
* ② 名下还有未删除表单时的删除(CUSTOM(5),msg 指点先迁先删)。
*/
import { computed, onMounted, reactive, ref } from "vue";
import { Message, Modal } from "view-ui-plus";
import { createApp, deleteApp, listApps, renameApp } from "@/api/module/apps";
import { copyToClipboard } from "@/utils/fillLink";
import type { AppRow } from "@/types/data";
const rows = ref<AppRow[]>([]);
const loading = ref(false);
const busy = ref(false);
const draft = reactive({ visible: false, creating: false, appCode: "", name: "", error: "" });
/** C10 的前端预检:只为即时反馈,服务端仍会按同一条正则判(两处规则同源,注释里的正则就是权威) */
const CODE_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$/;
const RESERVED = "form-center";
const codeError = computed(() => {
const code = draft.appCode.trim();
if (!code) return "app_code 必填";
if (code === RESERVED) return `「${RESERVED}」是中心保留的 app_code,不可注册`;
if (!CODE_RE.test(code)) return "需为 2~64 位小写 slug(字母/数字开头结尾,中间可带 -)";
return "";
});
const columns = computed(() => [
{ title: "应用码", key: "appCode", minWidth: 160 },
{ title: "名称", key: "name", minWidth: 160 },
{
title: "归属",
key: "schId",
width: 90,
render: (h: any, p: any) => h("span", p.row.schId === 0 ? "平台级" : "本校")
},
{
title: "入口链接",
key: "entryUrl",
minWidth: 220,
render: (h: any, p: any) =>
h("a", { class: "operating-link", onClick: () => copyEntry(p.row as AppRow) }, "复制应用入口")
},
{
title: "操作",
key: "ops",
width: 130,
render: (h: any, p: any) =>
h("div", { class: "action-buttons" }, [
h("div", { class: "operating-link", onClick: () => openRename(p.row as AppRow) }, "改名"),
h("div", { class: "operating-link text-error", onClick: () => confirmRemove(p.row as AppRow) }, "删除")
])
}
]);
async function load() {
loading.value = true;
try {
rows.value = (await listApps()) || [];
} catch (e: any) {
Message.error(e?.message || "应用清单加载失败");
} finally {
loading.value = false;
}
}
function openCreate() {
draft.visible = true;
draft.creating = true;
draft.appCode = "";
draft.name = "";
draft.error = "";
}
function openRename(row: AppRow) {
draft.visible = true;
draft.creating = false;
draft.appCode = row.appCode;
draft.name = row.name;
draft.error = "";
}
async function submit() {
const name = draft.name.trim();
if (!name) {
draft.error = "应用名称必填";
return;
}
busy.value = true;
try {
if (draft.creating) {
if (codeError.value) {
draft.error = codeError.value;
return;
}
const created = await createApp({ appCode: draft.appCode.trim(), name });
rows.value = [...rows.value, created];
Message.success(`已注册 ${created.appCode}`);
} else {
const renamed = await renameApp(draft.appCode, name);
rows.value = rows.value.map((r) => (r.appCode === renamed.appCode ? renamed : r));
Message.success("已改名");
}
draft.visible = false;
} catch (e: any) {
// 保留弹窗并把后端的拒绝原因亮出来:格式、保留字、重复注册、平台级归属都在这一支
draft.error = e?.message || "保存失败";
Message.error(draft.error);
} finally {
busy.value = false;
}
}
function confirmRemove(row: AppRow) {
Modal.confirm({
title: "删除应用",
content: `确定删除「${row.name}(${row.appCode})」?删除后该 app_code 不复用(全库唯一键含已删除行)。`,
okText: "删除",
cancelText: "取消",
onOk: async () => {
busy.value = true;
try {
await deleteApp(row.appCode);
rows.value = rows.value.filter((r) => r.appCode !== row.appCode);
Message.success("已删除");
} catch (e: any) {
// 在用禁删(名下还有未删除表单)按后端 msg 原样提示,前端不复制这条规则
Message.error(e?.message || "删除失败");
} finally {
busy.value = false;
}
}
});
}
/** 入口链接:后端给站内路径(按它自己的 context-path 组段),这里补 origin 成可粘贴的绝对地址 */
async function copyEntry(row: AppRow) {
const link = window.location.origin + row.entryUrl;
const ok = await copyToClipboard(link);
if (ok) {
Message.success("链接已复制");
} else {
Message.warning({ content: `复制失败,请手动复制:${link}`, duration: 8 });
}
}
onMounted(load);
defineExpose({ load });
</script>
<template>
<div class="app-registry">
<div class="toolbar">
<Button type="primary" icon="md-add" :disabled="busy" @click="openCreate">注册快应用</Button>
<span class="tip">
app_code 是小写 slug(2~64 位,发布后不可改);注册只上户口,谁能用这个应用由中心按会话在那张表单上现算
</span>
</div>
<Table :columns="columns" :data="rows" :loading="loading" size="small" :border="false" />
<Modal v-model="draft.visible" :title="draft.creating ? '注册快应用' : '改名'" :mask-closable="false"
:width="520">
<Form :label-width="90">
<FormItem label="app_code" required>
<Input v-model="draft.appCode" :disabled="!draft.creating" placeholder="如:survey-2026" />
<div v-if="draft.creating" class="tip">
入口链接与对外拉取都按它寻址,注册后不可改;{{ RESERVED }} 是中心保留字
</div>
</FormItem>
<FormItem label="应用名称" required>
<Input v-model="draft.name" :maxlength="128" placeholder="如:2026 学期课程问卷" />
</FormItem>
</Form>
<Alert v-if="draft.error" type="warning" show-icon class="tip">{{ draft.error }}</Alert>
<template #footer>
<Button @click="draft.visible = false">取消</Button>
<Button type="primary" :loading="busy" @click="submit">
{{ draft.creating ? "注册" : "保存" }}
</Button>
</template>
</Modal>
</div>
</template>
<style scoped>
.app-registry .toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.app-registry .tip {
color: #808695;
font-size: 12px;
}
.app-registry .action-buttons {
display: flex;
gap: 8px;
}
</style>
+14 -22
View File
@@ -13,10 +13,9 @@
* 4. **数据范围以后端为准**:响应里的 `scopes` 只用来把"你现在看到的是哪个范围"讲清楚, * 4. **数据范围以后端为准**:响应里的 `scopes` 只用来把"你现在看到的是哪个范围"讲清楚,
* 前端不据此再过滤一遍(任务书 T8 红线)。 * 前端不据此再过滤一遍(任务书 T8 红线)。
* *
* 快应用上下文(AppContext)复用本组件:传 `context="app"` + `app-code` + `codes` * 快应用上下文(通用壳)复用本组件:传 `context="app"` + `app-code` + `codes`
* (候选来自 `GET /api/form/my/fillable?appCode=` 按层级筛出的 code,经 resolve 拿 uuid—— * (候选由壳按"我管理的表单 ∩ 该应用"算出,经 resolve 拿 uuid——定义管理权不在通用壳里,§3.4)。
* 定义管理权不在快应用上下文里,§3.4;P95 W5 起不再从该应用模块树挑 `/f/` 行) * 导出按钮的显隐不再需要宿主传任何权限载体:本组件自己按 `my/managed` 清单判 ALL 档(契约 v2 §2.4)。
* + `modules`(**同一棵原始模块树**,只给按钮级权限判定用,P76)。
*/ */
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
@@ -25,8 +24,7 @@ import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight"; import { useElementHeight } from "@/utils/hook/useElementHeight";
import { useFormCandidates } from "@/utils/hook/useFormCandidates"; import { useFormCandidates } from "@/utils/hook/useFormCandidates";
import { dataApi, downloadBlob, exportBlob } from "@/api/module/data"; import { dataApi, downloadBlob, exportBlob } from "@/api/module/data";
import { useDefaultStore } from "@/store"; import { managesForm, useDefaultStore } from "@/store";
import { canExport } from "@/utils/modulePerms";
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue"; import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
import { import {
cellText, cellText,
@@ -42,7 +40,6 @@ import {
import { scopeLabel, type AnswerDetail, type AnswerRow, type FilterField, type FilterItem, type ListView } from "@/types/data"; import { scopeLabel, type AnswerDetail, type AnswerRow, type FilterField, type FilterItem, type ListView } from "@/types/data";
import type { StatusDefView } from "@/types/form"; import type { StatusDefView } from "@/types/form";
import type { DesignField } from "@/types/formSchema"; import type { DesignField } from "@/types/formSchema";
import type { IModule } from "@/types/Basics";
/** 行视图 + 勾选态(`_checked` 是 view-ui-plus Table 的行内选中字段,见 withSelectionState) */ /** 行视图 + 勾选态(`_checked` 是 view-ui-plus Table 的行内选中字段,见 withSelectionState) */
type RowVM = AnswerRow & { _checked?: boolean }; type RowVM = AnswerRow & { _checked?: boolean };
@@ -54,20 +51,13 @@ const props = withDefaults(
appCode?: string; appCode?: string;
/** app 上下文的候选 code(来自 /api/form/my/fillable 的层级筛取,P95 W5) */ /** app 上下文的候选 code(来自 /api/form/my/fillable 的层级筛取,P95 W5) */
codes?: string[]; codes?: string[];
/**
* 按钮级权限判定的**那棵树**(P76):app 上下文必须由宿主传入目标应用的模块树
* (GetUserMod?appCode= 的结果)——中心会话树里没有快应用的"导出"锚点行,
* 拿它判锚点等于永远判不中(fail-closed 方向:有授权也不画按钮)。
* center 上下文不传,仍按 `store.modules`(会话自己的树)判页面行。
*/
modules?: IModule[] | null;
/** /**
* 被宿主页面内嵌(快应用上下文 /app/:appCode/)时置 true:只渲染内容本体, * 被宿主页面内嵌(快应用上下文 /app/:appCode/)时置 true:只渲染内容本体,
* 不再套一层 `.page-wrapper > .main-container`——否则会出现双层白卡与双份内边距。 * 不再套一层 `.page-wrapper > .main-container`——否则会出现双层白卡与双份内边距。
*/ */
embedded?: boolean; embedded?: boolean;
}>(), }>(),
{ context: "center", appCode: "", codes: () => [], modules: null, embedded: false } { context: "center", appCode: "", codes: () => [], embedded: false }
); );
const route = useRoute(); const route = useRoute();
@@ -271,10 +261,10 @@ const tableColumns = computed(() => {
width: 170, width: 170,
render: (h2: any, p: any) => render: (h2: any, p: any) =>
h2(Tooltip, { h2(Tooltip, {
content: `submitter_id=${p.row.submitterId}${p.row.subjectId ? `,subject_id=${p.row.subjectId}(他人代填)` : ",无代填主体"}`, content: `submitter_id=${p.row.submitterId}${p.row.subjectId && p.row.subjectId !== p.row.submitterId ? `,subject_id=${p.row.subjectId}(他人代填)` : ",无代填主体"}`,
transfer: true transfer: true
}, { }, {
default: () => [h2("span", `提交人 ${p.row.submitterId}${p.row.subjectId ? `|主体 ${p.row.subjectId}` : ""}`)] default: () => [h2("span", `提交人 ${p.row.submitterId}${p.row.subjectId && p.row.subjectId !== p.row.submitterId ? `|主体 ${p.row.subjectId}` : ""}`)]
}) })
}); });
cols.push( cols.push(
@@ -446,12 +436,14 @@ async function submitBatchStatus() {
// ==================== 导出 ==================== // ==================== 导出 ====================
/** /**
* 权限判定用的树(P76):app 上下文=宿主编进来的**目标应用树**(快应用的"导出"锚点行在这里), * 导出按钮显隐(契约 v2 §2.4:`modulePerms.canExport` 随模块行退役删除):
* center 上下文=会话自己的树(按 `/admin/data` 页面行判,手册 §2.2 中心表没有导出行)。 * 判据换成"<b>该表单 ALL 档持有</b>"的清单形态——当前选中的这张表在 `my/managed` 里,就画导出。
* 两种上下文各自吃自己的树,`canExport` 的纯函数判据不变。 * 中心与快应用两种上下文从此**同一条判据**(不再有"两棵树"的差别,B13/P78 遗迹清零);
* 接口侧的 403 兜底仍在后端守卫,这里只决定画不画。
*/ */
const permsTree = computed(() => (props.context === "app" ? props.modules : store.modules)); const exportable = computed(() =>
const exportable = computed(() => canExport(permsTree.value, props.context === "app" ? "app" : "center")); managesForm(store.managed, current.value?.appCode || props.appCode, current.value?.code)
);
const exporting = ref(false); const exporting = ref(false);
function askExport() { function askExport() {
+4 -3
View File
@@ -21,7 +21,7 @@ import { useElementHeight } from "@/utils/hook/useElementHeight";
import FormConfigModal from "@/components/form/FormConfigModal.vue"; import FormConfigModal from "@/components/form/FormConfigModal.vue";
import FormAdminModal from "@/components/admin/FormAdminModal.vue"; import FormAdminModal from "@/components/admin/FormAdminModal.vue";
import type { FormView } from "@/types/form"; import type { FormView } from "@/types/form";
import { AUDIENCE, FORM_STATUS } from "@/types/form"; import { FORM_STATUS, audienceText } from "@/types/form";
import { CODE_PATTERN, MAX_NAME_LEN } from "@/types/formSchema"; import { CODE_PATTERN, MAX_NAME_LEN } from "@/types/formSchema";
const router = useRouter(); const router = useRouter();
@@ -86,8 +86,9 @@ const columns = computed(() => [
{ {
title: "面向", title: "面向",
key: "audience", key: "audience",
width: 90, width: 120,
render: (h2: any, p: any) => h2("span", p.row.audience === AUDIENCE.TEACHER ? "面向教师" : "面向学生") // 人群是多值(契约 v2 C1),列宽给得比原来宽一点:"学生/教师/匿名" 三种组合都要能读完
render: (h2: any, p: any) => h2("span", audienceText(p.row.audience))
}, },
{ {
title: "截止", title: "截止",
+7
View File
@@ -99,6 +99,13 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
target: "http://localhost:8086", target: "http://localhost:8086",
changeOrigin: true, changeOrigin: true,
}, },
// 匿名面(契约 v2 C6/T15):后端把 /anon/** 整族放在 CAS 墙外(yh.sso.url-pattern 仍只 /api/*),
// dev 同样要同源代理过去,否则填报页的匿名探针与提交在浏览器里是 404(生产由 Caddy 反代
// /form-center/anon/*,T15 部署前置的轻运维项)
"/anon": {
target: "http://localhost:8086",
changeOrigin: true,
},
// 附件/图片回显同源代理(P93:URL={serviceHost}/upload/{key},dev 下 serviceHost 空) // 附件/图片回显同源代理(P93:URL={serviceHost}/upload/{key},dev 下 serviceHost 空)
"/upload": { "/upload": {
target: "http://localhost:8086", target: "http://localhost:8086",