feat(settings): 设置中心三级重构(一层一角色)+ 审计缺陷修复
build / build (push) Successful in 44s

三级重构(S1-S6,方案见 docs/设置中心三级重构-执行方案-20261008.md):
- Settings.vue 三层化主体:curLayer=my/school/platform 单 pane 分层,主题/头像/壁纸/桌面/LOGO 按身份只渲染本级
- 桌面应用三级默认 pane(used_app SCHOOL/BASE 读写 + 角色分段 + ≤8 上限)+ 统计设置真身迁入(StatsBarModal 退役)
- SysSetting.vue 去基础设置/机构树;ThemePreview 预览壳套 .layout-wrap; UsedAppApi 新增
审计缺陷修复(设置中心三级重构与消息搜索-审计报告-20261008):
- 【P0】DESKTOP_ROLE_TYPES 角色 1/2 标签对齐 SysRoleEnum(1教师/2校领导/3学生),桌面默认不再张冠李戴
- 命令面板:Enter/↑↓ 加 isComposing 守卫防中文输入法误触;过滤后高亮索引归零;stagger 延迟 --i 封顶 20
- WS 通知标题移除 frame.appKey 死消费;BaseApi summary todos 类型同步去 appKey
This commit is contained in:
chenxing
2026-10-08 23:08:16 +08:00
parent 2bd7ef3b80
commit 49f15b8a05
10 changed files with 1233 additions and 1469 deletions
+1 -1
View File
@@ -125,7 +125,7 @@ class Base extends Abstract {
const url = path + "/msgCenter/summary";
return this.getRequest<{
unreadCount: number;
todos: { appKey: string; count: number }[];
todos: { count: number }[];
sysUnread?: number;
bizUnread?: number;
approvalUnread?: number;
+1 -8
View File
@@ -1,16 +1,9 @@
import Abstract from "@/utils/axios/abstract";
import { PageData, PageParams } from "@/types/Basics";
const path = "/api";
/** 系统设置-基础设置(机构树 / 壁纸 / Logo 管理视图) */
/** 门户外观管理视图(壁纸 / Logo 池;S4.7 后由设置中心「门户外观」pane 调用,机构树随「系统设置-基础设置」一起移除) */
class SysSettingApi extends Abstract {
/** 机构树(仅超管),已裁剪到学校层 */
getSchTree() {
const url = path + "/getSchTree";
return this.getRequest<any>({ url });
}
/** 壁纸管理列表(系统 + 目标学校,不含个人壁纸) */
getSchWallpaperManageList(schId?: number) {
const url = path + "/getSchWallpaperManageList";
+33
View File
@@ -0,0 +1,33 @@
import Abstract from "@/utils/axios/abstract";
const path = "/api";
/**
* 桌面/常用应用三级默认(used_app:BASE 平台 / SCHOOL 本校 / USER 个人)。
* 后端 UsedAppController + UsedAppServiceImpl.findById 已实现「用户 > 学校 > 系统」级联与覆盖式保存,
* 本封装只服务设置中心「桌面应用」pane(S6),不改后端接口签名。
*/
class UsedAppApi extends Abstract {
/**
* 取某角色生效的常用应用(BoaApp 详情,按 appIds 顺序)。
* @param roleType 角色(1 校管理员 / 2 教师 / 3 学生)
* @param schId 学校ID;0 时后端按登录人主学校→归属组推断
* @param isSch 学校级读取:true 且非超管时后端令 uid=schId,于是只回本校 SCHOOL 那一行;
* 超管传 true 也会被后端强制回 false(走级联,实际取到 BASE 平台行)
*/
getUsedApp(roleType: number, schId: number, isSch: boolean) {
const url = path + "/usedapp/getUsedApp";
return this.getRequest<any[]>({ url, params: { roleType, schId, isSch } });
}
/**
* 覆盖式保存某角色本级默认桌面(同 setId+type+roleType+schId 存在则更新)。
* 数量上限 ≤8 由前端控制(后端注释口径);超管传任何 type 都会被后端强制为 BASE(0)。
*/
saveUsedApp(params: { appIds: string; type: number; roleType: number; schId?: number }) {
const url = path + "/usedapp/saveUsedApp";
return this.postRequest({ url, data: params });
}
}
export default new UsedAppApi();
+3 -1
View File
@@ -154,7 +154,9 @@
.app-cmdk-layer.is-open & {
opacity: 1;
transform: none;
transition-delay: calc(0.14s + var(--i) * 0.035s), calc(0.14s + var(--i) * 0.035s), 0s;
/* --i 封顶 20:应用多时(100+)末尾条目延迟不再线性膨胀到数秒,滚动空白窗上限 ≈0.84s */
transition-delay: calc(0.14s + min(var(--i), 20) * 0.035s),
calc(0.14s + min(var(--i), 20) * 0.035s), 0s;
}
&.is-hl,
&:hover {
+27
View File
@@ -152,6 +152,33 @@ div.dib {
}
}
// 分段 Tab(设置中心「桌面应用」角色分段 / 系统设置「应用管理」角色分段共用,替代 iView Tabs 观感)
.seg-tabs {
display: inline-flex;
background: #f0f2f6;
border-radius: 9px;
padding: 3px;
gap: 2px;
.sg {
padding: 6px 16px;
font-size: 13px;
border-radius: 7px;
color: #7a8088;
cursor: pointer;
transition: all 0.15s;
user-select: none;
&:hover {
color: #5979ea;
}
&.active {
background: #fff;
color: #5979ea;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
}
}
// .chart-box {
// height: 50px;
// width: 100%;
+5 -14
View File
@@ -170,7 +170,6 @@
<!-- 审批直批弹窗(B3 §2)已抽共享组件(消息对接 2026-09-30),供 /msg 页复用;Header 内已无直批入口 -->
<MsgApprovalModal ref="refMsgApproval" />
<StatsBarModal ref="refStatsBarModal" />
<TeacherCopilotModal ref="refTeacherCopilotModal" />
<!-- 搜索命令面板(2026-10-08 定稿 B 方案):必须 Teleport 到 body ——
@@ -231,7 +230,6 @@ import { useDefaultStore } from "@/store";
import { useRoute, useRouter } from "vue-router";
import { requireImg } from "@/utils/hook/useImg";
import ContextMenu from "@/components/ContextMenu.vue";
import StatsBarModal from "@/components/StatsBarModal.vue";
import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue";
import BaseApi from "@/api/BaseApi";
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
@@ -257,7 +255,6 @@ const baseUrl = computed(() => $conf.fileHost);
* 但摘要拉取与 WS 推送仍需保留 —— /msg 页左栏与 LeftMenu 角标与本store 同源 */
const refSearchInput = ref<any>();
const refStatsBarModal = ref<any>();
const refTeacherCopilotModal = ref<any>();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
@@ -336,13 +333,8 @@ async function connectMsgWs() {
};
}
/** WS 帧按 appKey 匹配应用名(store 应用列表);查无回退类型标签(缺失降级,不报错) */
/** WS 通知标题用类型标签(appKey 属后端签名标识,帧消费已随 0e57022 口径移除) */
function frameAppName(frame: any) {
const appKey = frame && frame.appKey;
if (appKey) {
const hit = store.getFlattenAppList.find((x: any) => x.appKey == appKey || x.appCode == appKey);
if (hit && hit.name) return hit.name;
}
return msgTypeLabel(frame ? frame.msgType : null);
}
@@ -414,6 +406,7 @@ onUnmounted(() => {
// 不再 emit("searchChange") —— 背景应用网格保持原样(2026-10-08 用户修订 1)
watch(searchAppText, () => {
cmdkStagger.value = false; // 输入过滤时不重播逐条入场
cmdkIndex.value = 0; // 过滤使结果集变化,高亮回归首项(否则旧索引落空/错位,Enter 哑炮)
});
// ===== 搜索命令面板(2026-10-08 定稿 B 方案)=====
const refCmdkList = ref<any>();
@@ -474,6 +467,9 @@ function pickApp(item: any) {
function onCmdkKeydown(ev: KeyboardEvent) {
if (!isSearching.value) return; // 面板未开:绝不拦截全局按键
// IME 组合态(拼音候选未上屏)不响应导航/确认:keydown 的 Enter/↑↓ 属输入法内部按键,
// 不拦截防误开应用、不 preventDefault 防干扰候选词选择(keyCode 229 为旧式组合态标记)
if (ev.isComposing || ev.keyCode === 229) return;
if (ev.key === "Escape") {
closeSearch();
return;
@@ -542,11 +538,6 @@ function linkSidebarSetting() {
router.push("/settings?pane=sidebar");
}
async function settingsStats() {
isUserCardShow.value = false;
refStatsBarModal.value.show();
}
async function settingsTeacherCopilot() {
isUserCardShow.value = false;
refTeacherCopilotModal.value.show();
-301
View File
@@ -1,301 +0,0 @@
<template>
<Modal
v-model="isTableModalShow"
class-name="menuwapper-modal"
width="1160"
:mask="true"
title="统计模块设置"
:transfer="false"
>
<div class="fix mb-10">
<Button type="primary" size="small" class="fl ml-10 width-10" ghost @click="onEditFormShow()">
新建统计模块
</Button>
</div>
<Table
:columns="menuBarColumns"
:height="360"
:loading="tableLoading"
:data="tableList"
class="link-table"
no-data-text="暂无数据"
>
<template #icon="{ row }">
<img class="icon-box" v-if="!!row.icon" :src="$conf.serviceHost + row.icon" alt="" />
</template>
<template #isShow="{ row }">
<Switch :model-value="row.isShow" true-color="#5979ea" @on-change="onMenuBarShowChange(row.id)" />
</template>
<template #roleIds="{ row }">
<span>{{ row.roleIds.map((item: number) => RoleTypeDict.find(x => x.k == item)?.v).join(",") }}</span>
</template>
<!-- <template #orderNum="{ row }">
<InputNumber :model-value="row.orderNum" :min="1" />
</template> -->
</Table>
<template #footer>
<Button type="primary" ghost @click="isTableModalShow = false">关闭</Button>
</template>
</Modal>
<Modal
v-model="isEditModalShow"
scrollable
width="550"
:title="(!formData.id ? '添加' : '编辑') + '统计模块'"
:transfer="true"
>
<Form ref="refEditForm" class="mr-30" :model="formData" :rules="ruleValidate" :label-width="110">
<Form-Item label="权限角色:" class="fix" prop="roleIds">
<Checkbox
class="fl"
:indeterminate="indeterminate"
:model-value="compCheckAll"
@click.prevent="handleCheckAll"
>
全部
</Checkbox>
<CheckboxGroup v-model="formData.roleIds">
<Checkbox v-for="item in RoleTypeDict" :label="item.k">{{ item.v }}</Checkbox>
</CheckboxGroup>
</Form-Item>
<Form-Item label="名称:" prop="name">
<Input max="4" v-model="formData.name" maxlength="30" placeholder="请输入名称" />
</Form-Item>
<Form-Item label="网址:" prop="url">
<Input v-model="formData.url" placeholder="请输入链接" />
</Form-Item>
<Form-Item label="图标:" prop="icon">
<Upload
:on-success="uploadIconSuccess"
:show-upload-list="false"
:action="$conf.serviceHost + '/api/iconUpload'"
:format="['jpg', 'jpeg', 'png', 'gif']"
accept="image/gif,image/jpeg,image/jpg,image/png"
:with-credentials="true"
:headers="uploadHeaders"
>
<div v-if="!formData.icon" class="icon-upload-wrap">
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
</div>
<div class="icon-preview-wrap" v-else>
<SvgIcon
@click.stop="formData.icon = null"
class="cloase-btn"
name="widget-close"
size="18"
color="#fff"
/>
<img :src="$conf.serviceHost + formData.icon" alt="" />
</div>
</Upload>
</Form-Item>
<Form-Item label="排序号:" prop="orderNum">
<InputNumber style="width: 120px" v-model="formData.orderNum" :min="1" placeholder="请输入排序号" />
</Form-Item>
<Form-Item label="打开方式:" prop="linkOpenType">
<RadioGroup v-model="formData.linkOpenType" type="button" button-style="solid">
<Radio :label="item.k" v-for="item in linkOpenTypeDict">{{ item.v }}</Radio>
</RadioGroup>
</Form-Item>
</Form>
<template #footer>
<Button type="primary" :loading="editLoading" @click="onEditSave">保存</Button>
<Button @click="isEditModalShow = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import StatsBarApi from "@/api/StatsBarApi";
import { LinkOpenTypeDict, LinkOpenTypeEnum, RoleTypeEnum, RoleTypeDict } from "@/types";
import Utils from "@/utils";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { Message } from "view-ui-plus";
import { ref, reactive, computed } from "vue";
import SvgIcon from "@/components/SvgIcon.vue";
import MyConfirm from "@/components/MyConfirm";
import { BaseDict } from "@/types/Basics";
import { find } from "lodash";
interface FormData {
id: string | null;
roleIds: number[];
name: string | null;
url: string | null;
linkOpenType: number;
icon: string | null;
orderNum: number | null;
isShow: boolean;
isEdit: boolean;
}
// 初始化 formData 的默认值
const defaultFormData: FormData = {
id: null,
roleIds: [], // 添加 roleIds 属性
name: "",
url: "",
linkOpenType: 0,
icon: "",
orderNum: null,
isShow: true,
isEdit: true,
};
const { $conf } = useCurrentInstance();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
const refEditForm = ref<any>(null);
const isTableModalShow = ref(false),
isEditModalShow = ref(false),
tableLoading = ref(false),
editLoading = ref(false),
tableList = ref<any[]>([]),
formData = reactive(defaultFormData);
const ruleValidate = ref({
roleIds: { required: true, type: "array", message: "请选择权限角色", trigger: "change,submit" },
name: { required: true, message: "请输入名称", trigger: "blur" },
url: { required: true, message: "请输入网址", trigger: "blur" },
icon: { required: true, message: "请上传图标", trigger: "change,submit" },
});
const linkOpenTypeDict = computed(() => LinkOpenTypeDict.filter(x => x.k != LinkOpenTypeEnum.Modal));
const compCheckAll = computed(() => formData.roleIds.length == RoleTypeDict.length && RoleTypeDict.length > 0);
const indeterminate = computed(() => formData.roleIds.length > 0 && formData.roleIds.length < RoleTypeDict.length);
const menuBarColumns = ref([
{
title: "名称",
key: "name",
},
{
title: "图标",
slot: "icon",
},
{
title: "角色",
width: 150,
slot: "roleIds",
},
{
title: "地址",
key: "url",
width: 150,
tooltip: true,
},
{
title: "打开方式",
key: "linkType",
},
{
title: "是否显示",
width: 100,
align: "center",
slot: "isShow",
},
{
title: "排序号",
key: "orderNum",
align: "center",
},
{
title: "操作",
width: 120,
align: "center",
render: (h: any, params: any) => {
const disabled = !params.row.isEdit,
id = params.row.id;
return [
Utils.operatingTextHandle(h, "编辑", "mr-2", () => onEditFormShow(id), disabled),
Utils.operatingTextHandle(h, "删除", "text-error", () => onDeleteMenu(id), disabled),
];
},
},
]);
async function show() {
isTableModalShow.value = true;
await getMenuBarList();
console.log("show");
}
async function getMenuBarList() {
tableLoading.value = true;
const list = await StatsBarApi.getStatsBarSchList();
list.forEach(element => {
element.linkType = find(linkOpenTypeDict.value, { k: element.linkOpenType })?.v;
});
tableList.value = list;
tableLoading.value = false;
}
function handleCheckAll() {
formData.roleIds = !compCheckAll.value ? RoleTypeDict.map(item => item.k) : [];
}
function uploadIconSuccess(response: any, file: any, fileListt: any) {
if (response.code == 0) formData.icon = response.data.url;
else Message.error("上传错误!");
// console.log(response, file, fileListt);
}
async function onEditFormShow(id?: number) {
await refEditForm.value?.resetFields();
isEditModalShow.value = false;
if (!id) {
formData.id = null;
formData.linkOpenType = LinkOpenTypeEnum.Open;
} else {
let find = tableList.value.find(x => x.id == id);
Object.assign(formData, find);
}
isEditModalShow.value = true;
}
async function onDeleteMenu(id: number) {
await MyConfirm({
title: "删除",
msg: "确定删除该统计模块吗?",
info: "删除后无法恢复!",
async onOk() {
await StatsBarApi.deleteStatsBar(id);
},
});
await getMenuBarList();
}
async function onEditSave() {
let validate: boolean = await refEditForm.value?.validate();
if (!validate) return false;
editLoading.value = true;
try {
await StatsBarApi.saveStatsBar(formData);
Message.success("操作成功!");
await getMenuBarList();
isEditModalShow.value = false;
} catch (error) {
Message.success("操作失败!");
} finally {
editLoading.value = false;
}
}
function onMenuBarShowChange(id: number) {
StatsBarApi.toggleStatsBarShow(id);
}
defineExpose({
show,
});
</script>
<style scoped>
.icon-box {
width: 20px;
height: 20px;
}
</style>
+1024 -544
View File
File diff suppressed because it is too large Load Diff
+116 -553
View File
@@ -1,185 +1,102 @@
<template>
<div class="syssetting-wrap fix" v-if="isAdmin">
<!-- 左侧机构树(仅超管) -->
<div v-if="isSuperAdmin" class="left-box fl">
<h4 class="tree-title">机构</h4>
<Tree :data="treeData" select-node @on-select-change="onTreeSelect" />
</div>
<div :class="['right-box', isSuperAdmin ? 'ml-315' : '']">
<!-- 面包屑:仅超管展示当前选中的机构路径 -->
<Breadcrumb v-if="isSuperAdmin" class="sch-crumb">
<BreadcrumbItem v-for="(n, i) in currentSchPath" :key="n.id">
<span :class="['crumb-item', i === currentSchPath.length - 1 ? 'is-last' : '']">{{ n.title }}</span>
</BreadcrumbItem>
</Breadcrumb>
<Tabs v-model="tabIndex" type="card" class="syssetting-tabs">
<TabPane label="基础设置" name="basic">
<div class="basic-wrap">
<!-- Logo -->
<div class="setting-item">
<h4 class="item-title">
学校Logo
<span class="tip">(单张,上传即覆盖;推荐尺寸 70x70)</span>
</h4>
<div class="logo-uploader">
<ul class="logo-ul">
<li v-for="(item, index) in logoList" :key="item.id">
<label :class="['badge', item.type == 0 ? 'sys' : 'sch']">
{{ item.type == 0 ? "系统" : "校" }}
</label>
<img :src="item.url" alt="" />
<div class="operate" v-if="item.isEdit">
<a href="javascript:;" class="del" @click="onDeleteLogo(item)">删除</a>
</div>
</li>
<li class="add-cell">
<Upload
class="logo-upload"
:on-success="logoSuccess"
:show-upload-list="false"
:action="logoUploadAction"
accept="image/jpeg, image/jpg, image/png, image/gif, image/webp"
:with-credentials="true"
:headers="uploadHeaders"
>
<div class="add-box add-box--logo">
<SvgIcon name="upload-add" color="#939393" style="width: 22px; height: 22px" />
<p>上传Logo</p>
</div>
</Upload>
</li>
</ul>
</div>
</div>
<!-- 壁纸 -->
<div class="setting-item">
<h4 class="item-title">
壁纸
<span class="tip">(多张,与 Header「设置壁纸」弹窗数据互通;推荐尺寸 1920x1200)</span>
</h4>
<ul class="wallpaper-ul">
<li v-for="(item, index) in wallpaperList" :key="item.id">
<label :class="['badge', item.type == 0 ? 'sys' : 'sch']">
{{ item.type == 0 ? "系统" : "校" }}
</label>
<img :src="item.url" alt="" />
<div class="operate" v-if="item.isEdit">
<a href="javascript:;" class="del" @click="onDeleteWallpaper(item)">删除</a>
</div>
</li>
<li class="uploader">
<Upload
class="add-wrap tc"
:on-success="wallpaperSuccess"
:show-upload-list="false"
:action="wallpaperUploadAction"
accept="image/jpeg, image/jpg, image/png"
:with-credentials="true"
:headers="uploadHeaders"
>
<div class="add-box add-box--wallpaper">
<SvgIcon name="upload-add" color="#939393" style="width: 28px; height: 28px" />
<p>点击上传图片</p>
</div>
</Upload>
<!-- 应用管理(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>
</div>
</TabPane>
<TabPane label="应用管理" name="app">
<div class="app-manage">
<Tabs v-model="roleTypeTab" @on-click="onRoleTypeChange">
<TabPane :label="item.v" :name="item.k + ''" v-for="item in roleTypeList" :key="item.k" />
<template #extra>
<Button class="fl" size="small" type="primary" @click="showCategory()">新增应用</Button>
</template>
</Tabs>
<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>
</TabPane>
</Tabs>
<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>
@@ -189,126 +106,19 @@
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useDefaultStore } from "@/store";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import SvgIcon from "@/components/SvgIcon.vue";
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 SysSettingApi from "@/api/SysSettingApi";
import AppSettingApi, { AppCategoryItem } from "@/api/AppSettingApi";
import mittBus from "@/utils/mitt";
const router = useRouter();
const store = useDefaultStore();
const { $conf } = useCurrentInstance();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
const isSuperAdmin = computed(() => store.userInfo.type == 1);
const isAdmin = computed(() => store.getIsAdmin);
const tabIndex = ref("basic");
const currentSchId = ref<number>(0);
const treeData = ref<any[]>([
{ id: 0, title: "平台默认设置", selected: true },
]);
// 当前选中的机构路径(面包屑),默认平台默认设置
const currentSchPath = ref<{ id: number; title: string }[]>([
{ id: 0, title: "平台默认设置" },
]);
function findPathInTree(list: any[], id: number): { id: number; title: string }[] | null {
for (const n of list || []) {
if (n.id === id) return [{ id: n.id, title: n.title }];
if (n.children && n.children.length) {
const sub = findPathInTree(n.children, id);
if (sub) return [{ id: n.id, title: n.title }, ...sub];
}
}
return null;
}
function onTreeSelect(nodes: any[]) {
if (!nodes || nodes.length == 0) return;
const node = nodes[0];
currentSchId.value = node.id;
if (node.id === 0) {
currentSchPath.value = [{ id: 0, title: "平台默认设置" }];
} else {
const path = findPathInTree(treeData.value, node.id);
currentSchPath.value = path && path.length ? path : [{ id: node.id, title: node.title }];
}
loadBasicData();
}
function mapTree(list: any[]): any[] {
return (list || []).map((item: any) => ({
id: item.id,
title: item.name,
children: item.children && item.children.length ? mapTree(item.children) : [],
}));
}
function getTreeData() {
SysSettingApi.getSchTree()
.then((resp: any) => {
treeData.value = [{ id: 0, title: "平台默认设置", selected: true }, ...mapTree(resp.children)];
})
.catch(() => {
treeData.value = [{ id: 0, title: "平台默认设置", selected: true }];
});
}
// ==================== 基础设置(Logo + 壁纸) ====================
const logoList = ref<any[]>([]);
const wallpaperList = ref<any[]>([]);
const logoUploadAction = computed(() => {
let url = $conf.serviceHost + "/api/logoUpload";
if (isSuperAdmin.value && currentSchId.value > 0) url += "?schId=" + currentSchId.value;
return url;
});
const wallpaperUploadAction = computed(() => {
let url = $conf.serviceHost + "/api/wallpaperUpload";
if (isSuperAdmin.value && currentSchId.value > 0) url += "?schId=" + currentSchId.value;
return url;
});
async function loadBasicData() {
const [walls, logos] = await Promise.all([
SysSettingApi.getSchWallpaperManageList(isSuperAdmin.value ? currentSchId.value : undefined),
SysSettingApi.getSchLogoManageList(isSuperAdmin.value ? currentSchId.value : undefined),
]);
wallpaperList.value = walls || [];
logoList.value = logos || [];
}
function wallpaperSuccess() {
loadBasicData();
}
function logoSuccess() {
loadBasicData();
}
async function onDeleteWallpaper(item: any) {
await SysSettingApi.deleteWallById(item.id);
Message.success("删除成功");
loadBasicData();
}
async function onDeleteLogo(item: any) {
await SysSettingApi.deleteLogoById(item.id);
Message.success("删除成功");
loadBasicData();
}
// ==================== 应用管理(AppCategory) ====================
const roleTypeList = [
@@ -317,7 +127,7 @@ const roleTypeList = [
{ k: 3, v: "学生" },
];
const roleTypeTab = ref("1");
const roleTypeTab = ref<number>(1);
const dataParmas = reactive({ name: "" });
const pageParams: PageParams = reactive({ page: 1, count: 10 });
const refTablePage = ref<any>(null);
@@ -326,7 +136,7 @@ const tableHeight = Utils.useTableHeight({ isSearch: true, isTabs: true, isPage:
const tableDataApi = (params: PageParams) =>
AppSettingApi.getAppCategoryList({
...params,
roleType: Number(roleTypeTab.value),
roleType: roleTypeTab.value,
name: dataParmas.name || undefined,
});
@@ -379,7 +189,10 @@ watch(
{ deep: true }
);
function onRoleTypeChange() {
/** 分段 Tab 点击(原 iView @on-click 改为自绘分段,值即 roleType) */
function onRoleTypeChange(k: number) {
if (roleTypeTab.value === k) return;
roleTypeTab.value = k;
refTablePage.value?.reloadData();
}
@@ -416,7 +229,7 @@ async function onEditSave() {
}
isSubmitLoading.value = true;
try {
const item: AppCategoryItem = { ...formData, roleType: Number(roleTypeTab.value) };
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);
@@ -448,22 +261,14 @@ async function onDeleteCategory(id: number) {
// ==================== 生命周期 ====================
async function onSwitchRole() {
/** 切换身份/角色后重取应用列表(首屏由 TablePage 的 auto-load 负责) */
function onSwitchRole() {
if (!isAdmin.value) return;
if (isSuperAdmin.value) {
currentSchId.value = 0;
getTreeData();
}
await loadBasicData();
refTablePage.value?.reloadData();
}
onMounted(async () => {
onMounted(() => {
if (!isAdmin.value) return;
if (isSuperAdmin.value) {
getTreeData();
}
await loadBasicData();
mittBus.on("switchRole", onSwitchRole);
});
@@ -477,21 +282,6 @@ onUnmounted(() => {
height: 100%;
padding: 15px;
.left-box {
width: 300px;
height: 100%;
background: #fff;
padding: 15px;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
overflow-y: auto;
.tree-title {
margin-bottom: 10px;
color: #333;
}
}
.right-box {
height: 100%;
background: #fff;
@@ -501,36 +291,6 @@ onUnmounted(() => {
overflow: hidden;
display: flex;
flex-direction: column;
&.ml-315 {
margin-left: 315px;
}
}
.sch-crumb {
margin: 0 0 12px;
padding: 8px 14px;
background: #f5f7fa;
border-radius: 6px;
color: #606266;
font-size: 13px;
.crumb-item {
&.is-last {
color: #303133;
font-weight: 600;
}
}
:deep(.ivu-breadcrumb-item-separator) {
margin: 0 8px;
color: #c0c4cc;
}
}
.syssetting-tabs {
flex: 1;
min-height: 0;
}
}
@@ -542,217 +302,20 @@ onUnmounted(() => {
color: #999;
}
.basic-wrap {
height: calc(100% - 44px);
overflow-y: auto;
padding: 18px 24px 24px;
.setting-item {
margin-bottom: 28px;
& + .setting-item {
padding-top: 20px;
border-top: 1px dashed #ebeef5;
}
.item-title {
margin: 0 0 14px;
font-size: 15px;
font-weight: 600;
color: #303133;
line-height: 22px;
.tip {
font-size: 12px;
color: #909399;
font-weight: 400;
margin-left: 4px;
}
}
}
.logo-uploader {
display: block;
}
.logo-ul {
display: flex;
flex-wrap: wrap;
gap: 14px;
li {
position: relative;
width: 120px;
height: 90px;
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
img {
width: 100%;
height: 100%;
object-fit: contain;
background: #f7f8fa;
}
&.add-cell {
padding: 0;
border: none;
overflow: visible;
&:hover {
border: none;
box-shadow: none;
}
}
}
}
.wallpaper-ul {
display: flex;
flex-wrap: wrap;
gap: 16px;
li {
position: relative;
width: 250px;
height: 140px;
flex-shrink: 0;
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&.uploader {
padding: 0;
border: none;
overflow: visible;
&:hover {
border: none;
box-shadow: none;
}
}
}
// 强约束 view-ui-plus Upload 组件渲染层都撑满 li
li.uploader :deep(.ivu-upload),
li.uploader :deep(.ivu-upload-select) {
display: flex;
width: 100%;
height: 100%;
}
}
.badge {
position: absolute;
left: 8px;
top: 8px;
z-index: 2;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
color: #fff;
line-height: 18px;
&.sys {
background: #5979ea;
}
&.sch {
background: #45b972;
}
}
.operate {
position: absolute;
right: 8px;
top: 8px;
z-index: 2;
a.del {
background: rgba(0, 0, 0, 0.5);
color: #fff;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
line-height: 18px;
transition: background-color 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.7);
}
}
}
.add-box {
width: 100%;
height: 100%;
border: 1px dashed #dcdfe6;
border-radius: 6px;
background: #fafbfc;
text-align: center;
color: #909399;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
box-sizing: border-box;
transition: border-color 0.2s, color 0.2s, background-color 0.2s;
p {
font-size: 13px;
line-height: 1;
}
&:hover {
border-color: #5979ea;
color: #5979ea;
background: #f3f6fd;
}
&--logo {
width: 120px;
height: 90px;
}
&--wallpaper {
width: 250px;
height: 140px;
gap: 10px;
}
}
.logo-upload {
display: block;
width: 120px;
height: 90px;
}
}
/* 应用管理:.right-box 是纵向 flex,此处占满剩余高度(原外层 Tabs 的 44px 头部已随「基础设置」Tab 一起移除) */
.app-manage {
height: calc(100% - 44px);
flex: 1;
min-height: 0;
overflow: hidden;
.role-tabs-row {
margin-bottom: 12px;
.fr {
margin-top: 3px;
}
}
.search-wrap {
margin-bottom: 10px;
}
+23 -47
View File
@@ -3,11 +3,12 @@
只在本路由的 document 内加载通道(组件挂载时动态 append init 脚本),
档位类与通道 CSS 只落在本 document 的 <html> 上——UserHome 本体(父页)永远不吃通道 CSS。
纯展示:根容器 pointer-events:none,不渲染搜索区/分页操作/ThemeSwitcher。
DOM 结构对齐 Notice layout(header-container / left-menu / path-nav / main-container),
观感(配色/侧栏形态)由通道 base.layout.css 按 URL 覆盖参数(?yh-theme= 等)驱动的档位类提供。
默认头像轮(2026-09-30):logo 换云海图形、用户胶囊补三态头像圆 + icon-logout、
右侧补静态配色胶囊、左菜单换 iconfont + 可展开子菜单(预览 markup 落后于通道皮肤修复)。 -->
<div class="theme-preview-root">
DOM 结构逐层对齐 Notice(layout.vue + App.vue):.layout-wrap 作用域内放
header-container / left-menu / path-nav,内容区用 .base-wrap-main > .main-wrapper > .main-container;
通道皮肤(skin.src.css)的侧栏/顶栏/面包屑规则全部以 .layout-wrap 为祖先,故根必须挂该类,
侧栏深色/浅色/跟随顶栏、渐变Header 这些档位令牌(html.side-deep/.grad-on…)才会被消费到。
观感(配色、侧栏形态、内容区卡片)由通道按 URL 覆盖参数(?yh-theme= 等)驱动的档位提供,本页不写死。 -->
<div class="theme-preview-root layout-wrap">
<header class="header-container fix">
<span class="logo-badge fl"><img src="@/assets/image/yunhai-logo.svg" alt="云海" /></span>
<div class="delimiter fl align-middle"></div>
@@ -20,8 +21,7 @@
<a href="javascript:;" class="logout fl align-middle" title="退出登陆"><i class="iconfont icon-logout"></i></a>
</div>
<!-- 配色胶囊:静态展示(不接 ThemeSwitcher,避免预览里出现可点控件与真实切换器语义混淆)。
置于 header-aside 之后:本页不吃 .layout-wrap 皮肤(flex order 失效、走 float),
float:right 的 DOM 靠后者居左——才能复刻 Notice「配色在左、用户胶囊在右」 -->
顶栏是 flex,皮肤给 .theme-cap{order:1} / .header-aside{order:2} → 胶囊恒落在用户胶囊左侧。 -->
<div class="theme-cap fr"><span class="cap-btn">配色 ▾</span></div>
</header>
<div class="left-menu">
@@ -44,10 +44,12 @@
<div class="path-nav">
<span>首页</span><span class="crumb-sep">/</span><span>通知管理</span><span class="crumb-sep">/</span><span>通知列表</span>
</div>
<div class="preview-main">
<div class="main-container">
<h3 class="panel-title">通知列表</h3>
<Table :columns="columns" :data="rows" size="small" :border="true" />
<div class="base-wrap-main">
<div class="main-wrapper">
<div class="main-container">
<h3 class="panel-title">通知列表</h3>
<Table :columns="columns" :data="rows" size="small" :border="true" />
</div>
</div>
</div>
</div>
@@ -91,25 +93,17 @@ onMounted(() => {
</script>
<style scoped lang="scss">
/* 本页只补最小布局;配色/圆角/阴影等观感全部由通道 base.layout.css 的档位类驱动 */
/* 本页只补"铺满 iframe + 纯展示"这层壳;配色、顶栏/侧栏/面包屑观感、内容区卡片全部交给通道
(骨架 skeleton.src.css 出结构、皮肤 skin.src.css 出观感,皮肤规则以 .layout-wrap 为祖先——
故根元素同时挂 .layout-wrap,档位令牌变化才会实时反映到预览上)。 */
.theme-preview-root {
position: fixed;
inset: 0;
overflow: hidden;
pointer-events: none; /* 纯展示:整页不可交互 */
background: #f5f6f8;
font-size: 14px;
}
.header-container {
h4 {
color: #fff;
font-size: 16px;
font-weight: 600;
margin: 0 12px;
}
}
/* 配色胶囊(静态展示):观感对齐 ThemeSwitcher .theme-btn(通道皮肤只给 .theme-cap 排版,胶囊样式在切换器组件内) */
.cap-btn {
display: flex;
@@ -124,35 +118,17 @@ onMounted(() => {
font-size: 12.5px;
}
/* 菜单图标(通道下发的 ysch-icons 掩码类)与文字间距(Notice 同款覆盖) */
.left-menu a .iconfont {
margin-right: 8px;
}
/* 子菜单缩进:预览页不吃 .layout-wrap 前缀的皮肤层,骨架 a 无缩进差,补出层级(对齐皮肤 24px 缩进) */
.left-menu .child-item-wrap a {
padding-left: 59px;
}
/* 面包屑分隔符:预览用纯 span 复刻(Notice 侧是 iView Breadcrumb,皮肤只作用于 .ivu-breadcrumb-*) */
.path-nav .crumb-sep {
margin: 0 8px;
color: #c5c8cd;
}
.preview-main {
position: fixed;
top: calc(var(--topbar-h, 52px) + var(--crumb-h, 44px));
left: var(--side-w, 200px);
right: 0;
bottom: 0;
padding: 14px 24px 24px;
overflow: auto;
.panel-title {
font-size: 15px;
font-weight: 600;
color: #2c3138;
margin: 4px 0 12px;
}
/* 内容卡标题:通道未定义,本页补最小排版(色值取通道文本色变量,未加载时回退) */
.panel-title {
font-size: 15px;
font-weight: 600;
color: var(--text, #2c3138);
margin: 4px 0 12px;
}
</style>