Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e406a88b1 | ||
|
|
d1177a741c | ||
|
|
531f7641fb | ||
|
|
60083517c7 | ||
|
|
6401a69c18 | ||
|
|
4a7db3d22c | ||
|
|
25dc3eb2e5 |
+66
-26
@@ -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 = [];
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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
@@ -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 入参:域只从会话推导,因此候选天然限在本校树里(跨校的人在接口层就给不出来)。
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 位");
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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}` : "(本人自填)"}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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: "截止",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user