This commit is contained in:
+14
-1
@@ -5,7 +5,7 @@
|
||||
* 与后端 FormResolveController / FormAnswerController 对齐。
|
||||
*/
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import type { ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer";
|
||||
import type { HostedForm, ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer";
|
||||
import type { AnswerDetail, Capabilities, FillableForm, ManagedForm, MyAnswerViewItem } from "@/types/data";
|
||||
|
||||
/** 解析当前发布版本(契约 §1:入口寻址) */
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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,避免出现"两条都能进、只有一条有守卫"的口径。
|
||||
|
||||
@@ -34,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+66
-9
@@ -17,12 +17,12 @@ import { useRoute } from "vue-router";
|
||||
import { fieldWidget } from "@/components/fields";
|
||||
import type { DesignField } from "@/types/formSchema";
|
||||
import type { ResolvedForm, MyAnswerItem, SubmitRequest } from "@/types/answer";
|
||||
import { resolveForm, submitAnswer, listMyAnswers, deleteMyAnswer, editMyAnswer, getMyAnswerDetail } from "@/api/module/fill";
|
||||
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 } from "@/utils/embed";
|
||||
import { startHeightReporting, reportSubmitResult, reportNeedLogin, postToHostedParent } from "@/utils/embed";
|
||||
import Utils from "@/utils";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
|
||||
@@ -55,9 +55,17 @@ const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
/** 本次填报走的通道(null=还没判出来);提交、上传、"我的提交"段都按它分派 */
|
||||
type FillChannel = "session" | "anon";
|
||||
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}=表单归属校,
|
||||
@@ -81,6 +89,22 @@ async function loadForm() {
|
||||
error.value = null;
|
||||
const queryAppCode = appCode.value || undefined;
|
||||
try {
|
||||
// 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);
|
||||
@@ -113,6 +137,13 @@ async function loadForm() {
|
||||
* "无会话 ∧ 这又不是匿名表单"时被调用: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();
|
||||
@@ -200,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);
|
||||
|
||||
@@ -215,7 +248,7 @@ async function handleSubmit() {
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const idempotencyKey = Utils.uuid();
|
||||
const idempotencyKey = submitIdempotencyKey.value;
|
||||
const payload: SubmitRequest = {
|
||||
formCode: code.value,
|
||||
appCode: appCode.value || undefined,
|
||||
@@ -223,13 +256,34 @@ async function handleSubmit() {
|
||||
// 代录: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) : await submitAnswer(payload);
|
||||
// 三条通道同一条请求形状(匿名体里没有 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 ?? "");
|
||||
@@ -243,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 {
|
||||
@@ -355,7 +411,8 @@ onUnmounted(() => {
|
||||
if (stopHeightReporting) stopHeightReporting();
|
||||
});
|
||||
|
||||
watch(code, () => {
|
||||
watch([code, appCode, hostedToken], () => {
|
||||
submitIdempotencyKey.value = Utils.uuid();
|
||||
loadForm();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 应用设置(`/admin/app`,T10 方案 §3/§6.1):一行模块位置,页内三个 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`)。
|
||||
*
|
||||
* 「快应用」与「表单授权」刻意是两个 tab 而不是一个面板:前者是"有哪些应用"(目录,零角色),
|
||||
* 后者是"哪个应用能读哪张表"(授权)——把授权塞进注册表正是契约 v2 出清掉的东西(§19 作废-1/2)。
|
||||
*
|
||||
* 三条口径:
|
||||
* 1. **tab 不占模块行、不进导航**(同设计器子页),所以高亮/面包屑挂本行;`?tab=dicts|apps` 可深链,
|
||||
* 旧地址 `/admin/dicts` 由路由重定向到这里的字典 tab;
|
||||
@@ -18,13 +22,14 @@
|
||||
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();
|
||||
|
||||
const TABS = ["roles", "apps", "dicts"] as const;
|
||||
const TABS = ["roles", "apps", "access", "dicts"] as const;
|
||||
|
||||
/** 缺省落角色设置;只认闭集内的值,其它 query 一律按缺省 */
|
||||
const tab = computed(() => {
|
||||
@@ -47,6 +52,9 @@ function change(next: string) {
|
||||
<TabPane label="快应用" name="apps">
|
||||
<Apps />
|
||||
</TabPane>
|
||||
<TabPane label="表单授权" name="access">
|
||||
<FormAccessPanel />
|
||||
</TabPane>
|
||||
<TabPane label="字典管理" name="dicts">
|
||||
<Dicts />
|
||||
</TabPane>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user