Files
form-center-front/src/components/admin/FormAccessPanel.vue
T

386 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>