t16:快应用注册表入口——系统设置第三个 tab(注册/改名/复制入口链接/删除)
- api/module/apps.ts:C17 四端点,寻址一律按 appCode(库内 id 不外露,V5)。 - types/data.ts 增 AppRow(四业务字段 + entryUrl);无角色/授权字段(V59 的界面侧表达)。 - views/admin/Apps.vue:注册(C10 格式与保留字做即时预检,但保存失败以后端 msg 为准并留在弹窗里)、 改名(code 不随改,输入框禁用)、复制应用入口(后端给站内路径,前端补 origin,生产/本地都能点开)、 删除(在用禁删与"编码不复用"两类拒绝一律原样提示,前端不复制规则,同 AdminListPanel 口径)。 页面上没有任何"给这个应用配谁能用"的入口——那是有意的。 - AppSettings.vue:tab 值改闭集判定(roles/apps/dicts),?tab=apps 可深链; 入口本身仍由 capabilities.schoolAdmin 决定(S3 第三支路),不做"进了页再藏 tab"。 门禁:npx vue-tsc --noEmit exit 0;npm run build ✓ built in 12.16s。
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 快应用注册表 CRUD(契约 v2 C17/§5.2,T16,挂应用设置 · 快应用 tab)。
|
||||
*
|
||||
* 三处与直觉不同:
|
||||
* 1. **寻址按 `appCode`,不是库内 id**(V5:自增 id 绝不外露)——所以改名/删除的 URL 段是 slug 本身;
|
||||
* 2. **`schId` 只有超管切目标域时才带**,校管理员传别的值即被 `FormAdminGuard` 拒(同两表端点口径);
|
||||
* 3. **入口链接由后端生成**(`entryUrl` 是站内路径,按后端自己的 context-path 组段),
|
||||
* 前端只补 `origin`——生产得到 `/form-center/front/#/app/{code}/`,本地 dev 没有 context 段,
|
||||
* 得到 `/#/app/{code}/`,两处复制出来的链接都能直接点开。
|
||||
*/
|
||||
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||
import type { AppRow } from "@/types/data";
|
||||
|
||||
const BASE = "/api/form/admin/apps";
|
||||
|
||||
/** 清单:本校 ∪ 平台级(软删行不在内) */
|
||||
export function listApps(schId?: number) {
|
||||
const params: Record<string, string | number> = {};
|
||||
if (schId !== undefined && schId !== null) params.schId = schId;
|
||||
return alovaInstance.Get<AppRow[]>(BASE, { params }).send();
|
||||
}
|
||||
|
||||
/** 建(app_code + name;无审核,D14) */
|
||||
export function createApp(body: { appCode: string; name: string }) {
|
||||
return alovaInstance.Post<AppRow>(BASE, body).send();
|
||||
}
|
||||
|
||||
/** 改名——code 不随改(它是入口链接与 oapi/MQ 的域段) */
|
||||
export function renameApp(appCode: string, name: string) {
|
||||
return alovaInstance.Put<AppRow>(`${BASE}/${encodeURIComponent(appCode)}`, { name }).send();
|
||||
}
|
||||
|
||||
/** 软删(名下有未删除表单时后端按 CUSTOM(5) 拒,msg 指点先迁先删) */
|
||||
export function deleteApp(appCode: string) {
|
||||
return alovaInstance.Delete<AppRow>(`${BASE}/${encodeURIComponent(appCode)}`).send();
|
||||
}
|
||||
@@ -171,6 +171,22 @@ export interface Capabilities {
|
||||
schoolAdmin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快应用注册行(契约 v2 C17/§5.2,`GET /api/form/admin/apps`,T16)。
|
||||
*
|
||||
* 四个业务字段 + 中心生成的入口链接;**没有角色/授权列**(V59 红线:谁能用哪个应用一律由
|
||||
* 会话在该应用表单上的 ALL/SELF 档现算,注册表不存授权痕迹)。
|
||||
*/
|
||||
export interface AppRow {
|
||||
/** 全局唯一人读 slug(C10 格式;保留字 form-center 不可注册),发布后不随改 */
|
||||
appCode: string;
|
||||
name: string;
|
||||
/** 归属租户域;0=平台级(仅超管可建) */
|
||||
schId: number;
|
||||
/** 应用入口链接(站内路径,§5.2"由中心生成")——复制后贴去 UserBase 建应用 */
|
||||
entryUrl: string;
|
||||
}
|
||||
|
||||
/** 范围名 → 展示文案;未知名原样显示(后端加枚举值不破坏前端) */
|
||||
export function scopeLabel(name: string): string {
|
||||
return DATA_SCOPE_LABELS[name] ?? name;
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 应用设置(`/admin/app`,T10 方案 §3/§6.1):UserBase 侧一行模块行,页内两个 tab。
|
||||
* 应用设置(`/admin/app`,T10 方案 §3/§6.1):一行模块位置,页内三个 tab。
|
||||
*
|
||||
* - **角色设置** = 校级表单管理员名单(`school_admin`);
|
||||
* - **快应用** = app 注册表(契约 v2 §5.2/C17,T16;零角色列,入口链接由中心生成);
|
||||
* - **字典管理** = 原 `/admin/dicts` 整页迁入(`Dicts.vue` 原样复用,守卫行随本行换到 `/admin/app`)。
|
||||
*
|
||||
* 两条口径:
|
||||
* 1. **tab 不占模块行、不进导航**(同设计器子页),所以高亮/面包屑挂本行;`?tab=dicts` 可深链,
|
||||
* 三条口径:
|
||||
* 1. **tab 不占模块行、不进导航**(同设计器子页),所以高亮/面包屑挂本行;`?tab=dicts|apps` 可深链,
|
||||
* 旧地址 `/admin/dicts` 由路由重定向到这里的字典 tab;
|
||||
* ——深链落在模板的 {@code :model-value} 上:view-ui-plus 的 Tabs 受控 prop 是 modelValue,
|
||||
* 写 {@code :value} 会落进 attrs 不被消费(T10 验收轮实拍证伪过一次),勿"顺手改回";
|
||||
* 2. **一行把关两个 tab 与字典 API**:能不能进来由 UserBase 的 `/admin/app` 决定,
|
||||
* 前端不做"进了页再按身份藏 tab"——藏按钮不等于授权(契约 §7 身份行)。
|
||||
* 2. 不在闭集里的 tab 值一律回落缺省(角色设置),与 {@code Fill.vue} 的 mode 白名单同一条写法;
|
||||
* 3. **一行把关三个 tab 与全部维护 API**:能不能进来由 capabilities.schoolAdmin(契约 v2 §2.4 第三支路)
|
||||
* 决定,前端不做"进了页再按身份藏 tab"——藏按钮不等于授权(契约 §7 身份行)。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import AdminListPanel from "@/components/admin/AdminListPanel.vue";
|
||||
import Apps from "./Apps.vue";
|
||||
import Dicts from "./Dicts.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/** 缺省落角色设置(本页新增的主用途);只认 dicts 这一个显式值,其它 query 一律按缺省 */
|
||||
const tab = computed(() => (route.query.tab === "dicts" ? "dicts" : "roles"));
|
||||
const TABS = ["roles", "apps", "dicts"] as const;
|
||||
|
||||
/** 缺省落角色设置;只认闭集内的值,其它 query 一律按缺省 */
|
||||
const tab = computed(() => {
|
||||
const raw = String(route.query.tab ?? "");
|
||||
return (TABS as readonly string[]).includes(raw) ? raw : "roles";
|
||||
});
|
||||
|
||||
function change(next: string) {
|
||||
router.replace({ path: "/admin/app", query: { ...route.query, tab: next } });
|
||||
@@ -36,6 +44,9 @@ function change(next: string) {
|
||||
<TabPane label="角色设置" name="roles">
|
||||
<AdminListPanel mode="school" />
|
||||
</TabPane>
|
||||
<TabPane label="快应用" name="apps">
|
||||
<Apps />
|
||||
</TabPane>
|
||||
<TabPane label="字典管理" name="dicts">
|
||||
<Dicts />
|
||||
</TabPane>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 快应用注册表(契约 v2 §5.2/C17,T16):应用设置页的第三个 tab,守同一行(capabilities.schoolAdmin)。
|
||||
*
|
||||
* 页面上只有四件事:建(app_code + 名称,无审核)、改名、复制入口链接、删(在用禁删)。
|
||||
* **没有任何"给这个应用配谁能用"的入口**——那是有意的(V59 零角色红线):应用内每张表单的可见范围
|
||||
* 由会话在中心侧现算 ALL/SELF 档,注册表只给 slug 上户口。
|
||||
*
|
||||
* 两条拒绝都在后端,这里按信封 msg 原样提示,不在前端复制规则(同 AdminListPanel 的口径):
|
||||
* ① app_code 格式与保留字(C10:小写 slug 2~64 位,`form-center` 不可注册);
|
||||
* ② 名下还有未删除表单时的删除(CUSTOM(5),msg 指点先迁先删)。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { createApp, deleteApp, listApps, renameApp } from "@/api/module/apps";
|
||||
import { copyToClipboard } from "@/utils/fillLink";
|
||||
import type { AppRow } from "@/types/data";
|
||||
|
||||
const rows = ref<AppRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
|
||||
const draft = reactive({ visible: false, creating: false, appCode: "", name: "", error: "" });
|
||||
|
||||
/** C10 的前端预检:只为即时反馈,服务端仍会按同一条正则判(两处规则同源,注释里的正则就是权威) */
|
||||
const CODE_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$/;
|
||||
const RESERVED = "form-center";
|
||||
|
||||
const codeError = computed(() => {
|
||||
const code = draft.appCode.trim();
|
||||
if (!code) return "app_code 必填";
|
||||
if (code === RESERVED) return `「${RESERVED}」是中心保留的 app_code,不可注册`;
|
||||
if (!CODE_RE.test(code)) return "需为 2~64 位小写 slug(字母/数字开头结尾,中间可带 -)";
|
||||
return "";
|
||||
});
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: "应用码", key: "appCode", minWidth: 160 },
|
||||
{ title: "名称", key: "name", minWidth: 160 },
|
||||
{
|
||||
title: "归属",
|
||||
key: "schId",
|
||||
width: 90,
|
||||
render: (h: any, p: any) => h("span", p.row.schId === 0 ? "平台级" : "本校")
|
||||
},
|
||||
{
|
||||
title: "入口链接",
|
||||
key: "entryUrl",
|
||||
minWidth: 220,
|
||||
render: (h: any, p: any) =>
|
||||
h("a", { class: "operating-link", onClick: () => copyEntry(p.row as AppRow) }, "复制应用入口")
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "ops",
|
||||
width: 130,
|
||||
render: (h: any, p: any) =>
|
||||
h("div", { class: "action-buttons" }, [
|
||||
h("div", { class: "operating-link", onClick: () => openRename(p.row as AppRow) }, "改名"),
|
||||
h("div", { class: "operating-link text-error", onClick: () => confirmRemove(p.row as AppRow) }, "删除")
|
||||
])
|
||||
}
|
||||
]);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = (await listApps()) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "应用清单加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
draft.visible = true;
|
||||
draft.creating = true;
|
||||
draft.appCode = "";
|
||||
draft.name = "";
|
||||
draft.error = "";
|
||||
}
|
||||
|
||||
function openRename(row: AppRow) {
|
||||
draft.visible = true;
|
||||
draft.creating = false;
|
||||
draft.appCode = row.appCode;
|
||||
draft.name = row.name;
|
||||
draft.error = "";
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const name = draft.name.trim();
|
||||
if (!name) {
|
||||
draft.error = "应用名称必填";
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
if (draft.creating) {
|
||||
if (codeError.value) {
|
||||
draft.error = codeError.value;
|
||||
return;
|
||||
}
|
||||
const created = await createApp({ appCode: draft.appCode.trim(), name });
|
||||
rows.value = [...rows.value, created];
|
||||
Message.success(`已注册 ${created.appCode}`);
|
||||
} else {
|
||||
const renamed = await renameApp(draft.appCode, name);
|
||||
rows.value = rows.value.map((r) => (r.appCode === renamed.appCode ? renamed : r));
|
||||
Message.success("已改名");
|
||||
}
|
||||
draft.visible = false;
|
||||
} catch (e: any) {
|
||||
// 保留弹窗并把后端的拒绝原因亮出来:格式、保留字、重复注册、平台级归属都在这一支
|
||||
draft.error = e?.message || "保存失败";
|
||||
Message.error(draft.error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemove(row: AppRow) {
|
||||
Modal.confirm({
|
||||
title: "删除应用",
|
||||
content: `确定删除「${row.name}(${row.appCode})」?删除后该 app_code 不复用(全库唯一键含已删除行)。`,
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
busy.value = true;
|
||||
try {
|
||||
await deleteApp(row.appCode);
|
||||
rows.value = rows.value.filter((r) => r.appCode !== row.appCode);
|
||||
Message.success("已删除");
|
||||
} catch (e: any) {
|
||||
// 在用禁删(名下还有未删除表单)按后端 msg 原样提示,前端不复制这条规则
|
||||
Message.error(e?.message || "删除失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 入口链接:后端给站内路径(按它自己的 context-path 组段),这里补 origin 成可粘贴的绝对地址 */
|
||||
async function copyEntry(row: AppRow) {
|
||||
const link = window.location.origin + row.entryUrl;
|
||||
const ok = await copyToClipboard(link);
|
||||
if (ok) {
|
||||
Message.success("链接已复制");
|
||||
} else {
|
||||
Message.warning({ content: `复制失败,请手动复制:${link}`, duration: 8 });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
defineExpose({ load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-registry">
|
||||
<div class="toolbar">
|
||||
<Button type="primary" icon="md-add" :disabled="busy" @click="openCreate">注册快应用</Button>
|
||||
<span class="tip">
|
||||
app_code 是小写 slug(2~64 位,发布后不可改);注册只上户口,谁能用这个应用由中心按会话在那张表单上现算
|
||||
</span>
|
||||
</div>
|
||||
<Table :columns="columns" :data="rows" :loading="loading" size="small" :border="false" />
|
||||
|
||||
<Modal v-model="draft.visible" :title="draft.creating ? '注册快应用' : '改名'" :mask-closable="false"
|
||||
:width="520">
|
||||
<Form :label-width="90">
|
||||
<FormItem label="app_code" required>
|
||||
<Input v-model="draft.appCode" :disabled="!draft.creating" placeholder="如:survey-2026" />
|
||||
<div v-if="draft.creating" class="tip">
|
||||
入口链接与对外拉取都按它寻址,注册后不可改;{{ RESERVED }} 是中心保留字
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem label="应用名称" required>
|
||||
<Input v-model="draft.name" :maxlength="128" placeholder="如:2026 学期课程问卷" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<Alert v-if="draft.error" type="warning" show-icon class="tip">{{ draft.error }}</Alert>
|
||||
<template #footer>
|
||||
<Button @click="draft.visible = false">取消</Button>
|
||||
<Button type="primary" :loading="busy" @click="submit">
|
||||
{{ draft.creating ? "注册" : "保存" }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-registry .toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.app-registry .tip {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.app-registry .action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user