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
+403
View File
@@ -0,0 +1,403 @@
<template>
<div
class="wcard"
:class="{ off: !item.enabled, dragging, 'drag-over': dragOver }"
:draggable="draggable"
@mouseup="draggable = false"
@dragstart="onDragStart"
@dragover.prevent="onDragOver"
@drop.prevent="onDrop"
@dragend="onDragEnd"
>
<div class="wc-top">
<span class="badge" :class="item.compType === 1 ? 'builtin' : 'custom'">{{ typeLabel }}</span>
<template v-if="item.compType === 1">
<span v-if="registryHit" class="badge ok" title="前端 widgetRegistry 已收录该键">注册表 ✓</span>
<span v-else class="badge warn" title="前端注册表未收录,用户侧会落「暂不可用」空态">⚠ 未收录</span>
</template>
<span v-else class="badge mute" title="片段正文存在 widget_catalog.content">内容存库</span>
<span class="drag" title="按住拖拽排序" @mousedown.stop="draggable = true">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor">
<circle cx="9" cy="5.5" r="1.6" />
<circle cx="15" cy="5.5" r="1.6" />
<circle cx="9" cy="12" r="1.6" />
<circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18.5" r="1.6" />
<circle cx="15" cy="18.5" r="1.6" />
</svg>
</span>
</div>
<div class="wc-name">
<span>{{ item.name }}</span>
<span v-if="item.alias" class="badge alias" :title="`校级别名;超管下发名称:${item.originalName || item.name}`">别名</span>
<span v-if="adaptive" class="tag-adaptive">按角色适配</span>
</div>
<div class="wc-code">{{ item.code }}</div>
<div class="wc-meta">
<div class="row">
<span class="k">取数键</span>
<span class="v">
<code v-if="item.dataCode">{{ item.dataCode }}</code>
<span v-else class="dim">—</span>
</span>
</div>
<div class="row">
<span class="k">可见</span>
<span class="v">
<span class="rchip" :class="scopeCls">{{ scopeText }}</span>
<span v-if="roleAll" class="rchip all">全角色</span>
<template v-else>
<span v-for="r in roleList" :key="r" class="rchip">{{ roleNameOf(r) }}</span>
</template>
</span>
</div>
<div class="row">
<span class="k">更新</span>
<span class="v dim">{{ item.updateTime || "—" }}</span>
</div>
</div>
<div class="wc-ft">
<span class="wc-ops">
<a class="op" @click="$emit('edit', item)">{{ isSuperView ? "编辑" : "设置" }}</a>
<a class="op" @click="$emit('preview', item)">预览</a>
<a v-if="isSuperView" class="op danger" @click="$emit('del', item)">删除</a>
</span>
<span class="wc-sw">
<span class="sw" :class="{ 'is-on': !!item.enabled }" @click="$emit('toggle', item)"></span>
<span class="wc-sw-t" :class="{ on: !!item.enabled }">{{ item.enabled ? "已启用" : "已下线" }}</span>
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import type { WidgetCatalog } from "@/types";
import { RoleTypeEnum } from "@/types";
const props = defineProps<{
item: WidgetCatalog;
isSuperView: boolean;
registryHit: boolean;
schools: { id: number; name: string }[];
dragging: boolean;
dragOver: boolean;
}>();
const emit = defineEmits<{
(e: "edit" | "preview" | "toggle" | "del", item: WidgetCatalog): void;
(e: "dragstart" | "dragover" | "drop", id: number | undefined): void;
(e: "dragend"): void;
}>();
const draggable = ref(false);
const typeLabel = computed(() => (props.item.compType === 1 ? "内置组件" : "自定义片段"));
const adaptive = computed(() => props.item.compType === 1 && props.item.code === "syllabus");
const roleKeys = computed(() => {
const raw = props.item.roleTypes;
if (!raw) return [] as number[];
return raw
.split(",")
.map(x => Number(x.trim()))
.filter(x => !isNaN(x))
.sort();
});
const roleAll = computed(() => roleKeys.value.length === 0 || roleKeys.value.length === 3);
const roleList = computed(() => (roleAll.value ? [] : roleKeys.value));
const roleNameOf = (k: number) => ({ [RoleTypeEnum.Teacher]: "教师", [RoleTypeEnum.SchAdmin]: "校领导", [RoleTypeEnum.Student]: "学生" })[k] || `角色 ${k}`;
const schoolNameOf = (id: number) => props.schools.find(x => x.id === id)?.name || `学校 ${id}`;
const scopeCls = computed(() => (props.item.scopeType === 1 ? "sch" : props.item.scopeType === 2 ? "no" : "all"));
const scopeText = computed(() => {
const names = (props.item.scopeSchIds || []).map(schoolNameOf).join("、");
if (props.item.scopeType === 1) return `指定可见 ${names}`;
if (props.item.scopeType === 2) return `指定不可见 ${names}`;
return "全部学校";
});
function onDragStart(e: DragEvent) {
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(props.item.id));
}
emit("dragstart", props.item.id);
}
function onDragOver() {
if (draggable.value) emit("dragover", props.item.id);
}
function onDrop() {
emit("drop", props.item.id);
draggable.value = false;
}
function onDragEnd() {
draggable.value = false;
emit("dragend");
}
</script>
<style lang="scss" scoped>
@use "sass:color";
@use "../../assets/styles/utils/varible" as *;
$wm-brand: $color-primary; // 品牌主色唯一来源 = 项目 SCSS 变量
$wm-brand-weak: color.mix(#ffffff, $wm-brand, 90%);
$wm-brand-line: color.mix(#ffffff, $wm-brand, 78%);
.wcard {
--wm-t1: #2c3138;
--wm-t2: #4a4f55;
--wm-t3: #7a8088;
--wm-t4: #9aa0a8;
--wm-line: #eef0f3;
--wm-line2: #e5e8ee;
--wm-ok: #12a150;
--wm-ok-bg: #e8f7ee;
--wm-warn: #b54708;
--wm-warn-bg: #fff4e5;
--wm-danger: #d92d20;
--wm-danger-bg: #fdecea;
border: 1px solid var(--wm-line2);
border-radius: 10px;
padding: 13px 14px 0;
background: #fff;
transition: border-color 0.16s, box-shadow 0.16s, transform 0.16s;
position: relative;
// 同一行卡片等高、底部操作行对齐(网格 stretch + 底部自适应)
display: flex;
flex-direction: column;
&:hover {
border-color: $wm-brand-line;
box-shadow: 0 6px 18px rgba($wm-brand, 0.12);
transform: translateY(-1px);
}
&.off {
background: #fcfcfd;
.wc-name {
color: var(--wm-t3);
}
}
&.dragging {
opacity: 0.4;
}
&.drag-over {
border-color: $wm-brand;
box-shadow: 0 0 0 2px rgba($wm-brand, 0.22);
}
}
.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;
}
&.custom {
background: var(--wm-warn-bg);
color: var(--wm-warn);
}
&.ok {
background: var(--wm-ok-bg);
color: var(--wm-ok);
}
&.warn {
background: var(--wm-danger-bg);
color: var(--wm-danger);
}
&.mute {
background: #f0f1f3;
color: var(--wm-t3);
}
&.alias {
background: #eaf6ff;
color: #1677b3;
}
}
.wc-top {
display: flex;
align-items: center;
gap: 5px;
min-height: 22px;
}
.drag {
margin-left: auto;
width: 22px;
height: 22px;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: grab;
color: #c4c9d2;
transition: all 0.15s;
flex-shrink: 0;
&:hover {
color: $wm-brand;
background: $wm-brand-weak;
}
&:active {
cursor: grabbing;
}
}
.wc-name {
font-size: 14px;
font-weight: 600;
color: var(--wm-t1);
margin-top: 9px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.tag-adaptive {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 5px;
font-size: 11px;
background: #eaf6ff;
color: #1677b3;
white-space: nowrap;
}
.wc-code {
font-size: 11.5px;
color: var(--wm-t4);
font-family: Consolas, Menlo, monospace;
margin-top: 2px;
}
.wc-meta {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--wm-line2);
display: flex;
flex-direction: column;
gap: 7px;
.row {
display: flex;
gap: 8px;
font-size: 12px;
line-height: 1.7;
.k {
width: 46px;
flex-shrink: 0;
color: var(--wm-t4);
}
.v {
color: var(--wm-t2);
min-width: 0;
}
.dim {
color: var(--wm-t4);
}
}
}
.rchip {
display: inline-flex;
align-items: center;
height: 19px;
padding: 0 7px;
border-radius: 5px;
font-size: 11px;
background: #eef1f6;
color: #5b6472;
margin-right: 4px;
margin-bottom: 2px;
white-space: nowrap;
&.all {
background: #f0f1f3;
color: var(--wm-t3);
}
&.sch {
background: #eaf6ff;
color: #1677b3;
}
&.no {
background: var(--wm-danger-bg);
color: var(--wm-danger);
}
}
.wc-ft {
display: flex;
align-items: center;
gap: 10px;
margin-top: auto;
padding: 9px 0 11px;
border-top: 1px solid var(--wm-line);
}
.wc-ops {
display: flex;
gap: 11px;
min-width: 0;
}
.op {
font-size: 12.5px;
color: $wm-brand;
cursor: pointer;
white-space: nowrap;
&:hover {
text-decoration: underline;
}
&.danger {
color: var(--wm-danger);
}
}
.wc-sw {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
.sw {
position: relative;
display: inline-block;
width: 38px;
height: 21px;
border-radius: 11px;
background: #d5d8de;
cursor: pointer;
transition: background 0.18s;
vertical-align: middle;
flex-shrink: 0;
&::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 17px;
height: 17px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: left 0.18s;
}
&.is-on {
background: $wm-brand;
&::after {
left: 19px;
}
}
}
.wc-sw-t {
font-size: 12px;
color: var(--wm-t4);
&.on {
color: var(--wm-ok);
}
}
</style>