Compare commits

...
2 Commits
Author SHA1 Message Date
chenxing 71c08d6e9a feat(authz): 权限一览加「全部展开」开关与卡片布局加固;下线测试推送页
build / build (push) Successful in 2m3s
- 权限一览:应用层默认折叠,新增「全部展开」开关(事件不冒泡到标题行),MyTree 透传 expand-all
- 应用模块树卡片:分类名与数量让位(超大收缩系数 + min-width:0),「子组继承」加 tooltip 与弹性空隙,
  「全部展开」加标签样式与竖线分隔,避免整组被挤出可视区或被读成同一个开关
- 下线测试推送:删除 /push-test 路由、菜单项与 PushTest.vue
2026-09-18 11:13:36 +08:00
chenxing 7784492522 chore(types): 同步用户身份字段(删 SysUserVo.roles,identityNames 口径注释订正)
- SysUserVo.roles 已停写(后端本次一并删除该字段),前端类型同步移除
- identityNames 注明:组上下文列表只含当前组范围解析出的学校下的身份(与编辑弹窗「所在组」同源)
2026-09-18 11:13:27 +08:00
8 changed files with 83 additions and 261 deletions
+4 -2
View File
@@ -311,12 +311,14 @@ export interface SysUserVo {
email?: string;
gender?: number;
profilePicture?: string;
roles?: number[];
disabled?: number;
type?: number;
sortNo?: number;
groupStatus?: number;
/** 主校身份名(如「老师/校领导」):列表「用户身份」列展示字段(roles 列已停写) */
/**
* 用户身份名(如「老师、校领导」):列表「用户身份」列展示字段。
* 组上下文列表只含「当前组范围解析出的学校」下的身份(与编辑弹窗「所在组」同源);未选组才全量。
*/
identityNames?: string;
expired?: boolean;
createTime?: string;
-1
View File
@@ -62,7 +62,6 @@ export function getMenuList(userRole?: UserRoleEnum): Promise<IModule[]> {
{ id: 53, modId: 1111, name: "操作日志", page: "/oplog", roles: [UserRoleEnum.SuperAdmin] },
{ id: 56, modId: 1114, name: "开放接口日志", page: "/oapilog", roles: [UserRoleEnum.SuperAdmin] },
{ id: 54, modId: 1112, name: "推送日志", page: "/push-log", roles: [UserRoleEnum.SuperAdmin] },
{ id: 55, modId: 1113, name: "测试推送", page: "/push-test", roles: [UserRoleEnum.SuperAdmin] },
],
},
];
-7
View File
@@ -12,7 +12,6 @@ import PermissionView from "@/views/PermissionView.vue";
import OperationLogList from "@/views/oplog/OperationLogList.vue";
import OapiLogList from "@/views/oapilog/OapiLogList.vue";
import PushLog from "@/views/push/PushLog.vue";
import PushTest from "@/views/push/PushTest.vue";
import NoAuth from "@/views/NoAuth.vue";
// 管理端四级访问控制:meta.roles 为空/未声明 = 登录即可访问
@@ -112,12 +111,6 @@ const routes: RouteRecordRaw[] = [
component: PushLog,
meta: { title: "推送日志", icon: "icon-list", modId: 1112, keepAlive: true, roles: [UserRoleEnum.SuperAdmin] },
},
{
path: "/push-test",
name: "PushTest",
component: PushTest,
meta: { title: "测试推送", icon: "icon-list", modId: 1113, roles: [UserRoleEnum.SuperAdmin] },
},
// ===== 操作日志 =====
{
path: "/oplog",
+26 -4
View File
@@ -39,10 +39,11 @@
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
<span class="cat-count">{{ item.treeData.length }}个应用</span>
<span class="inherit-cap">子组继承</span>
<span class="cat-spacer"></span>
<span class="inherit-cap" title="勾选后子组继承本次授权,不勾选仅本组生效">子组继承</span>
<!-- 开关外层 @click.stop:否则点击会冒泡到卡片头部触发折叠 -->
<span class="expand-all" @click.stop>
<span class="expand-all-label">全部展开</span>
<span class="expand-all-label">全部展开:</span>
<Switch size="small" v-model="expandAllMap[String(item.cat.id)]" />
</span>
</div>
@@ -662,6 +663,10 @@ onMounted(async () => {
font-size: 13px;
color: #333;
margin-left: 6px;
/* 卡片是 3 列布局,列宽有限:用超大的收缩系数让分类名先让位(省略号),
保证右侧「子组继承 / 全部展开」整组不被挤出可视区 */
flex: 0 999 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -671,15 +676,28 @@ onMounted(async () => {
color: #999;
font-size: 12px;
margin-left: 8px;
flex: 1;
/* 次优先收缩:空间实在不够时先截断数量文案,也不挤走开关 */
flex: 0 10 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
/* 弹性空隙:把右侧「子组继承 | 全部展开」整组推到卡片头右端,与分类名/数量拉开距离 */
.cat-spacer {
flex: 1 1 0;
min-width: 0;
}
.inherit-cap {
font-size: 12px;
color: #999;
flex-shrink: 0;
margin-right: 6px;
padding: 0 6px;
line-height: 18px;
background: #f0f2f5;
border-radius: 2px;
cursor: default;
}
.expand-all {
@@ -687,6 +705,10 @@ onMounted(async () => {
align-items: center;
flex-shrink: 0;
gap: 4px;
/* 与左侧「子组继承」提示以竖线分隔:否则两个标签相邻会被读成同一个开关的说明 */
margin-left: 10px;
padding-left: 10px;
border-left: 1px solid #e8eaec;
}
.expand-all-label {
+37 -2
View File
@@ -62,11 +62,19 @@
</div>
<!-- 右列:已授权应用(原有);支持三种视图:用户聚合 / 部门树节点 / 角色树节点 -->
<div class="perm-main">
<div class="side-title">{{ permMainTitle }}</div>
<div class="side-title">
<span class="side-title-text">{{ permMainTitle }}</span>
<!-- 应用层默认折叠,这里提供一键展开/收起(事件不冒泡到标题行) -->
<span class="expand-all" @click.stop>
<span class="expand-all-label">全部展开:</span>
<Switch size="small" v-model="permExpandAll" />
</span>
</div>
<div class="side-body">
<div v-if="permLoading" class="loading-tip">权限加载中...</div>
<div v-else-if="permissionTree.length === 0" class="empty-tip">{{ permEmptyTip }}</div>
<MyTree v-else ref="permTreeRef" :treeData="permissionTree" :setting="treeSetting" />
<MyTree v-else ref="permTreeRef" :treeData="permissionTree" :setting="treeSetting"
:expand-all="permExpandAll" />
</div>
</div>
</div>
@@ -156,6 +164,9 @@ const permScope = ref<{ type: "user" | "group" | "role"; id?: number; label: str
label: "",
});
// 已授权应用面板的「全部展开」开关(默认关闭):应用层默认折叠,需按需展开模块
const permExpandAll = ref(false);
const permMainTitle = computed(() => {
if (permScope.value.type === "group") return `已授权应用(部门:${permScope.value.label})`;
if (permScope.value.type === "role") return `已授权应用(角色:${permScope.value.label})`;
@@ -571,6 +582,30 @@ function formatModules(modules: ModuleNode[]): any[] {
font-size: 13px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
}
.side-title-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expand-all {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 4px;
font-weight: 400;
}
.expand-all-label {
font-size: 12px;
color: #999;
white-space: nowrap;
}
.side-body {
+2 -1
View File
@@ -157,7 +157,8 @@ const columns = [
{ title: '手机号', key: 'phone', minWidth: 140 },
{
title: '用户身份', key: 'identityNames', minWidth: 180,
// 数据源:后端按页批量带出的主校身份名(is_main 行,roles 列已停写,不再直读旧列)
// 数据源:后端按页批量带出的身份名,且只取「当前所选组范围解析出的学校」下的身份行
// (与编辑弹窗「所在组」回显同口径,roles 列已停写不再直读旧列)
render: (h: any, { row }: any) => h('span', row.identityNames || '-'),
},
{
-242
View File
@@ -1,242 +0,0 @@
<template>
<div class="main-wrapper">
<div class="main-container">
<div class="test-card">
<h3 class="card-title">测试推送</h3>
<p class="card-desc">
手动触发一次推送(投递消息队列,由订阅方异步接收)。下方将展示本次请求预览(URL / 头 / 体),
实际响应结果与耗时请在「推送日志」中查看。
</p>
<Form :label-width="100" class="test-form">
<FormItem label="订阅方应用" required>
<Select v-model="form.appId" filterable placeholder="选择要推送的订阅方应用" style="width: 360px">
<Option v-for="opt in appOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</Option>
</Select>
</FormItem>
<FormItem label="订阅接口" required>
<Select v-model="form.interfacePath" filterable placeholder="选择接口" style="width: 360px"
@on-change="onInterfaceChange">
<Option v-for="opt in interfaceOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</Option>
</Select>
</FormItem>
<FormItem label="目标ID" :required="!isSync">
<Input v-model="targetIdsText" :disabled="isSync" :placeholder="isSync ? '全量同步无需指定' : '可选,逗号分隔,如 10,11,12'"
style="width: 360px" />
</FormItem>
<FormItem>
<Button type="primary" :loading="sending" @click="handleSend">发送测试推送</Button>
</FormItem>
</Form>
</div>
<div v-if="preview" class="result-card">
<div class="result-head">
<h3 class="card-title">请求预览</h3>
<Button type="text" @click="goLog">查看推送日志</Button>
</div>
<Alert v-if="preview.batch && preview.batch > 1" type="info" show-icon>
本次将拆分为 {{ preview.batch }} 条消息依次发送
</Alert>
<Form :label-width="100" class="result-form">
<FormItem label="请求URL:">
<span class="break-all">{{ preview.url }}</span>
</FormItem>
<FormItem label="请求方法:">
<span>{{ preview.method }}</span>
</FormItem>
<FormItem label="请求头:">
<pre class="kv-pre">{{ formatHeaders(preview.headers) }}</pre>
</FormItem>
<FormItem label="请求体:">
<pre class="json-pre">{{ formatJson(preview.body) }}</pre>
</FormItem>
</Form>
<Alert type="success" show-icon>{{ sendMessage }}</Alert>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Message } from 'view-ui-plus';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
defineOptions({ name: 'PushTest' });
const router = useRouter();
const { $utils } = useCurrentInstance();
const interfaceOptions = [
{ value: 'group/SyncGroup', label: '组全量同步', sync: true },
{ value: 'user/SyncUser', label: '用户全量同步', sync: true },
{ value: 'group/AddGroup', label: '添加组', sync: false },
{ value: 'group/UpdateGroup', label: '更新组', sync: false },
{ value: 'group/DeleteGroup', label: '删除组', sync: false },
{ value: 'user/AddUser', label: '添加用户', sync: false },
{ value: 'user/UpdateUser', label: '更新用户', sync: false },
{ value: 'user/DeleteUser', label: '删除用户', sync: false },
];
const appOptions = ref<{ value: number; label: string }[]>([]);
const form = ref({ appId: undefined as number | undefined, interfacePath: '' });
const targetIdsText = ref('');
const sending = ref(false);
const preview = ref<Record<string, any> | null>(null);
const sendMessage = ref('');
const isSync = computed(() => {
const opt = interfaceOptions.find((o) => o.value === form.value.interfacePath);
return !!opt?.sync;
});
function onInterfaceChange() {
if (isSync.value) targetIdsText.value = '';
}
function goLog() {
router.push('/push-log');
}
function formatHeaders(headers: Record<string, string> | undefined): string {
if (!headers) return '-';
return Object.entries(headers)
.map(([k, v]) => `${k}: ${v}`)
.join('\n');
}
function formatJson(val: any): string {
if (val == null) return '-';
if (typeof val === 'string') {
try {
return JSON.stringify(JSON.parse(val), null, 2);
} catch {
return val;
}
}
return JSON.stringify(val, null, 2);
}
function parseTargetIds(): number[] | null {
if (isSync.value) return null;
const txt = targetIdsText.value.trim();
if (!txt) return null;
const ids = txt
.split(/[,,\s]+/)
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
return ids.length ? ids : null;
}
function handleSend() {
if (form.value.appId == null) {
Message.error('请选择订阅方应用');
return;
}
if (!form.value.interfacePath) {
Message.error('请选择订阅接口');
return;
}
sending.value = true;
preview.value = null;
sendMessage.value = '';
(Apis as any)
.PushSubscription.test({
data: {
appId: form.value.appId,
interfacePath: form.value.interfacePath,
targetIds: parseTargetIds(),
},
cacheFor: null,
})
.then((res: any) => {
preview.value = res?.preview || null;
sendMessage.value = res?.message || '已发送';
})
.catch((e: any) => console.warn(e?.message || '发送失败'))
.finally(() => (sending.value = false));
}
async function loadApps() {
try {
const res = await (Apis as any).AppSettings.appList({
params: { pageIndex: 1, pageSize: 9999 },
cacheFor: null,
});
const list = (res && res.content) || [];
appOptions.value = list.map((a: any) => ({ value: a.id, label: `${a.name}(${a.appKey})` }));
} catch (e: any) {
appOptions.value = [];
}
}
loadApps();
</script>
<style lang="scss" scoped>
.main-wrapper {
height: 100%;
}
.main-container {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.test-card,
.result-card {
background: #fff;
border-radius: 4px;
padding: 16px 20px;
}
.card-title {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
}
.card-desc {
margin: 0 0 12px;
color: #808695;
font-size: 13px;
line-height: 1.6;
}
.test-form {
margin-top: 4px;
}
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.break-all {
word-break: break-all;
}
.kv-pre,
.json-pre {
margin: 0;
padding: 10px 12px;
background: #f7f8fa;
border: 1px solid #e8eaec;
border-radius: 4px;
max-height: 280px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
}
.kv-pre {
max-height: 140px;
}
</style>
+14 -2
View File
@@ -26,9 +26,10 @@
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
<span class="cat-count">{{ item.treeData.length }}个应用</span>
<span class="cat-spacer"></span>
<!-- 开关外层 @click.stop:否则点击会冒泡到卡片头部触发折叠 -->
<span class="expand-all" @click.stop>
<span class="expand-all-label">全部展开</span>
<span class="expand-all-label">全部展开:</span>
<Switch size="small" v-model="expandAllMap[String(item.cat.id)]" />
</span>
</div>
@@ -393,6 +394,9 @@ watch(
font-size: 13px;
color: #333;
margin-left: 6px;
/* 列宽有限:分类名优先让位(省略号),保证右侧「全部展开」不被挤出可视区 */
flex: 0 999 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -402,10 +406,18 @@ watch(
color: #999;
font-size: 12px;
margin-left: 8px;
flex: 1;
flex: 0 10 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
/* 弹性空隙:把「全部展开」整组推到卡片头右端 */
.cat-spacer {
flex: 1 1 0;
min-width: 0;
}
.cat-card-body {
padding: 6px 8px;
}