Files
xiaotutongxue/frontend-pc/src/layout/components/HeaderRightBar/index.vue
T
lovife 0bbac7d228 feat(pc,platform): 验收走查修复(平台按学校代管 + 菜单/403/日志范围/分页)
客户在验收里点出来的问题,一次改完。两段改动:

一、平台运营与租户范围(后端为主)
- 新增请求头 X-Tenant-Id:平台运营是唯一跨租户身份,租户拦截器对它整体关掉了过滤,
  于是「组织架构 / 账号管理 / 工作台」拿到的是所有租户混在一起的数据 —— 而只有一所
  学校有数据时,它和学校管理员看到的完全一样,看不出错(这是最危险的一点)。
  现在这些页面必须先选定学校,服务端再把本次请求的租户上下文换成目标租户:
  只认平台身份(其它身份带头一律忽略)、目标租户必须存在且启用。
- OrgService.tree / UserService.list 补显式 tenant_id 条件(原来只靠拦截器,而拦截器对
  平台不生效);TaskService.seesWholeTenant 改用新的 LoginUser.isPlatformStaff() ——
  isPlatformAdmin 回答"当前租户是不是平台租户",代管时它为 false,会让平台退化成普通
  用户(任务列表掉进"班主任只看自己带的班"分支,得到空列表);PLATFORM_ADMIN 补
  TASK_VIEW(PRD §3.2「查看填报进度看板:平台运营 ✅」)。
- 操作日志对平台永远是空的:SQL 写死 tenant_id = 当前租户,而平台的 tenantId 是 0。
  按契约 §13.4 补 tenantId 参数与 log:viewAll:平台默认看全量、可指定某所学校;
  学校管理员恒为本租户,传别家返回 40301(不静默忽略,否则会让人以为"筛过了")。
- 开放接口"推送重试"从未生效(F-112):定时扫描缺 @InterceptorIgnore,异常被 log.warn
  吞掉;同一方法里"接入方已停用"那条 update 也没有租户上下文,会每 10 秒重试到永远。
  E2E 补了"真的重试了"的断言(原来只断言"安排了下次重试")。

二、PC 端走查体验
- 去掉 3 条无用菜单(名单管理 / 表单设计器 / 材料规则):功能都在「收集任务」行内入口,
  菜单里那一条点进去只有指路;后端菜单键同步删除,entryAt 那套机制一并删掉。
- 班主任点工作台必弹 403:工作台无条件调了要 org:view / progress:viewAll 的两个接口。
  403 走 HTTP 状态码,try/catch 拦不住它的弹窗 —— 改成按权限决定发不发请求。
- 操作日志:分页器被裁在可视区外(新增 .g-list-card 让表体自己滚、表头钉住、分页器留在
  卡片底部)+ 去掉页面上那段免责声明;收集任务的状态切换改成单独一行 tab;去掉顶栏假数据
  铃铛;配色按客户要求还原。

顺带修两处"检查器自己不严":check-wiring 新增第 ⑦ 项(PC 端引用的权限码必须存在于
PermissionDef —— 本轮我自己把 progress:viewAll 写成 progress:view:all,判断恒为 false,
而编译 / typecheck / lint / E2E 全都不红);verify-docs 的表格列数检查不再把 GFM 转义
竖线当成列分隔符。两个检查器都拿"故意写错的样例"试过,确认真的会红。

验证:typecheck / mvn package / check-wiring(未接线声明 0 条)/ verify-docs(22 项)全过;
e2e-m0 36、e2e-m1-orgs 48、e2e-m0-user 55、e2e-m5-logs 44、e2e-m5-notify-wiring 43、
e2e-m1-tenant-domain 6、e2e-m8-open 61 全绿;浏览器按角色逐个真点复验。
详见 docs/交接摘要.md §一·三十三 与 §一·三十四。
2026-10-09 17:29:20 +08:00

