feat(小组件管理改版): 卡片网格管理台 + 校级覆盖层 + 退役系统设置/应用管理
build / build (push) Successful in 42s

后端
- widget_catalog 加 3 列:scope_type(三态可见范围)/ role_types(可见角色)/ role_alias(角色专属命名 JSON)
- 新增两表:widget_catalog_school(点名表,uk_widget_school)、widget_school_setting(校级覆盖层,uk_widget_sch)
- WidgetController 新增 GetWidgetCatalogList / SaveWidgetCatalog / DeleteWidgetCatalog /
  GetWidgetSchoolSetting / SaveWidgetSchoolSetting;权限拆 checkSuperAdmin(仅超管)与
  checkSchAdmin(超管或 roleId∈{2,5,7})
- SaveWidgetSchoolSetting 强制 schId 取当前登录校,前端传参不采信(防越权)
- 显示名三级链:校级别名 > 角色专属命名 > 组件名;role_alias 坏 JSON 降级不抛
- 启动期幂等 seeding(空库才写 3 条内置目录行)
- 手工 DDL 脚本 resources/sql/widget_catalog_v2.sql(含回滚)

前端
- WidgetManage 重写为卡片网格:超管/校管双视角、类型页签 + 搜索、拖拽排序、启停、
  删除、登记内置组件、空态引导
- 新增 WidgetCard / WidgetEditModal / WidgetRegisterModal
- UserSettingApi 加 6 个方法;types 加 WidgetCatalog 接口
- 管理端列表整行回传(SaveWidgetCatalog 为整行 merge)

退役
- 删除 SysSetting.vue / SysTempManage.vue / AppSettingApi.ts 与后端 AppSettingController /
  AppCategory(SQL) 全链路,清理路由、Settings 导航与样式分支
