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 池管理仍在使用)
343 lines
9.8 KiB
Vue
343 lines
9.8 KiB
Vue
<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>
|