179 lines
5.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
@file HeaderRightBar 组件
@description 头部右侧工具栏组件,包含项目配置、消息通知、全屏切换、主题切换和用户菜单等功能
-->
<template>
<a-row justify="end" align="center" class="header-right-bar">
<a-space size="medium">
<!-- 项目配置按钮 -->
<a-tooltip content="项目配置" position="bl">
<a-button size="mini" class="g-hover-btn" @click="handleOpenSettings">
<template #icon>
<icon-settings :size="18" />
</template>
</a-button>
</a-tooltip>
<!--
消息通知(铃铛)**暂时去掉**(2026-10-09 客户要求):
原来的铃铛是一个写死 9 条假数据的下拉框,站内信还没接到这个入口上。
真要做的时候,数据源是「通知记录」(`/collect/notify`,接口已实现),
不要恢复成写死的假列表。
-->
<!-- 全屏切换按钮 -->
<a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom">
<a-button size="mini" class="g-hover-btn" @click="toggle">
<template #icon>
<icon-fullscreen v-if="!isFullscreen" :size="18" />
<icon-fullscreen-exit v-else :size="18" />
</template>
</a-button>
</a-tooltip>
<!-- 暗黑模式切换 -->
<a-tooltip content="主题切换" position="bottom">
<GiThemeBtn></GiThemeBtn>
</a-tooltip>
<!-- 管理员账户 -->
<a-dropdown trigger="hover">
<a-row align="center" :wrap="false" class="header-right-bar__user">
<!-- 头像:后端未下发 avatarUrl(本项目用姓名首字兜底,避免渲染成裂图) -->
<a-avatar :size="32" :style="{ backgroundColor: 'rgb(var(--primary-6))' }">
<img v-if="userStore.avatar" :src="userStore.avatar" alt="" />
<template v-else>{{ avatarText }}</template>
</a-avatar>
<span class="header-right-bar__username">{{ userStore.name }}</span>
<icon-down />
</a-row>
<template #content>
<a-doption v-for="item in USER_MENUS" :key="item.key" @click="item.onClick">
<template #icon>
<GiIconBox :color="item.iconColor">
<component :is="item.icon" />
</GiIconBox>
</template>
<span>{{ item.label }}</span>
</a-doption>
<a-divider :margin="0" />
<a-doption @click="handleLogout">
<template #icon>
<GiIconBox color="warning">
<icon-export />
</GiIconBox>
</template>
<span>退出登录</span>
</a-doption>
</template>
</a-dropdown>
</a-space>
</a-row>
<ChangePasswordModal ref="changePwdRef" />
</template>
<script setup lang="ts">
import { Button, Drawer, Message, Modal } from '@arco-design/web-vue'
import { useFullscreen } from '@vueuse/core'
import { useBreakpoint, useTheme } from '@/hooks'
import { useAppStore, useUserStore } from '@/stores'
import ChangePasswordModal from './ChangePasswordModal.vue'
import SettingDrawerPanel from './SettingDrawerPanel.vue'
/** 组件名称 */
defineOptions({ name: 'HeaderRight' })
/** 路由实例 */
const router = useRouter()
/** 状态管理 */
const userStore = useUserStore()
const appStore = useAppStore()
const { setThemeColor } = useTheme()
/** 响应式断点 */
const { breakpoint } = useBreakpoint()
/** 全屏控制 */
const { isFullscreen, toggle } = useFullscreen()
/** 头像兜底文案:取姓名首字 */
const avatarText = computed(() => (userStore.name || '—').slice(0, 1))
/** 修改密码弹窗 */
const changePwdRef = useTemplateRef('changePwdRef')
/** 用户菜单配置 */
const USER_MENUS = [
{
key: 'password',
label: '修改密码',
icon: 'icon-unlock',
iconColor: 'primary',
onClick: () => changePwdRef.value?.open()
}
]
/** 恢复默认项目配置 */
const handleResetSettings = () => {
appStore.resetSettings()
setThemeColor(appStore.themeColor)
Message.success('已恢复默认配置')
}
/** 打开设置抽屉 */
const handleOpenSettings = () => {
Drawer.open({
title: '项目配置',
width: 300,
content: () => h(SettingDrawerPanel),
footer: () => h(Button, { type: 'primary', long: true, style: { margin: 0 }, onClick: handleResetSettings }, { default: () => '恢复默认配置' })
})
}
/** 处理退出登录 */
const handleLogout = () => {
Modal.warning({
title: '提示',
content: '确认退出登录?',
hideCancel: false,
closable: true,
onBeforeOk: async () => {
try {
await userStore.logout()
router.replace('/login')
return true
} catch {
Message.error('退出登录失败')
return false
}
}
})
}
</script>
<style lang="scss" scoped>
.arco-dropdown-open .arco-icon-down {
transform: rotate(180deg);
}
.header-right-bar {
&__user {
color: var(--color-text-1);
cursor: pointer;
}
&__username {
margin-left: 10px;
white-space: nowrap;
.arco-icon-down {
margin-left: 2px;
transition: all 0.3s;
}
}
}
</style>