This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user