Files
UserhomeFront/src/views/widget/WidgetRegisterModal.vue
T
chenxing 5a19a4ed79
build / build (push) Successful in 42s
feat(小组件管理改版): 卡片网格管理台 + 校级覆盖层 + 退役系统设置/应用管理
后端
- 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 池管理仍在使用)
2026-10-11 21:40:31 +08:00

343 lines
9.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>