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
+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>