t1~t3+精修: 授权面板/对账同步管理页/填报三通道;Fill.vue 幂等键失败重试复用(切坐标才重置)
build / build (push) Successful in 2m7s

This commit is contained in:
chenxing
2026-10-22 10:03:01 +08:00
parent 2661a4d4d3
commit 61398f354e
10 changed files with 1262 additions and 12 deletions
+14 -1
View File
@@ -5,7 +5,7 @@
* 与后端 FormResolveController / FormAnswerController 对齐。 * 与后端 FormResolveController / FormAnswerController 对齐。
*/ */
import { alovaInstance } from "@/utils/alova/alova-iview"; import { alovaInstance } from "@/utils/alova/alova-iview";
import type { ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer"; import type { HostedForm, ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer";
import type { AnswerDetail, Capabilities, FillableForm, ManagedForm, MyAnswerViewItem } from "@/types/data"; import type { AnswerDetail, Capabilities, FillableForm, ManagedForm, MyAnswerViewItem } from "@/types/data";
/** 解析当前发布版本(契约 §1:入口寻址) */ /** 解析当前发布版本(契约 §1:入口寻址) */
@@ -20,6 +20,19 @@ export function submitAnswer(body: SubmitRequest) {
return alovaInstance.Post<SubmitView>("/api/form/submit", body).send(); 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 过滤,"我的提交"段) */ /** 我的答卷列表(按 submitter 过滤,"我的提交"段) */
export function listMyAnswers(formCode: string, appCode?: string) { export function listMyAnswers(formCode: string, appCode?: string) {
const params: Record<string, string> = { formCode }; const params: Record<string, string> = { formCode };
+74
View File
@@ -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 }) });
}
+197
View File
@@ -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();
}
+385
View File
@@ -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>
+10
View File
@@ -69,6 +69,16 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/admin/AppSettings.vue"), component: () => import("@/views/admin/AppSettings.vue"),
meta: { title: "应用设置" }, 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)。 // 旧字典管理地址(六行版模块行时代的 path):只保留重定向,不再单独成页(T10 方案 §6.1)。
// 外部残留链接落到这里=进应用设置的字典 tab,避免出现"两条都能进、只有一条有守卫"的口径。 // 外部残留链接落到这里=进应用设置的字典 tab,避免出现"两条都能进、只有一条有守卫"的口径。
+31
View File
@@ -34,9 +34,40 @@ export interface SubmitRequest {
appCode?: string; appCode?: string;
idempotencyKey: string; idempotencyKey: string;
subjectId?: number; subjectId?: number;
/**
* 可信提交上下文令牌(answer-delivery-v2 §6.3,TASK_2 A.3)。
* 只有被业务应用(办事大厅)嵌进来的填报页才带它:后端据此按**签发时钉死的
* formUuid+version** 取 schema(不回落到当前发布版)、复核会话用户/学校,
* 并在答卷事务里把这份提交原子关联回那次业务意图。
* 它不是身份凭据——schId/submitterId 仍只由会话推导(V17 不变)。
*/
contextToken?: string;
values: Record<string, any>; 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) */ /** 提交响应(契约 §5:{answerId, answerNo};幂等命中时 replayed=true) */
export interface SubmitView { export interface SubmitView {
answerId: string; answerId: string;
+42
View File
@@ -111,3 +111,45 @@ export function listenFromParent(handler: (message: EmbedMessage, origin: string
window.addEventListener("message", listener); window.addEventListener("message", listener);
return () => window.removeEventListener("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
View File
@@ -17,12 +17,12 @@ import { useRoute } from "vue-router";
import { fieldWidget } from "@/components/fields"; import { fieldWidget } from "@/components/fields";
import type { DesignField } from "@/types/formSchema"; import type { DesignField } from "@/types/formSchema";
import type { ResolvedForm, MyAnswerItem, SubmitRequest } from "@/types/answer"; 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 { probeSession, probeAnonForm, submitAnonAnswer, anonUploadPath } from "@/api/module/anon";
import { baseApi } from "@/api/module/base"; import { baseApi } from "@/api/module/base";
import UserCheckModal from "@/components/UserCheckModal.vue"; import UserCheckModal from "@/components/UserCheckModal.vue";
import { CENTER_APP_CODE, managesForm, useDefaultStore } from "@/store"; 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 Utils from "@/utils";
import { Message, Modal } from "view-ui-plus"; import { Message, Modal } from "view-ui-plus";
@@ -55,9 +55,17 @@ const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
/** 本次填报走的通道(null=还没判出来);提交、上传、"我的提交"段都按它分派 */ /** 本次填报走的通道(null=还没判出来);提交、上传、"我的提交"段都按它分派 */
type FillChannel = "session" | "anon"; type FillChannel = "session" | "anon" | "hosted";
const channel = ref<FillChannel | null>(null); 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 它,缺省=登录面那条)。 * 附件控件的上传端点(upload/image 控件 inject 它,缺省=登录面那条)。
* 匿名必须走 `/anon/form/upload?code=`:后端只有表单能定 {@code form_file.sch_id}=表单归属校, * 匿名必须走 `/anon/form/upload?code=`:后端只有表单能定 {@code form_file.sch_id}=表单归属校,
@@ -81,6 +89,22 @@ async function loadForm() {
error.value = null; error.value = null;
const queryAppCode = appCode.value || undefined; const queryAppCode = appCode.value || undefined;
try { 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) { if ((await probeSession()).logged) {
channel.value = "session"; channel.value = "session";
form.value = await resolveForm(code.value, queryAppCode); form.value = await resolveForm(code.value, queryAppCode);
@@ -113,6 +137,13 @@ async function loadForm() {
* "无会话 ∧ 这又不是匿名表单"时被调用:embed 发 postMessage,page 整页跳 CAS。 * "无会话 ∧ 这又不是匿名表单"时被调用:embed 发 postMessage,page 整页跳 CAS。
*/ */
function guideToLogin() { function guideToLogin() {
if (channel.value === "hosted") {
error.value = "请先登录后填写";
// 托管态未登录时**不发**任何 postMessage:此刻还没取到服务端锁定的 embedOrigin
// (它跟着 hosted schema 一起回),而"随便找个父页发一句"正是 A.4 禁止的形态。
// 用户在办事页重新登录后再进入口即可;父页的确认调用会拿到明确的未登录错误。
return;
}
if (isEmbedded.value) { if (isEmbedded.value) {
error.value = "请先登录后填写"; error.value = "请先登录后填写";
reportNeedLogin(); reportNeedLogin();
@@ -200,6 +231,8 @@ function validateAll(): boolean {
// ==================== 提交 ==================== // ==================== 提交 ====================
const submitting = ref(false); 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 submitted = ref(false);
const submitResult = ref<{ answerId: string; answerNo: string | null } | null>(null); const submitResult = ref<{ answerId: string; answerNo: string | null } | null>(null);
@@ -215,7 +248,7 @@ async function handleSubmit() {
} }
submitting.value = true; submitting.value = true;
try { try {
const idempotencyKey = Utils.uuid(); const idempotencyKey = submitIdempotencyKey.value;
const payload: SubmitRequest = { const payload: SubmitRequest = {
formCode: code.value, formCode: code.value,
appCode: appCode.value || undefined, appCode: appCode.value || undefined,
@@ -223,13 +256,34 @@ async function handleSubmit() {
// 代录:subject=被记录人(后端按该表单 ALL 档把关,非档者带这个字段就是 403); // 代录:subject=被记录人(后端按该表单 ALL 档把关,非档者带这个字段就是 403);
// 没选人就不带——"以我自己为被记录人"与手工填报同一条路径 // 没选人就不带——"以我自己为被记录人"与手工填报同一条路径
subjectId: canProxy.value ? subject.value?.id : undefined, subjectId: canProxy.value ? subject.value?.id : undefined,
// 托管态带令牌:后端在答卷事务内兑换它并原子关联那次业务意图(身份仍取会话)
contextToken: channel.value === "hosted" ? hostedToken.value : undefined,
values: formValues.value values: formValues.value
}; };
// 两条通道同一条请求形状(匿名体里没有 subjectId,服务端也不读身份字段——C8/V17) // 三条通道同一条请求形状(匿名体里没有 subjectId,服务端也不读身份字段——C8/V17)
const result = channel.value === "anon" ? await submitAnonAnswer(payload) : await submitAnswer(payload); 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 }; submitResult.value = { answerId: result.answerId, answerNo: result.answerNo };
submitted.value = true; 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 }); reportSubmitResult({ success: true, answerId: result.answerId, answerNo: result.answerNo || undefined });
} else { } else {
const redirectUrl = String(route.query.redirect ?? ""); const redirectUrl = String(route.query.redirect ?? "");
@@ -243,7 +297,9 @@ async function handleSubmit() {
} catch (e: any) { } catch (e: any) {
const msg = e?.message || "提交失败"; const msg = e?.message || "提交失败";
Message.error(msg); 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 }); reportSubmitResult({ success: false, error: msg });
} }
} finally { } finally {
@@ -355,7 +411,8 @@ onUnmounted(() => {
if (stopHeightReporting) stopHeightReporting(); if (stopHeightReporting) stopHeightReporting();
}); });
watch(code, () => { watch([code, appCode, hostedToken], () => {
submitIdempotencyKey.value = Utils.uuid();
loadForm(); loadForm();
}); });
+10 -2
View File
@@ -1,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 应用设置(`/admin/app`,T10 方案 §3/§6.1):一行模块位置,页内三个 tab。 * 应用设置(`/admin/app`,T10 方案 §3/§6.1):一行模块位置,页内四个 tab。
* *
* - **角色设置** = 校级表单管理员名单(`school_admin`); * - **角色设置** = 校级表单管理员名单(`school_admin`);
* - **快应用** = app 注册表(契约 v2 §5.2/C17,T16;零角色列,入口链接由中心生成); * - **快应用** = app 注册表(契约 v2 §5.2/C17,T16;零角色列,入口链接由中心生成);
* - **表单授权** = 消费应用的 owner 绑定与读/订阅授权(v3/TASK_1,`FormAccessPanel`);
* - **字典管理** = 原 `/admin/dicts` 整页迁入(`Dicts.vue` 原样复用,守卫行随本行换到 `/admin/app`)。 * - **字典管理** = 原 `/admin/dicts` 整页迁入(`Dicts.vue` 原样复用,守卫行随本行换到 `/admin/app`)。
* *
* 「快应用」与「表单授权」刻意是两个 tab 而不是一个面板:前者是"有哪些应用"(目录,零角色),
* 后者是"哪个应用能读哪张表"(授权)——把授权塞进注册表正是契约 v2 出清掉的东西(§19 作废-1/2)。
*
* 三条口径: * 三条口径:
* 1. **tab 不占模块行、不进导航**(同设计器子页),所以高亮/面包屑挂本行;`?tab=dicts|apps` 可深链, * 1. **tab 不占模块行、不进导航**(同设计器子页),所以高亮/面包屑挂本行;`?tab=dicts|apps` 可深链,
* 旧地址 `/admin/dicts` 由路由重定向到这里的字典 tab; * 旧地址 `/admin/dicts` 由路由重定向到这里的字典 tab;
@@ -18,13 +22,14 @@
import { computed } from "vue"; import { computed } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import AdminListPanel from "@/components/admin/AdminListPanel.vue"; import AdminListPanel from "@/components/admin/AdminListPanel.vue";
import FormAccessPanel from "@/components/admin/FormAccessPanel.vue";
import Apps from "./Apps.vue"; import Apps from "./Apps.vue";
import Dicts from "./Dicts.vue"; import Dicts from "./Dicts.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const TABS = ["roles", "apps", "dicts"] as const; const TABS = ["roles", "apps", "access", "dicts"] as const;
/** 缺省落角色设置;只认闭集内的值,其它 query 一律按缺省 */ /** 缺省落角色设置;只认闭集内的值,其它 query 一律按缺省 */
const tab = computed(() => { const tab = computed(() => {
@@ -47,6 +52,9 @@ function change(next: string) {
<TabPane label="快应用" name="apps"> <TabPane label="快应用" name="apps">
<Apps /> <Apps />
</TabPane> </TabPane>
<TabPane label="表单授权" name="access">
<FormAccessPanel />
</TabPane>
<TabPane label="字典管理" name="dicts"> <TabPane label="字典管理" name="dicts">
<Dicts /> <Dicts />
</TabPane> </TabPane>
+433
View File
@@ -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>