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