后端
- 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:
@@ -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>
|
||||
Reference in New Issue
Block a user