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 §一·三十三 与 §一·三十四。
This commit is contained in:
2026-10-09 17:29:20 +08:00
parent db8aecc30a
commit 0bbac7d228
41 changed files with 1387 additions and 372 deletions
+32
View File
@@ -0,0 +1,32 @@
import type { AxiosRequestConfig } from 'axios'
/**
* 平台运营的「当前学校」——请求头 `X-Tenant-Id`。
*
* ## 为什么需要它
*
* 平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户拦截器因此对它
* **整个关掉了租户过滤**(`MybatisPlusConfig`)。于是「组织架构」「账号管理」
* 这类**本来就是租户内**的页面,在平台账号下拿到的是**所有租户混在一起**的数据 ——
* 而且看不出错:只有一所学校有数据时,它和学校管理员看到的完全一样。
*
* 所以平台要代管某所学校时,必须**显式指明是哪一所**:带上这个头,服务端会把
* 本次请求的租户上下文换成目标租户(`ActingTenantResolver` + `AuthInterceptor`),
* 之后租户过滤、写入归属、权限判定都按"我就是那所学校"走。
*
* ## 三条使用约定
*
* 1. **只有平台运营的页面才传它**(页面用 `userStore.roles.includes('PLATFORM_ADMIN')` 判断)。
* 非平台身份即使传了,服务端也一律忽略 —— 那是安全边界,不是"顺手支持一下"。
* 2. **不传 / 传空 = 不发这个头**:平台照旧可以跨租户看租户列表、全量日志。
* 3. 学校管理员的页面**不要**传(他们的租户由 token 决定,传了也没用)。
*/
export const ACTING_TENANT_HEADER = 'X-Tenant-Id'
/** 组装"代管租户"请求配置;没有租户时返回空对象(不发头) */
export function actingTenantConfig(tenantId?: number | null): AxiosRequestConfig {
if (!tenantId) {
return {}
}
return { headers: { [ACTING_TENANT_HEADER]: String(tenantId) } }
}
+3 -2
View File
@@ -24,6 +24,7 @@
* 数字只用于选色。这样即便枚举有调整,界面也不会说错话。
*/
import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 带业务码的错误(`@/utils/http` 的拦截器会把 `code` 挂到 error 上)。 */
export type BizError = Error & { bizCode?: number, bizData?: unknown }
@@ -422,6 +423,6 @@ export function getAuditStatistics(params: {
orgId?: number
from?: string
to?: string
}) {
return http.get<AuditStatisticsVO>('/audit/statistics', params)
}, tenantId?: number | null) {
return http.get<AuditStatisticsVO>('/audit/statistics', params, actingTenantConfig(tenantId))
}
+14 -9
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 组织树节点(§6.1) */
export interface OrgTreeVO {
@@ -34,24 +35,28 @@ export interface OrgUpsertReq {
/**
* 组织树。
* @param withCount 是否附带每节点学生数(会多一次聚合查询,列表页按需开)
* @param tenantId **仅平台运营**:代管哪个学校的组织(见 `acting-tenant.ts`)。
* 不传 = 不代管 —— 此时服务端只会返回平台自己租户的组织(空树),
* **不会**再把所有租户的组织混在一起端出来。
*/
export function getOrgTree(params: { withCount?: boolean, rootId?: number } = {}) {
return http.get<OrgTreeVO[]>('/orgs/tree', params)
export function getOrgTree(params: { withCount?: boolean, rootId?: number, tenantId?: number | null } = {}) {
const { tenantId, ...query } = params
return http.get<OrgTreeVO[]>('/orgs/tree', query, actingTenantConfig(tenantId))
}
/** 新增,返回新节点 id */
export function addOrg(data: OrgUpsertReq) {
return http.post<number>('/orgs', data)
/** 新增,返回新节点 id(`tenantId` 仅平台运营:新建到哪个学校下) */
export function addOrg(data: OrgUpsertReq, tenantId?: number | null) {
return http.post<number>('/orgs', data, actingTenantConfig(tenantId))
}
/** 修改;变更层级时服务端会自动重算整棵子树的 ancestor_path */
export function updateOrg(id: number, data: OrgUpsertReq) {
return http.put<void>(`/orgs/${id}`, data)
export function updateOrg(id: number, data: OrgUpsertReq, tenantId?: number | null) {
return http.put<void>(`/orgs/${id}`, data, actingTenantConfig(tenantId))
}
/** 删除;有下级组织或账号时返回 40015 并给出占用清单 */
export function deleteOrg(id: number) {
return http.delete<void>(`/orgs/${id}`)
export function deleteOrg(id: number, tenantId?: number | null) {
return http.delete<void>(`/orgs/${id}`, actingTenantConfig(tenantId))
}
/** 组织类型 → 中文名(与 DDL 的 org_type 对齐) */
+11 -5
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
import { getUserList } from './user'
/** 审核流配置(§3.2) */
@@ -117,9 +118,14 @@ export interface TaskListQuery {
pageSize?: number
}
/** 任务分页列表。班主任只会拿到自己带的班级的任务 */
export function getTaskList(params: TaskListQuery) {
return http.get<{ list: TaskVO[], total: number }>('/tasks', params)
/**
* 任务分页列表。班主任只会拿到自己带的班级的任务
*
* @param tenantId **仅平台运营**:看哪所学校的任务(见 `./acting-tenant`)。
* 不传 = 平台自己租户(空列表)—— 不再把各租户的任务混在一起。
*/
export function getTaskList(params: TaskListQuery, tenantId?: number | null) {
return http.get<{ list: TaskVO[], total: number }>('/tasks', params, actingTenantConfig(tenantId))
}
export function getTaskDetail(id: number) {
@@ -343,6 +349,6 @@ export interface TaskStatisticsVO {
* ⚠️ `submitRate`(已提交/应填)与 `passRate`(已通过/应填)**必须分开显示**:
* 已提交 ≠ 已通过,合并会让「快完成了」与「快通过了」混为一谈。
*/
export function getTaskStatistics(taskId: number) {
return http.get<TaskStatisticsVO>(`/tasks/${taskId}/statistics`)
export function getTaskStatistics(taskId: number, tenantId?: number | null) {
return http.get<TaskStatisticsVO>(`/tasks/${taskId}/statistics`, undefined, actingTenantConfig(tenantId))
}
+28 -13
View File
@@ -5,6 +5,7 @@
* 分页 `{ list, total }`(适配层会补一份 `records`)。
*/
import http from '@/utils/http'
import { actingTenantConfig } from './acting-tenant'
/** 账号列表项(§5.6) */
export interface UserVO {
@@ -75,43 +76,57 @@ export interface UserListQuery {
pageSize?: number
}
// ── 账号接口 ────────────────────────────────────────
//
// ⚠️ 每个接口的最后都有一个可选 `tenantId`:**仅平台运营**用来指明"代管哪所学校"
// (见 `acting-tenant.ts`)。学校管理员的页面一律不传 —— 他们的租户由 token 决定。
// 不传时服务端只返回平台自己租户的账号,不会再把所有租户的账号混在一起。
/** 账号分页列表 */
export function getUserList(params: UserListQuery) {
return http.get<{ list: UserVO[], total: number }>('/users', params)
export function getUserList(params: UserListQuery, tenantId?: number | null) {
return http.get<{ list: UserVO[], total: number }>('/users', params, actingTenantConfig(tenantId))
}
/** 新增账号,返回初始密码(仅此一次) */
export function addUser(data: UserUpsertReq) {
return http.post<UserCreateResult>('/users', data)
export function addUser(data: UserUpsertReq, tenantId?: number | null) {
return http.post<UserCreateResult>('/users', data, actingTenantConfig(tenantId))
}
/** 修改账号;登录名与密码不在此接口修改 */
export function updateUser(id: number, data: UserUpsertReq) {
return http.put<void>(`/users/${id}`, data)
export function updateUser(id: number, data: UserUpsertReq, tenantId?: number | null) {
return http.put<void>(`/users/${id}`, data, actingTenantConfig(tenantId))
}
/** 停用(软删)。有在办任务返回 40014;不能停用自己或最后一个管理员 */
export function deleteUser(id: number) {
return http.delete<void>(`/users/${id}`)
export function deleteUser(id: number, tenantId?: number | null) {
return http.delete<void>(`/users/${id}`, actingTenantConfig(tenantId))
}
/** 启用 / 停用(只改 status,数据保留可再启用) */
export function setUserStatus(id: number, status: number) {
export function setUserStatus(id: number, status: number, tenantId?: number | null) {
// status 后端设计为查询参数(PUT /users/{id}/status?status=0),
// 所以走 config.params 而不是 body
return http.put<void>(`/users/${id}/status`, undefined, { params: { status } })
return http.put<void>(`/users/${id}/status`, undefined, {
...actingTenantConfig(tenantId),
params: { status }
})
}
/** 重置密码。mode 省略即 GENERATE(服务端生成)会同时解除该账号的登录锁定 */
export function resetUserPassword(id: number, data: { mode?: string, newPassword?: string } = {}) {
return http.post<UserResetResult>(`/users/${id}/reset-password`, data)
export function resetUserPassword(
id: number,
data: { mode?: string, newPassword?: string } = {},
tenantId?: number | null
) {
return http.post<UserResetResult>(`/users/${id}/reset-password`, data, actingTenantConfig(tenantId))
}
/** 批量导入账号(Excel ≤10MB),返回体检报告 */
export function importUsers(file: File, defaultRoleCode?: string) {
export function importUsers(file: File, defaultRoleCode?: string, tenantId?: number | null) {
const fd = new FormData()
fd.append('file', file)
return http.post<UserImportResult>('/users/import', fd, {
...actingTenantConfig(tenantId),
params: defaultRoleCode ? { defaultRoleCode } : undefined
})
}
+2
View File
@@ -91,6 +91,7 @@ declare module 'vue' {
GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
GiTenantPicker: typeof import('./components/GiTenantPicker/GiTenantPicker.vue')['default']
GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
@@ -179,6 +180,7 @@ declare global {
const GiTable: typeof import('./components/GiTable/GiTable.vue')['default']
const GiTableSetting: typeof import('./components/GiTableSetting/GiTableSetting.vue')['default']
const GiTag: typeof import('./components/GiTag/GiTag.tsx')['default']
const GiTenantPicker: typeof import('./components/GiTenantPicker/GiTenantPicker.vue')['default']
const GiThemeBtn: typeof import('./components/GiThemeBtn/GiThemeBtn.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
@@ -0,0 +1,74 @@
<template>
<a-select
v-model="model"
class="gi-tenant-picker"
:placeholder="props.placeholder"
:loading="loading"
:disabled="props.disabled"
:style="{ width: props.width }"
allow-search
allow-clear
>
<a-option v-for="t in options" :key="t.id" :value="t.id" :disabled="t.status === 0">
{{ t.tenantName }}({{ t.tenantCode }}){{ t.status === 0 ? ' · 已停用' : '' }}
</a-option>
</a-select>
</template>
<script setup lang="ts">
import type { TenantVO } from '@/apis/xt/tenant'
import { getTenantList } from '@/apis/xt/tenant'
/**
* 「选择学校」——平台运营的当前租户。
*
* 为什么要有这个东西:平台运营是跨租户身份,服务端对它的租户过滤是**关掉的**,
* 所以它必须**先指明看哪所学校**,否则「组织架构」「账号管理」会拿到所有租户混在一起的数据。
* 选中的值由页面带进请求头 `X-Tenant-Id`(见 `@/apis/xt/acting-tenant`)。
*
* ⚠️ 只有平台运营的页面才渲染它 —— 学校管理员的租户由 token 决定,给他一个
* "选择学校"的下拉框是纯粹的困惑(而且服务端会忽略他传的值)。
*
* ⚠️ 租户列表接口只对平台运营开放(学校管理员调它得 40301),所以本组件
* **不要**在非平台页面里渲染。
*/
defineOptions({ name: 'GiTenantPicker' })
const model = defineModel<number | undefined>({ default: undefined })
const props = withDefaults(defineProps<{
placeholder?: string
disabled?: boolean
width?: string
}>(), {
placeholder: '选择学校',
disabled: false,
width: '100%'
})
const loading = ref(false)
const options = ref<TenantVO[]>([])
async function load() {
loading.value = true
try {
// 一期租户规模是"几十所"量级(接口上限 200),一次取完比做分页下拉简单得多。
// 已停用的也列出来但不可选 —— 服务端会拒("目标单位不存在或已停用"),
// 与其让人选完才知道,不如在选择时就说明。
const res = await getTenantList({ pageNum: 1, pageSize: 200 })
options.value = res.data?.list ?? []
} finally {
loading.value = false
}
}
onMounted(load)
defineExpose({ reload: load })
</script>
<style lang="scss" scoped>
.gi-tenant-picker {
min-width: 180px;
}
</style>
+1 -71
View File
@@ -30,20 +30,6 @@ export interface XtMenu {
ready: boolean
/** 待建说明 */
note?: string
/**
* 这个功能**其实已经有了**,只是入口不在左侧菜单里(例如"名单管理"是按任务的,
* 入口在「收集任务」每一行的链接里)。
*
* ⚠️ 有它的时候,「待建」页就不再显示"还没做",而是给出真实入口的指路 + 跳转按钮。
* 没有它 = 确实还没做。两者的区别很重要:说"还没做"而功能其实在隔壁,
* 会让人**得出错误结论**(比找不到更糟)。
*/
entryAt?: {
/** 跳到哪个路由 */
path: string
/** 到了之后该点哪里(要具体到"点哪一行里的哪个链接") */
hint: string
}
}
/** 分组顺序(决定左侧菜单顺序) */
@@ -67,22 +53,6 @@ export const XT_MENUS: XtMenu[] = [
sort: 11,
ready: true
},
{
key: 'roster',
title: '名单管理',
icon: 'icon-park-outline:user-business',
path: '/collect/roster',
component: 'roster/index',
group: 'collect',
sort: 12,
ready: false,
note: '名册是按任务的:入口在「收集任务」每一行里点「名单管理」。'
+ '导入、体检报告、名册列表都在那里;报考范围确认(M2-04/05)会接着做进同一个抽屉。',
entryAt: {
path: '/collect/task',
hint: '在「收集任务」里找到那个任务,点它这一行的「名单管理」'
}
},
{
key: 'join',
title: '加入申请',
@@ -128,36 +98,6 @@ export const XT_MENUS: XtMenu[] = [
},
// ── 配置 ─────────────────────────────────────────
{
key: 'form',
title: '表单设计器',
icon: 'icon-park-outline:form-one',
path: '/setting/form',
component: 'form/index',
group: 'setting',
sort: 21,
ready: false,
note: '内置字段锁定、扩展字段自由 —— 按任务设计,入口在「收集任务」每一行的「表单设计」。',
entryAt: {
path: '/collect/task',
hint: '在「收集任务」里找到那个任务,点它这一行的「表单设计」'
}
},
{
key: 'material',
title: '材料规则',
icon: 'icon-park-outline:file-editing',
path: '/setting/material',
component: 'material/index',
group: 'setting',
sort: 22,
ready: false,
note: '命名模板、别名、格式白名单、体积上限、压缩档位 —— 按任务配置,入口在「收集任务」每一行的「材料规则」。',
entryAt: {
path: '/collect/task',
hint: '在「收集任务」里找到那个任务,点它这一行的「材料规则」'
}
},
{
key: 'cleanup',
title: '历史整理器',
@@ -338,17 +278,7 @@ function readyItem(menu: XtMenu): UserRouteItem {
} as UserRouteItem
}
/** 按菜单键取定义(给"待建"页显示说明用) */
export function findMenu(key: string): XtMenu | undefined {
return XT_MENUS.find((m) => m.key === key)
}
/**
* 按路由路径取菜单定义(「待建」页要显示"去哪个页面")。
*
* ⚠️ 按钮上的目标页名**必须从这里取**,不要在页面里写死 ——
* 菜单哪天改名,写死的那个名字就会指向一个不存在的页面。
*/
/** 按路由路径取菜单定义(「待建」页要显示说明用) */
export function findMenuByPath(path: string): XtMenu | undefined {
return XT_MENUS.find((m) => m.path === path)
}
@@ -1,85 +0,0 @@
<!--
@file Message 组件
@description 消息通知组件,包含通知、关注和待办三个标签页
-->
<template>
<div class="message">
<!-- 消息标签页 -->
<a-tabs :active-key="activeTab" style="background-color: transparent;" @change="handleTabChange">
<a-tab-pane v-for="tab in tabs" :key="tab.key">
<template #title>{{ tab.title }}({{ tab.count }})</template>
</a-tab-pane>
</a-tabs>
<!-- 消息列表 -->
<section class="message-list">
<a-comment v-for="item in messageList" :key="item.id" :author="item.name" :content="item.content"
:datetime="item.datetime">
<template #avatar>
<a-avatar>
<img :src="item.avatar" :alt="item.name">
</a-avatar>
</template>
</a-comment>
</section>
</div>
</template>
<script setup lang="ts">
import type { TabsInstance } from '@arco-design/web-vue'
/** 组件名称 */
defineOptions({ name: 'Message' })
/** 消息类型定义 */
interface MessageItem {
id?: string | number
name: string
datetime: string
content: string
avatar: string
actionText?: string
}
/** 标签页配置 */
const tabs = [
{ key: '1', title: '通知', count: 1 },
{ key: '2', title: '关注', count: 1 },
{ key: '3', title: '待办', count: 2 }
]
/** 组件状态 */
const activeTab = ref('1')
/** 消息列表数据 */
const messageList = ref<MessageItem[]>([
{
id: 1,
name: 'Socrates',
datetime: '1小时之前',
content: 'Comment body content.',
avatar: 'https://img1.baidu.com/it/u=1817951587,3188870642&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
},
{
id: 2,
name: '木木糖醇',
datetime: '2小时之前',
content: '关注了你',
avatar: 'https://s1.ax1x.com/2022/06/14/XhteeO.jpg'
},
{
id: 3,
name: '徐欣',
datetime: '2个半小时之前',
content: '收藏了你的文章',
avatar: 'https://s1.ax1x.com/2022/06/14/XhtSwF.jpg'
}
])
/** 处理标签页切换 */
const handleTabChange: TabsInstance['onChange'] = (key) => {
activeTab.value = key as string
}
</script>
<style lang="scss" scoped></style>
@@ -14,19 +14,12 @@
</a-button>
</a-tooltip>
<!-- 消息通知下拉框 -->
<a-popover position="bottom" trigger="click">
<a-badge :count="9" dot>
<a-button size="mini" class="g-hover-btn">
<template #icon>
<icon-notification :size="18" />
</template>
</a-button>
</a-badge>
<template #content>
<Notice></Notice>
</template>
</a-popover>
<!--
消息通知(铃铛)**暂时去掉**(2026-10-09 客户要求):
原来的铃铛是一个写死 9 条假数据的下拉框,站内信还没接到这个入口上。
真要做的时候,数据源是「通知记录」(`/collect/notify`,接口已实现),
不要恢复成写死的假列表。
-->
<!-- 全屏切换按钮 -->
<a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom">
@@ -87,7 +80,6 @@ import { useFullscreen } from '@vueuse/core'
import { useBreakpoint, useTheme } from '@/hooks'
import { useAppStore, useUserStore } from '@/stores'
import ChangePasswordModal from './ChangePasswordModal.vue'
import Notice from './Notice.vue'
import SettingDrawerPanel from './SettingDrawerPanel.vue'
/** 组件名称 */
+1
View File
@@ -1,6 +1,7 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export * from './useActingTenantStore'
export * from './useAppStore'
export * from './useDictStore'
export * from './useMenuBadgeStore'
@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
/**
* 平台运营的「当前学校」。
*
* ## 为什么要有这个状态
*
* 平台运营(`tenantId = 0`)是唯一跨租户的身份,服务端的租户过滤对它整体关闭。
* 所以「组织架构」「账号管理」这类**本身就是租户内**的页面,平台必须先指明看哪所学校 ——
* 否则拿到的是所有租户混在一起的数据,而且看不出错。
*
* 这个值是**跟着请求走的**(`X-Tenant-Id` 请求头,见 `@/apis/xt/acting-tenant`),
* 不是登录态的一部分:token 里的 tenantId 始终是 0。
*
* ## 为什么放在 store 而不是各页面自己 `ref`
*
* 两个页面都要用同一个选择。各写一份的话,用户在「组织架构」选了学校、
* 切到「账号管理」又得重选一次 —— 而更糟的是**看起来像是两所学校的数据不一致**。
*
* ⚠️ `persist: false`:刷新页面就忘掉。这是刻意的 ——
* 一个跨越登录会话的"当前学校"会在下次登录时静默生效,
* 而那时候用户心里想的可能是另一所学校。
*/
const storeSetup = () => {
/** 当前代管的租户 id;`undefined` = 未选择(此时不应请求租户内数据) */
const tenantId = ref<number | undefined>(undefined)
const setTenant = (id?: number) => {
tenantId.value = id
}
const clear = () => {
tenantId.value = undefined
}
return { tenantId, setTenant, clear }
}
export const useActingTenantStore = defineStore('actingTenant', storeSetup, { persist: false })
+4
View File
@@ -10,6 +10,7 @@ import { buildXtRoutes } from '@/config/xt-routes'
import { resetRouter } from '@/router'
import { clearToken, getToken, setToken } from '@/utils/auth'
import { isHttp } from '@/utils/validate'
import { useActingTenantStore } from './useActingTenantStore'
import { useRouteStore } from './useRouteStore'
/** 登录参数:password 必须是 RSA 加密后的密文(见 @/utils/rsa) */
@@ -101,6 +102,9 @@ const storeSetup = () => {
resetRouter()
profile.value = null
menuKeys.value = []
// 平台运营的"当前学校"属于**上一次身份**的上下文,退出时一起清掉:
// 留着它,下一个登录的人(可能是学校管理员)会继承一个自己没选过的租户
useActingTenantStore().clear()
}
return true
}
+47
View File
@@ -190,6 +190,53 @@
margin-bottom: 12px;
}
/**
* 列表页卡片(卡片里装着「工具栏 + 表格 + 分页器」)。
*
* 为什么必须有它:`GiPageLayout` 的内容区是 `overflow: hidden`(页面本身不滚),
* 而一页 10~20 条比屏幕高 —— 不加这层高度链路,**分页器会被裁到可视区之外**:
* DOM 里明明有(`共 1245 条 … 20 条/页`),人却看不见,看着就像"分页器没做/坏了"。
* 操作日志页(20 条/页)与收集任务页(加了状态 tab 之后)都踩过这一脚。
*
* 用法:`<a-card class="g-list-card">`,表格必须给 `:scroll="{ x: 宽度, y: '100%' }"`——
* `y` 是字符串时 Arco 会把表头与表体**拆开**(内部 `splitTable`),表体自己滚、表头钉住;
* 不写 y 的话 `.arco-table-content` 的 `overflow-y` 是 hidden(那是横向滚动条专用的),
* 多出来的行会被直接裁掉且无法滚动。
*/
.g-list-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
.arco-card-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
// ⚠️ `.arco-spin` 必须在链路里:Arco 用 Spin 包住「表体 + 分页器」
.arco-table,
.arco-spin {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.arco-table-container {
flex: 1;
min-height: 0;
}
// 分页器不参与伸缩,永远留在卡片底部
.arco-table-pagination {
flex: none;
}
}
// 以下这些类名要放到下面,涉及到css权重优先级问题
.g-mt {
margin-top: var(--margin);
+16 -65
View File
@@ -8,43 +8,17 @@
</a-space>
</template>
<template #extra>
<a-tag :color="menu?.entryAt ? 'green' : 'orange'">
{{ menu?.entryAt ? '功能已就绪' : milestone }}
</a-tag>
<a-tag color="orange">{{ milestone }}</a-tag>
</template>
<!--
⚠️ 两种说法必须分开,不能都说「还没做」。
名单管理 / 材料规则 / 表单设计 这三个菜单项对应的功能**早就做好了**,
只是它们**按任务**(入口在「收集任务」每一行的链接里)。
以前这里一律说"这个页面还没做" + 说"已实现的页面只有工作台、组织架构、租户管理"——
而那句话早就过期了,且**没有任何机制会让它不过期**。
说"还没做"而功能其实在隔壁,比找不到更糟:**它让人得出错误结论。**
-->
<a-alert v-if="menu?.entryAt" type="success" :show-icon="false">
<template #title>
<strong>这个功能已经有了,只是入口不在这里。</strong>
{{ menu?.note }}
</template>
</a-alert>
<a-alert v-else type="info" :show-icon="false">
<a-alert type="info" :show-icon="false">
<template #title>
<strong>这个页面还没做。</strong>
{{ menu?.note ?? '该功能尚未实现。' }}
</template>
</a-alert>
<div v-if="menu?.entryAt" class="coming-soon__go">
<a-button type="primary" @click="goEntry">
<template #icon><IconArrowRight /></template>
去「{{ entryTitle }}」
</a-button>
<div class="coming-soon__hint">{{ menu?.entryAt?.hint }}</div>
</div>
<p v-else class="coming-soon__why">
<p class="coming-soon__why">
左侧菜单是按 <strong>服务端返回的角色权限</strong> 渲染的,所以你能看到它,
说明你的角色确实有这个权限 —— 只是界面还没排到。
</p>
@@ -53,7 +27,7 @@
</template>
<script setup lang="ts">
import { IconArrowRight, IconClockCircle } from '@arco-design/web-vue/es/icon'
import { IconClockCircle } from '@arco-design/web-vue/es/icon'
import { findMenuByPath } from '@/config/xt-routes'
/**
@@ -64,40 +38,27 @@ import { findMenuByPath } from '@/config/xt-routes'
* 2. 隐藏会让人以为"没这个功能",而显示"排队中 + 属于哪个里程碑"是更准确的信息;
* 3. 一个页面服务所有占位路由,成本极低。
*
* ⚠️ 但这个页面**过去在说谎**:它对"功能已经有了、只是入口在别处"的菜单项
* 也说"还没做",还列了一句早已过期的"已实现的页面"清单。
* 现在按 `entryAt` 分两种说法,并**删掉了那句清单** ——
* 任何"现状清单"写死在代码里都会静默过期,而没人会记得回来改它。
*/
defineOptions({ name: 'ComingSoon' })
const route = useRoute()
const router = useRouter()
/**
* ⚠️ 这一页**只服务"确实还没做"的菜单**(现在只剩「角色与授权」)。
* 曾经它还要区分另一种情况 —— "功能已经有了、只是入口在别处"(名单管理 / 表单设计 /
* 材料规则,都在「收集任务」行内的链接里)。那三条菜单已按客户要求**直接去掉**:
* 导航里挂着一条点进去只有指路的条目,既占位置又让人以为这里应该有事可做。
* 所以 `entryAt` 那套分支("功能已就绪"标签 + 跳转按钮)也一并删了 ——
* 留着就是一段永远不会被走到的代码。
*
* ⚠️ 按**路径**找,不要按 `route.meta.menu`。
*
* 原来写的是 `findMenu(route.meta.menu ?? '')`,而 `formatAsyncRoutes` 构造的 meta 里
* **没有 `menu` 这个键**(只有 hidden/keepAlive/title/icon/... )——
* 于是这里永远拿到 `undefined`,页面永远显示兜底文案
* 「功能 / 规划中 / 该功能尚未实现」,**每个菜单项自己的 note 从来没显示出来过**。
* 而 note 恰恰是这一页最有用的东西(它写着"真实入口在哪")。
* 而 note 恰恰是这一页最有用的东西。
*/
defineOptions({ name: 'ComingSoon' })
const route = useRoute()
const menu = computed(() => findMenuByPath(route.path))
/** 跳过去之后那个页面的标题(从路由表里取,免得两处各写一遍)。 */
const entryTitle = computed(() => {
const path = menu.value?.entryAt?.path
return path ? (findMenuByPath(path)?.title ?? '目标页面') : '目标页面'
})
function goEntry() {
const path = menu.value?.entryAt?.path
if (path) {
router.push(path)
}
}
/** 各菜单对应的里程碑(与《05-开发任务拆解》对齐) */
const MILESTONES: Record<string, string> = {
role: '二期'
@@ -117,14 +78,4 @@ const milestone = computed(() => MILESTONES[menu.value?.key ?? ''] ?? '规划中
color: var(--color-text-1);
}
}
.coming-soon__go {
margin-top: 20px;
}
.coming-soon__hint {
margin-top: 8px;
font-size: 13px;
color: var(--color-text-3);
}
</style>
+116 -21
View File
@@ -57,20 +57,33 @@
<template #title>数据看板</template>
<template #extra>
<a-space>
<!--
平台运营必须先选学校:任务、统计、审核数都是**租户内**的数据,
而服务端对平台的租户过滤是关掉的 —— 不选就会把各学校的任务混在一起。
-->
<GiTenantPicker
v-if="isPlatform"
v-model="actingStore.tenantId"
width="200px"
placeholder="选择学校(平台运营)"
/>
<a-select
v-model="boardTaskId"
placeholder="选择一个任务"
allow-search
style="width: 260px"
:loading="taskLoading"
:disabled="needPickTenant"
:options="taskOptions"
@change="loadBoard"
/>
<a-button :loading="boardLoading" @click="loadBoard">刷新</a-button>
<a-button :loading="boardLoading" :disabled="needPickTenant" @click="loadBoard">刷新</a-button>
</a-space>
</template>
<a-empty v-if="!boardTaskId" description="选一个任务,看它的填报进度与审核情况" />
<a-empty v-if="needPickTenant" description="平台运营看的是某一所学校的进度:请先选择学校" />
<a-empty v-else-if="!boardTaskId" description="选一个任务,看它的填报进度与审核情况" />
<a-spin v-else :loading="boardLoading" style="display: block">
<!-- 进度四格:应填 / 已提交 / 已通过 / 待审 -->
@@ -116,6 +129,7 @@
</span>
</div>
<a-table
v-if="canViewAllProgress"
:data="auditRows"
:pagination="false"
size="small"
@@ -127,6 +141,11 @@
<a-table-column title="说明" data-index="foot" ellipsis tooltip />
</template>
</a-table>
<!--
班主任(只有 progress:viewOwn)在这里看到的是**空表**,而空表不解释任何事。
如实说明"这里为什么不给你看",比留一张空表更清楚。
-->
<a-empty v-else description="审核统计按全租户口径,需要「全租户进度」权限" />
</a-col>
</a-row>
@@ -151,7 +170,7 @@ import * as orgApi from '@/apis/xt/org'
import * as taskApi from '@/apis/xt/task'
import * as tenantApi from '@/apis/xt/tenant'
import { ROLE_NAMES, XT_MENUS } from '@/config/xt-routes'
import { useUserStore } from '@/stores'
import { useActingTenantStore, useUserStore } from '@/stores'
/**
* 工作台。
@@ -177,6 +196,47 @@ const roleLabel = computed(() =>
// 只会让这个判断恒为 false(之前就是这样:平台管理员也看到"仅平台运营可见")
const isPlatform = computed(() => userStore.menuKeys?.includes('tenant') ?? false)
/**
* ⚠️ 两个"能不能调"的判断,缺一个就会在页面上弹 403。
*
* 工作台是所有角色共用的落地页,但它里面有两个接口是**按角色开放的**:
* · `GET /orgs/tree` 要 `org:view` —— 班主任/副班主任/审核员都没有;
* · `GET /audit/statistics` 要 `progress:viewAll` —— 班主任只有 `progress:viewOwn`。
*
* 以前这两个是无条件调的,于是**班主任一进工作台就弹「拒绝访问(403)」**(实测)。
* 而 403 来自 HTTP 状态码,`try/catch` 拦不住它的 toast —— 前端唯一正确的做法是
* **不去调它**,并把那一格如实显示成"—"(本页的取舍就是"只显示真实能查到的数字")。
*/
const canViewOrg = computed(() => userStore.permissions.includes('org:view'))
/**
* ⚠️ 权限码是 **`progress:viewAll`**(驼峰),不是 `progress:view:all` ——
* 权威来源是后端 `security/PermissionDef`(`PROGRESS_VIEW_ALL("progress:viewAll", ...)`)。
* 我第一版就写成了带冒号的,结果这个判断**恒为 false**:「审核情况」对**所有人**都不显示,
* 而 typecheck / lint / 后端全都不报错(字符串对不上是纯运行期的事)。
* 现在 `scripts/check-wiring.py` 会做这层对齐检查。
*/
const canViewAllProgress = computed(() => userStore.permissions.includes('progress:viewAll'))
/**
* 平台运营的「当前学校」(请求头 `X-Tenant-Id`,见 `@/apis/xt/acting-tenant`)。
*
* ⚠️ 工作台也必须有这个选择:这里的**任务、统计、审核数**全是租户内的数据,
* 而服务端对平台的租户过滤是关掉的 —— 不给选择就会把各学校的任务混在一个下拉里,
* 选中之后看到的进度是"另一所学校的",而界面上没有任何地方会提示这件事。
*
* 选择与「组织架构 / 账号管理」共用同一个 store(见 `useActingTenantStore`)。
*/
const actingStore = useActingTenantStore()
const needPickTenant = computed(() => isPlatform.value && !actingStore.tenantId)
/**
* 「组织节点」这一格能不能显示真实数字。
*
* 平台运营要先选定学校(选定后就是那所学校的节点数);没选时显示"—",
* 而不是让 `/orgs/tree` 返回一个"0 个"(那会让人以为全平台没有组织)。
*/
const orgCardShowable = computed(() => canViewOrg.value && !needPickTenant.value)
/** 已实现且当前角色可访问的页面 */
const shortcuts = computed(() =>
XT_MENUS.filter((m) => m.ready && userStore.menuKeys.includes(m.key))
@@ -186,9 +246,11 @@ const stats = computed(() => [
{
label: '组织节点',
icon: IconFolder,
value: loading.value ? '—' : (orgCount.value ?? '—'),
unit: '个',
foot: '学校 / 院系 / 班级',
value: orgCardShowable.value && !loading.value ? (orgCount.value ?? '—') : '—',
unit: orgCardShowable.value ? '个' : '',
foot: isPlatform.value
? (needPickTenant.value ? '选定学校后展示该校的组织' : '所选学校的节点数')
: (canViewOrg.value ? '学校 / 院系 / 班级' : '需要「组织查看」权限'),
tone: ''
},
{
@@ -250,10 +312,10 @@ const boardCards = computed(() => {
},
{
label: '待审',
value: audit.value?.pendingCount ?? '—',
foot: audit.value?.timeoutCount
? `其中超时 ${audit.value.timeoutCount} 件`
: '没有超时的',
value: canViewAllProgress.value ? (audit.value?.pendingCount ?? '—') : '—',
foot: canViewAllProgress.value
? (audit.value?.timeoutCount ? `其中超时 ${audit.value.timeoutCount} 件` : '没有超时的')
: '需要「全租户进度」权限',
tone: audit.value?.timeoutCount ? 'is-warn' : ''
}
]
@@ -294,10 +356,33 @@ function fmtHours(h: number | null | undefined): string {
return h >= 10 ? `${Math.round(h)} 小时` : `${h} 小时`
}
/** 组织节点数(平台运营按所选学校;其它角色就是自己租户的) */
async function loadOrgCount() {
// 组织树要 `org:view`(班主任/副班主任/审核员都没有);平台没选学校时也不该问
if (!orgCardShowable.value) {
orgCount.value = null
return
}
try {
const res = await orgApi.getOrgTree({ tenantId: actingStore.tenantId })
orgCount.value = countNodes(res.data ?? [])
} catch {
orgCount.value = null
}
}
async function loadTasks() {
// 平台没选学校时不发请求:拿到的是空列表,界面上会显示成"这所学校没有任务"
if (needPickTenant.value) {
taskOptions.value = []
boardTaskId.value = undefined
stat.value = null
audit.value = null
return
}
taskLoading.value = true
try {
const res = await taskApi.getTaskList({ pageNum: 1, pageSize: 100 })
const res = await taskApi.getTaskList({ pageNum: 1, pageSize: 100 }, actingStore.tenantId)
const list = res.data?.list ?? []
taskOptions.value = list.map((x) => ({
label: `${x.taskName}(${taskApi.TASK_STATUS_TEXT[x.status] ?? ''})`,
@@ -308,6 +393,10 @@ async function loadTasks() {
if (collecting) {
boardTaskId.value = collecting.id
loadBoard()
} else {
boardTaskId.value = undefined
stat.value = null
audit.value = null
}
} finally {
taskLoading.value = false
@@ -318,13 +407,17 @@ async function loadBoard() {
if (!boardTaskId.value) return
boardLoading.value = true
try {
// 两个接口互不依赖,并行取;任一个失败不影响另一个显示
// ⚠️ 审核统计要 `progress:viewAll`,班主任没有 —— 不能"调了再 catch":
// 403 是 HTTP 状态码,`http.ts` 的传输层分支会**先弹一个「拒绝访问(403)」**,
// catch 只能拦住异常,拦不住那个提示。所以按权限**决定发不发这个请求**。
const [s, a] = await Promise.allSettled([
taskApi.getTaskStatistics(boardTaskId.value),
auditApi.getAuditStatistics({ taskId: boardTaskId.value })
taskApi.getTaskStatistics(boardTaskId.value, actingStore.tenantId),
canViewAllProgress.value
? auditApi.getAuditStatistics({ taskId: boardTaskId.value }, actingStore.tenantId)
: Promise.resolve(null)
])
stat.value = s.status === 'fulfilled' ? s.value.data : null
audit.value = a.status === 'fulfilled' ? a.value.data : null
audit.value = a.status === 'fulfilled' ? (a.value?.data ?? null) : null
} finally {
boardLoading.value = false
}
@@ -334,13 +427,15 @@ function countNodes(nodes: OrgTreeVO[]): number {
return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children ?? []), 0)
}
// 换学校 = 换一整套数字(组织节点、任务下拉、看板)。不清空的话,
// 上一所学校的任务还留在下拉里,选中它看到的是**另一所学校**的进度
watch(() => actingStore.tenantId, () => {
loadOrgCount()
loadTasks()
})
onMounted(async () => {
try {
const res = await orgApi.getOrgTree()
orgCount.value = countNodes(res.data ?? [])
} catch {
orgCount.value = null
}
await loadOrgCount()
if (isPlatform.value) {
try {
const res = await tenantApi.getTenantList({ pageNum: 1, pageSize: 1 })
+69 -16
View File
@@ -1,6 +1,6 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<a-card :bordered="false" class="g-list-card">
<template #title>操作日志</template>
<template #extra>
<span class="log__hint">
@@ -8,14 +8,6 @@
</span>
</template>
<a-alert type="normal" class="log__tip">
<template #title>
证件号在这里**只有脱敏值与指纹**(日志泄露不该比业务库泄露更严重)。
IP 取代理链第一跳、**可以被伪造** —— 用于排查,不作合规证据;
真正的证据是「哪个账号在什么时候做了什么」。
</template>
</a-alert>
<div class="g-row-tool">
<a-space wrap>
<a-input-number v-model="query.targetId" placeholder="对象 ID" size="small"
@@ -58,9 +50,27 @@
</a-space>
</div>
<!--
`y: '100%'` 不是装饰:它让 Arco 把表头与表体**拆开**
(内部 `splitTable`),表体自己滚、表头钉住、分页器留在卡片底部。
没有它的话,`.arco-table-content` 的 `overflow-y` 是 hidden(横向滚动条专用),
多出来的行会被**直接裁掉**,而且没有任何滚动条能救回来。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 1200 }" @page-change="onPageChange">
:scroll="{ x: isPlatform ? 1340 : 1200, y: '100%' }" @page-change="onPageChange"
@page-size-change="onPageSizeChange">
<template #columns>
<!--
「租户」列**只给平台运营看**:它看的是全量日志(跨租户),
不显示单位就分不清"这条是哪个学校做的";学校管理员永远是本租户,列没有意义。
-->
<a-table-column v-if="isPlatform" title="租户" :width="140">
<template #cell="{ record }">
<span class="log__muted">
{{ tenantNames[record.tenantId] ?? (record.tenantId != null ? `#${record.tenantId}` : '—') }}
</span>
</template>
</a-table-column>
<a-table-column title="时间" :width="150">
<template #cell="{ record }">
<span class="log__muted">{{ fmt(record.createTime) }}</span>
@@ -68,8 +78,12 @@
</a-table-column>
<a-table-column title="操作人" :width="120">
<template #cell="{ record }">
<div>{{ record.operatorName || '—' }}</div>
<div class="log__muted">ID {{ record.operatorId ?? '—' }}</div>
<!--
⚠️ 只显示**账号/姓名**,不显示用户 ID(2026-10-09 客户要求)。
数字 ID 对看日志的人没有意义 —— 他要知道的是"哪个账号做的",
ID 只在排查时要,而那种时候查库比看这一列快。
-->
<span>{{ record.operatorName || '—' }}</span>
</template>
</a-table-column>
<a-table-column title="动作" :width="150">
@@ -119,6 +133,8 @@ import {
OPERATION_ACTIONS,
SENSITIVE_ACTIONS
} from '@/apis/xt/log'
import { getTenantList } from '@/apis/xt/tenant'
import { useUserStore } from '@/stores'
/**
* 操作日志(《03-接口设计》§13.4)。
@@ -134,9 +150,22 @@ import {
*
* 看这一屏的人 90% 就是为了看这两列。塞进"详情"弹窗里等于让每次查看多一次点击,
* 而合规核查往往是几十条连着看的。
*
* ## 范围:平台运营看**全量**,学校管理员看**本租户**
*
* 由服务端按 `log:viewAll` / `log:viewTenant` 决定(`LogController.logScopeTenantId`)。
* ⚠️ 平台运营看到的每条日志属于哪个单位,靠这一列「租户」——它**只对平台显示**。
* 以前平台的 tenantId 是 0 而日志挂在各租户下,于是这一屏对平台**永远是空的**,
* 且不报任何错(PRD 要求的却是「全量操作日志 ✅」)。
*/
defineOptions({ name: 'OperationLogs' })
const userStore = useUserStore()
/** 平台运营:看全量日志,因此要显示「租户」列并把 id 翻成单位名 */
const isPlatform = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const tenantNames = ref<Record<number, string>>({})
const loading = ref(false)
const list = ref<OperationLog[]>([])
const total = ref(0)
@@ -220,20 +249,44 @@ function onPageChange(page: number) {
load()
}
/**
* 改每页条数。
*
* ⚠️ **必须处理这个事件**,不能只处理 `page-change`:
* Arco 的表格在 `pagination` 非 false 时会**自己再切一次数据**
* (`flattenData`:`data.length > pageSize` 就 slice)。
* 只在本地改每页条数、不重新取数的话,会出现
* 「分页器显示 50 条/页、表里还是上一次那 20 条」这种自相矛盾的画面 ——
* 看起来就是"分页器坏了"。所以这里一律回到第 1 页重新取。
*/
function onPageSizeChange(size: number) {
query.pageSize = size
query.pageNum = 1
load()
}
// 平台运营才需要"租户 id → 单位名"。取不到也不影响看日志:列里退回显示 #id
onMounted(async () => {
if (!isPlatform.value) return
try {
const res = await getTenantList({ pageNum: 1, pageSize: 200 })
tenantNames.value = Object.fromEntries((res.data?.list ?? []).map((t) => [t.id, t.tenantName]))
} catch {
tenantNames.value = {}
}
})
load()
</script>
<style lang="scss" scoped>
/* 卡片/表格的高度链路(分页器为什么必须钉在底部)见 `styles/global.scss` 的 `.g-list-card` */
.log {
&__hint {
font-size: 12px;
color: var(--color-text-3);
}
&__tip {
margin-bottom: 12px;
}
&__num {
width: 110px;
}
+9 -2
View File
@@ -52,9 +52,16 @@ import { ORG_TYPE_NAMES } from '@/apis/xt/org'
* 1. 组织编码租户内唯一
* 2. 组织类型必须比上级更低一级
* 3. 修改时不提供改上级(避免误操作把整棵子树搬走)
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:它决定这个组织建到哪所学校下
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。学校管理员传 null,行为与以前一致。
*/
defineOptions({ name: 'OrgFormModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{
(e: 'save-success'): void
}>()
@@ -132,7 +139,7 @@ async function confirm() {
orgType: form.orgType,
orgCode: form.orgCode.trim(),
sortNo: form.sortNo ?? 0
})
}, props.tenantId)
Message.success('已保存')
} else {
await orgApi.addOrg({
@@ -141,7 +148,7 @@ async function confirm() {
orgType: form.orgType,
orgCode: form.orgCode.trim(),
sortNo: form.sortNo ?? 0
})
}, props.tenantId)
Message.success('已新增')
}
visible.value = false
+67 -7
View File
@@ -13,11 +13,34 @@
<span class="org__hint">{{ totalNodes }} 个节点</span>
</template>
<!--
平台运营必须先选学校:组织树本来就是租户内的东西,而平台的租户过滤是关掉的。
不选就请求,拿到的是"所有租户的组织混在一棵树里"—— 只有一所学校有数据时,
它和学校管理员看到的完全一样,看不出错。
-->
<GiTenantPicker
v-if="isPlatform"
v-model="actingStore.tenantId"
class="org__tenant"
placeholder="选择学校(平台运营)"
/>
<a-input-search v-model="keyword" placeholder="搜索名称或编码" allow-clear class="org__search" />
<a-spin v-if="loading" class="org__loading" />
<!--
⚠️ Arco 的 `a-empty` 里一旦给了默认插槽,`description` 就**不再渲染**
(它的实现是 `<slot>{{ description }}</slot>`)—— 所以文案要写在插槽里,
否则页面上只剩半句话。
-->
<a-empty v-if="needPickTenant">
<div class="org__empty-title">请先选择学校</div>
<span class="org__hint">组织架构按学校隔离,选定学校后即可查看与维护</span>
</a-empty>
<a-empty v-else-if="!tree.length" description="还没有组织">
<a-spin v-else-if="loading" class="org__loading" />
<a-empty v-else-if="!tree.length">
<div class="org__empty-title">还没有组织</div>
<a-button v-if="canManage" type="primary" size="small" @click="onAdd(null)">
新增顶级组织
</a-button>
@@ -35,7 +58,7 @@
</a-tree>
<template #footer>
<a-button v-if="canManage" long size="small" @click="onAdd(null)">
<a-button v-if="canManage && !needPickTenant" long size="small" @click="onAdd(null)">
<template #icon><IconPlus /></template>
新增顶级组织
</a-button>
@@ -111,7 +134,7 @@
</a-card>
</template>
<OrgFormModal ref="formModalRef" @save-success="load" />
<OrgFormModal ref="formModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
</GiPageLayout>
</template>
@@ -121,7 +144,7 @@ import { Message } from '@arco-design/web-vue'
import { IconDelete, IconEdit, IconFolder, IconPlus } from '@arco-design/web-vue/es/icon'
import * as orgApi from '@/apis/xt/org'
import { ORG_TYPE_NAMES } from '@/apis/xt/org'
import { useUserStore } from '@/stores'
import { useActingTenantStore, useUserStore } from '@/stores'
import OrgFormModal from './OrgFormModal.vue'
/**
@@ -135,6 +158,18 @@ defineOptions({ name: 'Org' })
const userStore = useUserStore()
/**
* 平台运营的「当前学校」。
*
* 服务端对平台关掉了租户过滤,所以平台**必须先指明看哪所学校**才能拿到租户内的数据
* (见 `@/apis/xt/acting-tenant`)。选择存在 `useActingTenantStore` 里,
* 与「账号管理」共用 —— 切页面不用重选,也不会出现两个页面各看一所学校。
* 学校管理员的 `isPlatform` 为 false,这个值恒为 undefined —— 不传头,行为与以前一致。
*/
const actingStore = useActingTenantStore()
const isPlatform = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
const needPickTenant = computed(() => isPlatform.value && !actingStore.tenantId)
const loading = ref(false)
const tree = ref<OrgTreeVO[]>([])
const selected = ref<OrgTreeVO | null>(null)
@@ -174,9 +209,16 @@ function findById(nodes: OrgTreeVO[], id: number): OrgTreeVO | null {
}
async function load() {
// 平台没选学校时不发请求(服务端会返回空树,徒增一次往返与一次困惑)
if (needPickTenant.value) {
tree.value = []
selected.value = null
selectedKeys.value = []
return
}
loading.value = true
try {
const res = await orgApi.getOrgTree({ withCount: true })
const res = await orgApi.getOrgTree({ withCount: true, tenantId: actingStore.tenantId })
tree.value = res.data ?? []
// 刷新后保持选中
if (selected.value) {
@@ -187,6 +229,15 @@ async function load() {
}
}
// 换学校 = 换一整棵树:不清空的话,上一所学校的节点会留在页面上,
// 而"点进去看到的是别人学校的组织"这种事没有任何报错会提醒你
watch(() => actingStore.tenantId, () => {
selected.value = null
selectedKeys.value = []
keyword.value = ''
load()
})
/**
* 树节点选中。
*
@@ -214,7 +265,7 @@ function onEdit(row: OrgTreeVO) {
}
async function onDelete(row: OrgTreeVO) {
await orgApi.deleteOrg(row.id)
await orgApi.deleteOrg(row.id, actingStore.tenantId)
Message.success('已删除')
if (selected.value?.id === row.id) {
selected.value = null
@@ -236,6 +287,10 @@ onMounted(load)
margin-bottom: 10px;
}
&__tenant {
margin-bottom: 10px;
}
&__loading {
display: block;
padding: 40px 0;
@@ -247,6 +302,11 @@ onMounted(load)
color: var(--color-text-3);
}
&__empty-title {
margin-bottom: 4px;
color: var(--color-text-1);
}
&__node {
display: inline-flex;
gap: 6px;
+69 -12
View File
@@ -1,11 +1,20 @@
<template>
<GiPageLayout>
<a-card :bordered="false">
<a-card :bordered="false" class="g-list-card">
<template #title>收集任务</template>
<template #extra>
<span class="task__hint">共 {{ total }} 个任务</span>
</template>
<!--
状态切换:**单独一行的 tab**(2026-10-09 客户要求),不再和
「新建任务 / 批次号 / 搜索」挤在同一行做成一个个按钮 ——
一排长得一样的按钮分不清"哪个是当前视图、哪个是动作"。
-->
<a-tabs class="task__tabs" :active-key="activeStatusKey" hide-content @change="onStatusChange">
<a-tab-pane v-for="t in STATUS_TABS" :key="t.key" :title="t.label" />
</a-tabs>
<div class="g-row-tool">
<a-space wrap>
<a-button v-if="canManage" type="primary" size="small" @click="onAdd">
@@ -15,10 +24,6 @@
</a-space>
<a-space wrap>
<a-button v-for="c in statusChips" :key="String(c.value)"
:type="statusFilter === c.value ? 'primary' : 'outline'" size="small" @click="setStatus(c.value)">
{{ c.label }}
</a-button>
<a-input v-model="query.batchNo" placeholder="批次号" allow-clear class="task__batch"
@press-enter="search" />
<a-input-search v-model="query.keyword" placeholder="搜索任务名 / 编码 / 专业" allow-clear
@@ -38,8 +43,13 @@
名册是准入名单,没有它整条链路走不通,它必须在一眼能看到的位置)
· 合计仍是 840,与改动前一样,所以横向滚动的行为没有变差。
-->
<!--
`y: '100%'`:让 Arco 拆开表头与表体(表体自己滚、表头钉住),
配合卡片上的 `.g-list-card` 把分页器留在卡片底部。原因见 `styles/global.scss`。
-->
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
:scroll="{ x: 820 }" @page-change="onPageChange">
:scroll="{ x: 820, y: '100%' }" @page-change="onPageChange"
@page-size-change="onPageSizeChange">
<template #columns>
<a-table-column title="任务" :width="200" ellipsis tooltip>
<template #cell="{ record }">
@@ -181,6 +191,13 @@ import TaskRosterDrawer from './TaskRosterDrawer.vue'
*/
defineOptions({ name: 'Task' })
/** 状态 tab 的一项:tab 的 key 是字符串,发给接口的是 status */
interface StatusTab {
key: string
label: string
value: number | undefined
}
const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('task:manage'))
@@ -214,14 +231,26 @@ const BLOCKING_HINTS: Record<number, string> = {
40020: '命名规则有问题,请点「编辑」检查文件命名模板'
}
const statusChips = [
{ label: '全部', value: undefined },
{ label: '草稿', value: TASK_STATUS_DRAFT },
{ label: '收集中', value: TASK_STATUS_COLLECTING },
{ label: '已暂停', value: TASK_STATUS_PAUSED },
{ label: '已截止', value: TASK_STATUS_CLOSED }
/**
* 状态 tab。
*
* `key` 必须是字符串(Arco 的 tab key 不支持 undefined),
* `value` 才是发给接口的 `status` —— 「全部」= 不带 status 参数,
* 这个映射只在这一处定义,别在别处再写一份 `?? undefined`。
*/
const STATUS_TABS: StatusTab[] = [
{ key: 'all', label: '全部', value: undefined },
{ key: String(TASK_STATUS_DRAFT), label: '草稿', value: TASK_STATUS_DRAFT },
{ key: String(TASK_STATUS_COLLECTING), label: '收集中', value: TASK_STATUS_COLLECTING },
{ key: String(TASK_STATUS_PAUSED), label: '已暂停', value: TASK_STATUS_PAUSED },
{ key: String(TASK_STATUS_CLOSED), label: '已截止', value: TASK_STATUS_CLOSED }
]
/** 当前选中的 tab(由 statusFilter 反推,保持单一数据源) */
const activeStatusKey = computed(() =>
STATUS_TABS.find((t) => t.value === statusFilter.value)?.key ?? 'all'
)
const pagination = computed(() => ({
current: query.pageNum,
pageSize: query.pageSize,
@@ -261,6 +290,23 @@ function setStatus(v: number | undefined) {
search()
}
/** 状态 tab 切换 */
function onStatusChange(key: string | number) {
setStatus(STATUS_TABS.find((t) => t.key === String(key))?.value)
}
/**
* 改每页条数。
*
* ⚠️ 与「操作日志」同一处坑:Arco 表格在 `pagination` 非 false 时**自己也会切数据**,
* 不重新取数就会出现「分页器说 50 条/页、表里还是 10 条」的自相矛盾画面。
*/
function onPageSizeChange(size: number) {
query.pageSize = size
query.pageNum = 1
load()
}
function onPageChange(page: number) {
query.pageNum = page
load()
@@ -407,6 +453,17 @@ onMounted(load)
color: var(--color-text-3);
}
/* 状态 tab 自己占一行(下面的工具栏是另一行) */
&__tabs {
:deep(.arco-tabs-nav) {
margin-bottom: 12px;
}
:deep(.arco-tabs-nav::before) {
background-color: var(--color-border-2);
}
}
&__batch {
width: 120px;
}
+27 -4
View File
@@ -150,9 +150,17 @@ import { useUserStore } from '@/stores'
* 3. **用户类型不让用户选,而是从角色推导** —— 二者一对一绑定,
* 让用户分别选就会产生"学生 + 学校管理员"这种自相矛盾的账号,
* 而服务端还会因此拒掉整个提交。改成推导后这类组合在界面上产生不了。
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"这个账号建到哪所学校下"
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定角色的可选范围:
* 给某所学校建账号时只能发**学校那套角色**,发 PLATFORM_ADMIN 会被服务端拒(40301)。
*/
defineOptions({ name: 'UserFormModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{
(e: 'save-success'): void
}>()
@@ -187,8 +195,17 @@ const getInitForm = () => ({
// 顺序要紧:下面几个 computed 读的是 form,所以 form 必须在前
const form = reactive(getInitForm())
/**
* 可选角色。
*
* ⚠️ 判据不只是"我是不是平台租户",还要看**有没有代管学校**:
* · 平台运营 + 没选学校 → 建的是平台自己的账号 → 只能发 PLATFORM_ADMIN;
* · 平台运营 + 选了学校 → 建的是**那所学校**的账号 → 只能发学校那套角色
* (发 PLATFORM_ADMIN 会被服务端按租户白名单拒掉,报 40301);
* · 学校管理员 → 学校那套。
*/
const roleOptions = computed(() =>
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS
isPlatformTenant.value && !props.tenantId ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS
)
/**
@@ -228,10 +245,16 @@ const rules: FormInstance['rules'] = {
async function loadOrgTree() {
if (orgTree.value.length) return
const res = await getOrgTree()
const res = await getOrgTree({ tenantId: props.tenantId })
orgTree.value = res.data ?? []
}
// 换了学校就把缓存的组织树丢掉:留着上一所学校的树,用户会在"组织"下拉里
// 看到别人学校的班级,而选中它提交会得到一个指向不存在组织的 orgId
watch(() => props.tenantId, () => {
orgTree.value = []
})
/** 新增 */
async function add() {
detailId.value = null
@@ -306,7 +329,7 @@ async function confirm() {
roleCodes: form.roleCodes,
userType,
status: form.status
})
}, props.tenantId)
Message.success('已保存')
visible.value = false
emit('save-success')
@@ -320,7 +343,7 @@ async function confirm() {
orgId: form.orgId ?? null,
roleCodes: form.roleCodes,
userType
})
}, props.tenantId)
const data = res.data
if (!data) {
Message.error('创建失败,请重试')
+12 -2
View File
@@ -174,9 +174,16 @@ import { useUserStore } from '@/stores'
* ② 哪几行没进去、为什么
* ③ 新账号的初始密码(只此一次)
* 三件事摊开给用户看,让他当场就能判断"这次导入能不能信"。
*
* `tenantId` 由父页面传入,**只有平台运营的页面会给值**:表示"导进哪所学校"
* (服务端按请求头 `X-Tenant-Id` 摆租户上下文)。它同时决定默认角色的可选范围。
*/
defineOptions({ name: 'UserImportModal' })
const props = withDefaults(defineProps<{ tenantId?: number | null }>(), {
tenantId: null
})
const emit = defineEmits<{
(e: 'save-success'): void
}>()
@@ -191,8 +198,11 @@ const report = ref<UserImportResult | null>(null)
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
/** 与 UserFormModal 同一口径:代管某所学校时只能发学校那套角色 */
const roleOptions = computed(() =>
isPlatformTenant.value ? PLATFORM_ROLE_OPTIONS : SCHOOL_ROLE_OPTIONS.filter((r) => r.value !== 'STUDENT')
isPlatformTenant.value && !props.tenantId
? PLATFORM_ROLE_OPTIONS
: SCHOOL_ROLE_OPTIONS.filter((r) => r.value !== 'STUDENT')
)
/** a-form 需要一个 model;文件选择走原生 input,不进这个对象 */
@@ -228,7 +238,7 @@ async function submit() {
if (!file.value) return
submitting.value = true
try {
const res = await userApi.importUsers(file.value, formModel.defaultRoleCode)
const res = await userApi.importUsers(file.value, formModel.defaultRoleCode, props.tenantId)
report.value = res.data ?? null
emit('save-success')
} finally {
+48 -9
View File
@@ -10,7 +10,17 @@
</a-space>
</template>
<a-spin v-if="orgLoading" class="user__loading" />
<!-- 平台运营必须先选学校:账号与组织都是租户内的数据(见 @/apis/xt/acting-tenant) -->
<GiTenantPicker
v-if="isPlatformTenant"
v-model="actingStore.tenantId"
class="user__tenant"
placeholder="选择学校(平台运营)"
/>
<a-empty v-if="needPickTenant" description="请先选择学校" />
<a-spin v-else-if="orgLoading" class="user__loading" />
<template v-else>
<a-button long size="small" :type="orgId === undefined ? 'primary' : 'outline'"
@@ -151,8 +161,8 @@
</a-table>
</a-card>
<UserFormModal ref="formModalRef" @save-success="load" />
<UserImportModal ref="importModalRef" @save-success="load" />
<UserFormModal ref="formModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
<UserImportModal ref="importModalRef" :tenant-id="actingStore.tenantId" @save-success="load" />
<!-- 重置密码结果:与创建一样,只显示这一次 -->
<a-modal v-model:visible="resetVisible" title="密码已重置" :width="460" :mask-closable="false"
@@ -192,7 +202,7 @@ import { IconCopy, IconFolder, IconPlus, IconUpload } from '@arco-design/web-vue
import { getOrgTree } from '@/apis/xt/org'
import * as userApi from '@/apis/xt/user'
import { PLATFORM_ROLE_OPTIONS, SCHOOL_ROLE_OPTIONS, USER_STATUS } from '@/apis/xt/user'
import { useUserStore } from '@/stores'
import { useActingTenantStore, useUserStore } from '@/stores'
import UserFormModal from './UserFormModal.vue'
import UserImportModal from './UserImportModal.vue'
@@ -215,6 +225,17 @@ const userStore = useUserStore()
const canManage = computed(() => userStore.permissions.includes('user:manage'))
const isPlatformTenant = computed(() => userStore.roles.includes('PLATFORM_ADMIN'))
/**
* 平台运营的「当前学校」(请求头 `X-Tenant-Id`,见 `@/apis/xt/acting-tenant`)。
*
* 服务端对平台是关掉租户过滤的,不指明学校就会把**所有租户的账号**混在一起返回 ——
* 而那种错看起来很正常(列表有数据、分页也对)。学校管理员不带这个头,行为不变。
*
* 选择与「组织架构」共用同一个 store:切页面不用重选,两个页面也不会各看一所学校。
*/
const actingStore = useActingTenantStore()
const needPickTenant = computed(() => isPlatformTenant.value && !actingStore.tenantId)
const ORG_FIELD_NAMES = { key: 'id', title: 'orgName', children: 'children' }
const loading = ref(false)
@@ -255,9 +276,13 @@ const pagination = computed(() => ({
}))
async function loadOrgTree() {
if (needPickTenant.value) {
orgTree.value = []
return
}
orgLoading.value = true
try {
const res = await getOrgTree()
const res = await getOrgTree({ tenantId: actingStore.tenantId })
orgTree.value = res.data ?? []
} finally {
orgLoading.value = false
@@ -265,6 +290,11 @@ async function loadOrgTree() {
}
async function load() {
if (needPickTenant.value) {
list.value = []
total.value = 0
return
}
loading.value = true
try {
const res = await userApi.getUserList({
@@ -274,7 +304,7 @@ async function load() {
status: statusFilter.value,
pageNum: query.pageNum,
pageSize: query.pageSize
})
}, actingStore.tenantId)
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} finally {
@@ -282,6 +312,15 @@ async function load() {
}
}
// 换学校 = 换一整套数据(组织树、列表、已选的班级都要重来)。
// 留着上一所学校的 orgId 去查新学校,会得到"一个空的列表" —— 看起来像这所学校没有账号。
watch(() => actingStore.tenantId, () => {
orgId.value = undefined
query.pageNum = 1
loadOrgTree()
load()
})
function search() {
query.pageNum = 1
load()
@@ -325,7 +364,7 @@ async function onToggleStatus(row: UserVO, status: number) {
content: '停用后该账号将无法登录,但数据保留,可以随时再启用。',
hideCancel: false,
onBeforeOk: async () => {
await userApi.setUserStatus(row.id, status)
await userApi.setUserStatus(row.id, status, actingStore.tenantId)
Message.success(`已${action}`)
await load()
resolve()
@@ -335,7 +374,7 @@ async function onToggleStatus(row: UserVO, status: number) {
})
})
}
await userApi.setUserStatus(row.id, status)
await userApi.setUserStatus(row.id, status, actingStore.tenantId)
Message.success(`已${action}`)
await load()
}
@@ -346,7 +385,7 @@ async function onResetPassword(row: UserVO) {
content: '系统会生成一个新的初始密码,旧密码立即失效。该账号的登录锁定也会一并解除。',
hideCancel: false,
onBeforeOk: async () => {
const res = await userApi.resetUserPassword(row.id, { mode: 'GENERATE' })
const res = await userApi.resetUserPassword(row.id, { mode: 'GENERATE' }, actingStore.tenantId)
resetResult.value = res.data ?? null
resetVisible.value = true
return true