386 lines
14 KiB
Vue
386 lines
14 KiB
Vue
<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>
|