- 保留 SysSettingApi.ts(壁纸/logo 池管理仍在使用)
This commit is contained in:
chenxing
2026-10-11 21:40:31 +08:00
parent 9980ea1264
commit 5a19a4ed79
13 changed files with 2559 additions and 1024 deletions
+342
View File
@@ -0,0 +1,342 @@
<template>
<Teleport to="body">
<div v-if="visible" class="wm-reg-root">
<div class="modal-mask show" @click="close"></div>
<div class="modal show sm">
<div class="m-head">
<div>
<h4>登记内置组件 <span class="badge builtin">超管专属</span></h4>
<div class="m-sub">键须与 front/src/components/widgets/index.ts 一致</div>
</div>
<button class="x" @click="close">×</button>
</div>
<div class="m-body">
<p>
<b>原理:</b>内置组件的<b>代码</b>打在前端工程里,并在注册表 <code>index.ts</code> 注册成一个<b>键</b>;
<code>widget_catalog</code> 只存「目录行」(键 + 展示名 + 排序 + 启用 + 范围 + 角色 + 别名覆盖),<b>不存内容</b>。
用户端拿到候选池后按 <code>code</code> 去注册表查,命中就渲染对应 Vue 组件。<br />
所以「登记」= 往目录表插一条 <code>comp_type=1, code=注册表里的键</code> 的记录,<b>不会让代码里凭空多出一个组件</b>。
</p>
<ol>
<li>前端新建 <code>components/widgets/XxxWidget.vue</code></li>
<li>在 <code>components/widgets/index.ts</code> 注册 <code>{ code, name }</code>(键一旦发布就别改,改键 = 换身份)</li>
<li>需要动态数据时,后端 <code>WidgetDataService</code> 加白名单分支</li>
<li><b>发版部署</b>(内置组件必须发版才生效)</li>
<li>回到本弹窗点「登记」,或跑 <code>resources/sql/widget_catalog.sql</code> 种子</li>
</ol>
<ul class="reg-list">
<li v-for="row in rows" :key="row.code">
<input v-model="row.name" class="inp name-inp" maxlength="30" placeholder="展示名" />
<span class="rc">{{ row.code }}</span>
<span class="grow"></span>
<span v-if="row.registered" class="badge ok">已登记</span>
<button v-else class="btn sm primary" :disabled="saving" @click="registerOne(row)">登记</button>
</li>
<li v-if="rows.length === 0" class="empty">前端注册表里没有可登记的键</li>
</ul>
</div>
<div class="m-foot">
<span class="save-tip">{{ tip }}</span>
<button class="btn plain" @click="close">关闭</button>
<button v-if="pendingCount > 1" class="btn primary" :disabled="saving" @click="registerAll">
一键登记 {{ pendingCount }} 个未登记键
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import UserSettingApi from "@/api/UserSettingApi";
const props = defineProps<{
visible: boolean;
/** 前端注册表的键(来自 components/widgets/index.ts) */
registryCodes: string[];
/** 已登记进候选池的内置键 */
registeredCodes: string[];
/** 登记时的起始排序基数 */
nextSort: number;
}>();
const emit = defineEmits<{ (e: "close"): void; (e: "saved"): void }>();
const saving = ref(false);
const tip = ref("");
const rows = reactive<{ code: string; name: string; registered: boolean }[]>([]);
const pendingCount = computed(() => rows.filter(x => !x.registered).length);
watch(
() => props.visible,
show => {
if (!show) return;
rows.splice(0, rows.length);
props.registryCodes.forEach(code => {
rows.push({ code, name: code, registered: props.registeredCodes.includes(code) });
});
tip.value = "";
},
{ immediate: true }
);
function close() {
emit("close");
}
async function registerOne(row: { code: string; name: string }) {
await registerBatch([{ code: row.code, name: row.name }]);
}
async function registerAll() {
const payload = rows.filter(x => !x.registered).map(x => ({ code: x.code, name: x.name }));
await registerBatch(payload);
}
async function registerBatch(payload: { code: string; name: string }[]) {
if (payload.length === 0) return;
saving.value = true;
let done = 0;
let baseSort = props.nextSort;
for (const one of payload) {
try {
await UserSettingApi.saveWidgetCatalog({
id: null,
code: one.code,
name: (one.name || one.code).trim(),
compType: 1,
content: null,
dataCode: one.code,
scopeType: 0,
scopeSchIds: [],
roleTypes: null,
roleAlias: null,
sort: baseSort,
enabled: 1,
});
done++;
baseSort += 10;
} catch (e) {
console.warn("[widget] 登记失败", one.code, e);
Message.error(typeof e === "string" ? e : `登记失败:${one.code}`);
}
}
saving.value = false;
if (done > 0) {
Message.success(`已登记 ${done} 个内置组件`);
tip.value = "登记后进入候选池,可回主界面继续配范围 / 角色";
emit("saved");
}
}
</script>
<style lang="scss" scoped>
@use "sass:color";
@use "../../assets/styles/utils/varible" as *;
$wm-brand: $color-primary;
$wm-brand-weak: color.mix(#ffffff, $wm-brand, 90%);
.wm-reg-root {
--wm-t1: #2c3138;
--wm-t2: #4a4f55;
--wm-t3: #7a8088;
--wm-t4: #9aa0a8;
--wm-line: #eef0f3;
--wm-ok-bg: #e8f7ee;
--wm-ok: #12a150;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(16, 24, 40, 0.3);
z-index: 110;
}
.modal {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 780px;
max-width: 94vw;
max-height: 88vh;
background: #fff;
border-radius: 12px;
z-index: 111;
box-shadow: 0 20px 60px rgba(16, 24, 40, 0.24);
display: flex;
flex-direction: column;
overflow: hidden;
&.sm {
width: 560px;
}
}
.m-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 22px 12px;
border-bottom: 1px solid var(--wm-line);
h4 {
font-size: 15px;
font-weight: 600;
color: var(--wm-t1);
display: flex;
align-items: center;
gap: 8px;
}
.m-sub {
font-size: 12px;
color: var(--wm-t4);
margin-top: 4px;
font-family: Consolas, Menlo, monospace;
}
.x {
border: none;
background: transparent;
font-size: 20px;
line-height: 1;
color: var(--wm-t4);
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
&:hover {
background: #f2f4f8;
color: var(--wm-t2);
}
}
}
.m-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px 22px 18px;
> p {
font-size: 12.5px;
color: var(--wm-t3);
line-height: 1.75;
margin-bottom: 12px;
}
ol {
font-size: 12.5px;
color: var(--wm-t3);
line-height: 1.9;
padding-left: 20px;
margin-bottom: 12px;
b {
color: var(--wm-t2);
}
}
}
.m-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 22px 16px;
border-top: 1px solid var(--wm-line);
.save-tip {
flex: 1;
font-size: 12px;
color: var(--wm-ok);
}
}
.badge {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 500;
white-space: nowrap;
&.builtin {
background: $wm-brand-weak;
color: $wm-brand;
}
&.ok {
background: var(--wm-ok-bg);
color: var(--wm-ok);
}
}
.reg-list {
list-style: none;
border: 1px solid var(--wm-line);
border-radius: 8px;
overflow: hidden;
li {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
border-bottom: 1px solid var(--wm-line);
font-size: 13px;
&:last-child {
border-bottom: none;
}
&.empty {
color: var(--wm-t4);
font-size: 12.5px;
}
.grow {
flex: 1;
}
.rc {
font-size: 11.5px;
color: var(--wm-t4);
font-family: Consolas, Menlo, monospace;
}
}
}
.inp {
height: 30px;
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 0 10px;
font-size: 12.5px;
outline: none;
background: #fff;
&:focus {
border-color: $wm-brand;
box-shadow: 0 0 0 3px rgba($wm-brand, 0.12);
}
&.name-inp {
width: 150px;
flex-shrink: 0;
}
}
.btn {
border: 1px solid #d0d5dd;
background: #fff;
color: #344054;
height: 30px;
padding: 0 12px;
border-radius: 6px;
font-size: 12.5px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
&:hover {
border-color: var(--wm-t4);
}
&.primary {
background: $wm-brand;
border-color: $wm-brand;
color: #fff;
}
&.sm {
height: 26px;
padding: 0 10px;
font-size: 12px;
}
&[disabled] {
opacity: 0.5;
cursor: default;
}
}
</style>