Compare commits
11
Commits
48e46e367b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61398f354e | ||
|
|
2661a4d4d3 | ||
|
|
37a61328a0 | ||
|
|
74305f99f6 | ||
|
|
2e406a88b1 | ||
|
|
d1177a741c | ||
|
|
531f7641fb | ||
|
|
60083517c7 | ||
|
|
6401a69c18 | ||
|
|
4a7db3d22c | ||
|
|
25dc3eb2e5 |
@@ -19,7 +19,10 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- name: 安装 git / curl / zip(alpine)
|
||||
run: apk add --no-cache git curl zip
|
||||
run: |
|
||||
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||
apk add --no-cache git curl zip
|
||||
|
||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -38,21 +41,29 @@ jobs:
|
||||
- name: 构建前端(node 容器)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f frontbuild 2>/dev/null || true
|
||||
docker run -d --name frontbuild -w /build node:20 sleep 3600
|
||||
docker cp "${PWD}/." frontbuild:/build/
|
||||
docker exec frontbuild sh -c '
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
|
||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||
docker exec "$BUILD_CONTAINER" sh -c '
|
||||
set -e
|
||||
cd /build
|
||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
||||
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
npm ci
|
||||
npm run build
|
||||
'
|
||||
echo "=== dist 产物 ==="
|
||||
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20'
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
|
||||
# 从容器 cp dist 到宿主工作目录
|
||||
docker cp frontbuild:/build/dist ./dist
|
||||
docker rm -f frontbuild
|
||||
docker cp "$BUILD_CONTAINER":/build/dist ./dist
|
||||
docker rm -f "$BUILD_CONTAINER"
|
||||
echo "BUILD CHECK OK"
|
||||
|
||||
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
||||
|
||||
+66
-26
@@ -4,10 +4,10 @@ import layout from "./components/layout.vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { baseApi } from "@/api/module/base";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
import { buildNavTree, firstNavPage, isSingleNav, type NavNode } from "@/utils/menuNav";
|
||||
import { probeSession } from "@/api/module/anon";
|
||||
import { getMyCapabilities, listFillableForms, listManagedForms } from "@/api/module/fill";
|
||||
import { firstNavPage, isSingleNav, type NavNode } from "@/utils/menuNav";
|
||||
import { Message } from "view-ui-plus";
|
||||
import type { IModule } from "@/types/Basics";
|
||||
import { useDefaultStore } from "@/store";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -17,7 +17,7 @@ const isReady = ref(false);
|
||||
|
||||
/**
|
||||
* 页面即组件(方案 §3.3):被 iframe 嵌入时自动剥壳——不渲染顶栏/左导航/面包屑,
|
||||
* 也不发两个鉴权请求(嵌入上下文里本应用 /api/base/* 取不到会话会弹错误提示并画出带壳页面)。
|
||||
* 也不发会话请求(嵌入上下文里本应用 /api/base/* 取不到会话会弹错误提示并画出带壳页面)。
|
||||
* 显式 mode 参数(?mode=embed|page)的闭集判定在 T7 填报页落地,壳这层只兜 self != top 这一条。
|
||||
*/
|
||||
const isEmbedded = window.self !== window.top;
|
||||
@@ -37,11 +37,36 @@ const layoutData = reactive({
|
||||
baseHost: config.baseHost,
|
||||
ssoHost: config.ssoHost,
|
||||
userInfo: null as any,
|
||||
/** 已按「path 空=权限锚点不进导航」规则过滤的可渲染菜单树(见 utils/menuNav.ts) */
|
||||
/** 自解析出来的可渲染导航(契约 v2 §2.4 三支路;不再由 UserBase 模块树生成) */
|
||||
navList: [] as NavNode[],
|
||||
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 () => {
|
||||
// 首次导航异步完成后再判默认跳转:mount 时 currentRoute 可能仍是初始 "/"
|
||||
await router.isReady();
|
||||
@@ -49,39 +74,54 @@ onMounted(async () => {
|
||||
isReady.value = true;
|
||||
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 {
|
||||
// 1. 当前会话用户
|
||||
const user: any = await baseApi.getUserInfo().send();
|
||||
// 会话用户(GetUserMod 已随模块行退役删除,引导只剩这一把 + 三支路自解析)
|
||||
const user: any = probed?.user ?? (await baseApi.getUserInfo().send());
|
||||
const { id, name, profilePicture } = user || {};
|
||||
// 存一份给页面用(如字典页判平台级维护权);权限判定仍在后端,这里只是不重复发一次请求
|
||||
store.setUser(user || null);
|
||||
|
||||
// 2. 当前用户已授权的模块树(左导航数据源;path 空的权限锚点行也在内,由导航层跳过)
|
||||
const modList: IModule[] = (await baseApi.getUserMod().send()) || [];
|
||||
// 三支路并行取(互相独立,串行只会让首屏多等两个 RTT)
|
||||
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.navList = buildNavTree(modList, resolveMenuPage);
|
||||
layoutData.navList = buildSelfResolvedNav();
|
||||
layoutData.isAlone = isSingleNav(layoutData.navList);
|
||||
// 原始树留一份:按钮级权限(快应用的"导出"锚点)按它判,导航树已把锚点行过滤掉了
|
||||
store.setModules(modList);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// 冒烟取证:原始树 vs 可渲染导航(path 空的权限锚点应当只出现在前者)
|
||||
console.table(
|
||||
(modList || []).map((m: any) => ({
|
||||
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 }))
|
||||
layoutData.navList.map((n) => ({ 渲染项: n.name, page: n.page }))
|
||||
);
|
||||
}
|
||||
|
||||
// 入口页:跳第一个可跳转菜单项(无授权用户即「我的填写」)
|
||||
// 入口页:跳第一个可跳转菜单项
|
||||
if (router.currentRoute.value.path === "/") {
|
||||
const first = firstNavPage(layoutData.navList);
|
||||
if (first) router.replace(first);
|
||||
@@ -91,7 +131,7 @@ onMounted(async () => {
|
||||
if (error?.message !== "redirect") {
|
||||
Message.error("载入用户信息失败,请检查后端服务是否正常!");
|
||||
}
|
||||
// 降级只降「导航为空」,不造兜底菜单树——静默回落会把「平台未配模块/未授权」伪装成正常
|
||||
// 降级只降「导航为空」,不造兜底菜单树——静默回落会把"接口没通"伪装成正常
|
||||
store.setUser(null);
|
||||
layoutData.userInfo = { name: "用户", avatar: "" };
|
||||
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 type { IModule } from "@/types/Basics";
|
||||
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 须逐字对上
|
||||
* router/routes.ts(手册 §2.2 表 v1.5 五行版)。数据范围升级位不再挂在模块行上:
|
||||
* T10 起由后端管理员两表(school_admin / form_admin)承载,前端只在 /admin/app 的角色设置 tab 维护它。
|
||||
* 契约 v2 §2.4 之后只剩两把:
|
||||
* - `getUserInfo`:会话用户本体,App.vue 引导的唯一请求;
|
||||
* - `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 第一个
|
||||
* 泛型位是 AlovaGenerics(不是响应类型),strict vue-tsc 下报 TS2344;本端改用实例方法
|
||||
@@ -22,19 +24,6 @@ class BaseApi {
|
||||
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。
|
||||
* 没有 schId 入参:域只从会话推导,因此候选天然限在本校树里(跨校的人在接口层就给不出来)。
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
AnswerDetail,
|
||||
BatchItemResult,
|
||||
FilterField,
|
||||
ImportResult,
|
||||
ListRequest,
|
||||
ListView,
|
||||
QueryRequest,
|
||||
@@ -93,10 +94,53 @@ class DataApi {
|
||||
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();
|
||||
|
||||
/**
|
||||
* 导入模板下载(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 铁律④:不设行数上限、不做异步任务与下载中心)。
|
||||
*
|
||||
|
||||
+34
-5
@@ -5,8 +5,8 @@
|
||||
* 与后端 FormResolveController / FormAnswerController 对齐。
|
||||
*/
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import type { ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer";
|
||||
import type { AnswerDetail, FillableForm, MyAnswerViewItem } from "@/types/data";
|
||||
import type { HostedForm, ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer";
|
||||
import type { AnswerDetail, Capabilities, FillableForm, ManagedForm, MyAnswerViewItem } from "@/types/data";
|
||||
|
||||
/** 解析当前发布版本(契约 §1:入口寻址) */
|
||||
export function resolveForm(code: string, appCode?: string) {
|
||||
@@ -20,6 +20,19 @@ export function submitAnswer(body: SubmitRequest) {
|
||||
return alovaInstance.Post<SubmitView>("/api/form/submit", body).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* hosted 取址(answer-delivery-v2 §6.3,TASK_2 A.3):按上下文令牌拿**它钉住那一版**的 schema。
|
||||
* 与 resolveForm 的区别就是这条链没有"当前发布版"概念——升版窗口里在途的办件必须按当时那版填。
|
||||
*/
|
||||
export function resolveHostedForm(contextToken: string) {
|
||||
return alovaInstance.Get<HostedForm>("/api/form/hosted/schema", { params: { contextToken } }).send();
|
||||
}
|
||||
|
||||
/** hosted 提交(同一份管线,多带 contextToken;后端在答卷事务内关联意图) */
|
||||
export function submitHostedAnswer(body: SubmitRequest) {
|
||||
return alovaInstance.Post<SubmitView>("/api/form/hosted/submit", body).send();
|
||||
}
|
||||
|
||||
/** 我的答卷列表(按 submitter 过滤,"我的提交"段) */
|
||||
export function listMyAnswers(formCode: string, appCode?: string) {
|
||||
const params: Record<string, string> = { formCode };
|
||||
@@ -56,13 +69,29 @@ export function getMyAnswerDetail(answerId: string) {
|
||||
return alovaInstance.Get<AnswerDetail>("/api/form/my/detail", { params: { answerId } }).send();
|
||||
}
|
||||
|
||||
// ==================== 可填写列表(P95 W3/W4,/my/fillable) ====================
|
||||
// 数据源=后端按 P95 三层模型解析的**填报入口**清单,不再是会话菜单树里挑 /f/ 行(P91 的形态)。
|
||||
// ==================== 可填写列表(契约 v2 C2,/my/fillable) ====================
|
||||
// 数据源=后端按 **audience 门**(会话身份类型 ∈ 表单面向人群)解析的填报入口清单,
|
||||
// 不再是会话菜单树里挑 /f/ 行(P91 的形态),也不再有"入口开了但不能自填"的中间态。
|
||||
// appCode 可选:缺省=全部应用(中心 + 快应用),快应用上下文传自身 appCode 收窄。
|
||||
|
||||
/** 我可填的表单(入口层非 NONE 的都下发;本人能不能填看 canFill) */
|
||||
/** 我可填的表单(入集即等价于可填;资格判定在后端,与提交时同一个公式) */
|
||||
export function listFillableForms(appCode?: string) {
|
||||
const params: Record<string, string> = {};
|
||||
if (appCode) params.appCode = appCode;
|
||||
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,74 @@
|
||||
/**
|
||||
* 消费应用授权管理面(v3/TASK_1,挂应用设置 · 表单授权 tab)。
|
||||
*
|
||||
* 后端 = `FormAccessController`(守卫=校管理员 ∪ 超管,与快应用注册同一把 C4③)。
|
||||
* 三条口径写在这里,免得下一个人"顺手补一个":
|
||||
* 1. **这里的 appKey 是平台身份,不是 app_code**。快应用那个 slug(`apps.ts` 的 appCode)
|
||||
* 只是资源筛选项,不能拿来授权;填进去的值必须是 UserBase 应用注册分配的 appKey,
|
||||
* 后端会查平台密钥注册表核验存在性(不存在直接拒),**响应里永不出现密钥**。
|
||||
* 2. **默认拒绝**:读与订阅是两把独立的开关,没有隐式全集;owner 天然能读,但订阅要另开一行。
|
||||
* 所以新建授权时两把开关都不勾是被后端拒的,不在前端预先塞一个"全给"。
|
||||
* 3. **撤权不删行**:撤销是 `enabled=0` + `grantVersion` 进位,清单里仍看得见(含已撤销行),
|
||||
* 重申会再进位。`grantVersion` 是给事件投递侧判旧用的,页面只展示、不改它。
|
||||
*
|
||||
* `schId` 只有超管切目标域(含 0=平台级表单)时才带,校级传别的值会被 `FormAdminGuard` 拒。
|
||||
*/
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
|
||||
const BASE = "/api/form/admin/form-access";
|
||||
|
||||
/** 资源拥有者绑定(未绑定时后端回 null) */
|
||||
export interface OwnershipView {
|
||||
formUuid: string;
|
||||
formSchoolId: number;
|
||||
ownerAppKey: string;
|
||||
}
|
||||
|
||||
/** 消费授权行(含已撤销行) */
|
||||
export interface GrantView {
|
||||
formUuid: string;
|
||||
consumerSchoolId: number;
|
||||
consumerAppKey: string;
|
||||
readAllowed: boolean;
|
||||
subscribeAllowed: boolean;
|
||||
enabled: boolean;
|
||||
grantVersion: number;
|
||||
}
|
||||
|
||||
/** 目标域参数:undefined 一律剔除,避免 query 里出现空 schId */
|
||||
function paramsOf(params: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 这张表的资源拥有者(null=还没绑) */
|
||||
export function getOwner(formUuid: string, schId?: number) {
|
||||
return alovaInstance.Get<OwnershipView | null>(`${BASE}/owner`, { params: paramsOf({ formUuid, schId }) });
|
||||
}
|
||||
|
||||
/** 设/换 owner 绑定(表单自己的 sch_id 由服务端落,前端不给归属校) */
|
||||
export function setOwner(formUuid: string, ownerAppKey: string, schId?: number) {
|
||||
return alovaInstance.Put<OwnershipView>(`${BASE}/owner`, { ownerAppKey }, { params: paramsOf({ formUuid, schId }) });
|
||||
}
|
||||
|
||||
/** grant 清单(含已撤销行:撤了也要看得见、能重申) */
|
||||
export function listGrants(formUuid: string, schId?: number) {
|
||||
return alovaInstance.Get<GrantView[]>(`${BASE}/grants`, { params: paramsOf({ formUuid, schId }) });
|
||||
}
|
||||
|
||||
/** 授权/重申(三元键 upsert;两把开关至少开一把,否则后端拒) */
|
||||
export function saveGrant(
|
||||
body: { formUuid: string; consumerSchoolId: number; consumerAppKey: string; readAllowed: boolean; subscribeAllowed: boolean },
|
||||
schId?: number
|
||||
) {
|
||||
return alovaInstance.Post<GrantView>(`${BASE}/grants`, body, { params: paramsOf({ schId }) });
|
||||
}
|
||||
|
||||
/** 撤销(enabled=0 + grantVersion 进位;走 POST 是为了带体,语义上它是写操作不是查询) */
|
||||
export function revokeGrant(body: { formUuid: string; consumerSchoolId: number; consumerAppKey: string }, schId?: number) {
|
||||
return alovaInstance.Post<GrantView>(`${BASE}/grants/revoke`, body, { params: paramsOf({ schId }) });
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 差异对账管理面(TASK_3,挂独立路由 /admin/sync,不动应用设置页)。
|
||||
*
|
||||
* 三条与直觉不同的口径,前端必须照抄后端语义、不自作聪明:
|
||||
* 1. **派发 2xx ≠ 对账完成**:任务列表里的 DISPATCHED 只代表"消费方收下了指令",
|
||||
* 差异有没有修、修了几条,只看 `latestResult`(由 result 回报推进);
|
||||
* 2. **COMPLETED 与 PARTIAL 是两种结论**:前者=完整扫描且无未决差异,后者=跑完了但还有
|
||||
* 未决项(只报告的多余行、暂停的修复、重试耗尽的条目),不能并列成"成功";
|
||||
* 3. **拒绝建任务也返回一行任务**(`state=FAILED` + `errorCode`/`errorDetail`),
|
||||
* 超限与范围不符是留痕事实,不是屏幕上闪过的一行字。
|
||||
*/
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
|
||||
const BASE = "/api/form/admin/sync";
|
||||
|
||||
/** 对账任务行(与 FC FormSyncModels.TaskView 同构) */
|
||||
export interface SyncTaskRow {
|
||||
taskId: string;
|
||||
consumerAppKey: string;
|
||||
consumerSchoolId: number;
|
||||
formUuid: string;
|
||||
formVersion: number;
|
||||
triggerType?: string;
|
||||
operator?: string;
|
||||
/** QUEUED / DISPATCHED / RUNNING / COMPLETED / PARTIAL / FAILED / CANCELLED */
|
||||
state: string;
|
||||
/** PENDING / MATERIALIZING / COMPLETE / FAILED */
|
||||
manifestState: string;
|
||||
manifestComplete?: boolean;
|
||||
totalRows?: number;
|
||||
totalPages?: number;
|
||||
materializedRows?: number;
|
||||
materializedPages?: number;
|
||||
attempt?: number;
|
||||
windowStart?: string;
|
||||
windowEnd?: string;
|
||||
dispatchedAt?: string;
|
||||
finishedAt?: string;
|
||||
cancelRequested?: boolean;
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
latestResult?: SyncResultRow;
|
||||
}
|
||||
|
||||
/** 一次执行的差异与修复计数(一条 = 一个 attempt) */
|
||||
export interface SyncResultRow {
|
||||
taskId: string;
|
||||
attempt: number;
|
||||
state: string;
|
||||
localScanComplete?: boolean;
|
||||
manifestComplete?: boolean;
|
||||
scannedRows?: number;
|
||||
pagesProcessed?: number;
|
||||
missingRepaired?: number;
|
||||
staleRepaired?: number;
|
||||
hashConflicts?: number;
|
||||
tombstonesRepaired?: number;
|
||||
localAhead?: number;
|
||||
deferred?: number;
|
||||
extraReported?: number;
|
||||
extraRetired?: number;
|
||||
errorCount?: number;
|
||||
errors?: SyncErrorRow[];
|
||||
resumed?: boolean;
|
||||
reportedAt?: string;
|
||||
}
|
||||
|
||||
/** 错误明细:每条都能定位 consumer / task / range / answer,永不携带答卷值 */
|
||||
export interface SyncErrorRow {
|
||||
code: string;
|
||||
taskId?: string;
|
||||
consumerAppKey?: string;
|
||||
consumerSchoolId?: number;
|
||||
formUuid?: string;
|
||||
answerUuid?: string;
|
||||
revision?: number;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Spring Page 的极简形态(列表只取这三项) */
|
||||
export interface SyncTaskPage {
|
||||
content: SyncTaskRow[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/** 投递失败/死信行(TASK_2 failedPage 的转发,不代表对账结论) */
|
||||
export interface FailedDeliveryRow {
|
||||
eventId: string;
|
||||
answerUuid: string;
|
||||
answerRevision: number;
|
||||
consumerAppKey: string;
|
||||
consumerSchoolId: number;
|
||||
state: string;
|
||||
retryCount?: number;
|
||||
lastError?: string;
|
||||
nextRetryAt?: string;
|
||||
brokerAcceptedAt?: string;
|
||||
consumerAppliedAt?: string;
|
||||
}
|
||||
|
||||
export interface FailedDeliveryPage {
|
||||
total: number;
|
||||
rows: FailedDeliveryRow[];
|
||||
}
|
||||
|
||||
/** 重放结论(稳定码:QUEUED / ALREADY_DELIVERED / GRANT_REVOKED / REVISION_MISMATCH / NO_DELIVERY_ROW) */
|
||||
export interface ReplayResult {
|
||||
state: string;
|
||||
eventId?: string;
|
||||
answerRevision?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function withSch(params: Record<string, string | number>, schId?: number) {
|
||||
if (schId !== undefined && schId !== null) params.schId = schId;
|
||||
return params;
|
||||
}
|
||||
|
||||
/** 建任务:scope=ANSWER / FORM / ALL(ALL 按生效订阅裂变成多行,受后端上限约束) */
|
||||
export function createSyncTasks(body: {
|
||||
scope: string;
|
||||
consumerAppKey: string;
|
||||
consumerSchoolId: number;
|
||||
formUuid?: string;
|
||||
answerUuid?: string;
|
||||
idempotencyKey?: string;
|
||||
}, schId?: number) {
|
||||
const params = withSch({}, schId);
|
||||
return alovaInstance.Post<SyncTaskRow[]>(BASE + "/tasks", body, { params }).send();
|
||||
}
|
||||
|
||||
/** 任务列表(状态与进度) */
|
||||
export function listSyncTasks(query: {
|
||||
consumerAppKey?: string;
|
||||
consumerSchoolId?: number;
|
||||
state?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}, schId?: number) {
|
||||
const params: Record<string, string | number> = {};
|
||||
if (query.consumerAppKey) params.consumerAppKey = query.consumerAppKey;
|
||||
if (query.consumerSchoolId !== undefined && query.consumerSchoolId !== null) {
|
||||
params.consumerSchoolId = query.consumerSchoolId;
|
||||
}
|
||||
if (query.state) params.state = query.state;
|
||||
params.page = query.page || 1;
|
||||
params.size = query.size || 20;
|
||||
return alovaInstance.Get<SyncTaskPage>(BASE + "/tasks", { params: withSch(params, schId) }).send();
|
||||
}
|
||||
|
||||
/** 任务详情(含最近一次回报摘要) */
|
||||
export function getSyncTask(taskId: string, schId?: number) {
|
||||
return alovaInstance.Get<SyncTaskRow>(`${BASE}/tasks/${encodeURIComponent(taskId)}`,
|
||||
{ params: withSch({}, schId) }).send();
|
||||
}
|
||||
|
||||
/** 逐 attempt 的结果与失败明细 */
|
||||
export function listSyncResults(taskId: string, schId?: number) {
|
||||
return alovaInstance.Get<SyncResultRow[]>(`${BASE}/tasks/${encodeURIComponent(taskId)}/results`,
|
||||
{ params: withSch({}, schId) }).send();
|
||||
}
|
||||
|
||||
/** 取消任务:剩余投递与修复一律停(幂等) */
|
||||
export function cancelSyncTask(taskId: string, schId?: number) {
|
||||
return alovaInstance.Post<SyncTaskRow>(`${BASE}/tasks/${encodeURIComponent(taskId)}/cancel`, undefined,
|
||||
{ params: withSch({}, schId) }).send();
|
||||
}
|
||||
|
||||
/** 单答卷重放(先重新过 ACL 再执行;旧 outbox 日志不是新答卷,历史版本内容不可重建) */
|
||||
export function replaySyncAnswer(body: {
|
||||
consumerAppKey: string;
|
||||
consumerSchoolId: number;
|
||||
answerUuid: string;
|
||||
expectedRevision: number;
|
||||
operationId: string;
|
||||
}, schId?: number) {
|
||||
const params = withSch({}, schId);
|
||||
return alovaInstance.Post<ReplayResult>(BASE + "/replay", body, { params }).send();
|
||||
}
|
||||
|
||||
/** 投递失败/死信分页(TASK_2 的投递面查看,与对账结论互不作证) */
|
||||
export function listFailedDeliveries(query: {
|
||||
consumerAppKey: string;
|
||||
consumerSchoolId: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}, schId?: number) {
|
||||
const params: Record<string, string | number> = {
|
||||
consumerAppKey: query.consumerAppKey,
|
||||
consumerSchoolId: query.consumerSchoolId,
|
||||
page: query.page || 1,
|
||||
size: query.size || 20,
|
||||
};
|
||||
return alovaInstance.Get<FailedDeliveryPage>(BASE + "/deliveries/failed",
|
||||
{ params: withSch(params, schId) }).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 生效)。
|
||||
*
|
||||
* 背景与授权口径:执行手册 §5 P1 的降级取证先例——本地 CAS/UserBase 不可用时,
|
||||
* 以本文件的模块树验证「壳渲染 + 左导航 + path 空节点跳过」,并把结论记入问题单。
|
||||
* 用本文件的桩验证「壳渲染 + 自解析左导航」,并把结论记入问题单。
|
||||
*
|
||||
* 形状严格对齐后端 FormBaseController.toFrontendTree 的下发字段
|
||||
* (id/modId/name/parentId/page/level/icon/orderNumber/usable/children,信封 {code,msg,data}),
|
||||
* 模块行取执行手册 §2.2 的 v1.5 现行表(中心管理员四行 + 我的填写,共五行)。
|
||||
* 契约 v2 §2.4(T14)之后本桩只剩两把基础端点:`GetUserInfo` 与 `GetSchoolAll`。
|
||||
* 原 `GetUserMod` 与两棵模块树桩(中心五行 / 快应用演练行)随模块行整条退役删除——
|
||||
* 左导航改由三支路(`my/fillable`、`my/managed`、`my/capabilities`)自解析,
|
||||
* 那三支的桩在 `dataapi.ts` 里。
|
||||
*/
|
||||
import type { IModule } from "@/types/Basics";
|
||||
|
||||
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 [
|
||||
{
|
||||
url: "/api/base/GetUserInfo",
|
||||
@@ -163,18 +29,6 @@ export default [
|
||||
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 是人员叶子)。
|
||||
// id 即 UserBase 用户主键,提交给 /school-admins 与 /forms/{uuid}/admins 的就是它。
|
||||
|
||||
+44
-18
@@ -18,7 +18,7 @@
|
||||
* 游标(keyset):`cursor`=上一页末行 answerId(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 bizErr = (code: number, msg: string) => ({ code, msg, data: null });
|
||||
@@ -206,7 +206,7 @@ export default [
|
||||
appCode: queryValue(query, "appCode") || APP,
|
||||
name: "三好学生申报",
|
||||
version: 2,
|
||||
audience: 1,
|
||||
audience: ["STUDENT"],
|
||||
fillLimit: 0,
|
||||
answerNoEnabled: 0,
|
||||
deadline: null,
|
||||
@@ -317,27 +317,26 @@ export default [
|
||||
return ok(results);
|
||||
} },
|
||||
|
||||
// ==================== 可填写入口清单(P95 W3/W4,/my/fillable) ====================
|
||||
// ==================== 可填写入口清单(契约 v2 C2,/my/fillable) ====================
|
||||
/**
|
||||
* 三行种子刻意覆盖三层模型的可观测差异(冒烟脚本 p95-fill-scope-smoke.sh 就断言这三件事):
|
||||
* ① FILL:学生面向、canFill=true → 「可填写」画卡;
|
||||
* ② MANAGE:学生面向、canFill=false(教师那种"只能代填")→ 「可填写」<b>不</b>画卡,
|
||||
* 但快应用上下文的数据管理 tab 要靠它出现;
|
||||
* ③ ADMIN:教师面向、canFill=true + 最高层 → 统计 tab 只由它决定。
|
||||
* 行形状按 C2:无 canFill/myTier,`audience` 是数组。<b>入集即等价于可填</b>
|
||||
* (后端 audience 门筛过一次,不再有"进了列表但不能自填、只给代填"的中间态)。
|
||||
* 种子刻意覆盖三种人群形态:
|
||||
* ① 纯学生面向;② 学生+教师共集(C1 多值,一张表两类人都能填);③ 纯教师面向。
|
||||
* appCode 传值时按该应用收窄(缺省=全部应用),与后端 FillableFormService 同一口径。
|
||||
*/
|
||||
{ url: "/api/form/my/fillable", method: "get", response: ({ query }: any) => {
|
||||
const rows: FillableForm[] = [
|
||||
{ code: "honor_apply", name: "三好学生申报", appCode: APP, audience: 1,
|
||||
deadline: "2026-12-31 23:59:59", fillLimit: 1, groupTag: null, canFill: true, myTier: "FILL" },
|
||||
{ code: "home_visit", name: "家访登记(教师代填)", appCode: APP, audience: 1,
|
||||
deadline: null, fillLimit: 0, groupTag: "五年级", canFill: false, myTier: "MANAGE" },
|
||||
{ code: "teacher_survey", name: "教师问卷", appCode: APP, audience: 2,
|
||||
deadline: "2026-11-30 23:59:59", fillLimit: 0, groupTag: null, canFill: true, myTier: "ADMIN" },
|
||||
{ code: "drill-honour", name: "荣誉申报(演练应用)", appCode: "fc-drill", audience: 1,
|
||||
deadline: "2026-10-31 23:59:59", fillLimit: 0, groupTag: null, canFill: true, myTier: "FILL" },
|
||||
{ code: "drill_roster", name: "花名册(演练应用,教师代填)", appCode: "fc-drill", audience: 1,
|
||||
deadline: null, fillLimit: 0, groupTag: null, canFill: false, myTier: "MANAGE" }
|
||||
{ code: "honor_apply", name: "三好学生申报", appCode: APP, audience: ["STUDENT"],
|
||||
deadline: "2026-12-31 23:59:59", fillLimit: 1, groupTag: null },
|
||||
{ code: "home_visit", name: "家访登记(师生共集)", appCode: APP, audience: ["STUDENT", "TEACHER"],
|
||||
deadline: null, fillLimit: 0, groupTag: "五年级" },
|
||||
{ code: "teacher_survey", name: "教师问卷", appCode: APP, audience: ["TEACHER"],
|
||||
deadline: "2026-11-30 23:59:59", fillLimit: 0, groupTag: null },
|
||||
{ code: "drill-honour", name: "荣誉申报(演练应用)", appCode: "fc-drill", audience: ["STUDENT"],
|
||||
deadline: "2026-10-31 23:59:59", fillLimit: 0, groupTag: null },
|
||||
{ code: "drill_roster", name: "花名册(演练应用,师生共集)", appCode: "fc-drill",
|
||||
audience: ["STUDENT", "TEACHER"], deadline: null, fillLimit: 0, groupTag: null }
|
||||
];
|
||||
// 排序沿后端口径:deadline 升序、不限截止在后、同档按 code
|
||||
const app = String(query?.appCode || "").trim();
|
||||
@@ -350,6 +349,33 @@ export default [
|
||||
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) ====================
|
||||
{ url: "/api/form/my/submit-list", method: "get", response: () => {
|
||||
const items: MyAnswerViewItem[] = seed
|
||||
|
||||
@@ -95,10 +95,12 @@ const forms: MockForm[] = [
|
||||
statusName: "PUBLISHED",
|
||||
currentVersion: 1,
|
||||
draftVersion: 2,
|
||||
audience: AUDIENCE.STUDENT,
|
||||
audience: [AUDIENCE.STUDENT],
|
||||
fillLimit: 1,
|
||||
deadline: null,
|
||||
answerNoEnabled: 0,
|
||||
frozen: 0,
|
||||
anonCookieMinutes: 10,
|
||||
groupTag: null,
|
||||
statusDefs: DEFAULT_STATUS_DEFS,
|
||||
createTime: now(),
|
||||
@@ -161,10 +163,12 @@ function view(f: MockForm): FormView {
|
||||
statusName: f.statusName,
|
||||
currentVersion: f.currentVersion,
|
||||
draftVersion: f.draftVersion,
|
||||
audience: f.audience,
|
||||
audience: [...f.audience],
|
||||
fillLimit: f.fillLimit,
|
||||
deadline: f.deadline,
|
||||
answerNoEnabled: f.answerNoEnabled,
|
||||
frozen: f.frozen,
|
||||
anonCookieMinutes: f.anonCookieMinutes,
|
||||
groupTag: f.groupTag,
|
||||
statusDefs: f.statusDefs ? JSON.parse(JSON.stringify(f.statusDefs)) : null,
|
||||
createTime: f.createTime,
|
||||
@@ -332,10 +336,12 @@ export default [
|
||||
statusName: "DRAFT",
|
||||
currentVersion: null,
|
||||
draftVersion: 1,
|
||||
audience: AUDIENCE.STUDENT,
|
||||
audience: [AUDIENCE.STUDENT],
|
||||
fillLimit: 0,
|
||||
deadline: null,
|
||||
answerNoEnabled: 0,
|
||||
frozen: 0,
|
||||
anonCookieMinutes: 10,
|
||||
groupTag: null,
|
||||
statusDefs: DEFAULT_STATUS_DEFS,
|
||||
createTime: now(),
|
||||
@@ -443,13 +449,31 @@ export default [
|
||||
const f = findForm(body.uuid);
|
||||
if (!f) return bizErr(2, "表单未找到");
|
||||
if (body.audience !== undefined) {
|
||||
if (body.audience !== AUDIENCE.STUDENT && body.audience !== AUDIENCE.TEACHER) return bizErr(2, "audience 只能是 1 或 2");
|
||||
f.audience = body.audience;
|
||||
const codes = Array.isArray(body.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 !== 0 && body.fillLimit !== 1) return bizErr(2, "fillLimit 只能是 0 或 1");
|
||||
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 !== 0 && body.answerNoEnabled !== 1) return bizErr(2, "answerNoEnabled 只能是 0 或 1");
|
||||
if (body.answerNoEnabled === 1 && f.code.length > 49) return bizErr(2, "开启报名号要求 code ≤49 位");
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 表单授权面板(v3/TASK_1,应用设置 · 表单授权 tab)。
|
||||
*
|
||||
* 一张表两件事:**资源拥有者**(这张表是谁的资产)与**消费授权**(允许哪个租户的哪个应用读/订阅)。
|
||||
* 守的是应用设置那一行(capabilities.schoolAdmin,契约 v2 §2.4 第三支路),后端再按
|
||||
* `FormAdminGuard.requirePlatformAdmin` 判一次——藏按钮不等于授权,这里的前端判断只负责少发一次注定被拒的请求。
|
||||
*
|
||||
* 四条不能改的口径:
|
||||
* 1. **填的是 appKey(平台身份),不是快应用 slug**。app_code 只是资源筛选项,永不相认拥有权;
|
||||
* 后端会查平台密钥注册表核验这个 appKey 确实注册过,**页面拿不到也永不显示密钥**。
|
||||
* 2. **默认拒绝**:读与订阅两把开关独立,都不勾时后端直接拒(所以提交前在前端也拦住,不给一个"看起来存了"的空行)。
|
||||
* owner 天然能读,但订阅同样要显式开一行。
|
||||
* 3. **校级管理员只能管本校**:消费租户那一格锁成会话校;超管才给填目标域(含 0=平台级表单)。
|
||||
* 平台级表单的 owner 换绑是超管专属——否则校管理员能把平台资源认领给自己应用。
|
||||
* 4. **撤权不删行**:撤销后清单里仍看得见(enabled=0,grantVersion 进位),重申是新纪元。
|
||||
* 已生成的任务不被撤权改写,那是事件侧(TASK_3)的语义,本页面只负责把授权关掉。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { formApi } from "@/api/module/form";
|
||||
import { getOwner, listGrants, revokeGrant, saveGrant, setOwner } from "@/api/module/formAccess";
|
||||
import type { GrantView, OwnershipView } from "@/api/module/formAccess";
|
||||
import type { FormView } from "@/types/form";
|
||||
import { useDefaultStore } from "@/store";
|
||||
|
||||
const store = useDefaultStore();
|
||||
|
||||
const forms = ref<FormView[]>([]);
|
||||
const formUuid = ref("");
|
||||
/** 超管的目标域(含 0=平台级);校级留空=会话校,后端按同一条守卫判 */
|
||||
const targetSchId = ref<number | undefined>(undefined);
|
||||
const owner = ref<OwnershipView | null>(null);
|
||||
const grants = ref<GrantView[]>([]);
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
|
||||
const ownerDraft = reactive({ appKey: "", error: "" });
|
||||
const draft = reactive({
|
||||
visible: false,
|
||||
appKey: "",
|
||||
schoolId: undefined as number | undefined,
|
||||
read: true,
|
||||
subscribe: false,
|
||||
error: ""
|
||||
});
|
||||
|
||||
const selected = computed(() => forms.value.find((f) => f.formUuid === formUuid.value) || null);
|
||||
|
||||
/** 目标域:超管显式填了用填的,否则用会话校(校级管理员那一格是锁死的) */
|
||||
const domainSchId = computed<number | undefined>(() => {
|
||||
if (store.isSuperAdmin && targetSchId.value !== undefined && targetSchId.value !== null) {
|
||||
return targetSchId.value;
|
||||
}
|
||||
return store.sessionSchId ?? undefined;
|
||||
});
|
||||
|
||||
const grantColumns = computed(() => [
|
||||
{ title: "消费应用 appKey", key: "consumerAppKey", minWidth: 170 },
|
||||
{ title: "消费租户", key: "consumerSchoolId", width: 100 },
|
||||
{
|
||||
title: "读",
|
||||
key: "readAllowed",
|
||||
width: 70,
|
||||
render: (h: any, p: any) => h("span", p.row.readAllowed ? "允许" : "—")
|
||||
},
|
||||
{
|
||||
title: "订阅",
|
||||
key: "subscribeAllowed",
|
||||
width: 80,
|
||||
render: (h: any, p: any) => h("span", p.row.subscribeAllowed ? "允许" : "—")
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "enabled",
|
||||
width: 90,
|
||||
render: (h: any, p: any) =>
|
||||
h("span", { class: p.row.enabled ? "" : "text-error" }, p.row.enabled ? "生效" : "已撤销")
|
||||
},
|
||||
{ title: "授权版本", key: "grantVersion", width: 90 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "ops",
|
||||
width: 110,
|
||||
render: (h: any, p: any) =>
|
||||
h("div", { class: "action-buttons" }, [
|
||||
p.row.enabled
|
||||
? h("div", { class: "operating-link text-error", onClick: () => confirmRevoke(p.row as GrantView) }, "撤销")
|
||||
: h("div", { class: "operating-link", onClick: () => openRegrant(p.row as GrantView) }, "重申")
|
||||
])
|
||||
}
|
||||
]);
|
||||
|
||||
async function loadForms() {
|
||||
loading.value = true;
|
||||
try {
|
||||
forms.value = (await formApi.list({ schId: domainSchId.value }).send()) || [];
|
||||
if (!formUuid.value && forms.value.length > 0) {
|
||||
formUuid.value = forms.value[0].formUuid;
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "表单清单加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTarget() {
|
||||
if (!formUuid.value) {
|
||||
owner.value = null;
|
||||
grants.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const [own, rows] = await Promise.all([
|
||||
getOwner(formUuid.value, domainSchId.value).send(),
|
||||
listGrants(formUuid.value, domainSchId.value).send()
|
||||
]);
|
||||
owner.value = own || null;
|
||||
grants.value = rows || [];
|
||||
ownerDraft.appKey = owner.value?.ownerAppKey || "";
|
||||
ownerDraft.error = "";
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "授权信息加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOwner() {
|
||||
const appKey = ownerDraft.appKey.trim();
|
||||
if (!formUuid.value) {
|
||||
ownerDraft.error = "先选择表单";
|
||||
return;
|
||||
}
|
||||
if (!appKey) {
|
||||
ownerDraft.error = "ownerAppKey 必填(UserBase 应用注册分配的 appKey)";
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
owner.value = await setOwner(formUuid.value, appKey, domainSchId.value).send();
|
||||
Message.success("已绑定资源拥有者");
|
||||
ownerDraft.error = "";
|
||||
} catch (e: any) {
|
||||
// 未注册 / 平台级表单非超管 / 他校表单,三条拒绝都在后端,这里按 msg 原样亮出来
|
||||
ownerDraft.error = e?.message || "绑定失败";
|
||||
Message.error(ownerDraft.error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openGrant() {
|
||||
if (!formUuid.value) {
|
||||
Message.warning("先选择表单");
|
||||
return;
|
||||
}
|
||||
draft.visible = true;
|
||||
draft.appKey = "";
|
||||
draft.schoolId = domainSchId.value;
|
||||
draft.read = true;
|
||||
draft.subscribe = false;
|
||||
draft.error = "";
|
||||
}
|
||||
|
||||
/** 重申=用同一组三元键再授权一次(开关默认沿用它被撤销前的形态) */
|
||||
function openRegrant(row: GrantView) {
|
||||
draft.visible = true;
|
||||
draft.appKey = row.consumerAppKey;
|
||||
draft.schoolId = row.consumerSchoolId;
|
||||
draft.read = row.readAllowed;
|
||||
draft.subscribe = row.subscribeAllowed;
|
||||
draft.error = "";
|
||||
}
|
||||
|
||||
async function submitGrant() {
|
||||
const appKey = draft.appKey.trim();
|
||||
if (!appKey) {
|
||||
draft.error = "consumerAppKey 必填(平台 appKey,不是快应用 code)";
|
||||
return;
|
||||
}
|
||||
if (!draft.schoolId && draft.schoolId !== 0) {
|
||||
draft.error = "消费租户必填(0=平台级不是合法消费域)";
|
||||
return;
|
||||
}
|
||||
if (!draft.read && !draft.subscribe) {
|
||||
draft.error = "读与订阅至少开一把(默认拒绝,不给一个全关的空授权)";
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const saved = await saveGrant(
|
||||
{
|
||||
formUuid: formUuid.value,
|
||||
consumerSchoolId: draft.schoolId as number,
|
||||
consumerAppKey: appKey,
|
||||
readAllowed: draft.read,
|
||||
subscribeAllowed: draft.subscribe
|
||||
},
|
||||
domainSchId.value
|
||||
).send();
|
||||
grants.value = upsertGrant(grants.value, saved);
|
||||
draft.visible = false;
|
||||
Message.success(`已授权 ${saved.consumerAppKey}(v${saved.grantVersion})`);
|
||||
} catch (e: any) {
|
||||
draft.error = e?.message || "授权失败";
|
||||
Message.error(draft.error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRevoke(row: GrantView) {
|
||||
Modal.confirm({
|
||||
title: "撤销授权",
|
||||
content: `确定撤销「${row.consumerAppKey}」在租户 ${row.consumerSchoolId} 上对这张表的授权?`
|
||||
+ "撤后目录里不再出现、读取与发布检查即失败;已生成的任务不被改写。",
|
||||
okText: "撤销",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
busy.value = true;
|
||||
try {
|
||||
const saved = await revokeGrant(
|
||||
{
|
||||
formUuid: row.formUuid,
|
||||
consumerSchoolId: row.consumerSchoolId,
|
||||
consumerAppKey: row.consumerAppKey
|
||||
},
|
||||
domainSchId.value
|
||||
).send();
|
||||
grants.value = upsertGrant(grants.value, saved);
|
||||
Message.success("已撤销");
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "撤销失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 后端写操作回单行(与两表/快应用那组端点同一形态),这里按三元键就地替换 */
|
||||
function upsertGrant(rows: GrantView[], row: GrantView): GrantView[] {
|
||||
const key = (g: GrantView) => `${g.consumerSchoolId}::${g.consumerAppKey}`;
|
||||
const next = rows.filter((g) => key(g) !== key(row));
|
||||
return [...next, row];
|
||||
}
|
||||
|
||||
/** 展开表单下拉时才刷清单(进页不抢这一次请求;新建的表当场就能选到) */
|
||||
function onFormsDropdownOpen(open: boolean) {
|
||||
if (open) {
|
||||
loadForms();
|
||||
}
|
||||
}
|
||||
|
||||
watch(formUuid, loadTarget);
|
||||
watch(domainSchId, async () => {
|
||||
await loadForms();
|
||||
await loadTarget();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadForms();
|
||||
await loadTarget();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-access">
|
||||
<div class="toolbar">
|
||||
<Select v-model="formUuid" style="width: 320px" placeholder="选择表单" :disabled="loading" filterable
|
||||
@on-open-change="onFormsDropdownOpen">
|
||||
<Option v-for="f in forms" :key="f.formUuid" :value="f.formUuid">
|
||||
{{ f.name }}({{ f.appCode }}/{{ f.code }})
|
||||
</Option>
|
||||
</Select>
|
||||
<template v-if="store.isSuperAdmin">
|
||||
<InputNumber v-model="targetSchId" :min="0" placeholder="目标域 schId(0=平台级)" style="width: 200px" />
|
||||
</template>
|
||||
<span class="tip">
|
||||
这里管的是应用(平台 appKey + 消费租户)能不能读这张表,不是给人配权限
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Alert v-if="!selected" type="info" show-icon class="block">
|
||||
先在上方选一张表单。没绑拥有者也没发授权的表,对外一律不可读(默认拒绝)。
|
||||
</Alert>
|
||||
|
||||
<div v-else class="block">
|
||||
<div class="section-title">资源拥有者</div>
|
||||
<div class="owner-row">
|
||||
<Input v-model="ownerDraft.appKey" placeholder="owner 应用的平台 appKey,如 100018" style="width: 280px"
|
||||
:disabled="busy" />
|
||||
<Button type="primary" :loading="busy" @click="submitOwner">
|
||||
{{ owner ? "换绑" : "绑定" }}
|
||||
</Button>
|
||||
<span class="tip">
|
||||
当前:{{ owner ? `${owner.ownerAppKey}(归属校 ${owner.formSchoolId})` : "未绑定" }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="store.isSuperAdmin && selected.schId === 0" class="tip">
|
||||
平台级表单的拥有者绑定只对超管开放;owner 只能读,订阅仍要给具体租户开行授权。
|
||||
</div>
|
||||
<Alert v-if="ownerDraft.error" type="warning" show-icon class="block">{{ ownerDraft.error }}</Alert>
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="block">
|
||||
<div class="section-title">
|
||||
消费授权
|
||||
<Button size="small" type="primary" :disabled="busy" @click="openGrant" class="add-btn">新增授权</Button>
|
||||
</div>
|
||||
<Table :columns="grantColumns" :data="grants" :loading="loading" size="small" :border="false" />
|
||||
<div class="tip">
|
||||
读=目录/schema/答卷;订阅=答卷变更事件投递。两把开关独立,撤销只是关开关(行留着,版本号进位)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal v-model="draft.visible" title="新增/重申消费授权" :mask-closable="false" :width="520">
|
||||
<Form :label-width="110">
|
||||
<FormItem label="消费应用 appKey" required>
|
||||
<Input v-model="draft.appKey" placeholder="UserBase 应用注册分配的 appKey(不是快应用 code)" />
|
||||
</FormItem>
|
||||
<FormItem label="消费租户" required>
|
||||
<InputNumber v-model="draft.schoolId" :min="1"
|
||||
:disabled="!store.isSuperAdmin" style="width: 200px" />
|
||||
<div class="tip">校级管理员固定为本校;超管可代某校授权。0 不是合法消费域。</div>
|
||||
</FormItem>
|
||||
<FormItem label="能力">
|
||||
<Checkbox v-model="draft.read">读(目录/schema/答卷)</Checkbox>
|
||||
<Checkbox v-model="draft.subscribe">订阅(答卷变更事件)</Checkbox>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<Alert v-if="draft.error" type="warning" show-icon>{{ draft.error }}</Alert>
|
||||
<template #footer>
|
||||
<Button @click="draft.visible = false">取消</Button>
|
||||
<Button type="primary" :loading="busy" @click="submitGrant">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-access .toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-access .tip {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-access .block {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-access .section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-access .owner-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-access .action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-access .text-error {
|
||||
color: #ed4014;
|
||||
}
|
||||
</style>
|
||||
@@ -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——中心上传接口直接返回这三件,
|
||||
* 前端只存返回值,绝不把 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 { FileValue } from "@/types/formSchema";
|
||||
import { Upload } from "view-ui-plus";
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
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 相对路径 */
|
||||
const fileBase = `${conf.serviceHost || ""}/upload`;
|
||||
|
||||
@@ -57,7 +61,7 @@ async function handleUpload(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>(
|
||||
"/api/form/upload",
|
||||
uploadUrl.value,
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
).send();
|
||||
|
||||
@@ -4,12 +4,19 @@
|
||||
* 字段级 accept/maxSize/maxFiles 只能往更严收,不能往宽放)。
|
||||
* 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 { FileValue } from "@/types/formSchema";
|
||||
import { Upload } from "view-ui-plus";
|
||||
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 & {
|
||||
modelValue?: FileValue[];
|
||||
readonly?: boolean;
|
||||
@@ -54,7 +61,7 @@ async function handleUpload(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>(
|
||||
"/api/form/upload",
|
||||
uploadUrl.value,
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
).send();
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { formApi } from "@/api/module/form";
|
||||
import type { FormView, StatusDefView } from "@/types/form";
|
||||
import { AUDIENCE_OPTIONS, FORM_STATUS } from "@/types/form";
|
||||
import type { AudienceCode, FormView, StatusDefView } from "@/types/form";
|
||||
import { AUDIENCE, AUDIENCE_OPTIONS, FORM_STATUS } from "@/types/form";
|
||||
import { MAX_NAME_LEN, CODE_PATTERN } from "@/types/formSchema";
|
||||
|
||||
const props = defineProps<{ form: FormView | null }>();
|
||||
@@ -23,30 +23,39 @@ const emit = defineEmits<{ (e: "saved", form: FormView): void }>();
|
||||
const visible = ref(false);
|
||||
const saving = ref(false);
|
||||
const model = reactive<{
|
||||
audience: number;
|
||||
audience: string[];
|
||||
fillLimit: number;
|
||||
deadline: string;
|
||||
answerNoEnabled: number;
|
||||
frozen: number;
|
||||
anonCookieMinutes: number;
|
||||
groupTag: string;
|
||||
}>({
|
||||
audience: AUDIENCE_OPTIONS[0].value,
|
||||
audience: [AUDIENCE.STUDENT],
|
||||
fillLimit: 0,
|
||||
deadline: "",
|
||||
answerNoEnabled: 0,
|
||||
frozen: 0,
|
||||
anonCookieMinutes: 10,
|
||||
groupTag: ""
|
||||
});
|
||||
const statusDefs = ref<StatusDefView[]>([]);
|
||||
const newStatus = reactive({ code: "", label: "" });
|
||||
|
||||
/** 含匿名时后端强制 fill_limit=0(§3.2/作废-11:匿名无 limit_key)——前端同判据提前挡,不给点了才报错 */
|
||||
const anonymousChosen = computed(() => model.audience.includes(AUDIENCE.ANONYMOUS));
|
||||
|
||||
watch(
|
||||
() => [visible.value, props.form?.formUuid] as const,
|
||||
() => {
|
||||
const f = props.form;
|
||||
if (!visible.value || !f) return;
|
||||
model.audience = f.audience ?? AUDIENCE_OPTIONS[0].value;
|
||||
model.audience = f.audience?.length ? [...f.audience] : [AUDIENCE.STUDENT];
|
||||
model.fillLimit = f.fillLimit ?? 0;
|
||||
model.deadline = f.deadline ? String(f.deadline).slice(0, 10) : "";
|
||||
model.answerNoEnabled = f.answerNoEnabled ?? 0;
|
||||
model.frozen = f.frozen ?? 0;
|
||||
model.anonCookieMinutes = f.anonCookieMinutes ?? 10;
|
||||
model.groupTag = f.groupTag || "";
|
||||
statusDefs.value = (f.statusDefs || []).map((d) => ({ ...d }));
|
||||
newStatus.code = "";
|
||||
@@ -110,15 +119,25 @@ async function submit() {
|
||||
Message.error(`开启报名号要求 code ≤49 位(当前 ${f.code.length})`);
|
||||
return;
|
||||
}
|
||||
if (!model.audience.length) {
|
||||
Message.error("面向人群至少要选一项(空集合等于谁都不能填)");
|
||||
return;
|
||||
}
|
||||
if (anonymousChosen.value && model.fillLimit === 1) {
|
||||
Message.error("含「匿名可填」的表单不支持限次(匿名没有可比对的身份),请把限次改回不限");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await formApi
|
||||
.config({
|
||||
uuid: f.formUuid,
|
||||
audience: model.audience,
|
||||
audience: model.audience as AudienceCode[],
|
||||
fillLimit: model.fillLimit,
|
||||
deadline: model.deadline,
|
||||
answerNoEnabled: model.answerNoEnabled,
|
||||
frozen: model.frozen,
|
||||
anonCookieMinutes: model.anonCookieMinutes,
|
||||
groupTag: model.groupTag
|
||||
})
|
||||
.send();
|
||||
@@ -184,21 +203,24 @@ defineExpose({ open });
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">面向人群</span>
|
||||
<RadioGroup v-model="model.audience">
|
||||
<Radio v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Radio>
|
||||
</RadioGroup>
|
||||
<span class="cfg-note">决定数据范围矩阵落在哪一列(契约 §8)</span>
|
||||
<CheckboxGroup v-model="model.audience">
|
||||
<Checkbox v-for="a in AUDIENCE_OPTIONS" :key="a.value" :label="a.value">{{ a.label }}</Checkbox>
|
||||
</CheckboxGroup>
|
||||
<span class="cfg-note">
|
||||
可多选(契约 v2 C1)。填报资格=登录身份类型落在集合内;「匿名可填」只对无会话的
|
||||
<code>/anon</code> 通道生效,登录用户不会因此多出入口
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="cfg-row">
|
||||
<span class="cfg-label">限次</span>
|
||||
<RadioGroup v-model="model.fillLimit">
|
||||
<Radio :label="0">不限次数</Radio>
|
||||
<Radio :label="1">每人限填一次</Radio>
|
||||
<Radio :label="1" :disabled="anonymousChosen">每人限填一次</Radio>
|
||||
</RadioGroup>
|
||||
<span class="cfg-note">
|
||||
「每人」按提交主体判:管理员代填指定了被代填人时按那个人限,否则按提交人限;
|
||||
软删答卷会释放名额(契约 §5)
|
||||
「每人」按提交主体判:管理员代录指定了被记录人时按那个人限,否则按提交人限;软删答卷会释放名额。
|
||||
<template v-if="anonymousChosen">含匿名人群时不可限次(匿名无 limit_key,防重只有 cookie)。</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -217,6 +239,21 @@ defineExpose({ open });
|
||||
</span>
|
||||
</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">
|
||||
<span class="cfg-label">分组标签</span>
|
||||
<Input v-model="model.groupTag" :maxlength="64" placeholder="可空,用于列表筛选归类" style="width: 220px" />
|
||||
|
||||
+13
-2
@@ -69,6 +69,16 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/admin/AppSettings.vue"),
|
||||
meta: { title: "应用设置" },
|
||||
},
|
||||
{
|
||||
// 差异对账(TASK_3 独占页):建任务/进度/结果/取消/单答卷重放。
|
||||
// 刻意不做成应用设置的第六个 tab——那一页由 TASK_1 独占(授权 tab),本轮不碰它。
|
||||
// UserBase 侧还没登记这条模块行(本任务禁改 UserBase/**),所以它暂时不进左导航,
|
||||
// 直达地址 #/admin/sync 可用;登记模块行时 path 必须与本行逐字一致(约束①)。
|
||||
path: "/admin/sync",
|
||||
name: "fc_admin_sync",
|
||||
component: () => import("@/views/admin/SyncManage.vue"),
|
||||
meta: { title: "差异对账" },
|
||||
},
|
||||
{
|
||||
// 旧字典管理地址(六行版模块行时代的 path):只保留重定向,不再单独成页(T10 方案 §6.1)。
|
||||
// 外部残留链接落到这里=进应用设置的字典 tab,避免出现"两条都能进、只有一条有守卫"的口径。
|
||||
@@ -130,8 +140,9 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { title: "关于我的", parentPath: "/my" },
|
||||
},
|
||||
{
|
||||
// P91 起第三视图;P95 W4 换数据源=GET /api/form/my/fillable(中心按三层模型解析的填报入口,
|
||||
// 不再从会话菜单树挑 /f/ 行)。子页面路由不占模块行、不进导航(手册 §2.2 注记),入口在 My 容器顶部 Tab。
|
||||
// P91 起第三视图;P95 W4 换数据源=GET /api/form/my/fillable,契约 v2 C2 定行形状
|
||||
// (中心按 audience 门解析填报入口,不再从会话菜单树挑 /f/ 行)。
|
||||
// 子页面路由不占模块行、不进导航(手册 §2.2 注记),入口在 My 容器顶部 Tab。
|
||||
path: "/my/fillable",
|
||||
name: "fc_my_fillable",
|
||||
component: () => import("@/views/My.vue"),
|
||||
|
||||
+46
-14
@@ -1,25 +1,34 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { IModule } from "@/types/Basics";
|
||||
import type { Capabilities, FillableForm, ManagedForm } from "@/types/data";
|
||||
|
||||
/**
|
||||
* 全局 store(notice/front 基线形态)。
|
||||
* T6 起开第一个业务切片:会话用户本体——字典管理页要按它判「能不能进平台级上下文维护」
|
||||
* (UserBase 侧只有 type==1 超管可写 sch_id=0 的行,方案 §6.5;后端 FormAdminGuard 同判)。
|
||||
* 这里存的是**展示与门禁提示用的会话身份**,不是权限来源:接口侧一律会话自取,
|
||||
* 前端藏掉按钮不等于授权(契约 §7 身份行)。
|
||||
*
|
||||
* 存两类东西:
|
||||
* 1. **会话用户本体**——展示与门禁提示用(字典页判"能不能进平台级上下文维护")。
|
||||
* 这里存的是展示位,**不是权限来源**:接口侧一律会话自取,前端藏掉按钮不等于授权(契约 §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", {
|
||||
state: () => ({
|
||||
userRole: "school" as string,
|
||||
/** /api/base/GetUserInfo 原样下发的会话用户(未拿到时为 null) */
|
||||
user: null as Record<string, any> | null,
|
||||
/**
|
||||
* /api/base/GetUserMod 的<b>原始模块树</b>(T8 起留一份给按钮级判定用)。
|
||||
* 必须是原始树而不是 `utils/menuNav.ts` 过滤后的导航树——导航按规则跳过了
|
||||
* path 空的权限锚点行,而按钮位(快应用的"导出")正是这类行(方案 §6.2)。
|
||||
* 判定函数在 `utils/modulePerms.ts`;这里只是避免同一棵树重复发一次请求。
|
||||
*/
|
||||
modules: [] as IModule[]
|
||||
/** 三支路之一:可填清单(后端按 audience 门筛过,入集即可填) */
|
||||
fillable: [] as FillableForm[],
|
||||
/** 三支路之二:我管理的清单(创建人 ∪ 表单管理员) */
|
||||
managed: [] as ManagedForm[],
|
||||
/** 三支路之三:能力位("系统设置"入口的唯一数据源) */
|
||||
capabilities: { schoolAdmin: false } as Capabilities
|
||||
}),
|
||||
getters: {
|
||||
/** 超管(UserBase UserTypeEnum.SUPER_ADMIN=1):唯一能操作平台级行的身份 */
|
||||
@@ -33,6 +42,10 @@ export const useDefaultStore = defineStore("defaultStore", {
|
||||
/** 会话用户 id:判断"这条答卷是不是我提交的"(编辑/删除入口显隐)用 */
|
||||
sessionUserId(state): number | null {
|
||||
return state.user?.id ?? null;
|
||||
},
|
||||
/** "我管理的表单"键集:导出/详情管理按钮的显隐判据(ALL 档的清单形态) */
|
||||
managedKeys(state): Set<string> {
|
||||
return new Set(state.managed.map((r) => rowKey(r.appCode, r.code)));
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
@@ -42,8 +55,27 @@ export const useDefaultStore = defineStore("defaultStore", {
|
||||
setUser(user: Record<string, any> | null) {
|
||||
this.user = user || null;
|
||||
},
|
||||
setModules(modules: IModule[] | null) {
|
||||
this.modules = modules || [];
|
||||
setFillable(rows: FillableForm[] | null) {
|
||||
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);
|
||||
}
|
||||
|
||||
+33
-1
@@ -18,7 +18,8 @@ export interface ResolvedForm {
|
||||
appCode: string;
|
||||
name: string;
|
||||
version: number;
|
||||
audience: number;
|
||||
/** 面向人群(契约 v2 C1 数组形态;填报页只用于展示与预提示,资格判定在后端 audience 门) */
|
||||
audience: string[];
|
||||
fillLimit: number;
|
||||
answerNoEnabled: number;
|
||||
deadline: string | null;
|
||||
@@ -33,9 +34,40 @@ export interface SubmitRequest {
|
||||
appCode?: string;
|
||||
idempotencyKey: string;
|
||||
subjectId?: number;
|
||||
/**
|
||||
* 可信提交上下文令牌(answer-delivery-v2 §6.3,TASK_2 A.3)。
|
||||
* 只有被业务应用(办事大厅)嵌进来的填报页才带它:后端据此按**签发时钉死的
|
||||
* formUuid+version** 取 schema(不回落到当前发布版)、复核会话用户/学校,
|
||||
* 并在答卷事务里把这份提交原子关联回那次业务意图。
|
||||
* 它不是身份凭据——schId/submitterId 仍只由会话推导(V17 不变)。
|
||||
*/
|
||||
contextToken?: string;
|
||||
values: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* hosted 填报页元数据({@code GET /api/form/hosted/schema},TASK_2 A.3)。
|
||||
* 与 {@link ResolvedForm} 的字段大致同形,但刻意<b>不继承</b>它:这条链上没有"当前发布版"
|
||||
* (version 由上下文令牌钉死),也没有状态定义编辑入口(statusDefs 恒不给,页面按默认态渲染),
|
||||
* 用 extends 会把"后端其实没返回的字段"写成必填,是类型上的假话。
|
||||
*/
|
||||
export interface HostedForm {
|
||||
formUuid: string;
|
||||
code: string;
|
||||
appCode: string;
|
||||
name: string;
|
||||
version: number;
|
||||
audience: string[];
|
||||
fillLimit: number;
|
||||
deadline: string | null;
|
||||
deadlineReached: boolean;
|
||||
groups: any;
|
||||
/** 上下文过期时刻(到点必须回办事页重新发起,不给旧页面继续提交的机会) */
|
||||
expiresAt: string | null;
|
||||
/** 服务端锁定的父页面精确 origin:被嵌页 postMessage 的唯一允许目标 */
|
||||
embedOrigin: string;
|
||||
}
|
||||
|
||||
/** 提交响应(契约 §5:{answerId, answerNo};幂等命中时 replayed=true) */
|
||||
export interface SubmitView {
|
||||
answerId: string;
|
||||
|
||||
+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 三层模型逐张表算出"这个人在这张表上算哪一层",
|
||||
* NONE 的行不下发。`canFill` 与 `myTier` 是两件事——`canFill=false` 且 `myTier="MANAGE"`
|
||||
* 就是"教师×面向学生"那种只能代填的表:「可填写」列表把它筛掉,但快应用上下文的
|
||||
* 数据管理 tab 仍要它(AppContext.vue 按 myTier 决定 tab 显隐)。</p>
|
||||
* <p>这不是答卷行,是**表单入口**行:后端按 audience 门逐张表判"这个人的身份类型在不在面向人群里",
|
||||
* 不在就不下发。<b>入集即等价于可填</b>——原 P95 的 `canFill`/`myTier` 两字段随三层模型出清
|
||||
* (§19 作废-3),不再有"进了列表但不能自填、只给代填"的中间态。</p>
|
||||
*/
|
||||
export interface FillableForm {
|
||||
code: string;
|
||||
name: string;
|
||||
appCode: string;
|
||||
/** 面向人群:1 学生 / 2 教师(与定义域 FormView 同一个数字口径) */
|
||||
audience: number;
|
||||
/** 面向人群(C1 数组形态) */
|
||||
audience: string[];
|
||||
/** yyyy-MM-dd HH:mm:ss;null=不限截止 */
|
||||
deadline: string | null;
|
||||
/** 限填次数:0 不限 / 1 限一次 */
|
||||
fillLimit: number;
|
||||
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> = {
|
||||
ALL: "全部",
|
||||
TEACHING_CLASS: "任教班级",
|
||||
SELF_SUBMITTED: "仅本人提交",
|
||||
ABOUT_ME: "关于我"
|
||||
SELF: "我相关"
|
||||
};
|
||||
|
||||
/**
|
||||
* 「我管理的表单」的行(契约 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 {
|
||||
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
|
||||
} as const;
|
||||
|
||||
/** 面向人群(后端 FormEnums.Audience):1 面向学生 / 2 面向教师 */
|
||||
/**
|
||||
* 面向人群(契约 v2 C1 多选):后端列值=逗号分隔枚举码,JSON 面一律字符串数组。
|
||||
* 闭集三值;ANONYMOUS 只对匿名通道生效(登录会话不会因它拿到填报入口)。
|
||||
*/
|
||||
export const AUDIENCE = {
|
||||
STUDENT: 1,
|
||||
TEACHER: 2
|
||||
STUDENT: "STUDENT",
|
||||
TEACHER: "TEACHER",
|
||||
ANONYMOUS: "ANONYMOUS"
|
||||
} 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.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 正文) */
|
||||
export interface FormView {
|
||||
formUuid: string;
|
||||
@@ -36,11 +56,16 @@ export interface FormView {
|
||||
currentVersion: number | null;
|
||||
/** 待发布的草稿版本号,null=无草稿(P20:UI 必须区分这两根指针) */
|
||||
draftVersion: number | null;
|
||||
audience: number;
|
||||
/** 面向人群(C1 数组形态) */
|
||||
audience: string[];
|
||||
fillLimit: number;
|
||||
/** yyyy-MM-dd HH:mm:ss,null=不限 */
|
||||
deadline: string | null;
|
||||
answerNoEnabled: number;
|
||||
/** 提交后禁编辑(契约 v2 §4.2):1=本人编辑与管理员编辑同关 */
|
||||
frozen: number;
|
||||
/** 匿名 cookie 防重窗口分钟数(C7) */
|
||||
anonCookieMinutes: number;
|
||||
groupTag: string | null;
|
||||
statusDefs: StatusDefView[] | null;
|
||||
createTime: string;
|
||||
@@ -104,11 +129,16 @@ export interface FormSaveRequest {
|
||||
|
||||
export interface FormConfigRequest {
|
||||
uuid: string;
|
||||
audience?: number;
|
||||
/** 面向人群(C1 数组;缺省=不改这一项) */
|
||||
audience?: AudienceCode[];
|
||||
fillLimit?: number;
|
||||
/** yyyy-MM-dd(后端补 23:59:59)或 yyyy-MM-dd HH:mm:ss;空串=不限 */
|
||||
deadline?: string | null;
|
||||
answerNoEnabled?: number;
|
||||
/** 提交后禁编辑(契约 v2 §4.2) */
|
||||
frozen?: number;
|
||||
/** 匿名 cookie 防重窗口分钟数(C7,1~1440) */
|
||||
anonCookieMinutes?: number;
|
||||
groupTag?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,3 +111,45 @@ export function listenFromParent(handler: (message: EmbedMessage, origin: string
|
||||
window.addEventListener("message", listener);
|
||||
return () => window.removeEventListener("message", listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* hosted 通道(answer-delivery-v2 §6.4,TASK_2 A.4):向**服务端锁定的那一个精确 origin** 上报。
|
||||
*
|
||||
* <p>与上面 {@link postToParent} 的区别是刻意的,也是这条链的安全边界所在:</p>
|
||||
* <ul>
|
||||
* <li>目标 origin 必须由后端(签发上下文时从注册信息取)给,<b>不回落 document.referrer</b>——
|
||||
* referrer 是页面可影响的输入,拿它当"能收到我提交回执的人"等于把成功消息投递给任意父页;</li>
|
||||
* <li>比较是<b>整串 origin 相等</b>(scheme+host+port 逐位),不走"同 hostname 即可"的宽松分支:
|
||||
* 同域名不同端口/不同 scheme 在浏览器眼里是两个可Origin,把 http 的消息交给 https 的父页
|
||||
* 或反过来,都是真实存在的部署形态(本地跨端口、生产灰度站点);</li>
|
||||
* <li>{@code proto/v} 由本函数强制带上,接收方逐字核对(父页侧同样核对 event.source)。</li>
|
||||
* </ul>
|
||||
* 不满足前两条时一律**不发**并在控制台点名原因:宁可父页收不到"可确认"信号(用户回到办事页
|
||||
* 由服务端收据确认,那才是权威),也不能把提交回执发错人。
|
||||
*/
|
||||
export function postToHostedParent(message: Omit<EmbedMessage, "proto" | "v">, expectedOrigin: string): boolean {
|
||||
if (window.self === window.top) {
|
||||
return false;
|
||||
}
|
||||
const target = (expectedOrigin || "").trim();
|
||||
if (!target) {
|
||||
console.warn("[embed] hosted 通道缺少服务端锁定的 embedOrigin,已拒发");
|
||||
return false;
|
||||
}
|
||||
let normalized: string;
|
||||
try {
|
||||
const parsed = new URL(target);
|
||||
normalized = parsed.origin;
|
||||
} catch {
|
||||
console.warn("[embed] embedOrigin 不是合法绝对 origin,已拒发:", target);
|
||||
return false;
|
||||
}
|
||||
if (normalized !== target.replace(/\/+$/, "")) {
|
||||
// 只接受"就是 origin 本身"的写法(带 path/query 的 URL 说明配置被拼接过,不是可信坐标)
|
||||
console.warn("[embed] embedOrigin 必须是精确 origin(scheme+host+port),已拒发:", target);
|
||||
return false;
|
||||
}
|
||||
const fullMessage: EmbedMessage = { proto: "fc", v: 1, type: message.type, ...message };
|
||||
window.parent.postMessage(fullMessage, normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
+10
-70
@@ -1,39 +1,24 @@
|
||||
/**
|
||||
* 左导航渲染模型(GetUserMod 模块树 → 可渲染菜单树)。
|
||||
* 左导航的渲染模型(契约 v2 §2.4/D5 之后的形态)。
|
||||
*
|
||||
* 纯函数、零运行时依赖(只 import type),解析器由调用方注入(resolveMenuPage)——
|
||||
* 便于在冒烟脚本里直接跑断言,不必起浏览器。
|
||||
*
|
||||
* 三条渲染规则(执行手册 §2.2「path 空行=纯权限锚点」的前端一侧):
|
||||
* ① <b>跳过判据取平台原始 path 是否为空</b>,不取解析结果:权限锚点(快应用面的「导入/导出」
|
||||
* 按钮行)本就不进导航,靠名字/modId 猜出路由再显示反而是假象——中心应用自手册 §2.2 v1.5
|
||||
* 起没有 path 空行,这条规则留着是给快应用面用的;
|
||||
* ② 有子节点的分组行即使自身 path 为空也保留为<b>分组头</b>(头本身不可点),
|
||||
* 子节点按 ① 过滤后全空则整个分组不渲染;
|
||||
* ③ 原始 path 非空但解析不出已注册路由的行<b>照常显示</b>,点击时提示配置错误——
|
||||
* 配置问题要暴露成可见告警,不能静默少一条菜单(与 utils/menuRoute.ts 的告警口径一致)。
|
||||
* <p>原 `buildNavTree(模块树 → 导航树)` 与它的三条规则(path 空=权限锚点跳过、分组头保留、
|
||||
* 配错照常显示)随 **UserBase 模块行整条退役** 一起删除(§19 作废-10)——导航不再从平台树生成,
|
||||
* 而由 `App.vue` 按后端三支路(可填 / 我管理的 / 能力位)直接构造 `NavNode`。
|
||||
* 本文件因此只留"导航数据结构 + 高亮/面包屑/默认跳转"这三件与权限无关的事。</p>
|
||||
*/
|
||||
|
||||
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 {
|
||||
id: number;
|
||||
/** 自解析形态下 modId 就是目标 path(layout 只用它做高亮对照,不参与任何判定) */
|
||||
modId: number | string;
|
||||
name: string;
|
||||
/** 平台配置的图标名(如 list),空串由 layout 兜底 icon-list */
|
||||
/** 平台配置的图标名,空由 layout 兜底 icon-list */
|
||||
icon: string;
|
||||
/** 解析后的内部路由 path(外链为完整 URL);解析不出时为空串 */
|
||||
/** 内部路由 path(外链为完整 URL);为空表示这一项没有可跳转页面 */
|
||||
page: string;
|
||||
external: boolean;
|
||||
/** 平台原始配置串,仅排障用 */
|
||||
/** 原始配置串(自解析形态下与 page 同值,仅排障对照用) */
|
||||
raw: string;
|
||||
/** 分组头(有可渲染子节点)为 true,本身不参与高亮/跳转 */
|
||||
group: boolean;
|
||||
@@ -50,51 +35,6 @@ export interface FlatNav {
|
||||
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[] {
|
||||
const out: FlatNav[] = [];
|
||||
@@ -126,7 +66,7 @@ export function countNavItems(tree: NavNode[]): number {
|
||||
|
||||
/**
|
||||
* 「单模块独占」= 只有一个可跳转菜单项且无分组:隐藏左导航、内容区铺满。
|
||||
* 无授权用户只余「我的填写」一行时即为此形态。
|
||||
* 三支路都空的人(例如只被授了一张表当填报人、`/my` 一项)即为此形态。
|
||||
*/
|
||||
export function isSingleNav(tree: NavNode[]): boolean {
|
||||
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">
|
||||
/**
|
||||
* 快应用上下文 /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/fillable?appCode=<目标应用>`——中心按
|
||||
* `audience × 身份 × 管理员两表` 解析出的填报入口。**不再吃该应用的模块树**,
|
||||
* 发一张表单不需要超管加一条 `/f/{code}` 模块行(P95 §0 病根第 1 条就此拆掉)。
|
||||
* - **导航壳按身份渲染**(三档写死,不再由"树里有什么"决定):
|
||||
* · 填报 tab <b>恒显</b>:`canFill=true` 的行;0 行=空态;恰 1 行且地址没带子视图段=
|
||||
* <b>单表单直落</b>形态保留(内容区直接给这张表的填报入口,不叠导航);
|
||||
* · 数据管理 tab:存在 `myTier∈{MANAGE, ADMIN}` 的行时显,`:codes` 喂接口结果;
|
||||
* · 统计 tab:存在 `myTier=ADMIN` 的行时显,codes 同上。
|
||||
* - **"范围以后端为准"这条红线不动**:tab 只决定画不画;进去能看哪些行仍由后端
|
||||
* (`DataScopeService` + `FormAdminGuard.requireDataView`)把关,前端不按角色再筛一遍(任务书 T8 红线)。
|
||||
* - **模块树仍然拉一次**,唯一用途=把原始树经 `modules` prop 下传给内嵌 Data.vue 判"导出"锚点
|
||||
* (P76:中心会话树里没有快应用的锚点行,拿错树永远判不中)。空树/取树失败=fail-closed 不画导出按钮,
|
||||
* <b>不再整页报"这个应用进不来"</b>——原"空树"告警与"树里没有 /f/ 行"的分支随这条依赖收敛而退役。
|
||||
* 一个壳承载全部形态,**壳零自有权限逻辑**——两视图的判定只看后端两把清单:
|
||||
* · `GET /api/form/my/managed?appCode=`(创建人 ∪ 表单管理员=该 app 域内的 ALL 档)
|
||||
* · `GET /api/form/my/fillable?appCode=`(audience 门筛过的可填清单,入集即可填)
|
||||
*
|
||||
* 两视图(§5.1 原文):
|
||||
* · <b>管理员视图</b>:数据列表 / 添加(代录,T18 接)/ 导入(T18 接)/ 删除 / 复制填报路径;
|
||||
* · <b>填报者视图</b>:只有「填报」tab。
|
||||
* 判定即"对这张 app 域表单是不是 ALL 档持有者"——原 P95 的 `myTier` 三档写死分组随三层模型出清
|
||||
* (§19 作废-9),原 `GetUserMod?appCode=` 拉目标应用树那一路也整条摘掉(它唯一剩余用途=导出锚点判定,
|
||||
* 现改 managed 成员判定,B13/P78 遗迹清零)。
|
||||
*
|
||||
* 仍保留的既有形态:只有一个可填项且地址没带子视图段时<b>单表单直落</b>(T8/P95 的入口语义,不叠导航)。
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { baseApi } from "@/api/module/base";
|
||||
import { listFillableForms } from "@/api/module/fill";
|
||||
import { listFillableForms, listManagedForms } from "@/api/module/fill";
|
||||
import { fillRouteOf } from "@/utils/fillLink";
|
||||
import Data from "@/views/admin/Data.vue";
|
||||
import Stats from "@/views/admin/Stats.vue";
|
||||
import type { FillableForm } from "@/types/data";
|
||||
import type { IModule } from "@/types/Basics";
|
||||
import ImportPanel from "@/components/admin/ImportPanel.vue";
|
||||
import type { FillableForm, ManagedForm } from "@/types/data";
|
||||
import { Message } from "view-ui-plus";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const appCode = computed(() => String(route.params.appCode ?? ""));
|
||||
/** 子视图段(/app/:appCode/:view):空=填报(单表单直落或多表单列表) */
|
||||
/** 子视图段(/app/:appCode/:view):空=按视图默认档(管理员→数据,填报者→填报) */
|
||||
const viewParam = computed(() => String(route.params.view ?? ""));
|
||||
|
||||
const fillRows = ref<FillableForm[]>([]);
|
||||
/** 目标应用的原始模块树:本页只把它交给内嵌 Data.vue 判导出锚点(P76),不再拿它算导航 */
|
||||
const rawTree = ref<IModule[]>([]);
|
||||
const managedRows = ref<ManagedForm[]>([]);
|
||||
const loading = ref(false);
|
||||
const errorText = ref("");
|
||||
|
||||
/** 填报 tab 的行:后端 canFill 判定(层②"只能代填"的行 canFill=false,不进这张列表) */
|
||||
const fillCards = computed(() => fillRows.value.filter((r) => r.canFill));
|
||||
/** 数据管理候选=层②∪层③;统计候选=层③(与后端 FillAccessService 的三个谓词一一对应) */
|
||||
const manageRows = computed(() => fillRows.value.filter((r) => r.myTier === "MANAGE" || r.myTier === "ADMIN"));
|
||||
const adminRows = computed(() => fillRows.value.filter((r) => r.myTier === "ADMIN"));
|
||||
/** ALL 档持有=该 app 域里有 managed 行——这是两视图唯一的分岔判据 */
|
||||
const isAdminView = computed(() => managedRows.value.length > 0);
|
||||
const fillCards = computed(() => fillRows.value);
|
||||
const adminCodes = computed(() => managedRows.value.map((r) => r.code));
|
||||
|
||||
function codesOf(rows: FillableForm[]): string[] {
|
||||
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 activeView = computed<"data" | "fill" | "unknown">(() => {
|
||||
const v = viewParam.value.trim();
|
||||
if (!v || v === "fill") return "fill";
|
||||
if (v === "data") return showDataTab.value ? "data" : "unknown";
|
||||
if (v === "stats") return showStatsTab.value ? "stats" : "unknown";
|
||||
if (v === "data") return isAdminView.value ? "data" : "unknown";
|
||||
if (v === "fill") return "fill";
|
||||
if (!v) return isAdminView.value ? "data" : "fill";
|
||||
return "unknown";
|
||||
});
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
if (activeView.value === "data") return "数据查看";
|
||||
if (activeView.value === "stats") return "统计";
|
||||
if (activeView.value === "fill") return "填报";
|
||||
return "应用表单";
|
||||
});
|
||||
/** 单表单直落:填报者视图下只有一个可填项且地址没带子视图段 */
|
||||
const isSingleForm = computed(() => !isAdminView.value && !viewParam.value.trim() && fillCards.value.length === 1);
|
||||
|
||||
const currentTitle = computed(() => (activeView.value === "data" ? "数据查看" : "填报"));
|
||||
|
||||
async function loadAll() {
|
||||
if (!appCode.value) {
|
||||
@@ -84,24 +62,23 @@ async function loadAll() {
|
||||
loading.value = true;
|
||||
errorText.value = "";
|
||||
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) {
|
||||
fillRows.value = [];
|
||||
errorText.value = e?.message || "可填清单取不到";
|
||||
managedRows.value = [];
|
||||
errorText.value = e?.message || "该应用的清单取不到";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// 导出锚点那棵树:失败或空都按"判不出锚点=不给导出按钮"处理(fail-closed),
|
||||
// 刻意不并进 errorText——它取不到不影响填报/数据/统计三档壳的成立
|
||||
try {
|
||||
rawTree.value = (await baseApi.getUserMod(appCode.value).send()) || [];
|
||||
} catch {
|
||||
rawTree.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** tab 点击:填报段回到应用入口(保留单表单直落判定),其余段按名分派 */
|
||||
function gotoView(seg: "" | "data" | "stats") {
|
||||
/** tab 点击:空段=按视图默认档,其余段按名分派 */
|
||||
function gotoView(seg: "" | "data" | "fill") {
|
||||
router.push(seg ? `/app/${appCode.value}/${seg}` : `/app/${appCode.value}/`);
|
||||
}
|
||||
|
||||
@@ -109,9 +86,40 @@ function goFill(row: FillableForm) {
|
||||
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 {
|
||||
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);
|
||||
@@ -124,33 +132,57 @@ watch(appCode, () => loadAll());
|
||||
<div class="app-head">
|
||||
<span class="app-tag">快应用</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<Alert v-if="errorText" show-icon type="error" class="region-tip">{{ errorText }}</Alert>
|
||||
|
||||
<!-- 导航壳:三档由身份决定(不是由模块树里有什么决定) -->
|
||||
<!-- 两视图导航:管理员才有「数据查看」档;填报档两种视图都在 -->
|
||||
<div class="light-nav">
|
||||
<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:;"
|
||||
@click="gotoView('')">填报({{ fillCards.length }})</a>
|
||||
<a v-if="showDataTab" :class="['nav-item', { active: activeView === 'data' }]" href="javascript:;"
|
||||
@click="gotoView('data')">数据管理</a>
|
||||
<a v-if="showStatsTab" :class="['nav-item', { active: activeView === 'stats' }]" href="javascript:;"
|
||||
@click="gotoView('stats')">统计</a>
|
||||
@click="gotoView('fill')">填报({{ fillCards.length }})</a>
|
||||
</span>
|
||||
<span class="toolbar-note">
|
||||
{{ isAdminView ? "你在该应用下有 ALL 档的表单(创建人或表单管理员)" : "你在这里只是填报人——没有可管理的数据" }}
|
||||
</span>
|
||||
<span class="toolbar-note">数据管理/统计两档按你在这些表单上的层级出现;导出按钮仍按该应用的模块树判</span>
|
||||
</div>
|
||||
|
||||
<h5 class="section-title">{{ currentTitle }}</h5>
|
||||
|
||||
<!-- 数据/统计视图复用中心管理端组件:定义管理权不在快应用,codes 来自可填清单(接口驱动,P95);
|
||||
embedded 让它只渲染本体,避免与本页白卡/内边距叠成两层;
|
||||
modules 把**本页拉到的那棵应用树**传进去——导出按钮的锚点判定吃它,不吃中心会话树(P76) -->
|
||||
<Data v-if="activeView === 'data'" context="app" :app-code="appCode" :codes="dataCodes"
|
||||
:modules="rawTree" embedded />
|
||||
<Stats v-else-if="activeView === 'stats'" context="app" :app-code="appCode" :codes="statsCodes" embedded />
|
||||
<!-- 管理员视图:数据列表(内嵌 Data,候选=managed 的 code)+ 复制填报路径
|
||||
+ 添加(代录)+ 导入(T18 接进这一档:同一条 submit 管线、同一 ALL 档守卫)。 -->
|
||||
<template v-if="activeView === 'data'">
|
||||
<div class="manage-rows">
|
||||
<div v-for="row in managedRows" :key="row.code" class="manage-row">
|
||||
<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-if="isSingleForm">
|
||||
@@ -158,12 +190,10 @@ watch(appCode, () => loadAll());
|
||||
单表单直落:该应用在你名下只有一个可填入口,内容区直接给它,不再叠一层导航。
|
||||
<br />填报目标:<code>{{ fillPathText(fillCards[0]) }}</code>
|
||||
</Alert>
|
||||
<div class="fill-mount">
|
||||
<div class="fill-hint">
|
||||
直接进入填报页:
|
||||
<a href="javascript:;" @click="goFill(fillCards[0])">{{ fillCards[0].name }}</a>
|
||||
(填报页本体是 T7 交付的 /f/:code,本页不复制一份)
|
||||
</div>
|
||||
<div class="fill-hint">
|
||||
直接进入填报页:
|
||||
<a href="javascript:;" @click="goFill(fillCards[0])">{{ fillCards[0].name }}</a>
|
||||
(填报页本体是 T7 交付的 /f/:code,本页不复制一份)
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="fillCards.length" class="fill-cards">
|
||||
@@ -177,22 +207,20 @@ watch(appCode, () => loadAll());
|
||||
</div>
|
||||
<Alert v-else show-icon type="warning" class="region-tip">
|
||||
「{{ appCode }}」这个应用在你名下当前没有可填报的表单。
|
||||
<br />表单发布后会按它的<b>面向人群</b>自动出现在这里(面向学生的表出现在学生账号下,
|
||||
面向教师的表出现在教师账号下)——与基础平台的模块行授权无关;
|
||||
如果你是教师且这里为空,说明该应用没有面向学生的表单可代填。
|
||||
<br />表单发布后按它的<b>面向人群</b>自动出现在这里(面向学生的表出现在学生账号下,
|
||||
面向教师的表出现在教师账号下)——与基础平台的模块行授权无关;匿名表单(ANONYMOUS)
|
||||
不下发给登录会话,持 URL 直达即可。
|
||||
</Alert>
|
||||
</template>
|
||||
<Alert v-else show-icon type="warning">
|
||||
这一段路径对你不可见(本页只有 填报 / 数据管理 / 统计 三档,后两档按你在这些表单上的层级出现)。
|
||||
<br />能直接地址进来但看不到档,说明层级不够——数据面与统计的把关在后端,不在这里。
|
||||
这一段路径对你不可见(本页只有 数据查看 / 填报 两档,前者要求该应用下有 ALL 档的表单)。
|
||||
<br />能直接地址进来但看不到档,说明不是这张表的 ALL 档持有者——把关在后端守卫,不在这里。
|
||||
</Alert>
|
||||
|
||||
<p class="toolbar-note foot-note">
|
||||
V26 前端侧三半句(P95 改版):①无导出授权不画导出按钮——快应用按该应用树里 path 空的"导出"
|
||||
锚点行判(本页把那棵树原样交给内嵌 Data.vue,判不中就不画,fail-closed);
|
||||
②导航不再由模块树生成,按身份三档写死,path 空节点自然不进导航;
|
||||
③学生身份只见填报档=层①只给 canFill、不给数据/统计档的直接结果。
|
||||
接口侧的拒绝是 T4 已验的另一半,<b>前端藏按钮/藏档不等于授权</b>。
|
||||
V72 前端侧三半句:①导出/管理按钮按「该表单 ALL 档」显隐(内嵌 Data.vue 自己读 managed 清单,
|
||||
宿主不再传权限树);②导航不再由模块树生成,按两支清单构造,path 空的权限锚点形态从此不存在;
|
||||
③接口侧的 403 是另一半,<b>前端藏按钮/藏档不等于授权</b>。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,10 +287,41 @@ watch(appCode, () => loadAll());
|
||||
border-left: 3px solid #2d8cf0;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.fill-mount {
|
||||
padding: 16px 0;
|
||||
.manage-rows {
|
||||
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 {
|
||||
padding: 12px 0;
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
+213
-30
@@ -1,25 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 填报页 /f/:code(T7 落地,替换 T5 占位)。
|
||||
* 填报页 /f/:code(T7 落地,替换 T5 占位;T15 换双探针通道引导)。
|
||||
*
|
||||
* 契约纪律:
|
||||
* 1. mode 闭集(page/embed),显式优先,self!=top 仅作未传时自动兜底(契约 §7);
|
||||
* 2. 身份一律 CAS 会话自取,任何身份注入被禁止(V17);
|
||||
* 2. 登录面身份一律 CAS 会话自取,任何身份注入被禁止(V17);
|
||||
* 3. 提交带客户端幂等键(uuid),命中返回 200+原 answerId(契约 §5);
|
||||
* 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 { fieldWidget } from "@/components/fields";
|
||||
import type { DesignField } from "@/types/formSchema";
|
||||
import type { ResolvedForm, MyAnswerItem } from "@/types/answer";
|
||||
import { resolveForm, submitAnswer, listMyAnswers, deleteMyAnswer, editMyAnswer } from "@/api/module/fill";
|
||||
import { startHeightReporting, reportSubmitResult, reportNeedLogin } from "@/utils/embed";
|
||||
import type { ResolvedForm, MyAnswerItem, SubmitRequest } from "@/types/answer";
|
||||
import { resolveForm, resolveHostedForm, submitAnswer, submitHostedAnswer, 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, postToHostedParent } from "@/utils/embed";
|
||||
import Utils from "@/utils";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
|
||||
const route = useRoute();
|
||||
const store = useDefaultStore();
|
||||
|
||||
// ==================== mode 闭集(契约 §7) ====================
|
||||
|
||||
@@ -46,25 +54,127 @@ const form = ref<ResolvedForm | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
/** 本次填报走的通道(null=还没判出来);提交、上传、"我的提交"段都按它分派 */
|
||||
type FillChannel = "session" | "anon" | "hosted";
|
||||
const channel = ref<FillChannel | null>(null);
|
||||
|
||||
/**
|
||||
* 可信提交上下文令牌(?ctx=,只从地址栏读一次;answer-delivery-v2 §6.3,TASK_2 A.3)。
|
||||
* 带它时:schema 按令牌<b>钉住的那一版</b>取(不回落当前发布版)、提交走 hosted 端点、
|
||||
* 成功回执只允许发给服务端锁定的 embedOrigin。它不是身份凭据——本人身份仍由 CAS 会话判。
|
||||
*/
|
||||
const hostedToken = computed(() => String(route.query.ctx ?? "").trim());
|
||||
const hostedEmbedOrigin = ref("");
|
||||
|
||||
/**
|
||||
* 附件控件的上传端点(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() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const queryAppCode = appCode.value || undefined;
|
||||
try {
|
||||
form.value = await resolveForm(code.value, appCode.value || undefined);
|
||||
// hosted 分支排在双探针之前:这条链只有"登录本人"一种形态(办事大厅首批口径)。
|
||||
// 匿名与普通提交仍是中心自用的两条既有通道,不因托管入口而改变;
|
||||
// 匿名完成的消息也永远进不了本人办件(后端 submitterId 逐字比对)。
|
||||
if (hostedToken.value) {
|
||||
channel.value = "hosted";
|
||||
if (!(await probeSession()).logged) {
|
||||
guideToLogin();
|
||||
return;
|
||||
}
|
||||
const hosted = await resolveHostedForm(hostedToken.value);
|
||||
hostedEmbedOrigin.value = hosted.embedOrigin;
|
||||
// 托管态没有"我的提交"段:这一页只为这一次办件服务,摆历史只会诱导重复提交
|
||||
form.value = { ...hosted, answerNoEnabled: 0, statusDefs: [] } as any;
|
||||
myAnswers.value = [];
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
error.value = e?.message || "表单加载失败";
|
||||
if (e?.code === 4) {
|
||||
if (isEmbedded.value) {
|
||||
reportNeedLogin();
|
||||
} else {
|
||||
window.location.href = "/cas/login?service=" + encodeURIComponent(window.location.href);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 未登录引导(**F1 顺修**)。旧判据 `e?.code === 4` 是个死分支——alova 的 errorHandle 抛的是
|
||||
* 不带 code 的普通 Error,所以 embed 形态的 needLogin 从来没发出去过(契约 §7 要求 embed 未登录
|
||||
* 走引导协议而不是白屏/iframe 内跳登录页)。通道既然由探针决定,这里就只在
|
||||
* "无会话 ∧ 这又不是匿名表单"时被调用:embed 发 postMessage,page 整页跳 CAS。
|
||||
*/
|
||||
function guideToLogin() {
|
||||
if (channel.value === "hosted") {
|
||||
error.value = "请先登录后填写";
|
||||
// 托管态未登录时**不发**任何 postMessage:此刻还没取到服务端锁定的 embedOrigin
|
||||
// (它跟着 hosted schema 一起回),而"随便找个父页发一句"正是 A.4 禁止的形态。
|
||||
// 用户在办事页重新登录后再进入口即可;父页的确认调用会拿到明确的未登录错误。
|
||||
return;
|
||||
}
|
||||
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>>({});
|
||||
@@ -107,9 +217,8 @@ function validateField(field: DesignField): string | null {
|
||||
|
||||
function validateAll(): boolean {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!form.value?.groups) return true;
|
||||
const groups = form.value.groups as any[];
|
||||
for (const group of groups) {
|
||||
// 校验与渲染同源:编辑态吃钉住版 groups(renderGroups),否则按当前发布版(Bug#1)
|
||||
for (const group of renderGroups.value) {
|
||||
for (const field of group.fields || []) {
|
||||
const err = validateField(field);
|
||||
if (err) errors[field.key] = err;
|
||||
@@ -122,6 +231,8 @@ function validateAll(): boolean {
|
||||
// ==================== 提交 ====================
|
||||
|
||||
const submitting = ref(false);
|
||||
// Reuse the request identity if the server accepted but the response was lost.
|
||||
const submitIdempotencyKey = ref(Utils.uuid());
|
||||
const submitted = ref(false);
|
||||
const submitResult = ref<{ answerId: string; answerNo: string | null } | null>(null);
|
||||
|
||||
@@ -137,16 +248,42 @@ async function handleSubmit() {
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const idempotencyKey = Utils.uuid();
|
||||
const result = await submitAnswer({
|
||||
const idempotencyKey = submitIdempotencyKey.value;
|
||||
const payload: SubmitRequest = {
|
||||
formCode: code.value,
|
||||
appCode: appCode.value || undefined,
|
||||
idempotencyKey,
|
||||
// 代录:subject=被记录人(后端按该表单 ALL 档把关,非档者带这个字段就是 403);
|
||||
// 没选人就不带——"以我自己为被记录人"与手工填报同一条路径
|
||||
subjectId: canProxy.value ? subject.value?.id : undefined,
|
||||
// 托管态带令牌:后端在答卷事务内兑换它并原子关联那次业务意图(身份仍取会话)
|
||||
contextToken: channel.value === "hosted" ? hostedToken.value : undefined,
|
||||
values: formValues.value
|
||||
});
|
||||
};
|
||||
// 三条通道同一条请求形状(匿名体里没有 subjectId,服务端也不读身份字段——C8/V17)
|
||||
const result =
|
||||
channel.value === "anon"
|
||||
? await submitAnonAnswer(payload)
|
||||
: channel.value === "hosted"
|
||||
? await submitHostedAnswer(payload)
|
||||
: await submitAnswer(payload);
|
||||
submitResult.value = { answerId: result.answerId, answerNo: result.answerNo };
|
||||
submitted.value = true;
|
||||
if (isEmbedded.value) {
|
||||
if (channel.value === "hosted") {
|
||||
/**
|
||||
* 托管回执(A.4):<b>只报"可以去确认了",不报答案 id 也不报版本号</b>——
|
||||
* 父页(办事大厅)拿到这个信号后必须回服务端读签名收据,浏览器这句话
|
||||
* 在整个建件链里没有任何证据力。目标 origin 用服务端锁定的那一个,
|
||||
* 精确到 scheme+host+port,不回落 referrer、不同域名降级。
|
||||
*/
|
||||
const posted = postToHostedParent(
|
||||
{ type: "success", success: true, hosted: true },
|
||||
hostedEmbedOrigin.value
|
||||
);
|
||||
if (!posted) {
|
||||
error.value = "提交已完成,但未能通知办事页;请回到办事页手动刷新确认";
|
||||
}
|
||||
} else if (isEmbedded.value) {
|
||||
reportSubmitResult({ success: true, answerId: result.answerId, answerNo: result.answerNo || undefined });
|
||||
} else {
|
||||
const redirectUrl = String(route.query.redirect ?? "");
|
||||
@@ -160,7 +297,9 @@ async function handleSubmit() {
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || "提交失败";
|
||||
Message.error(msg);
|
||||
if (isEmbedded.value) {
|
||||
if (channel.value === "hosted") {
|
||||
postToHostedParent({ type: "error", success: false, error: msg, hosted: true }, hostedEmbedOrigin.value);
|
||||
} else if (isEmbedded.value) {
|
||||
reportSubmitResult({ success: false, error: msg });
|
||||
}
|
||||
} finally {
|
||||
@@ -172,6 +311,14 @@ async function handleSubmit() {
|
||||
|
||||
const myAnswers = ref<MyAnswerItem[]>([]);
|
||||
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() {
|
||||
try {
|
||||
@@ -181,9 +328,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;
|
||||
formValues.value = { ...answer.values };
|
||||
formErrors.value = {};
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
@@ -198,6 +357,7 @@ async function handleUpdateAnswer() {
|
||||
await editMyAnswer(editingAnswer.value.answerId, formValues.value);
|
||||
Message.success("更新成功");
|
||||
editingAnswer.value = null;
|
||||
editingGroups.value = null;
|
||||
formValues.value = {};
|
||||
await loadMyAnswers();
|
||||
} catch (e: any) {
|
||||
@@ -225,6 +385,7 @@ function handleDeleteAnswer(answer: MyAnswerItem) {
|
||||
|
||||
function handleCancelEdit() {
|
||||
editingAnswer.value = null;
|
||||
editingGroups.value = null;
|
||||
formValues.value = {};
|
||||
formErrors.value = {};
|
||||
}
|
||||
@@ -239,8 +400,8 @@ const containerRef = ref<HTMLElement | null>(null);
|
||||
let stopHeightReporting: (() => void) | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
// 引导里含"我的提交"段(只有登录通道会取),不再并行走两遍鉴权
|
||||
loadForm();
|
||||
loadMyAnswers();
|
||||
if (isEmbedded.value && containerRef.value) {
|
||||
stopHeightReporting = startHeightReporting(containerRef.value);
|
||||
}
|
||||
@@ -250,9 +411,9 @@ onUnmounted(() => {
|
||||
if (stopHeightReporting) stopHeightReporting();
|
||||
});
|
||||
|
||||
watch(code, () => {
|
||||
watch([code, appCode, hostedToken], () => {
|
||||
submitIdempotencyKey.value = Utils.uuid();
|
||||
loadForm();
|
||||
loadMyAnswers();
|
||||
});
|
||||
|
||||
// ==================== 只读态判定 ====================
|
||||
@@ -287,7 +448,8 @@ const readonlyReason = computed(() => {
|
||||
<div v-else-if="error" class="error-state">
|
||||
<Alert show-icon type="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>
|
||||
</template>
|
||||
</Alert>
|
||||
@@ -304,6 +466,20 @@ const readonlyReason = computed(() => {
|
||||
<div v-else class="form-content">
|
||||
<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">
|
||||
{{ readonlyReason }},当前为只读模式
|
||||
@@ -313,7 +489,8 @@ const readonlyReason = computed(() => {
|
||||
<div v-if="submitted && submitResult" class="success-state">
|
||||
<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>
|
||||
</template>
|
||||
</Alert>
|
||||
@@ -327,7 +504,7 @@ const readonlyReason = computed(() => {
|
||||
|
||||
<!-- 表单字段 -->
|
||||
<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>
|
||||
<div v-for="field in (group.fields || [])" :key="field.key" class="form-item">
|
||||
<label class="form-label">
|
||||
@@ -423,6 +600,12 @@ const readonlyReason = computed(() => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.proxy-subject {
|
||||
margin-left: 10px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
}
|
||||
|
||||
.success-state {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
+11
-10
@@ -9,11 +9,11 @@
|
||||
* 两者是**两个端点、两个谓词**,页面按路由分派;顶部 Tab 只是 UI 导航(切路由),不是接口参数。
|
||||
* 管理员代填后:这条答卷在**填表人的"我提交的"**里出现,同时在**被代填人的"关于我的"**里出现(V18)。
|
||||
*
|
||||
* P91 起第三视图「可填写」(/my/fillable),P95 W4 换数据源:这不再是答卷视图,而是**填报入口列表**。
|
||||
* 数据源从"会话菜单树里挑 path 形如 /f/{code} 的行"改成 `GET /api/form/my/fillable`——
|
||||
* 谁能填哪张表由中心按三层模型(audience × 身份 × 管理员两表)自解析,
|
||||
* P91 起第三视图「可填写」(/my/fillable),P95 W4 换数据源、契约 v2 C2 定行形状:这不再是答卷视图,
|
||||
* 而是**填报入口列表**。数据源从"会话菜单树里挑 path 形如 /f/{code} 的行"改成 `GET /api/form/my/fillable`——
|
||||
* 谁能填哪张表由中心按 <b>audience 门</b>(会话身份类型 ∈ 表单面向人群)自解析,
|
||||
* UserBase 侧不再需要逐表单配 `/f/` 模块行(任务书 P95 §0.1/§0.2 红线 6:分享链接与直链解析不动)。
|
||||
* 前端照旧**不做本地角色过滤**:接口下发 canFill=true 的才画卡(canFill 是后端算的,不是我筛的)。
|
||||
* 前端照旧**不做本地角色过滤**:接口下发什么就画什么(入集即等价于可填,行里不再有 canFill)。
|
||||
*
|
||||
* 三条交互口径沿 T7 的"我的提交"段(不另造轮子):
|
||||
* 1. **编辑入口只在"我提交的"**:编辑=填表人的动作;"关于我的"整列只读
|
||||
@@ -67,7 +67,7 @@ const VIEW_META = {
|
||||
fillable: {
|
||||
title: "可填写",
|
||||
endpoint: "GET /api/form/my/fillable",
|
||||
predicate: "谓词:后端按三层模型解析出的填报入口(canFill=true 才画卡)",
|
||||
predicate: "谓词:会话身份类型 ∈ form.audience(audience 门,与提交时同一公式)",
|
||||
note: "这里是当前账号能填报的表单。表单发布后按面向人群自动出现在这里——不需要有人在基础平台逐张表配模块行。"
|
||||
}
|
||||
} as const;
|
||||
@@ -84,12 +84,13 @@ const loadedOnce = ref(false);
|
||||
const errorText = ref("");
|
||||
|
||||
/**
|
||||
* 「可填写」的卡片集=接口里 `canFill=true` 的行。
|
||||
* 「可填写」的卡片集=接口下发的全部行。
|
||||
*
|
||||
* <p>过滤条件只有这一条,且判定<b>来自后端</b>:`canFill=false` 的行(教师×面向学生那种"只能代填")
|
||||
* 由后端按三层模型算出,前端只是不把它画成"进入填报"的卡——前端不重新按角色判一遍(T8 红线:范围以后端为准)。</p>
|
||||
* <p>契约 v2 C2 之后行里没有 `canFill`:<b>入集即等价于可填</b>(后端按 audience 门筛过一次,
|
||||
* 不再有"进了列表但不能自填、只给代填"的中间态)。前端依旧不做任何本地身份过滤
|
||||
* (T8 红线:范围与资格以后端为准,这里只画后端给的东西)。</p>
|
||||
*/
|
||||
const fillableCards = computed<FillableForm[]>(() => fillableRows.value.filter((r) => r.canFill));
|
||||
const fillableCards = computed<FillableForm[]>(() => fillableRows.value);
|
||||
|
||||
/** 会话用户 id:判"这条是不是我提交的"(编辑/删除入口显隐),身份来源仍是会话(不传后端) */
|
||||
const sessionUserId = computed(() => store.sessionUserId);
|
||||
@@ -208,7 +209,7 @@ const tableColumns = computed(() => {
|
||||
render: (h2: any, p: any) =>
|
||||
h2(
|
||||
"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 AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { hasPage } from "@/utils/modulePerms";
|
||||
import { managesForm } from "@/store";
|
||||
import { statusDisplay } from "@/utils/dataValue";
|
||||
import type { AnswerDetail } from "@/types/data";
|
||||
import type { StatusDefView } from "@/types/form";
|
||||
@@ -36,8 +36,14 @@ const detail = ref<AnswerDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
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 statusModal = ref<{ visible: boolean; target: string }>({ visible: false, target: "" });
|
||||
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.submitTime }}</span></div>
|
||||
<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 class="meta-row"><span class="meta-key">表单 uuid</span><code>{{ detail.formUuid }}</code></div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 应用设置(`/admin/app`,T10 方案 §3/§6.1):UserBase 侧一行模块行,页内两个 tab。
|
||||
* 应用设置(`/admin/app`,T10 方案 §3/§6.1):一行模块位置,页内四个 tab。
|
||||
*
|
||||
* - **角色设置** = 校级表单管理员名单(`school_admin`);
|
||||
* - **快应用** = app 注册表(契约 v2 §5.2/C17,T16;零角色列,入口链接由中心生成);
|
||||
* - **表单授权** = 消费应用的 owner 绑定与读/订阅授权(v3/TASK_1,`FormAccessPanel`);
|
||||
* - **字典管理** = 原 `/admin/dicts` 整页迁入(`Dicts.vue` 原样复用,守卫行随本行换到 `/admin/app`)。
|
||||
*
|
||||
* 两条口径:
|
||||
* 1. **tab 不占模块行、不进导航**(同设计器子页),所以高亮/面包屑挂本行;`?tab=dicts` 可深链,
|
||||
* 「快应用」与「表单授权」刻意是两个 tab 而不是一个面板:前者是"有哪些应用"(目录,零角色),
|
||||
* 后者是"哪个应用能读哪张表"(授权)——把授权塞进注册表正是契约 v2 出清掉的东西(§19 作废-1/2)。
|
||||
*
|
||||
* 三条口径:
|
||||
* 1. **tab 不占模块行、不进导航**(同设计器子页),所以高亮/面包屑挂本行;`?tab=dicts|apps` 可深链,
|
||||
* 旧地址 `/admin/dicts` 由路由重定向到这里的字典 tab;
|
||||
* ——深链落在模板的 {@code :model-value} 上:view-ui-plus 的 Tabs 受控 prop 是 modelValue,
|
||||
* 写 {@code :value} 会落进 attrs 不被消费(T10 验收轮实拍证伪过一次),勿"顺手改回";
|
||||
* 2. **一行把关两个 tab 与字典 API**:能不能进来由 UserBase 的 `/admin/app` 决定,
|
||||
* 前端不做"进了页再按身份藏 tab"——藏按钮不等于授权(契约 §7 身份行)。
|
||||
* 2. 不在闭集里的 tab 值一律回落缺省(角色设置),与 {@code Fill.vue} 的 mode 白名单同一条写法;
|
||||
* 3. **一行把关三个 tab 与全部维护 API**:能不能进来由 capabilities.schoolAdmin(契约 v2 §2.4 第三支路)
|
||||
* 决定,前端不做"进了页再按身份藏 tab"——藏按钮不等于授权(契约 §7 身份行)。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import AdminListPanel from "@/components/admin/AdminListPanel.vue";
|
||||
import FormAccessPanel from "@/components/admin/FormAccessPanel.vue";
|
||||
import Apps from "./Apps.vue";
|
||||
import Dicts from "./Dicts.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/** 缺省落角色设置(本页新增的主用途);只认 dicts 这一个显式值,其它 query 一律按缺省 */
|
||||
const tab = computed(() => (route.query.tab === "dicts" ? "dicts" : "roles"));
|
||||
const TABS = ["roles", "apps", "access", "dicts"] as const;
|
||||
|
||||
/** 缺省落角色设置;只认闭集内的值,其它 query 一律按缺省 */
|
||||
const tab = computed(() => {
|
||||
const raw = String(route.query.tab ?? "");
|
||||
return (TABS as readonly string[]).includes(raw) ? raw : "roles";
|
||||
});
|
||||
|
||||
function change(next: string) {
|
||||
router.replace({ path: "/admin/app", query: { ...route.query, tab: next } });
|
||||
@@ -36,6 +49,12 @@ function change(next: string) {
|
||||
<TabPane label="角色设置" name="roles">
|
||||
<AdminListPanel mode="school" />
|
||||
</TabPane>
|
||||
<TabPane label="快应用" name="apps">
|
||||
<Apps />
|
||||
</TabPane>
|
||||
<TabPane label="表单授权" name="access">
|
||||
<FormAccessPanel />
|
||||
</TabPane>
|
||||
<TabPane label="字典管理" name="dicts">
|
||||
<Dicts />
|
||||
</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` 只用来把"你现在看到的是哪个范围"讲清楚,
|
||||
* 前端不据此再过滤一遍(任务书 T8 红线)。
|
||||
*
|
||||
* 快应用上下文(AppContext)复用本组件:传 `context="app"` + `app-code` + `codes`
|
||||
* (候选来自 `GET /api/form/my/fillable?appCode=` 按层级筛出的 code,经 resolve 拿 uuid——
|
||||
* 定义管理权不在快应用上下文里,§3.4;P95 W5 起不再从该应用模块树挑 `/f/` 行)
|
||||
* + `modules`(**同一棵原始模块树**,只给按钮级权限判定用,P76)。
|
||||
* 快应用上下文(通用壳)复用本组件:传 `context="app"` + `app-code` + `codes`
|
||||
* (候选由壳按"我管理的表单 ∩ 该应用"算出,经 resolve 拿 uuid——定义管理权不在通用壳里,§3.4)。
|
||||
* 导出按钮的显隐不再需要宿主传任何权限载体:本组件自己按 `my/managed` 清单判 ALL 档(契约 v2 §2.4)。
|
||||
*/
|
||||
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
@@ -25,8 +24,7 @@ import { actionColumn } from "@/utils/tableActions";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
import { useFormCandidates } from "@/utils/hook/useFormCandidates";
|
||||
import { dataApi, downloadBlob, exportBlob } from "@/api/module/data";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { canExport } from "@/utils/modulePerms";
|
||||
import { managesForm, useDefaultStore } from "@/store";
|
||||
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
|
||||
import {
|
||||
cellText,
|
||||
@@ -42,7 +40,6 @@ import {
|
||||
import { scopeLabel, type AnswerDetail, type AnswerRow, type FilterField, type FilterItem, type ListView } from "@/types/data";
|
||||
import type { StatusDefView } from "@/types/form";
|
||||
import type { DesignField } from "@/types/formSchema";
|
||||
import type { IModule } from "@/types/Basics";
|
||||
|
||||
/** 行视图 + 勾选态(`_checked` 是 view-ui-plus Table 的行内选中字段,见 withSelectionState) */
|
||||
type RowVM = AnswerRow & { _checked?: boolean };
|
||||
@@ -54,20 +51,13 @@ const props = withDefaults(
|
||||
appCode?: string;
|
||||
/** app 上下文的候选 code(来自 /api/form/my/fillable 的层级筛取,P95 W5) */
|
||||
codes?: string[];
|
||||
/**
|
||||
* 按钮级权限判定的**那棵树**(P76):app 上下文必须由宿主传入目标应用的模块树
|
||||
* (GetUserMod?appCode= 的结果)——中心会话树里没有快应用的"导出"锚点行,
|
||||
* 拿它判锚点等于永远判不中(fail-closed 方向:有授权也不画按钮)。
|
||||
* center 上下文不传,仍按 `store.modules`(会话自己的树)判页面行。
|
||||
*/
|
||||
modules?: IModule[] | null;
|
||||
/**
|
||||
* 被宿主页面内嵌(快应用上下文 /app/:appCode/)时置 true:只渲染内容本体,
|
||||
* 不再套一层 `.page-wrapper > .main-container`——否则会出现双层白卡与双份内边距。
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}>(),
|
||||
{ context: "center", appCode: "", codes: () => [], modules: null, embedded: false }
|
||||
{ context: "center", appCode: "", codes: () => [], embedded: false }
|
||||
);
|
||||
|
||||
const route = useRoute();
|
||||
@@ -271,10 +261,10 @@ const tableColumns = computed(() => {
|
||||
width: 170,
|
||||
render: (h2: any, p: any) =>
|
||||
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
|
||||
}, {
|
||||
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(
|
||||
@@ -446,12 +436,14 @@ async function submitBatchStatus() {
|
||||
// ==================== 导出 ====================
|
||||
|
||||
/**
|
||||
* 权限判定用的树(P76):app 上下文=宿主编进来的**目标应用树**(快应用的"导出"锚点行在这里),
|
||||
* center 上下文=会话自己的树(按 `/admin/data` 页面行判,手册 §2.2 中心表没有导出行)。
|
||||
* 两种上下文各自吃自己的树,`canExport` 的纯函数判据不变。
|
||||
* 导出按钮显隐(契约 v2 §2.4:`modulePerms.canExport` 随模块行退役删除):
|
||||
* 判据换成"<b>该表单 ALL 档持有</b>"的清单形态——当前选中的这张表在 `my/managed` 里,就画导出。
|
||||
* 中心与快应用两种上下文从此**同一条判据**(不再有"两棵树"的差别,B13/P78 遗迹清零);
|
||||
* 接口侧的 403 兜底仍在后端守卫,这里只决定画不画。
|
||||
*/
|
||||
const permsTree = computed(() => (props.context === "app" ? props.modules : store.modules));
|
||||
const exportable = computed(() => canExport(permsTree.value, props.context === "app" ? "app" : "center"));
|
||||
const exportable = computed(() =>
|
||||
managesForm(store.managed, current.value?.appCode || props.appCode, current.value?.code)
|
||||
);
|
||||
const exporting = ref(false);
|
||||
|
||||
function askExport() {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
import FormConfigModal from "@/components/form/FormConfigModal.vue";
|
||||
import FormAdminModal from "@/components/admin/FormAdminModal.vue";
|
||||
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";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -86,8 +86,9 @@ const columns = computed(() => [
|
||||
{
|
||||
title: "面向",
|
||||
key: "audience",
|
||||
width: 90,
|
||||
render: (h2: any, p: any) => h2("span", p.row.audience === AUDIENCE.TEACHER ? "面向教师" : "面向学生")
|
||||
width: 120,
|
||||
// 人群是多值(契约 v2 C1),列宽给得比原来宽一点:"学生/教师/匿名" 三种组合都要能读完
|
||||
render: (h2: any, p: any) => h2("span", audienceText(p.row.audience))
|
||||
},
|
||||
{
|
||||
title: "截止",
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 差异对账管理面(TASK_3 改动点 7,独立路由 /admin/sync)。
|
||||
*
|
||||
* 页面只做四件事,且都不复制后端规则:建任务(三种范围)、看任务进度与结果、取消、
|
||||
* 单答卷重放 + 投递失败查看。守卫在服务层(`FormAdminGuard.requirePlatformAdmin`),
|
||||
* 跨域寻址后端按"任务不存在"回答,这里原样显示 msg,不在前端判身份。
|
||||
*
|
||||
* 三条必须显示给用户、也不能被前端美化的事实:
|
||||
* ① `DISPATCHED` 只代表消费方收下了指令,**不代表修复完成**;
|
||||
* ② `COMPLETED`(完整扫描且无未决差异)与 `PARTIAL`(跑完了但还有只报告项/暂停项)是两种结论;
|
||||
* ③ 超限或范围不符时后端返回一行 `FAILED` 任务并带拒绝原因——那是可查的事实,不是弹窗里的过路话。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import {
|
||||
cancelSyncTask,
|
||||
createSyncTasks,
|
||||
listFailedDeliveries,
|
||||
listSyncResults,
|
||||
listSyncTasks,
|
||||
replaySyncAnswer,
|
||||
} from "@/api/module/formSync";
|
||||
import type { FailedDeliveryRow, SyncErrorRow, SyncResultRow, SyncTaskRow } from "@/api/module/formSync";
|
||||
|
||||
const STATES = ["QUEUED", "DISPATCHED", "RUNNING", "COMPLETED", "PARTIAL", "FAILED", "CANCELLED"];
|
||||
/** 状态色:把"已派发"与"已完成"用同一色是这张表最容易骗人的地方 */
|
||||
const STATE_COLOR: Record<string, string> = {
|
||||
QUEUED: "default",
|
||||
DISPATCHED: "cyan",
|
||||
RUNNING: "blue",
|
||||
COMPLETED: "green",
|
||||
PARTIAL: "orange",
|
||||
FAILED: "red",
|
||||
CANCELLED: "default",
|
||||
};
|
||||
|
||||
const tasks = ref<SyncTaskRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const size = ref(20);
|
||||
|
||||
const filter = reactive({ state: "", consumerAppKey: "", consumerSchoolId: "" as string });
|
||||
const draft = reactive({
|
||||
scope: "FORM",
|
||||
consumerAppKey: "",
|
||||
consumerSchoolId: "" as string,
|
||||
formUuid: "",
|
||||
answerUuid: "",
|
||||
});
|
||||
const replay = reactive({
|
||||
consumerAppKey: "",
|
||||
consumerSchoolId: "" as string,
|
||||
answerUuid: "",
|
||||
expectedRevision: "" as string,
|
||||
});
|
||||
const deliveries = reactive({ consumerAppKey: "", consumerSchoolId: "" as string, rows: [] as FailedDeliveryRow[], total: 0 });
|
||||
|
||||
const detail = reactive({
|
||||
visible: false,
|
||||
task: null as SyncTaskRow | null,
|
||||
results: [] as SyncResultRow[],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (!draft.consumerAppKey.trim()) return false;
|
||||
if (!Number(draft.consumerSchoolId)) return false;
|
||||
if (draft.scope === "FORM") return !!draft.formUuid.trim();
|
||||
if (draft.scope === "ANSWER") return !!draft.answerUuid.trim();
|
||||
return true;
|
||||
});
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: "任务", key: "taskId", minWidth: 190 },
|
||||
{ title: "消费方", key: "consumerAppKey", width: 100 },
|
||||
{ title: "租户域", key: "consumerSchoolId", width: 90 },
|
||||
{ title: "表单", key: "formUuid", minWidth: 190 },
|
||||
{
|
||||
title: "状态",
|
||||
key: "state",
|
||||
width: 110,
|
||||
render: (h: any, p: any) => h("Tag", { color: STATE_COLOR[p.row.state] || "default" }, p.row.state),
|
||||
},
|
||||
{
|
||||
title: "清单",
|
||||
key: "manifest",
|
||||
width: 130,
|
||||
render: (h: any, p: any) =>
|
||||
h("span", `${p.row.materializedRows ?? 0}/${p.row.totalRows ?? 0} 行 · ${p.row.materializedPages ?? 0}/${p.row.totalPages ?? 0} 页`),
|
||||
},
|
||||
{
|
||||
title: "派发",
|
||||
key: "attempt",
|
||||
width: 80,
|
||||
render: (h: any, p: any) => h("span", `第 ${p.row.attempt ?? 0} 次`),
|
||||
},
|
||||
{
|
||||
title: "最近结论",
|
||||
key: "latest",
|
||||
minWidth: 180,
|
||||
render: (h: any, p: any) => h("span", briefResult(p.row.latestResult)),
|
||||
},
|
||||
{
|
||||
title: "拒绝/错误原因",
|
||||
key: "errorDetail",
|
||||
minWidth: 220,
|
||||
render: (h: any, p: any) =>
|
||||
h("span", p.row.errorCode ? `${p.row.errorCode}:${p.row.errorDetail || ""}` : ""),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "ops",
|
||||
width: 130,
|
||||
render: (h: any, p: any) =>
|
||||
h("div", { class: "action-buttons" }, [
|
||||
h("div", { class: "operating-link", onClick: () => openDetail(p.row as SyncTaskRow) }, "结果"),
|
||||
h("div", { class: "operating-link text-error", onClick: () => confirmCancel(p.row as SyncTaskRow) }, "取消"),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
|
||||
const resultColumns = computed(() => [
|
||||
{ title: "attempt", key: "attempt", width: 80 },
|
||||
{ title: "结论", key: "state", width: 110 },
|
||||
{ title: "比较行数", key: "scannedRows", width: 90 },
|
||||
{ title: "缺失修复", key: "missingRepaired", width: 90 },
|
||||
{ title: "陈旧/错hash", key: "staleRepaired", width: 110 },
|
||||
{ title: "墓碑", key: "tombstonesRepaired", width: 70 },
|
||||
{ title: "本地更高", key: "localAhead", width: 90 },
|
||||
{ title: "暂停", key: "deferred", width: 70 },
|
||||
{ title: "多余行(报/退)", key: "extra", width: 120, render: (h: any, p: any) => h("span", `${p.row.extraReported ?? 0}/${p.row.extraRetired ?? 0}`) },
|
||||
{ title: "完整扫描", key: "complete", width: 120, render: (h: any, p: any) => h("span", `${p.row.manifestComplete ? "清单完整" : "清单未完整"} · ${p.row.localScanComplete ? "本地完整" : "本地未完整"}`) },
|
||||
{ title: "回报时刻", key: "reportedAt", minWidth: 160 },
|
||||
]);
|
||||
|
||||
const errorColumns = computed(() => [
|
||||
{ title: "码", key: "code", width: 150 },
|
||||
{ title: "答卷", key: "answerUuid", minWidth: 180 },
|
||||
{ title: "revision", key: "revision", width: 90 },
|
||||
{ title: "明细", key: "detail", minWidth: 260 },
|
||||
]);
|
||||
|
||||
const deliveryColumns = computed(() => [
|
||||
{ title: "答卷", key: "answerUuid", minWidth: 180 },
|
||||
{ title: "revision", key: "answerRevision", width: 90 },
|
||||
{ title: "投递状态", key: "state", width: 130 },
|
||||
{ title: "重试次数", key: "retryCount", width: 90 },
|
||||
{ title: "总线收下", key: "brokerAcceptedAt", minWidth: 150 },
|
||||
{ title: "消费方应用", key: "consumerAppliedAt", minWidth: 150 },
|
||||
{ title: "末次错误", key: "lastError", minWidth: 200 },
|
||||
]);
|
||||
|
||||
function briefResult(row?: SyncResultRow) {
|
||||
if (!row) return "尚无回报(派发只表示对端收下了指令)";
|
||||
const parts = [
|
||||
`修缺失 ${row.missingRepaired ?? 0}`,
|
||||
`修陈旧 ${row.staleRepaired ?? 0}`,
|
||||
`错hash ${row.hashConflicts ?? 0}`,
|
||||
`墓碑 ${row.tombstonesRepaired ?? 0}`,
|
||||
`本地更高 ${row.localAhead ?? 0}`,
|
||||
`多余 ${row.extraReported ?? 0}/退 ${row.extraRetired ?? 0}`,
|
||||
];
|
||||
return `${row.state}:${parts.join(" · ")}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const view = await listSyncTasks(
|
||||
{
|
||||
state: filter.state || undefined,
|
||||
consumerAppKey: filter.consumerAppKey.trim() || undefined,
|
||||
consumerSchoolId: Number(filter.consumerSchoolId) || undefined,
|
||||
page: page.value,
|
||||
size: size.value,
|
||||
},
|
||||
);
|
||||
tasks.value = view?.content || [];
|
||||
total.value = view?.totalElements ?? tasks.value.length;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "对账任务加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value) {
|
||||
Message.warning("消费方 appKey、租户域与范围坐标都必填");
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const created = await createSyncTasks({
|
||||
scope: draft.scope,
|
||||
consumerAppKey: draft.consumerAppKey.trim(),
|
||||
consumerSchoolId: Number(draft.consumerSchoolId),
|
||||
formUuid: draft.scope === "ANSWER" || draft.scope === "FORM" ? draft.formUuid.trim() || undefined : undefined,
|
||||
answerUuid: draft.scope === "ANSWER" ? draft.answerUuid.trim() : undefined,
|
||||
});
|
||||
const rejected = (created || []).filter((t) => t.state === "FAILED");
|
||||
if (rejected.length) {
|
||||
// 拒绝也是结果:把原因亮出来,同时仍刷新列表——那一行 FAILED 是可查留痕
|
||||
Message.warning(`${rejected.length} 个任务被拒绝:${rejected[0].errorCode} ${rejected[0].errorDetail || ""}`);
|
||||
} else {
|
||||
Message.success(`已建 ${(created || []).length} 个对账任务(物化与派发异步进行)`);
|
||||
}
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "建任务失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: SyncTaskRow) {
|
||||
detail.visible = true;
|
||||
detail.task = row;
|
||||
detail.results = [];
|
||||
detail.loading = true;
|
||||
try {
|
||||
detail.results = (await listSyncResults(row.taskId)) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "结果加载失败");
|
||||
} finally {
|
||||
detail.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmCancel(row: SyncTaskRow) {
|
||||
if (!window.confirm(`取消任务 ${row.taskId}?剩余投递与修复会一律停止。`)) return;
|
||||
cancelSyncTask(row.taskId)
|
||||
.then(() => {
|
||||
Message.success("已请求取消");
|
||||
return load();
|
||||
})
|
||||
.catch((e: any) => Message.error(e?.message || "取消失败"));
|
||||
}
|
||||
|
||||
async function submitReplay() {
|
||||
if (!replay.consumerAppKey.trim() || !Number(replay.consumerSchoolId) || !replay.answerUuid.trim()
|
||||
|| !Number(replay.expectedRevision)) {
|
||||
Message.warning("重放需要消费方 appKey、租户域、答卷 uuid 与期望 revision");
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const view = await replaySyncAnswer({
|
||||
consumerAppKey: replay.consumerAppKey.trim(),
|
||||
consumerSchoolId: Number(replay.consumerSchoolId),
|
||||
answerUuid: replay.answerUuid.trim(),
|
||||
expectedRevision: Number(replay.expectedRevision),
|
||||
operationId: `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`,
|
||||
});
|
||||
Message.success(`重放结论:${view.state} ${view.message || ""}`);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "重放失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeliveries() {
|
||||
if (!deliveries.consumerAppKey.trim() || !Number(deliveries.consumerSchoolId)) {
|
||||
Message.warning("先看投递失败需要消费方 appKey 与租户域");
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const view = await listFailedDeliveries({
|
||||
consumerAppKey: deliveries.consumerAppKey.trim(),
|
||||
consumerSchoolId: Number(deliveries.consumerSchoolId),
|
||||
});
|
||||
deliveries.rows = view?.rows || [];
|
||||
deliveries.total = view?.total ?? 0;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "投递失败清单加载失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function errorsOf(row: SyncResultRow): SyncErrorRow[] {
|
||||
return row.errors || [];
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
page.value = next;
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<Card :bordered="false" disborder class="sync-card">
|
||||
<p slot="title">建对账任务</p>
|
||||
<Form :label-width="110" inline>
|
||||
<FormItem label="范围">
|
||||
<Select v-model="draft.scope" style="width: 150px">
|
||||
<Option value="ANSWER">单答卷</Option>
|
||||
<Option value="FORM">单表单</Option>
|
||||
<Option value="ALL">该消费方全部生效订阅</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="消费方 appKey">
|
||||
<Input v-model="draft.consumerAppKey" placeholder="已验签的平台 appKey" style="width: 150px" />
|
||||
</FormItem>
|
||||
<FormItem label="消费租户域">
|
||||
<Input v-model="draft.consumerSchoolId" placeholder="学校 id(≥1)" style="width: 120px" />
|
||||
</FormItem>
|
||||
<FormItem v-if="draft.scope !== 'ALL'" label="表单 uuid">
|
||||
<Input v-model="draft.formUuid" placeholder="32 位表单 uuid" style="width: 260px" />
|
||||
</FormItem>
|
||||
<FormItem v-if="draft.scope === 'ANSWER'" label="答卷 uuid">
|
||||
<Input v-model="draft.answerUuid" placeholder="32 位答卷 uuid" style="width: 260px" />
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button type="primary" :loading="busy" :disabled="!canSubmit" @click="submit">建任务</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<p class="sync-note">
|
||||
只有生效订阅(读权 + 订阅权同时开着)才会建任务;超过行数/页数上限或跨校范围会被拒绝,
|
||||
并在列表里留下一行带原因的 FAILED 任务。清单物化与派发都是异步的,不在本页面里同步跑全表。
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card :bordered="false" disborder class="sync-card">
|
||||
<p slot="title">任务进度与结果</p>
|
||||
<div class="toolbar">
|
||||
<Select v-model="filter.state" style="width: 150px" placeholder="状态(可不选)" clearable>
|
||||
<Option v-for="s in STATES" :key="s" :value="s" :label="s" />
|
||||
</Select>
|
||||
<Input v-model="filter.consumerAppKey" placeholder="消费方 appKey" style="width: 150px" />
|
||||
<Input v-model="filter.consumerSchoolId" placeholder="消费租户域" style="width: 120px" />
|
||||
<Button icon="md-refresh" @click="load">刷新</Button>
|
||||
<span class="toolbar-note">DISPATCHED 只代表对端收下指令;结论只看「最近结论」列。</span>
|
||||
</div>
|
||||
<Table :columns="columns" :data="tasks" :loading="loading" size="small" />
|
||||
<div class="sync-pager">
|
||||
<Page :total="total" :current="page" :page-size="size" show-total @on-change="changePage" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card :bordered="false" disborder class="sync-card">
|
||||
<p slot="title">单答卷重放与投递失败查看</p>
|
||||
<Form :label-width="120" inline>
|
||||
<FormItem label="消费方 appKey">
|
||||
<Input v-model="replay.consumerAppKey" style="width: 140px" />
|
||||
</FormItem>
|
||||
<FormItem label="消费租户域">
|
||||
<Input v-model="replay.consumerSchoolId" style="width: 110px" />
|
||||
</FormItem>
|
||||
<FormItem label="答卷 uuid">
|
||||
<Input v-model="replay.answerUuid" style="width: 240px" />
|
||||
</FormItem>
|
||||
<FormItem label="期望 revision">
|
||||
<Input v-model="replay.expectedRevision" style="width: 120px" />
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button :loading="busy" @click="submitReplay">重放这一份</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<p class="sync-note">
|
||||
重放只搬运<b>已存在的投递行</b>:<code>ALREADY_DELIVERED</code> 不代表消费方本地数据仍然正确,
|
||||
<code>NO_DELIVERY_ROW</code> 也不能当成答卷不存在——这两种情况都请改建对账任务,
|
||||
由权威清单与快照比对后走修复入口。
|
||||
</p>
|
||||
<div class="toolbar">
|
||||
<Input v-model="deliveries.consumerAppKey" placeholder="消费方 appKey" style="width: 140px" />
|
||||
<Input v-model="deliveries.consumerSchoolId" placeholder="消费租户域" style="width: 110px" />
|
||||
<Button :loading="busy" @click="loadDeliveries">看投递失败</Button>
|
||||
<span class="toolbar-note">共 {{ deliveries.total }} 条(FAILED/DEAD)</span>
|
||||
</div>
|
||||
<Table :columns="deliveryColumns" :data="deliveries.rows" size="small" />
|
||||
</Card>
|
||||
|
||||
<Modal v-model="detail.visible" title="对账结果(逐 attempt)" width="70" footer-hide>
|
||||
<div v-if="detail.task" class="sync-detail-head">
|
||||
任务 {{ detail.task.taskId }} · 消费方 {{ detail.task.consumerAppKey }} ·
|
||||
租户域 {{ detail.task.consumerSchoolId }} · 表单 {{ detail.task.formUuid }}(v{{ detail.task.formVersion }})
|
||||
<br>
|
||||
窗口起点 {{ detail.task.windowStart || "-" }} → 物化完成 {{ detail.task.windowEnd || "-" }} ·
|
||||
派发 {{ detail.task.attempt || 0 }} 次 · 当前状态 {{ detail.task.state }}
|
||||
</div>
|
||||
<Table :columns="resultColumns" :data="detail.results" :loading="detail.loading" size="small" />
|
||||
<div v-for="row in detail.results" :key="row.attempt" class="sync-errors">
|
||||
<p class="sync-note">attempt {{ row.attempt }} 的未决明细(最多若干条,答卷值不进明细):</p>
|
||||
<Table :columns="errorColumns" :data="errorsOf(row)" size="small" />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sync-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.sync-pager {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sync-detail-head {
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.sync-errors {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.toolbar-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -99,6 +99,13 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
target: "http://localhost:8086",
|
||||
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 空)
|
||||
"/upload": {
|
||||
target: "http://localhost:8086",
|
||||
|
||||
Reference in New Issue
Block a user