后端
- 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:
@@ -1,63 +0,0 @@
|
||||
import Abstract from "@/utils/axios/abstract";
|
||||
import { PageData, PageParams } from "@/types/Basics";
|
||||
|
||||
const path = "/api";
|
||||
|
||||
export interface AppCategoryItem {
|
||||
id?: number;
|
||||
name: string;
|
||||
apiUrl: string;
|
||||
dataKey: string;
|
||||
paramsKey: string;
|
||||
roleType: number;
|
||||
cId?: number;
|
||||
cDate?: string;
|
||||
resDate?: string;
|
||||
}
|
||||
|
||||
/** 系统设置-应用管理(应用类别 CRUD + 模板管理) */
|
||||
class AppSettingApi extends Abstract {
|
||||
/** 应用分页列表 */
|
||||
getAppCategoryList(params: PageParams & { roleType: number; name?: string }) {
|
||||
const url = path + "/GetAppCategoryList";
|
||||
return this.getRequest<PageData>({ url, params });
|
||||
}
|
||||
|
||||
/** 保存应用(新增/编辑) */
|
||||
saveAppCategory(data: AppCategoryItem) {
|
||||
const url = path + "/SaveAppCategory";
|
||||
return this.postRequest({ url, data });
|
||||
}
|
||||
|
||||
/** 删除应用 */
|
||||
deleteAppCategory(id: number) {
|
||||
const url = path + "/DelAppCategory";
|
||||
return this.postRequest({ url, params: { id } });
|
||||
}
|
||||
|
||||
/** 查询应用详情 */
|
||||
findAppCategory(id: number) {
|
||||
const url = path + "/FindAppCategory";
|
||||
return this.getRequest<AppCategoryItem>({ url, params: { id } });
|
||||
}
|
||||
|
||||
/** 刷新资源时间戳(上传模板后调用) */
|
||||
updateResDate(id: number) {
|
||||
const url = path + "/UpdateResDate";
|
||||
return this.postRequest({ url, params: { id } });
|
||||
}
|
||||
|
||||
/** 模板文件列表 */
|
||||
getTempList(appModId: number) {
|
||||
const url = path + "/GetTempList";
|
||||
return this.getRequest<any[]>({ url, params: { appModId } });
|
||||
}
|
||||
|
||||
/** 保存模板文件内容(JSON body,避免大文件内容超出 URL/请求头长度限制) */
|
||||
saveTempFile(appModId: number, fileName: string, content: string) {
|
||||
const url = path + "/SaveTempFile";
|
||||
return this.postRequest({ url, data: { appModId, fileName, content } });
|
||||
}
|
||||
}
|
||||
|
||||
export default new AppSettingApi();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DesktopAppTypeEnum, LightApp, UserSettingInfo, WidgetPositionResp } from "@/types";
|
||||
import { DesktopAppTypeEnum, LightApp, UserSettingInfo, WidgetCatalog, WidgetPositionResp } from "@/types";
|
||||
import { BaseDict, PageData, PageParams } from "@/types/Basics";
|
||||
import Abstract from "@/utils/axios/abstract";
|
||||
|
||||
@@ -107,13 +107,38 @@ class UserSettings extends Abstract {
|
||||
|
||||
getWidgetCatalogList() {
|
||||
const url = path + "/GetWidgetCatalogList";
|
||||
return this.getRequest<any[]>({ url });
|
||||
return this.getRequest<WidgetCatalog[]>({ url });
|
||||
}
|
||||
|
||||
saveWidgetCatalog(catalog: Record<string, any>) {
|
||||
/** 新增/编辑全局目录(仅超管):scopeType/roleTypes/roleAlias/scopeSchIds 一并提交 */
|
||||
saveWidgetCatalog(catalog: Partial<WidgetCatalog> & Record<string, any>) {
|
||||
const url = path + "/SaveWidgetCatalog";
|
||||
return this.postRequest<any>({ url, data: catalog });
|
||||
}
|
||||
|
||||
/** 删除全局目录(仅超管),后端连带删点名表与校级覆盖行 */
|
||||
deleteWidgetCatalog(id: number) {
|
||||
const url = path + "/DeleteWidgetCatalog";
|
||||
return this.postRequest<any>({ url, params: { id } });
|
||||
}
|
||||
|
||||
/** 读本校覆盖行(编辑回显):schId 由后端取当前登录校 */
|
||||
getWidgetSchoolSetting(widgetId: number) {
|
||||
const url = path + "/GetWidgetSchoolSetting";
|
||||
return this.getRequest<any>({ url, params: { widgetId } });
|
||||
}
|
||||
|
||||
/** 保存本校覆盖(校管/超管):schId 由后端强制取当前登录校,前端不传 */
|
||||
saveWidgetSchoolSetting(setting: Record<string, any>) {
|
||||
const url = path + "/SaveWidgetSchoolSetting";
|
||||
return this.postRequest<any>({ url, data: setting });
|
||||
}
|
||||
|
||||
/** 机构树(仅超管):递归裁剪到学校层,children 即学校列表,用于可见范围多选 */
|
||||
getSchTree() {
|
||||
const url = path + "/getSchTree";
|
||||
return this.getRequest<any>({ url });
|
||||
}
|
||||
}
|
||||
|
||||
export default new UserSettings();
|
||||
|
||||
@@ -613,11 +613,6 @@ async function toggleWidgetShow() {
|
||||
await UserSettingApi.toggleWidgetShow(id);
|
||||
}
|
||||
|
||||
function linkSysSetting() {
|
||||
isUserCardShow.value = false;
|
||||
router.push("/syssetting");
|
||||
}
|
||||
|
||||
function linkBackendManage() {
|
||||
isUserCardShow.value = false;
|
||||
const url = `${$conf.baseHost}/userbase/front/index.html`;
|
||||
|
||||
@@ -7,8 +7,6 @@ import LightApp from "@/views/LightApp.vue";
|
||||
import Stats from "@/views/Stats.vue";
|
||||
import StatsRelay from "@/views/StatsRelay/index.vue";
|
||||
import TeacherCopilot from "@/views/TeacherCopilot.vue";
|
||||
import SysSetting from "@/views/SysSetting.vue";
|
||||
import SysTempManage from "@/views/SysTempManage.vue";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -59,18 +57,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: TeacherCopilot,
|
||||
meta: { title: "教师助手" },
|
||||
},
|
||||
{
|
||||
path: "/syssetting",
|
||||
name: "SysSetting",
|
||||
component: SysSetting,
|
||||
meta: { title: "系统设置" },
|
||||
},
|
||||
{
|
||||
path: "/syssetting/tempmanage/:id",
|
||||
name: "SysTempManage",
|
||||
component: SysTempManage,
|
||||
meta: { title: "模板管理" },
|
||||
},
|
||||
{
|
||||
path: "/msg",
|
||||
name: "Msg",
|
||||
|
||||
@@ -51,6 +51,36 @@ export interface WidgetPositionResp extends WidgetPosition {
|
||||
schId?: number;
|
||||
}
|
||||
|
||||
/** 小组件候选池目录行(widget_catalog + 管理端视角附加字段) */
|
||||
export interface WidgetCatalog {
|
||||
id?: number;
|
||||
/** 组件键:内置 = 前端 widgetRegistry 的键;自定义 = 自定义标识 */
|
||||
code: string;
|
||||
/** 显示名(校管视角已按「校级别名 > 角色命名 > 名称」合成) */
|
||||
name: string;
|
||||
/** 1 = 前端内置组件 2 = 自定义 HTML 片段 */
|
||||
compType: number;
|
||||
content?: string | null;
|
||||
dataCode?: string | null;
|
||||
/** 下发范围:0 = 全部学校 1 = 白名单 2 = 黑名单 */
|
||||
scopeType?: number;
|
||||
/** 点名的学校 id 列表(scopeType=1/2 时有效) */
|
||||
scopeSchIds?: number[];
|
||||
/** 可见角色:逗号分隔 RoleTypeEnum;NULL/空 = 全角色 */
|
||||
roleTypes?: string | null;
|
||||
/** 角色专属命名 JSON 文本:{"2":"全校课表"} */
|
||||
roleAlias?: string | null;
|
||||
/** 校管视角:本校别名 */
|
||||
alias?: string | null;
|
||||
/** 校管视角:超管下发的全局名称 */
|
||||
originalName?: string | null;
|
||||
/** 校管视角:本校是否已有覆盖行 */
|
||||
covered?: boolean;
|
||||
sort?: number;
|
||||
enabled?: number;
|
||||
updateTime?: string | null;
|
||||
}
|
||||
|
||||
export interface BschTransferRoleInfo {
|
||||
roleInfo: RoleInfo;
|
||||
ticket: string;
|
||||
|
||||
+8
-34
@@ -22,13 +22,8 @@
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<!-- 右区:sys pane 直接内嵌旧路由页 SysSetting(自带左右双卡视觉,故右区不再套白卡) -->
|
||||
<div v-if="showSys" class="set-body set-body--sys">
|
||||
<SysSetting />
|
||||
</div>
|
||||
|
||||
<!-- 右卡:其余 pane -->
|
||||
<div v-else class="set-body">
|
||||
<!-- 右卡:内容区 -->
|
||||
<div class="set-body">
|
||||
<!-- 无权限:普通用户直达管理项 / 显式 ?pane=noperm -->
|
||||
<div v-if="isDenied || curPane === 'noperm'" class="pane">
|
||||
<div class="no-perm">
|
||||
@@ -722,7 +717,6 @@ import { ref, reactive, computed, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import SysSetting from "@/views/SysSetting.vue";
|
||||
import WidgetManage from "@/views/widget/WidgetManage.vue";
|
||||
import BaseApi from "@/api/BaseApi";
|
||||
import SysSettingApi from "@/api/SysSettingApi";
|
||||
@@ -747,7 +741,7 @@ import { Message } from "view-ui-plus";
|
||||
* profile 读 store.userInfo(手机/邮箱本人可改:UpdateContact 直写 UserBase,2026-10-02 任务B);
|
||||
* password 走 updatePassword + 确认登出流程(照抄 UserInfoModal);
|
||||
* avatar 移植 UserInfoModal 直传上传(「使用默认头像」保持占位,存储方案本轮不做);
|
||||
* logo/wallpaper 池管理(列表 + 直传 + 删除)照抄 SysSetting.vue,LOGO 保存走 saveSchLogo(schId 后端按登录人推断);
|
||||
* logo/wallpaper 池管理(列表 + 直传 + 删除)照抄旧系统设置页管理语义,LOGO 保存走 saveSchLogo(schId 后端按登录人推断);
|
||||
* 壁纸「点选即生效」的 checkWallpaper 个人语义只保留在 my 层,默认层只维护可选池(不再代用户指定)。
|
||||
*/
|
||||
|
||||
@@ -757,13 +751,13 @@ const store = useDefaultStore();
|
||||
const { $conf } = useCurrentInstance();
|
||||
|
||||
const isAdmin = computed(() => !!store.getIsAdmin);
|
||||
/** 超管判据与 SysSetting.vue 的 isSuperAdmin 同源(userInfo.type==1);徽标三态用 */
|
||||
/** 超管判据 = userInfo.type==1;徽标三态用 */
|
||||
const isSuper = computed(() => store.userInfo?.type == 1);
|
||||
|
||||
/** 普通用户不可用的管理 pane(导航隐藏;URL 直达渲染 noperm 黄条,不白屏不报错)。
|
||||
* 三层化(S4)后「头像/主题/壁纸」所有身份都可用——每角色只编辑自己那一层,不再是管理项 */
|
||||
const ADMIN_PANES = ["logo", "sidebar", "sys", "statset", "widget"];
|
||||
const ALL_PANES = ["profile", "password", "avatar", "theme", "wallpaper", "desktop", "logo", "sidebar", "sys", "statset", "widget", "noperm"];
|
||||
const ADMIN_PANES = ["logo", "sidebar", "statset", "widget"];
|
||||
const ALL_PANES = ["profile", "password", "avatar", "theme", "wallpaper", "desktop", "logo", "sidebar", "statset", "widget", "noperm"];
|
||||
|
||||
/** 历史 ?pane= 键兼容(S4 重命名:mytheme/schooltheme→theme、mywallpaper→wallpaper),保证旧链接不 404/不白屏 */
|
||||
const LEGACY_PANE_KEYS: Record<string, string> = {
|
||||
@@ -793,8 +787,6 @@ watch(curPane, pane => {
|
||||
});
|
||||
|
||||
const isDenied = computed(() => !isAdmin.value && ADMIN_PANES.includes(curPane.value));
|
||||
/** sys pane 内嵌旧路由页 SysSetting(管理员态自带机构树+内容双卡) */
|
||||
const showSys = computed(() => isAdmin.value && curPane.value === "sys");
|
||||
|
||||
function setPane(key: string) {
|
||||
curPane.value = key;
|
||||
@@ -823,7 +815,6 @@ const NAV_PORTAL: NavItem[] = [
|
||||
{ key: "sidebar", name: "边栏设置", icon: svgWrap('<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>') },
|
||||
];
|
||||
const NAV_SYSTEM: NavItem[] = [
|
||||
{ key: "sys", name: "系统设置", icon: svgWrap('<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>') },
|
||||
{ key: "statset", name: "统计设置", icon: svgWrap('<path d="M18 20V10M12 20V4M6 20v-6"/>') },
|
||||
{ key: "widget", name: "小组件管理", icon: svgWrap('<rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="5" rx="1.5"/><rect x="13" y="10" width="8" height="11" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/>') },
|
||||
];
|
||||
@@ -984,7 +975,7 @@ function onAvatarFormatError() {
|
||||
Message.error("仅支持 jpg/jpeg/png/gif/webp 格式图片!");
|
||||
}
|
||||
|
||||
/* ===== logo / wallpaper:管理列表 + 直传 + 删除(照抄 SysSetting.vue 管理语义) ===== */
|
||||
/* ===== logo / wallpaper:管理列表 + 直传 + 删除(沿用旧系统设置页管理语义) ===== */
|
||||
const logoManageList = ref<any[]>([]);
|
||||
const wallpaperManageList = ref<any[]>([]);
|
||||
const logoSelUrl = ref<string>("");
|
||||
@@ -2029,7 +2020,7 @@ watch(
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 壳:透明,不平铺白(B 方案),gap 加在两卡的直接父容器上。
|
||||
padding 与 .syssetting-wrap 对齐(15px 四周),overflow:hidden 与 .app-list-wrapper/.stats-wrap 对齐,
|
||||
padding 与 .app-list-wrapper/.stats-wrap 对齐(15px 四周),overflow:hidden 同 .set-shell,
|
||||
避免内容撑出页面级滚动条把右侧留白撑大。 */
|
||||
.set-shell {
|
||||
display: flex;
|
||||
@@ -2130,23 +2121,6 @@ watch(
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||
overflow-y: auto;
|
||||
padding: 22px 26px 30px;
|
||||
|
||||
/* sys pane:右区不套白卡,由内嵌的 SysSetting 自带左右双卡 */
|
||||
&.set-body--sys {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* 内嵌的 SysSetting 自带 padding:15px,与 .set-shell 的 15px 叠加成 30px,此处抹平。
|
||||
仅作用于本内嵌场景,独立页 /syssetting 不受影响(该页退役后可删本段) */
|
||||
:deep(.syssetting-wrap) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pane {
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
<template>
|
||||
<div class="syssetting-wrap fix" v-if="isAdmin">
|
||||
<!-- 应用管理(S4.7:原「基础设置」Tab 的 Logo/壁纸已迁到设置中心「门户外观」,此处只剩应用管理,
|
||||
故去掉外层 Tabs 与只为基础设置服务的机构树/面包屑——应用管理接口不带 schId,跨校无需树) -->
|
||||
<div class="right-box">
|
||||
<div class="app-manage">
|
||||
<!-- 角色分段用全局 .seg-tabs(与设置中心「桌面应用」同一套分段样式,方案 §10「应用管理自定义 Tab」) -->
|
||||
<div class="role-tabs-row fix">
|
||||
<div class="seg-tabs fl">
|
||||
<div
|
||||
v-for="item in roleTypeList"
|
||||
:key="item.k"
|
||||
:class="['sg', { active: roleTypeTab === item.k }]"
|
||||
@click="onRoleTypeChange(item.k)"
|
||||
>{{ item.v }}</div>
|
||||
</div>
|
||||
<Button class="fr" size="small" type="primary" @click="showCategory()">新增应用</Button>
|
||||
</div>
|
||||
<div class="search-wrap fix">
|
||||
<Input
|
||||
placeholder="输入关键字搜索"
|
||||
class="fr"
|
||||
search
|
||||
suffix="ios-search"
|
||||
style="width: 200px"
|
||||
@on-search="onSearch"
|
||||
v-model="dataParmas.name"
|
||||
/>
|
||||
</div>
|
||||
<TablePage
|
||||
:table-height="tableHeight"
|
||||
:data-api="tableDataApi"
|
||||
:columns="columns"
|
||||
:page-params="pageParams"
|
||||
:auto-load="true"
|
||||
ref="refTablePage"
|
||||
/>
|
||||
<!-- 新增/编辑应用 -->
|
||||
<Modal
|
||||
v-model="isModal"
|
||||
:mask-closable="false"
|
||||
width="600"
|
||||
:title="currentId == null ? '新增应用' : '编辑应用'"
|
||||
>
|
||||
<Form ref="refFormData" class="mt-8 mr-24" :model="formData" :rules="ruleValidate" :label-width="120">
|
||||
<FormItem label="应用名称:" prop="name">
|
||||
<Input placeholder="请输入应用名称" v-model="formData.name" />
|
||||
</FormItem>
|
||||
<FormItem label="接口地址:" prop="apiUrl">
|
||||
<Input placeholder="请输入接口地址" v-model="formData.apiUrl" />
|
||||
</FormItem>
|
||||
<FormItem label="接口参数:" prop="paramsKey">
|
||||
<ul class="parmas-ul">
|
||||
<li class="fix mb-12" v-for="(item, index) in parmasList" :key="index">
|
||||
<span class="ivu-form-item-label">参数名称</span>
|
||||
<Input class="fl mr-12" v-model="item.k" style="width: 80px" />
|
||||
<template v-if="item.type == 1">
|
||||
<span class="ivu-form-item-label">参数类型</span>
|
||||
<span>userId</span>
|
||||
</template>
|
||||
<template v-else-if="item.type == 2">
|
||||
<span class="ivu-form-item-label">参数类型</span>
|
||||
<span>学校Id</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="ivu-form-item-label">参数定值</span>
|
||||
<Input class="fl mr-12" style="width: 80px" v-model="item.v" />
|
||||
</template>
|
||||
<Button
|
||||
v-if="item.type != 1"
|
||||
type="error"
|
||||
ghost
|
||||
icon="md-remove"
|
||||
class="ml-12"
|
||||
@click="parmasList.splice(index, 1)"
|
||||
></Button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="fix">
|
||||
<Button class="fl" @click="parmasList.push({ k: '', v: '', type: 3 })">添加固定参数</Button>
|
||||
<Button
|
||||
v-if="!!currentId"
|
||||
class="ml-8"
|
||||
@click="parmasList.push({ k: 'schId', v: '', type: 2 })"
|
||||
>
|
||||
添加SchId参数
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem label="数据解析:" prop="dataKey">
|
||||
<Input placeholder="请输入数据字段" v-model="formData.dataKey" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="isSubmitLoading" @click="onEditSave">提交</Button>
|
||||
<Button @click="onReset">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="no-permission" v-else>无权限访问</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import TablePage from "@/components/TablePage.vue";
|
||||
import Utils from "@/utils";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { OperatorEnum } from "@/types";
|
||||
import { PageParams } from "@/types/Basics";
|
||||
import AppSettingApi, { AppCategoryItem } from "@/api/AppSettingApi";
|
||||
import mittBus from "@/utils/mitt";
|
||||
|
||||
const router = useRouter();
|
||||
const store = useDefaultStore();
|
||||
|
||||
const isAdmin = computed(() => store.getIsAdmin);
|
||||
|
||||
// ==================== 应用管理(AppCategory) ====================
|
||||
|
||||
const roleTypeList = [
|
||||
{ k: 1, v: "校管理员" },
|
||||
{ k: 2, v: "教师" },
|
||||
{ k: 3, v: "学生" },
|
||||
];
|
||||
|
||||
const roleTypeTab = ref<number>(1);
|
||||
const dataParmas = reactive({ name: "" });
|
||||
const pageParams: PageParams = reactive({ page: 1, count: 10 });
|
||||
const refTablePage = ref<any>(null);
|
||||
const tableHeight = Utils.useTableHeight({ isSearch: true, isTabs: true, isPage: true });
|
||||
|
||||
const tableDataApi = (params: PageParams) =>
|
||||
AppSettingApi.getAppCategoryList({
|
||||
...params,
|
||||
roleType: roleTypeTab.value,
|
||||
name: dataParmas.name || undefined,
|
||||
});
|
||||
|
||||
const columns = ref<any[]>([
|
||||
{ type: "index", title: "序号", width: 70, align: "center" },
|
||||
{ title: "应用名称", width: 200, align: "left", key: "name" },
|
||||
{
|
||||
title: "数据类别",
|
||||
width: 100,
|
||||
align: "left",
|
||||
render: (h: any, params: any) => {
|
||||
let find = roleTypeList.find(x => x.k == params.row.roleType);
|
||||
return h("span", !find ? "未知" : find.v);
|
||||
},
|
||||
},
|
||||
{ title: "接口地址", align: "left", key: "apiUrl", tooltip: true },
|
||||
{ title: "数据解析", align: "center", key: "dataKey" },
|
||||
{
|
||||
title: "操作",
|
||||
width: 180,
|
||||
align: "center",
|
||||
render: (h: any, params: any) => {
|
||||
return [
|
||||
Utils.operatingTextHandle(h, "编辑", "mr-2", () => showCategory(params.row.id)),
|
||||
Utils.operatingTextHandle(h, "模板管理", "mr-2", () => {
|
||||
let { id, name } = params.row;
|
||||
router.push({ name: "SysTempManage", params: { id }, query: { name } });
|
||||
}),
|
||||
Utils.operatingTextHandle(h, "删除", "text-error", () => onDeleteCategory(params.row.id)),
|
||||
];
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const isSubmitLoading = ref(false);
|
||||
const currentId = ref<number | null>(null);
|
||||
const parmasList = ref<any[]>([]);
|
||||
const isModal = ref(false);
|
||||
const formData = reactive<AppCategoryItem>({ id: null as any, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
|
||||
|
||||
const ruleValidate = {
|
||||
name: [{ required: true, message: "请输入应用名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
watch(
|
||||
parmasList,
|
||||
val => {
|
||||
formData.paramsKey = JSON.stringify(val);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
/** 分段 Tab 点击(原 iView @on-click 改为自绘分段,值即 roleType) */
|
||||
function onRoleTypeChange(k: number) {
|
||||
if (roleTypeTab.value === k) return;
|
||||
roleTypeTab.value = k;
|
||||
refTablePage.value?.reloadData();
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
refTablePage.value?.reloadData();
|
||||
}
|
||||
|
||||
function showCategory(id?: number) {
|
||||
if (typeof id === "undefined") {
|
||||
currentId.value = null;
|
||||
Object.assign(formData, { id: null, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
|
||||
parmasList.value = [
|
||||
{ k: "userId", v: "", type: 1 },
|
||||
{ k: "schId", v: "", type: 2 },
|
||||
];
|
||||
} else {
|
||||
const row = refTablePage.value.list.find((x: any) => x.id == id);
|
||||
if (!row) return;
|
||||
currentId.value = id;
|
||||
Object.assign(formData, { id: row.id, name: row.name, apiUrl: row.apiUrl, dataKey: row.dataKey });
|
||||
try {
|
||||
parmasList.value = JSON.parse(row.paramsKey || "[]");
|
||||
} catch (e) {
|
||||
parmasList.value = [];
|
||||
}
|
||||
}
|
||||
isModal.value = true;
|
||||
}
|
||||
|
||||
async function onEditSave() {
|
||||
if (!formData.name) {
|
||||
Message.info("请填写应用名称");
|
||||
return;
|
||||
}
|
||||
isSubmitLoading.value = true;
|
||||
try {
|
||||
const item: AppCategoryItem = { ...formData, roleType: roleTypeTab.value };
|
||||
await AppSettingApi.saveAppCategory(item);
|
||||
Message.success(`${currentId.value == null ? "添加" : "操作"}成功!`);
|
||||
refTablePage.value.reloadData(currentId.value == null ? OperatorEnum.add : OperatorEnum.edit);
|
||||
isModal.value = false;
|
||||
isSubmitLoading.value = false;
|
||||
currentId.value = null;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
isSubmitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
currentId.value = null;
|
||||
Object.assign(formData, { id: null, name: "", apiUrl: "", dataKey: "", paramsKey: "" });
|
||||
parmasList.value = [];
|
||||
isModal.value = false;
|
||||
}
|
||||
|
||||
async function onDeleteCategory(id: number) {
|
||||
try {
|
||||
await AppSettingApi.deleteAppCategory(id);
|
||||
Message.success("操作成功!");
|
||||
refTablePage.value.reloadData(OperatorEnum.del);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
/** 切换身份/角色后重取应用列表(首屏由 TablePage 的 auto-load 负责) */
|
||||
function onSwitchRole() {
|
||||
if (!isAdmin.value) return;
|
||||
refTablePage.value?.reloadData();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isAdmin.value) return;
|
||||
mittBus.on("switchRole", onSwitchRole);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
mittBus.off("switchRole", onSwitchRole);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.syssetting-wrap {
|
||||
height: 100%;
|
||||
padding: 15px;
|
||||
|
||||
.right-box {
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||
padding: 15px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.no-permission {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 应用管理:.right-box 是纵向 flex,此处占满剩余高度(原外层 Tabs 的 44px 头部已随「基础设置」Tab 一起移除) */
|
||||
.app-manage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.role-tabs-row {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.fr {
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.parmas-ul {
|
||||
.ivu-form-item-label {
|
||||
float: left;
|
||||
margin-right: 8px;
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,235 +0,0 @@
|
||||
<template>
|
||||
<div class="tempmanage-wrap fix">
|
||||
<div class="file-box fl">
|
||||
<h4 class="file-title">{{ tempName }}</h4>
|
||||
<ul class="file-list">
|
||||
<li
|
||||
v-for="item in fileList"
|
||||
:key="item.name"
|
||||
:class="{ active: currentFileName == item.name }"
|
||||
@click="onSelectFile(item)"
|
||||
>
|
||||
<SvgIcon name="menu-2" size="14" color="#5979ea" style="margin-right: 6px" />
|
||||
{{ item.name }}
|
||||
</li>
|
||||
<li v-if="fileList.length == 0" class="empty">暂无文件,请上传 zip/rar</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="main-box">
|
||||
<div class="fix mb-12">
|
||||
<Upload
|
||||
:on-success="onUploadSuccess"
|
||||
:show-upload-list="false"
|
||||
:action="tempUploadAction"
|
||||
:accept="fileAccept"
|
||||
:data="uploadParams"
|
||||
:with-credentials="true"
|
||||
:headers="uploadHeaders"
|
||||
class="fl"
|
||||
>
|
||||
<Button class="fl mr-12" type="primary" size="large">上传应用</Button>
|
||||
</Upload>
|
||||
<Button class="fl mr-12" type="primary" size="large" @click="downTemp">下载应用</Button>
|
||||
<h4 class="title fl tc">{{ tempName }}模板空间</h4>
|
||||
</div>
|
||||
<div class="editor-box">
|
||||
<Codemirror
|
||||
v-model="currentContent"
|
||||
:extensions="extensions"
|
||||
:style="{ height: '100%' }"
|
||||
:indent-with-tab="true"
|
||||
placeholder="请选择左侧文件进行编辑"
|
||||
/>
|
||||
<div class="editor-footer fix">
|
||||
<Button type="primary" :disabled="!currentFileName" :loading="isSaving" @click="saveTemp">
|
||||
保存更改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Codemirror } from "vue-codemirror";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { xml } from "@codemirror/lang-xml";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import Utils from "@/utils";
|
||||
import { Message } from "view-ui-plus";
|
||||
import AppSettingApi from "@/api/AppSettingApi";
|
||||
|
||||
const route = useRoute();
|
||||
const { $conf } = useCurrentInstance();
|
||||
|
||||
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
|
||||
|
||||
const appId = computed(() => (route.params.id as string) || "");
|
||||
const tempName = computed(() => (route.query.name as string) || "");
|
||||
|
||||
const fileList = ref<any[]>([]);
|
||||
const currentFileName = ref<string | null>(null);
|
||||
const currentContent = ref<string>("");
|
||||
const isSaving = ref(false);
|
||||
const fileAccept = ".rar,.zip";
|
||||
|
||||
const tempUploadAction = computed(() => $conf.serviceHost + "/api/tempUpload");
|
||||
const uploadParams = computed(() => ({ appModId: appId.value }));
|
||||
|
||||
const extensions = computed(() => {
|
||||
if (!currentFileName.value) return [EditorView.lineWrapping];
|
||||
const suffix = currentFileName.value.substring(currentFileName.value.lastIndexOf(".") + 1).toLowerCase();
|
||||
let lang: any[] = [];
|
||||
if (["js", "ts", "jsx", "tsx"].includes(suffix)) lang = [javascript()];
|
||||
else if (suffix == "json") lang = [json()];
|
||||
else if (["css", "less", "scss"].includes(suffix)) lang = [css()];
|
||||
else if (suffix == "html" || suffix == "htm") lang = [html()];
|
||||
else if (suffix == "xml") lang = [xml()];
|
||||
return [...lang, EditorView.lineWrapping];
|
||||
});
|
||||
|
||||
async function getFileList() {
|
||||
const list = await AppSettingApi.getTempList(Number(appId.value));
|
||||
fileList.value = list || [];
|
||||
}
|
||||
|
||||
async function onSelectFile(item: any) {
|
||||
currentFileName.value = item.name;
|
||||
await loadContent(item.path);
|
||||
}
|
||||
|
||||
/** 读取文件内容:静态资源跨域读取,携带 yschapirequest 头触发后端 CORS 过滤器 */
|
||||
async function loadContent(path: string) {
|
||||
try {
|
||||
const resp = await fetch(path, {
|
||||
headers: { yschapirequest: "1" },
|
||||
credentials: "include",
|
||||
});
|
||||
if (!resp.ok) {
|
||||
Message.error("读取文件失败");
|
||||
return;
|
||||
}
|
||||
currentContent.value = await resp.text();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Message.error("读取文件失败");
|
||||
}
|
||||
}
|
||||
|
||||
function onUploadSuccess() {
|
||||
Message.success("上传成功!");
|
||||
AppSettingApi.updateResDate(Number(appId.value));
|
||||
getFileList();
|
||||
}
|
||||
|
||||
function downTemp() {
|
||||
window.location.href =
|
||||
$conf.serviceHost + `/api/downloadTemp?appModId=${appId.value}&name=${encodeURIComponent(tempName.value)}`;
|
||||
}
|
||||
|
||||
async function saveTemp() {
|
||||
if (!currentFileName.value) return;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await AppSettingApi.saveTempFile(Number(appId.value), currentFileName.value, currentContent.value);
|
||||
Message.success("保存成功!");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getFileList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tempmanage-wrap {
|
||||
height: 100%;
|
||||
padding: 15px;
|
||||
|
||||
.file-box {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
|
||||
.file-title {
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #eaf1ff;
|
||||
color: #5979ea;
|
||||
}
|
||||
|
||||
&.empty {
|
||||
color: #999;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-box {
|
||||
margin-left: 275px;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.title {
|
||||
margin-left: 20px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.editor-box {
|
||||
height: calc(100% - 52px);
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.cm-editor) {
|
||||
height: calc(100% - 48px);
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
height: 48px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #e8eaec;
|
||||
background: #fafafa;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -429,7 +429,7 @@ function fmtTime(sendTime?: string) {
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 壳:透明,不平铺白,壁纸透出(B 方案)。
|
||||
padding 与 .set-shell / .syssetting-wrap 对齐(15px 四周),overflow:hidden 同 .set-shell。 */
|
||||
padding 与 .set-shell 对齐(15px 四周),overflow:hidden 同 .set-shell。 */
|
||||
.msg-shell {
|
||||
display: flex;
|
||||
gap: 16px; /* ★ 两卡间隔 —— 必须加在两卡的直接父容器(本容器)上 */
|
||||
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
+604
-338
File diff suppressed because it is too large
Load Diff
@@ -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