fix: 修复 CI — 安装 AI 依赖 + 移除 lucide-react + 删除 docs #47
1061
docs/PRD.md
1061
docs/PRD.md
File diff suppressed because it is too large
Load Diff
@@ -1,54 +0,0 @@
|
||||
# 权限与默认角色矩阵
|
||||
|
||||
## 页面入口权限
|
||||
|
||||
| 页面 | 权限 |
|
||||
|---|---|
|
||||
| 数据面板 | `dashboard:view` |
|
||||
| 宿舍总览 / 宿舍管理 | `room:view` |
|
||||
| 入住管理 | `occupancy:view` |
|
||||
| 学生列表 / 学生档案 | `student:view` |
|
||||
| 班级列表 / 班级详情 / 教师工作台 | `class:view` |
|
||||
| 考勤管理 | `attendance:view` |
|
||||
| 排课管理 | `schedule:view` |
|
||||
| 教室列表 | `classroom:view` |
|
||||
| 排期总览 | `rental:view` |
|
||||
| 租赁订单 | `rental:view` |
|
||||
| 机构管理 | `organization:view` |
|
||||
| 费用管理 | `expense:view` |
|
||||
| 押金管理 | `deposit:view` |
|
||||
| 账单管理 | `bill:view` |
|
||||
| 通知中心 | `notification:view` |
|
||||
| 操作日志 | `log:view` |
|
||||
| 角色管理 / 权限一览 | `role:view` |
|
||||
| 钉钉集成配置 | `integration:read` |
|
||||
| AI 模型配置 | `ai:config:read` |
|
||||
| 账号 / 教师管理 | `user:view` |
|
||||
|
||||
登录后不再固定跳转 Dashboard,而是按上表顺序进入当前账号拥有权限的第一个页面。
|
||||
|
||||
## 特殊功能与 Tab
|
||||
|
||||
| 功能 | 权限 |
|
||||
|---|---|
|
||||
| 押金“待审批退款” Tab | `deposit:approve` |
|
||||
| 钉钉集成“同步用户” Tab | 同时需要 `sync:read`、`class:view`、`class:edit` |
|
||||
| 保存钉钉配置 | `integration:trigger` |
|
||||
| 查看/测试钉钉配置 | `integration:read` |
|
||||
| 保存 AI 配置 | `ai:config:write` |
|
||||
| 测试 AI 连接 | `ai:config:test` |
|
||||
| 清除 AI 密钥 | `ai:config:write` |
|
||||
|
||||
## 默认角色范围
|
||||
|
||||
| 角色 | 默认范围 |
|
||||
|---|---|
|
||||
| 超管 | 全部权限 |
|
||||
| 宿管老师 | 学生、宿舍、入住、费用、账单、押金、日志、Dashboard、班级、排课、考勤、通知、个人资料 |
|
||||
| 老师 | 本班学生查看、班级查看、排课查看、考勤查看/录入/导出、通知、个人资料;无 Dashboard |
|
||||
| 机构负责人 | 教室、租赁、机构、通知、个人资料;无 Dashboard |
|
||||
| 财务 | 费用、账单、押金、Dashboard、通知、个人资料 |
|
||||
| 宿管 | 学生、宿舍、入住、押金、Dashboard、通知、个人资料 |
|
||||
| 教务 | 班级、排课、考勤、教室、学习、考试、Dashboard、通知、个人资料 |
|
||||
|
||||
系统角色的预置权限只会自动补齐,不会删除管理员手动追加的权限。
|
||||
@@ -1,534 +0,0 @@
|
||||
# PRD 批次1 — 钉钉集成配置持久化(后端)
|
||||
|
||||
> 你是一名 NestJS 后端工程师。本项目是 `gongxue-base`,一个 NestJS + TypeORM 的宿舍管理系统。
|
||||
> 你**只做本文件描述的事**,不要改动本文件未提到的任何文件。严格按字段名/列名/路径照抄,不要自己发明命名。
|
||||
> 每写完一个文件,回到本文档核对"验收清单"。
|
||||
|
||||
## 背景(只读,不要改这些文件)
|
||||
|
||||
现状:钉钉配置目前从环境变量读取(`DINGTALK_APP_KEY` / `DINGTALK_APP_SECRET`),没有数据库表、没有配置界面。
|
||||
本批次目标:**新增一张配置表 + 一个配置服务 + 一个 config controller**,让钉钉/企微的 corpId、AppKey、AppSecret 可以存进数据库并通过接口读写。本批次**不做**同步逻辑(那是批次2)。
|
||||
|
||||
已知的项目约定(必须遵守):
|
||||
1. 全局路由前缀是 `api`(在 `main.ts` 里 `app.setGlobalPrefix('api')`)。所以 controller 里写 `@Controller('integration/config')`,实际路径是 `/api/integration/config`。
|
||||
2. 权限用装饰器 `@RequirePermission('code')`,从 `../auth/decorators/permission.decorator.ts` 引入。已存在的权限码:`integration:read`(读配置)、`integration:trigger`(触发同步)。本批次读接口用 `integration:read`,写接口也用 `integration:read`(本项目没有单独的 write 码,先复用)。
|
||||
3. 类级别要加 `@UseGuards(JwtAuthGuard)`,从 `../auth/guards/jwt-auth.guard.ts` 引入。
|
||||
4. 实体列命名风格:属性名用 camelCase,数据库列名用 snake_case,通过 `@Column({ name: 'snake_case' })` 映射。参考现有实体 `apps/server/src/entities/user-ding-mapping.entity.ts` 的写法。
|
||||
5. 时间戳用 `@CreateDateColumn({ name: 'created_at' })` 和 `@UpdateDateColumn({ name: 'updated_at' })`。
|
||||
6. 本项目**没有 orgId/多组织概念**。源项目 dorm-sys 有 orgId,你要**去掉所有 orgId 相关字段和参数**。全局只有一份配置。
|
||||
|
||||
---
|
||||
|
||||
## 任务清单(4 个新文件 + 1 处修改)
|
||||
|
||||
### 文件 1(新建):`apps/server/src/integration/entities/integration-config.entity.ts`
|
||||
|
||||
新建两个实体。**去掉源项目的 orgId 和 Organization 外键**。完整内容如下(照抄):
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* 第三方集成主配置表。全局单例(本项目无多组织)。
|
||||
* type 目前固定为 'THIRD'。
|
||||
*/
|
||||
@Entity('integration_config')
|
||||
export class IntegrationConfig {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
/** 配置类型,目前固定 'THIRD' */
|
||||
@Column({ length: 50 })
|
||||
type: string;
|
||||
|
||||
/** 最近一次同步的来源: 'WECOM' | 'DINGTALK' | null */
|
||||
@Column({ name: 'sync_resource', length: 50, nullable: true })
|
||||
syncResource: string;
|
||||
|
||||
/** 是否已同步过 */
|
||||
@Column({ name: 'is_sync', default: false })
|
||||
isSync: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 第三方集成明细配置表。一个主配置对应多条明细(钉钉/企微各一条)。
|
||||
* content 存 JSON 字符串,含加密/明文的 corpId、appSecret 等。
|
||||
*/
|
||||
@Entity('integration_config_detail')
|
||||
@Index(['configId'])
|
||||
export class IntegrationConfigDetail {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
/** 关联主配置表 IntegrationConfig.id */
|
||||
@Column({ name: 'config_id', type: 'integer' })
|
||||
configId: number;
|
||||
|
||||
@ManyToOne(() => IntegrationConfig)
|
||||
@JoinColumn({ name: 'config_id' })
|
||||
config: IntegrationConfig;
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
name: string;
|
||||
|
||||
/** 明细类型: 'DINGTALK_SYNC' | 'WECOM_SYNC' */
|
||||
@Column({ length: 50 })
|
||||
type: string;
|
||||
|
||||
/** 配置 JSON 字符串: { type, verify, config: {...} } */
|
||||
@Column({ type: 'text', nullable: true })
|
||||
content: string;
|
||||
|
||||
/** 该明细是否验证通过(能拿到 token) */
|
||||
@Column({ default: false })
|
||||
enable: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
**验收**:文件存在,导出两个 class `IntegrationConfig`、`IntegrationConfigDetail`,无 orgId 字段,无 import Organization。
|
||||
|
||||
---
|
||||
|
||||
### 文件 2(新建):`apps/server/src/integration/config/dto/config.dto.ts`
|
||||
|
||||
配置的类型定义。完整内容(照抄):
|
||||
|
||||
```typescript
|
||||
/** 钉钉配置 */
|
||||
export interface DingTalkThirdConfig {
|
||||
agentId: string; // AppKey
|
||||
appSecret: string; // AppSecret
|
||||
corpId: string; // CorpId
|
||||
startEnable: boolean; // 是否启用同步
|
||||
appId?: string; // 内部应用ID,用于消息推送(可选)
|
||||
}
|
||||
|
||||
/** 企微配置 */
|
||||
export interface WeComThirdConfig {
|
||||
agentId: string;
|
||||
appSecret: string;
|
||||
corpId: string;
|
||||
startEnable: boolean;
|
||||
}
|
||||
|
||||
/** 对外返回的配置(脱敏后,不含 appSecret) */
|
||||
export interface ThirdConfigBaseDTO<T = unknown> {
|
||||
type: string;
|
||||
verify?: boolean;
|
||||
config: T;
|
||||
}
|
||||
|
||||
/** 保存配置的请求体 */
|
||||
export interface SaveConfigRequest {
|
||||
type: 'WECOM' | 'DINGTALK';
|
||||
config: DingTalkThirdConfig | WeComThirdConfig;
|
||||
}
|
||||
```
|
||||
|
||||
**验收**:文件存在,导出 4 个 interface。
|
||||
|
||||
---
|
||||
|
||||
### 文件 3(新建):`apps/server/src/integration/config/integration-config.service.ts`
|
||||
|
||||
配置服务。职责:读配置(脱敏)、保存配置(保留旧 appSecret)、测试连接、读写同步状态、给同步逻辑提供原始 appKey/appSecret。
|
||||
|
||||
**重要:如何测试连接 / 拿 token?**
|
||||
本项目已有一个 `DingTalkService`(在 `apps/server/src/integration/dingtalk.service.ts`),但它现在从 env 读取密钥、**没有**接受传参的 `getAccessToken(appKey, appSecret)` 方法。为避免改动它(那是批次2的事),**本批次的测试连接直接自己用 fetch 调钉钉接口**,逻辑如下:
|
||||
|
||||
```
|
||||
POST https://api.dingtalk.com/v1.0/oauth2/accessToken
|
||||
body: { "appKey": <agentId>, "appSecret": <appSecret> }
|
||||
成功响应含 { accessToken, expireIn }
|
||||
```
|
||||
|
||||
企微暂时只做占位(返回 false 即可,本项目重点是钉钉)。
|
||||
|
||||
完整内容(照抄,仔细核对每个方法):
|
||||
|
||||
```typescript
|
||||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
IntegrationConfig,
|
||||
IntegrationConfigDetail,
|
||||
} from '../entities/integration-config.entity';
|
||||
import {
|
||||
ThirdConfigBaseDTO,
|
||||
DingTalkThirdConfig,
|
||||
WeComThirdConfig,
|
||||
SaveConfigRequest,
|
||||
} from './dto/config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationConfigService {
|
||||
private readonly logger = new Logger(IntegrationConfigService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(IntegrationConfig)
|
||||
private readonly configRepo: Repository<IntegrationConfig>,
|
||||
@InjectRepository(IntegrationConfigDetail)
|
||||
private readonly detailRepo: Repository<IntegrationConfigDetail>,
|
||||
) {}
|
||||
|
||||
/** 获取或创建主配置(全局单例) */
|
||||
private async ensureConfig(): Promise<IntegrationConfig> {
|
||||
let config = await this.configRepo.findOne({ where: { type: 'THIRD' } });
|
||||
if (!config) {
|
||||
config = this.configRepo.create({ type: 'THIRD', isSync: false });
|
||||
await this.configRepo.save(config);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** DINGTALK -> DINGTALK_SYNC, WECOM -> WECOM_SYNC */
|
||||
private getDetailType(type: string): string {
|
||||
switch (type.toUpperCase()) {
|
||||
case 'WECOM':
|
||||
return 'WECOM_SYNC';
|
||||
case 'DINGTALK':
|
||||
return 'DINGTALK_SYNC';
|
||||
default:
|
||||
throw new BadRequestException(`不支持的第三方类型: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取所有配置(脱敏,不返回 appSecret) */
|
||||
async getThirdConfig(): Promise<ThirdConfigBaseDTO[]> {
|
||||
const config = await this.ensureConfig();
|
||||
const details = await this.detailRepo.find({ where: { configId: config.id } });
|
||||
return details.map((detail) => ({
|
||||
type: detail.type.includes('WECOM')
|
||||
? 'WECOM'
|
||||
: detail.type.includes('DINGTALK')
|
||||
? 'DINGTALK'
|
||||
: detail.type,
|
||||
verify: detail.enable,
|
||||
config: this.parseAndMaskConfig(detail.content),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 按类型获取单个配置(脱敏) */
|
||||
async getConfigByType(type: string): Promise<ThirdConfigBaseDTO | null> {
|
||||
const all = await this.getThirdConfig();
|
||||
return all.find((c) => c.type === type.toUpperCase()) || null;
|
||||
}
|
||||
|
||||
/** 保存/更新配置 */
|
||||
async saveConfig(request: SaveConfigRequest): Promise<void> {
|
||||
const config = await this.ensureConfig();
|
||||
const detailType = this.getDetailType(request.type);
|
||||
|
||||
let existingDetail = await this.detailRepo.findOne({
|
||||
where: { configId: config.id, type: detailType },
|
||||
});
|
||||
|
||||
const finalConfig = { ...request.config } as Record<string, unknown>;
|
||||
|
||||
// 更新时若前端未传 appSecret,则保留旧值
|
||||
if (existingDetail && existingDetail.content) {
|
||||
if (!finalConfig.appSecret) {
|
||||
try {
|
||||
const oldParsed = JSON.parse(existingDetail.content);
|
||||
const oldCfg = oldParsed.config || oldParsed;
|
||||
if (oldCfg.appSecret) finalConfig.appSecret = oldCfg.appSecret;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else if (!finalConfig.appSecret) {
|
||||
throw new BadRequestException('首次配置必须提供 AppSecret');
|
||||
}
|
||||
|
||||
// 连通性验证
|
||||
const token = await this.getTokenForTest(request.type, finalConfig);
|
||||
const verified = !!token;
|
||||
|
||||
const content = JSON.stringify({
|
||||
type: request.type,
|
||||
verify: verified,
|
||||
config: finalConfig,
|
||||
});
|
||||
|
||||
if (existingDetail) {
|
||||
existingDetail.content = content;
|
||||
existingDetail.enable = verified;
|
||||
await this.detailRepo.save(existingDetail);
|
||||
} else {
|
||||
existingDetail = this.detailRepo.create({
|
||||
configId: config.id,
|
||||
name: '第三方设置',
|
||||
type: detailType,
|
||||
content,
|
||||
enable: verified,
|
||||
});
|
||||
await this.detailRepo.save(existingDetail);
|
||||
}
|
||||
this.logger.log(`第三方配置已保存: ${request.type}, 验证: ${verified}`);
|
||||
}
|
||||
|
||||
/** 测试连接 */
|
||||
async testConnection(
|
||||
type: string,
|
||||
config: DingTalkThirdConfig | WeComThirdConfig,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.getTokenForTest(type, config as Record<string, unknown>);
|
||||
return !!token;
|
||||
} catch (e) {
|
||||
this.logger.error(`连接测试失败: ${(e as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读同步状态:某类型是否已同步过 */
|
||||
async getSyncStatus(type: string): Promise<boolean> {
|
||||
const config = await this.configRepo.findOne({ where: { type: 'THIRD' } });
|
||||
if (!config || !config.isSync) return false;
|
||||
return config.syncResource === type.toUpperCase();
|
||||
}
|
||||
|
||||
/** 写同步状态 */
|
||||
async setSyncStatus(syncing: boolean, type?: string): Promise<void> {
|
||||
const config = await this.ensureConfig();
|
||||
config.isSync = syncing;
|
||||
if (type) config.syncResource = type.toUpperCase();
|
||||
await this.configRepo.save(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 供同步逻辑使用:读原始(未脱敏)配置。
|
||||
* 返回 { agentId, appSecret, corpId, appId? } 或 null。
|
||||
*/
|
||||
async getRawConfig(type: string): Promise<Record<string, any> | null> {
|
||||
const config = await this.ensureConfig();
|
||||
const detailType = this.getDetailType(type);
|
||||
const detail = await this.detailRepo.findOne({
|
||||
where: { configId: config.id, type: detailType },
|
||||
});
|
||||
if (!detail || !detail.content) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(detail.content);
|
||||
return parsed.config || parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 供同步逻辑使用:拿一个可用 access_token(未脱敏配置直接用)。
|
||||
* 目前仅实现钉钉。
|
||||
*/
|
||||
async getAccessToken(type: string): Promise<string> {
|
||||
const config = await this.getRawConfig(type);
|
||||
if (!config) throw new NotFoundException(`未配置 ${type} 平台信息`);
|
||||
const token = await this.getTokenForTest(type, config);
|
||||
if (!token) throw new BadRequestException(`获取 ${type} access_token 失败`);
|
||||
return token;
|
||||
}
|
||||
|
||||
// ── 私有工具 ──
|
||||
|
||||
/** 用给定配置获取 token(钉钉真实调用,企微暂返回 null) */
|
||||
private async getTokenForTest(
|
||||
type: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
if (type.toUpperCase() === 'DINGTALK') {
|
||||
const appKey = String(config.agentId || '');
|
||||
const appSecret = String(config.appSecret || '');
|
||||
if (!appKey || !appSecret) return null;
|
||||
return await this.fetchDingTalkToken(appKey, appSecret);
|
||||
}
|
||||
// 企微暂不实现,返回 null
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 调钉钉新版接口拿 access_token */
|
||||
private async fetchDingTalkToken(appKey: string, appSecret: string): Promise<string | null> {
|
||||
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appKey, appSecret }),
|
||||
});
|
||||
const body = (await res.json()) as { accessToken?: string; expireIn?: number };
|
||||
return body.accessToken || null;
|
||||
}
|
||||
|
||||
/** 解析并脱敏:删掉 appSecret 后返回 config 对象 */
|
||||
private parseAndMaskConfig(content: string | null): unknown {
|
||||
if (!content) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const cfg = parsed.config || parsed;
|
||||
if (cfg.appSecret) delete cfg.appSecret;
|
||||
return cfg;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**验收**:
|
||||
- 导出 `IntegrationConfigService`。
|
||||
- 没有任何 orgId 参数。
|
||||
- `getThirdConfig` 返回脱敏配置(无 appSecret)。
|
||||
- `saveConfig` 更新时若未传 appSecret 会保留旧值。
|
||||
|
||||
---
|
||||
|
||||
### 文件 4(新建):`apps/server/src/integration/config/integration-config.controller.ts`
|
||||
|
||||
REST 接口。完整内容(照抄):
|
||||
|
||||
```typescript
|
||||
import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../../auth/decorators/permission.decorator';
|
||||
import { IntegrationConfigService } from './integration-config.service';
|
||||
import { SaveConfigRequest } from './dto/config.dto';
|
||||
|
||||
@Controller('integration/config')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class IntegrationConfigController {
|
||||
constructor(private readonly service: IntegrationConfigService) {}
|
||||
|
||||
/** 获取全部配置(脱敏) */
|
||||
@Get()
|
||||
@RequirePermission('integration:read')
|
||||
async getConfigs() {
|
||||
const data = await this.service.getThirdConfig();
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/** 按类型获取单个配置 */
|
||||
@Get(':type')
|
||||
@RequirePermission('integration:read')
|
||||
async getConfig(@Param('type') type: string) {
|
||||
const data = await this.service.getConfigByType(type.toUpperCase());
|
||||
if (!data) {
|
||||
return { success: false, message: `未找到 ${type} 的配置` };
|
||||
}
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/** 保存配置 */
|
||||
@Post()
|
||||
@RequirePermission('integration:read')
|
||||
async saveConfig(@Body() body: SaveConfigRequest) {
|
||||
await this.service.saveConfig(body);
|
||||
return { success: true, message: '配置已保存' };
|
||||
}
|
||||
|
||||
/** 测试连接 */
|
||||
@Post('test')
|
||||
@RequirePermission('integration:read')
|
||||
async testConnection(@Body() body: SaveConfigRequest) {
|
||||
const success = await this.service.testConnection(body.type, body.config);
|
||||
return { success, message: success ? '连接成功' : '连接失败,请检查配置信息' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意路由顺序陷阱**:`@Get(':type')` 会匹配任意路径段。但 `@Post('test')` 是 POST,`@Get()` 无参,所以本文件没有 GET 冲突。**不要**新增会和 `:type` 冲突的 GET 路由。
|
||||
|
||||
**验收**:4 个路由,全部 `integration:read` 权限,路径前缀 `integration/config`。
|
||||
|
||||
---
|
||||
|
||||
### 文件 5(新建):`apps/server/src/integration/config/config.module.ts`
|
||||
|
||||
模块,注册实体和 provider。完整内容(照抄):
|
||||
|
||||
```typescript
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import {
|
||||
IntegrationConfig,
|
||||
IntegrationConfigDetail,
|
||||
} from '../entities/integration-config.entity';
|
||||
import { IntegrationConfigService } from './integration-config.service';
|
||||
import { IntegrationConfigController } from './integration-config.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([IntegrationConfig, IntegrationConfigDetail])],
|
||||
controllers: [IntegrationConfigController],
|
||||
providers: [IntegrationConfigService],
|
||||
exports: [IntegrationConfigService],
|
||||
})
|
||||
export class IntegrationConfigModule {}
|
||||
```
|
||||
|
||||
**验收**:导出 `IntegrationConfigModule`,exports 里有 `IntegrationConfigService`(批次2 要用)。
|
||||
|
||||
---
|
||||
|
||||
### 修改 1:`apps/server/src/app.module.ts`
|
||||
|
||||
有 3 处要改,**只加不删**:
|
||||
|
||||
**(a) 注册两个新实体到 `allEntities` 数组。** 找到 `allEntities = [ ... ]` 数组(里面有 `Student, Room, ...UserDingMapping`)。在数组**末尾 `UserDingMapping,` 之后**加两行:
|
||||
|
||||
```typescript
|
||||
UserDingMapping,
|
||||
IntegrationConfig,
|
||||
IntegrationConfigDetail,
|
||||
```
|
||||
|
||||
**(b) 在文件顶部 import 这两个实体。** 这两个实体**不在** `./entities` 桶文件里(它们在 integration 目录下),所以单独 import。在其它 import 语句附近(比如 `import { AttendanceModule } ...` 那一片)加一行:
|
||||
|
||||
```typescript
|
||||
import { IntegrationConfig, IntegrationConfigDetail } from './integration/entities/integration-config.entity';
|
||||
```
|
||||
|
||||
**(c) 注册模块。** 找到 `@Module({ imports: [ ... ] })` 里的模块列表(有 `AttendanceModule, SchedulesModule, ...`),在合适位置加:
|
||||
|
||||
```typescript
|
||||
IntegrationConfigModule,
|
||||
```
|
||||
|
||||
并在顶部 import:
|
||||
|
||||
```typescript
|
||||
import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
```
|
||||
|
||||
**验收**:`app.module.ts` 里能看到新增的实体(2 个,在 allEntities 数组里)+ 实体 import + 模块 import + `IntegrationConfigModule` 出现在 imports 数组里。
|
||||
|
||||
---
|
||||
|
||||
## 全局验收(做完全部后自检)
|
||||
|
||||
1. 运行 `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json` —— 你新建/修改的文件**不能有任何报错**。(如果看到 `attendance/dto/dingtalk-import.dto.ts` 之类你没碰过的文件报错,忽略,那不是你的。)
|
||||
2. 不要改动本 PRD 未提到的任何文件。
|
||||
3. 不要引入 orgId。
|
||||
4. 所有新文件都在 `apps/server/src/integration/` 目录下(entities 子目录、config 子目录)。
|
||||
|
||||
做完后,用一句话总结你创建/修改了哪些文件。
|
||||
@@ -1,295 +0,0 @@
|
||||
# PRD 批次2 — 钉钉「指定节点 BFS 同步」+ 组织树接口(后端)
|
||||
|
||||
> 你是 NestJS 后端工程师,项目 `gongxue-base`。本批次是**改造已有文件**,不是全新建。
|
||||
> 严格按下面给的方法体照抄替换。不要改动未提到的方法或文件。
|
||||
> 完成后跑 tsc 自检。
|
||||
|
||||
## 背景
|
||||
|
||||
现状:`apps/server/src/integration/dingtalk.service.ts` 里的 `DingTalkService.syncAll()` 会从钉钉**根部门(dept_id=1)**开始 BFS 遍历所有部门+用户,写入本地。现在写死从 1 开始。
|
||||
|
||||
本批次目标:
|
||||
1. 让 `syncAll` 支持传一个**起始部门 ID**,从该节点开始 BFS(默认仍是 1,保持向后兼容)。
|
||||
2. 新增一个方法 `fetchOrgTree()`:只拉钉钉部门(不拉用户),返回**树形结构**,给前端"组织树选择器"用来勾选同步起点。
|
||||
3. 新增一个 controller 接口把组织树暴露给前端。
|
||||
4. 让现有的 `SyncService.syncDingTalk` / `triggerSync` 能把起始节点透传下去。
|
||||
|
||||
## 关键事实(现有代码,供你理解,不要改这些无关部分)
|
||||
|
||||
`DingTalkService` 已有这些**私有方法**(保持不动,你会复用它们):
|
||||
- `private async getAccessToken(): Promise<string>` — 拿 token(从 env 读密钥)
|
||||
- `private async getAllDeptIds(token: string): Promise<number[]>` — **当前写死 `queue=[1]`**,你要改它
|
||||
- `private async getDeptDetail(token, deptId): Promise<{dept_id, name, parent_id} | null>`
|
||||
- `private async getDeptUsers(token, deptId): Promise<...>`
|
||||
- `private async rateLimit()` / `private delay(i)` / `private sleep(ms)`
|
||||
- `private get configured(): boolean`
|
||||
|
||||
`syncAll()` 现在签名是 `async syncAll(): Promise<{ deptCount: number; userCount: number }>`,内部第一步调用 `const deptIds = await this.getAllDeptIds(token);`。
|
||||
|
||||
---
|
||||
|
||||
## 任务
|
||||
|
||||
### 修改 1:`apps/server/src/integration/dingtalk.service.ts`
|
||||
|
||||
#### (1a) 改造 `getAllDeptIds` —— 支持传起始节点
|
||||
|
||||
找到现有方法(大致长这样):
|
||||
|
||||
```typescript
|
||||
private async getAllDeptIds(token: string): Promise<number[]> {
|
||||
const ids: number[] = [];
|
||||
const queue: number[] = [1];
|
||||
// ... while 循环 BFS ...
|
||||
}
|
||||
```
|
||||
|
||||
把签名和第一行改成接受可选起始节点,**其余循环体不动**:
|
||||
|
||||
```typescript
|
||||
private async getAllDeptIds(token: string, rootDeptId = 1): Promise<number[]> {
|
||||
const ids: number[] = [];
|
||||
const queue: number[] = [rootDeptId];
|
||||
// ... 下面的 while 循环体保持原样,一个字都不要改 ...
|
||||
}
|
||||
```
|
||||
|
||||
#### (1b) 改造 `syncAll` —— 接受可选起始节点并透传
|
||||
|
||||
找到 `async syncAll()`,把签名改成:
|
||||
|
||||
```typescript
|
||||
async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> {
|
||||
```
|
||||
|
||||
然后在方法体里找到这一行:
|
||||
|
||||
```typescript
|
||||
const deptIds = await this.getAllDeptIds(token);
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```typescript
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
```
|
||||
|
||||
**方法体其它部分(同步部门、同步用户、日志)全部保持原样。**
|
||||
|
||||
#### (1c) 新增方法 `fetchOrgTree` —— 返回部门树给前端
|
||||
|
||||
在类里新增一个 **public** 方法(放在 `syncAll` 之后即可)。它 BFS 拉所有部门详情,然后组装成树。照抄:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点。
|
||||
* 返回从指定 rootDeptId 开始的树;默认根部门 1。
|
||||
*/
|
||||
async fetchOrgTree(rootDeptId = 1): Promise<DingOrgTreeNode[]> {
|
||||
if (!this.configured) {
|
||||
throw new Error('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET)');
|
||||
}
|
||||
const token = await this.getAccessToken();
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
|
||||
// 拉每个部门详情
|
||||
const nodes: DingOrgTreeNode[] = [];
|
||||
for (let i = 0; i < deptIds.length; i++) {
|
||||
if (i > 0) await this.delay(i);
|
||||
const detail = await this.getDeptDetail(token, deptIds[i]);
|
||||
if (detail) {
|
||||
nodes.push({
|
||||
id: detail.dept_id,
|
||||
name: detail.name,
|
||||
parentId: detail.parent_id,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 组装成树
|
||||
const map = new Map<number, DingOrgTreeNode>();
|
||||
nodes.forEach((n) => map.set(n.id, n));
|
||||
const roots: DingOrgTreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const parent = map.get(node.parentId);
|
||||
if (parent && node.id !== rootDeptId) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
```
|
||||
|
||||
#### (1d) 新增类型定义 `DingOrgTreeNode`
|
||||
|
||||
在文件**顶部的类型定义区**(现有那些 `interface DingTalkTokenResponse {...}` 附近)新增并导出:
|
||||
|
||||
```typescript
|
||||
/** 钉钉部门树节点,供前端选择器使用 */
|
||||
export interface DingOrgTreeNode {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNode[];
|
||||
}
|
||||
```
|
||||
|
||||
**验收 (修改1)**:
|
||||
- `getAllDeptIds(token, rootDeptId=1)` 第一行是 `const queue: number[] = [rootDeptId];`
|
||||
- `syncAll(rootDeptId=1)` 内部调用 `getAllDeptIds(token, rootDeptId)`
|
||||
- 新增 public 方法 `fetchOrgTree(rootDeptId=1)`,返回 `DingOrgTreeNode[]`
|
||||
- 导出 `DingOrgTreeNode` interface
|
||||
- 其余原有方法体不变
|
||||
|
||||
---
|
||||
|
||||
### 修改 2:`apps/server/src/sync/sync.service.ts`
|
||||
|
||||
现有 `SyncService` 里有:
|
||||
- `async syncDingTalk(): Promise<SyncLog>` — 内部调用 `this.performDingTalkSync(lastSyncAt)`
|
||||
- `private async performDingTalkSync(lastSyncAt): Promise<number>` — 内部第一行 `const result = await this.dingTalkService.syncAll();`
|
||||
- `async triggerSync(platform?): Promise<SyncLog[]>`
|
||||
|
||||
我们要让触发同步时能带一个可选起始部门 ID `rootDeptId`。
|
||||
|
||||
#### (2a) `performDingTalkSync` 接受 rootDeptId
|
||||
|
||||
找到:
|
||||
|
||||
```typescript
|
||||
private async performDingTalkSync(lastSyncAt: Date | null): Promise<number> {
|
||||
// Stage 1: Sync departments and users
|
||||
const result = await this.dingTalkService.syncAll();
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```typescript
|
||||
private async performDingTalkSync(lastSyncAt: Date | null, rootDeptId = 1): Promise<number> {
|
||||
// Stage 1: Sync departments and users
|
||||
const result = await this.dingTalkService.syncAll(rootDeptId);
|
||||
```
|
||||
|
||||
**该方法其余部分(考勤导入那段)保持原样。**
|
||||
|
||||
#### (2b) `syncDingTalk` 接受并透传 rootDeptId
|
||||
|
||||
找到 `async syncDingTalk(): Promise<SyncLog> {`,把签名改成:
|
||||
|
||||
```typescript
|
||||
async syncDingTalk(rootDeptId = 1): Promise<SyncLog> {
|
||||
```
|
||||
|
||||
在方法体里找到调用 `performDingTalkSync` 的那行(大概是 `const recordsCount = await this.performDingTalkSync(lastSyncAt);`),改成:
|
||||
|
||||
```typescript
|
||||
const recordsCount = await this.performDingTalkSync(lastSyncAt, rootDeptId);
|
||||
```
|
||||
|
||||
**该方法其余部分(createSyncLog、finishSyncLog、catch 等)保持原样。**
|
||||
|
||||
#### (2c) `triggerSync` 接受 rootDeptId 并透传给钉钉
|
||||
|
||||
找到:
|
||||
|
||||
```typescript
|
||||
async triggerSync(platform?: SyncPlatform): Promise<SyncLog[]> {
|
||||
if (platform === 'dingtalk') return [await this.syncDingTalk()];
|
||||
if (platform === 'wecom') return [await this.syncWeCom()];
|
||||
return [await this.syncDingTalk(), await this.syncWeCom()];
|
||||
}
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```typescript
|
||||
async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise<SyncLog[]> {
|
||||
if (platform === 'dingtalk') return [await this.syncDingTalk(rootDeptId)];
|
||||
if (platform === 'wecom') return [await this.syncWeCom()];
|
||||
return [await this.syncDingTalk(rootDeptId), await this.syncWeCom()];
|
||||
}
|
||||
```
|
||||
|
||||
#### (2d) 新增一个获取组织树的方法
|
||||
|
||||
在 `SyncService` 类里新增一个 public 方法(放在 `triggerSync` 之后):
|
||||
|
||||
```typescript
|
||||
/** 获取钉钉组织部门树,供前端选择同步起点 */
|
||||
async getDingTalkOrgTree(rootDeptId = 1) {
|
||||
return this.dingTalkService.fetchOrgTree(rootDeptId);
|
||||
}
|
||||
```
|
||||
|
||||
注意:`SyncService` 构造函数里已经注入了 `private readonly dingTalkService: DingTalkService`,直接用即可,不用改构造函数。
|
||||
|
||||
**验收 (修改2)**:`triggerSync(platform?, rootDeptId=1)`、`syncDingTalk(rootDeptId=1)`、`performDingTalkSync(lastSyncAt, rootDeptId=1)` 三处签名都改了并透传;新增 `getDingTalkOrgTree(rootDeptId=1)`。
|
||||
|
||||
---
|
||||
|
||||
### 修改 3:`apps/server/src/sync/sync.controller.ts`
|
||||
|
||||
现有 controller(`@Controller('sync')`,全局前缀 api,所以是 `/api/sync/...`)有:
|
||||
- `@Post('trigger')` → `triggerSync(@Query('platform') platform?)`
|
||||
- `@Get('status')`
|
||||
- `@Get('logs')`
|
||||
|
||||
#### (3a) 给 trigger 接口加 rootDeptId 查询参数
|
||||
|
||||
找到:
|
||||
|
||||
```typescript
|
||||
@Post('trigger')
|
||||
@RequirePermission('sync:trigger')
|
||||
async triggerSync(@Query('platform') platform?: SyncPlatform) {
|
||||
const logs = await this.syncService.triggerSync(platform);
|
||||
return { synced: logs.length, logs };
|
||||
}
|
||||
```
|
||||
|
||||
改成(新增 `rootDeptId` 查询参数,字符串转数字):
|
||||
|
||||
```typescript
|
||||
@Post('trigger')
|
||||
@RequirePermission('sync:trigger')
|
||||
async triggerSync(
|
||||
@Query('platform') platform?: SyncPlatform,
|
||||
@Query('rootDeptId') rootDeptId?: string,
|
||||
) {
|
||||
const rootId = rootDeptId ? Number(rootDeptId) : 1;
|
||||
const logs = await this.syncService.triggerSync(platform, rootId);
|
||||
return { synced: logs.length, logs };
|
||||
}
|
||||
```
|
||||
|
||||
#### (3b) 新增获取组织树接口
|
||||
|
||||
在 controller 里新增(放在 `getStatus` 之后):
|
||||
|
||||
```typescript
|
||||
/** 获取钉钉组织部门树,供前端选择同步起点 */
|
||||
@Get('dingtalk/org-tree')
|
||||
@RequirePermission('sync:read')
|
||||
async getDingTalkOrgTree(@Query('rootDeptId') rootDeptId?: string) {
|
||||
const rootId = rootDeptId ? Number(rootDeptId) : 1;
|
||||
const tree = await this.syncService.getDingTalkOrgTree(rootId);
|
||||
return { success: true, data: tree };
|
||||
}
|
||||
```
|
||||
|
||||
**路由顺序检查**:现有 GET 路由是 `status`、`logs`,新增的是 `dingtalk/org-tree`,三者路径不同,无冲突。无需调整顺序。
|
||||
|
||||
**验收 (修改3)**:`trigger` 接口多了 `rootDeptId` 查询参数;新增 `GET /api/sync/dingtalk/org-tree` 接口,权限 `sync:read`。
|
||||
|
||||
---
|
||||
|
||||
## 全局验收(做完自检)
|
||||
|
||||
1. `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json` —— 你改的文件不能有 error。(忽略 `attendance/dto/dingtalk-import.dto.ts` 等你没碰的历史文件。)
|
||||
2. 只改了 3 个文件:`integration/dingtalk.service.ts`、`sync/sync.service.ts`、`sync/sync.controller.ts`。没动别的。
|
||||
3. 所有改动都保持"默认 rootDeptId=1"的向后兼容——不传参时行为和以前完全一样。
|
||||
|
||||
做完后用一句话总结改了哪些文件、加了哪些方法。
|
||||
@@ -1,735 +0,0 @@
|
||||
---
|
||||
change: migrate-to-turborepo
|
||||
design-doc: docs/superpowers/specs/2026-07-02-migrate-to-turborepo-design.md
|
||||
base-ref: 72db78daed6d840f2ddc7c0103f69325253b9098
|
||||
archived-with: 2026-07-02-migrate-to-turborepo
|
||||
---
|
||||
|
||||
# Turborepo 单体仓库迁移实施计划
|
||||
|
||||
> **面向自动化执行器:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 按任务逐步实施此计划。任务步骤使用 checkbox (`- [x]`) 语法进行追踪。
|
||||
|
||||
**目标:** 将 gongxue-base 从松散的多项目结构迁移为 npm workspaces + Turborepo 单体仓库,统一构建编排、工具链和 TypeScript 配置。
|
||||
|
||||
**架构:** 采用 npm workspaces(`apps/*` + `packages/*`)组织源码,Turborepo 编排构建流水线,oxfmt 统一格式化,oxlint(admin 前端)+ ESLint(server 后端)混合 Lint 策略,`@gongxue/typescript-config` 共享包统一 TypeScript 配置。
|
||||
|
||||
**技术栈:** Turborepo, npm workspaces, oxfmt, oxlint, ESLint 9, TypeScript ~6.0.2, NestJS, React + Vite, Docker Compose
|
||||
|
||||
## 全局约束
|
||||
|
||||
- 使用 `git mv` 移动文件,保留完整 Git 历史
|
||||
- npm workspaces 模式,不更换包管理器
|
||||
- TypeScript 统一版本 `~6.0.2`
|
||||
- Docker 容器名 `dorm_billing_backend` / `dorm_billing_frontend` 保持不变
|
||||
- 所有 workspace 统一使用 `lint` / `format` / `build` / `dev` / `test` 脚本名
|
||||
- 每个任务完成后立即 `git commit`,便于独立审查和回滚
|
||||
|
||||
archived-with: 2026-07-02-migrate-to-turborepo
|
||||
---
|
||||
|
||||
### Task 1: 目录重组
|
||||
|
||||
**文件:**
|
||||
- 创建: `apps/`(目录)
|
||||
- 创建: `packages/`(目录)
|
||||
- 移动: `backend/` → `apps/server/`
|
||||
- 移动: `frontend/` → `apps/admin/`
|
||||
- 创建: `packages/typescript-config/package.json`
|
||||
- 创建: `packages/typescript-config/base.json`
|
||||
- 创建: `packages/typescript-config/nestjs.json`
|
||||
- 创建: `packages/typescript-config/react-vite.json`
|
||||
|
||||
**接口:**
|
||||
- 产出: `apps/server/`(完整 NestJS 项目,路径已更新)
|
||||
- 产出: `apps/admin/`(完整 Vite + React 项目,路径已更新)
|
||||
- 产出: `packages/typescript-config/`(三个 TS 预设文件,Task 4 使用)
|
||||
|
||||
- [x] Step 1: 创建顶层目录结构
|
||||
|
||||
```bash
|
||||
mkdir -p apps packages
|
||||
```
|
||||
|
||||
- [x] Step 2: 使用 git mv 将 backend 移动至 apps/server
|
||||
|
||||
```bash
|
||||
git mv backend apps/server
|
||||
```
|
||||
|
||||
预期结果:无错误,`git status` 显示 `renamed: backend/... -> apps/server/...`
|
||||
|
||||
- [x] Step 3: 使用 git mv 将 frontend 移动至 apps/admin
|
||||
|
||||
```bash
|
||||
git mv frontend apps/admin
|
||||
```
|
||||
|
||||
预期结果:无错误,`git status` 显示 `renamed: frontend/... -> apps/admin/...`
|
||||
|
||||
- [x] Step 4: 创建 packages/typescript-config/package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@gongxue/typescript-config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"files": ["base.json", "nestjs.json", "react-vite.json"]
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Step 5: 创建 packages/typescript-config/base.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Step 6: 创建 packages/typescript-config/nestjs.json
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"sourceMap": true,
|
||||
"incremental": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Step 7: 创建 packages/typescript-config/react-vite.json
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Step 8: 验证目录结构
|
||||
|
||||
```bash
|
||||
ls -d apps/server apps/admin packages/typescript-config
|
||||
```
|
||||
|
||||
预期输出:三个目录路径均存在。
|
||||
|
||||
- [x] Step 9: 提交 Task 1
|
||||
|
||||
```bash
|
||||
git add apps/ packages/
|
||||
git commit -m "feat(task1): restructure directories for turborepo monorepo
|
||||
|
||||
- Move backend/ to apps/server/ via git mv
|
||||
- Move frontend/ to apps/admin/ via git mv
|
||||
- Create packages/typescript-config/ with base, nestjs, and react-vite presets"
|
||||
```
|
||||
|
||||
archived-with: 2026-07-02-migrate-to-turborepo
|
||||
---
|
||||
|
||||
### Task 2: 根配置
|
||||
|
||||
**文件:**
|
||||
- 修改: `package.json`(根目录)— 添加 workspaces 和 turbo 脚本
|
||||
- 创建: `turbo.json`
|
||||
- 修改: `.gitignore` — 适配新路径
|
||||
- 删除: `node_modules/`、`package-lock.json`(根目录),重新安装
|
||||
|
||||
**接口:**
|
||||
- 消费: `apps/server/`、`apps/admin/`、`packages/typescript-config/`(Task 1 产出)
|
||||
- 产出: 功能正常的 npm workspaces 环境,`turbo run` 可用(Task 3-6 使用)
|
||||
|
||||
- [x] Step 1: 更新根 package.json
|
||||
|
||||
将根 `package.json` 内容替换为:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "gongxue-base",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"test": "turbo run test",
|
||||
"format": "turbo run format",
|
||||
"typecheck": "turbo run typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fission-ai/openspec": "^1.5.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:保留 `@fission-ai/openspec` 依赖(根目录 cli 工具使用),新增 `turbo` devDependency,所有根脚本委托 `turbo run`。
|
||||
|
||||
- [x] Step 2: 创建 turbo.json
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"lint": {},
|
||||
"test": {},
|
||||
"format": {
|
||||
"cache": false
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Step 3: 更新 .gitignore
|
||||
|
||||
在现有 `.gitignore` 末尾追加以下条目(现有内容保持不变):
|
||||
|
||||
```gitignore
|
||||
# Turborepo
|
||||
.turbo/
|
||||
|
||||
# Monorepo pattern matches
|
||||
apps/server/node_modules/
|
||||
apps/server/dist/
|
||||
apps/admin/node_modules/
|
||||
apps/admin/dist/
|
||||
```
|
||||
|
||||
- [x] Step 4: 删除旧的 node_modules 和 lock 文件,重新安装
|
||||
|
||||
```bash
|
||||
rm -rf node_modules package-lock.json apps/server/node_modules apps/server/package-lock.json apps/admin/node_modules apps/admin/package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
预期结果:安装成功,根目录 `node_modules/` 重新创建,Turbo 被安装。
|
||||
|
||||
- [x] Step 5: 验证 turbo 可用
|
||||
|
||||
```bash
|
||||
npx turbo --version
|
||||
```
|
||||
|
||||
预期输出:Turbo 版本号(如 `2.x.x`)。
|
||||
|
||||
- [x] Step 6: 提交 Task 2
|
||||
|
||||
```bash
|
||||
git add package.json turbo.json .gitignore package-lock.json
|
||||
git commit -m "feat(task2): add root turborepo and npm workspaces configuration
|
||||
|
||||
- Add workspaces field (apps/*, packages/*)
|
||||
- Delegate all root scripts to turbo run
|
||||
- Configure turbo.json with build/dev/lint/test/format/typecheck pipelines
|
||||
- Update .gitignore for turborepo artifacts"
|
||||
```
|
||||
|
||||
archived-with: 2026-07-02-migrate-to-turborepo
|
||||
---
|
||||
|
||||
### Task 3: 工具链迁移(oxlint + oxfmt)
|
||||
|
||||
**文件:**
|
||||
- 创建: `.oxfmtrc.json`(根目录)
|
||||
- 创建: `oxlint.config.ts`(根目录)
|
||||
- 修改: `apps/admin/package.json` — 替换 lint 脚本,移除 ESLint 依赖
|
||||
- 删除: `apps/admin/eslint.config.js`
|
||||
- 修改: `apps/server/package.json` — 移除 Prettier 相关依赖,保留 ESLint
|
||||
- 修改: `apps/server/eslint.config.mjs` — 移除 Prettier 集成
|
||||
- 删除: `apps/server/.prettierrc`
|
||||
|
||||
**接口:**
|
||||
- 消费: 根 `package.json`、`turbo.json`(Task 2 产出)
|
||||
- 产出: oxfmt 统一格式化 + admin oxlint / server ESLint 混合 Lint 策略(Task 6 验证使用)
|
||||
|
||||
- [x] Step 1: 安装 oxfmt
|
||||
|
||||
```bash
|
||||
npm install --save-dev oxfmt
|
||||
```
|
||||
|
||||
- [x] Step 2: 创建根目录 .oxfmtrc.json
|
||||
|
||||
```json
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
```
|
||||
|
||||
此配置与原来的 `apps/server/.prettierrc` 完全一致。
|
||||
|
||||
- [x] Step 3: 为 apps/server 添加 format 脚本
|
||||
|
||||
修改 `apps/server/package.json` 的 `scripts` 字段,添加:
|
||||
|
||||
```json
|
||||
"format": "oxfmt"
|
||||
```
|
||||
|
||||
保持其他脚本不变。
|
||||
|
||||
- [x] Step 4: 为 apps/admin 添加 format 脚本
|
||||
|
||||
修改 `apps/admin/package.json` 的 `scripts` 字段,添加:
|
||||
|
||||
```json
|
||||
"format": "oxfmt"
|
||||
```
|
||||
|
||||
- [x] Step 5: 验证 oxfmt 格式化
|
||||
|
||||
```bash
|
||||
npx oxfmt --check apps/server/src apps/admin/src
|
||||
```
|
||||
|
||||
预期结果:`No formatting issues found`(或报告格式化差异,使用 `npx oxfmt --write` 自动修复)。
|
||||
|
||||
- [x] Step 6: 安装 oxlint
|
||||
|
||||
```bash
|
||||
npm install --save-dev oxlint
|
||||
```
|
||||
|
||||
- [x] Step 7: 创建根目录 oxlint.config.ts
|
||||
|
||||
```typescript
|
||||
import type { OxlintConfig } from 'oxlint';
|
||||
|
||||
const config: OxlintConfig = {
|
||||
plugins: ['typescript', 'react', 'import'],
|
||||
rules: {
|
||||
'typescript/no-explicit-any': 'off',
|
||||
'typescript/no-non-null-assertion': 'warn',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
- [x] Step 8: 将 apps/admin 的 lint 脚本迁移为 oxlint
|
||||
|
||||
修改 `apps/admin/package.json` 的 `scripts` 字段,将 `lint` 脚本替换为:
|
||||
|
||||
```json
|
||||
"lint": "oxlint --config ../../oxlint.config.ts"
|
||||
```
|
||||
|
||||
并从 `devDependencies` 中移除以下 ESLint 相关依赖:
|
||||
- `@eslint/js`
|
||||
- `eslint`
|
||||
- `eslint-plugin-react-hooks`
|
||||
- `eslint-plugin-react-refresh`
|
||||
- `globals`
|
||||
- `typescript-eslint`
|
||||
|
||||
- [x] Step 9: 删除 apps/admin 的 ESLint 配置文件
|
||||
|
||||
```bash
|
||||
rm apps/admin/eslint.config.js
|
||||
```
|
||||
|
||||
- [x] Step 10: 从 apps/server 移除 Prettier 集成
|
||||
|
||||
修改 `apps/server/eslint.config.mjs`,删除 Prettier 相关行。新内容:
|
||||
|
||||
```javascript
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
关键变更:
|
||||
- 删除 `import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';`
|
||||
- 删除 `eslintPluginPrettierRecommended` 配置项
|
||||
- 删除 `"prettier/prettier": ["error", { endOfLine: "auto" }]` 规则
|
||||
|
||||
- [x] Step 11: 从 apps/server 移除 Prettier 依赖
|
||||
|
||||
修改 `apps/server/package.json`,从 `devDependencies` 中移除:
|
||||
- `eslint-config-prettier`
|
||||
- `eslint-plugin-prettier`
|
||||
- `prettier`
|
||||
|
||||
将 `format` 脚本更新为:
|
||||
|
||||
```json
|
||||
"format": "oxfmt"
|
||||
```
|
||||
|
||||
- [x] Step 12: 删除 apps/server 的 .prettierrc
|
||||
|
||||
```bash
|
||||
rm apps/server/.prettierrc
|
||||
```
|
||||
|
||||
格式化配置已统一由根目录 `.oxfmtrc.json` 管理。
|
||||
|
||||
- [x] Step 13: 验证 lint 工具链
|
||||
|
||||
```bash
|
||||
cd apps/server && npx eslint "{src,test}/**/*.ts" && cd ../..
|
||||
cd apps/admin && npx oxlint --config ../../oxlint.config.ts && cd ../..
|
||||
```
|
||||
|
||||
预期结果:两个 Lint 检查均通过(或报告现有代码问题,此时应手动修复或将规则降级为 warn)。
|
||||
|
||||
- [x] Step 14: 提交 Task 3
|
||||
|
||||
```bash
|
||||
git add .oxfmtrc.json oxlint.config.ts \
|
||||
apps/server/package.json apps/server/eslint.config.mjs \
|
||||
apps/admin/package.json
|
||||
git rm apps/admin/eslint.config.js apps/server/.prettierrc
|
||||
git commit -m "feat(task3): migrate toolchain to oxfmt + oxlint
|
||||
|
||||
- Install oxfmt with .oxfmtrc.json (maps Prettier config)
|
||||
- Install oxlint with oxlint.config.ts (TypeScript + React rules)
|
||||
- Replace admin ESLint with oxlint
|
||||
- Remove Prettier integration from server ESLint (keep ESLint for type checking)
|
||||
- Delete server .prettierrc and admin eslint.config.js"
|
||||
```
|
||||
|
||||
archived-with: 2026-07-02-migrate-to-turborepo
|
||||
---
|
||||
|
||||
### Task 4: TypeScript 配置更新
|
||||
|
||||
**文件:**
|
||||
- 修改: `apps/server/tsconfig.json` — 继承 `@gongxue/typescript-config/nestjs.json`
|
||||
- 修改: `apps/admin/tsconfig.app.json` — 继承 `@gongxue/typescript-config/react-vite.json`
|
||||
- 修改: `apps/admin/tsconfig.node.json` — 继承 `@gongxue/typescript-config/base.json`
|
||||
- 修改: `apps/server/package.json` — 更新 name、TypeScript 版本、添加依赖
|
||||
- 修改: `apps/admin/package.json` — 更新 name、添加依赖
|
||||
|
||||
**接口:**
|
||||
- 消费: `packages/typescript-config/`(Task 1 产出),npm workspaces(Task 2 产出)
|
||||
- 产出: server 和 admin 均通过 typecheck(Task 6 验证使用)
|
||||
|
||||
- [x] Step 1: 更新 apps/server/package.json — name 和 TypeScript 版本
|
||||
|
||||
修改 `apps/server/package.json`:
|
||||
- 将 `"name"` 从 `"backend"` 改为 `"@gongxue/server"`
|
||||
- 将 `"typescript"` 从 `"^5.7.3"` 改为 `"~6.0.2"`
|
||||
- 在 `devDependencies` 中添加:
|
||||
```json
|
||||
"@gongxue/typescript-config": "*"
|
||||
```
|
||||
|
||||
- [x] Step 2: 更新 apps/server/tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@gongxue/typescript-config/nestjs.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Step 3: 更新 apps/admin/package.json — name 和依赖
|
||||
|
||||
修改 `apps/admin/package.json`:
|
||||
- 将 `"name"` 从 `"frontend"` 改为 `"@gongxue/admin"`
|
||||
- 在 `devDependencies` 中添加:
|
||||
```json
|
||||
"@gongxue/typescript-config": "*"
|
||||
```
|
||||
|
||||
- [x] Step 4: 更新 apps/admin/tsconfig.app.json
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@gongxue/typescript-config/react-vite.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Step 5: 更新 apps/admin/tsconfig.node.json
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@gongxue/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Step 6: 重新安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
预期结果:npm workspaces 解析 `@gongxue/typescript-config: "*"` 工作区内部引用,安装成功。
|
||||
|
||||
- [x] Step 7: 验证 TypeScript 类型检查
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit && cd ../..
|
||||
cd apps/admin && npx tsc -b --noEmit && cd ../..
|
||||
```
|
||||
|
||||
预期结果:两个项目的类型检查均通过。
|
||||
|
||||
- [x] Step 8: 提交 Task 4
|
||||
|
||||
```bash
|
||||
git add apps/server/package.json apps/server/tsconfig.json \
|
||||
apps/admin/package.json apps/admin/tsconfig.app.json apps/admin/tsconfig.node.json
|
||||
git commit -m "feat(task4): update TypeScript configs to use shared presets
|
||||
|
||||
- Server tsconfig extends @gongxue/typescript-config/nestjs.json
|
||||
- Admin tsconfig.app extends @gongxue/typescript-config/react-vite.json
|
||||
- Admin tsconfig.node extends @gongxue/typescript-config/base.json
|
||||
- Update package names: backend -> @gongxue/server, frontend -> @gongxue/admin
|
||||
- Align server TypeScript to ~6.0.2"
|
||||
```
|
||||
|
||||
archived-with: 2026-07-02-migrate-to-turborepo
|
||||
---
|
||||
|
||||
### Task 5: Docker 适配
|
||||
|
||||
**文件:**
|
||||
- 修改: `docker-compose.yml` — 更新 build.context 路径
|
||||
|
||||
**接口:**
|
||||
- 消费: `apps/server/`、`apps/admin/`(Task 1 产出)
|
||||
- 产出: Docker compose build 和 up 成功运行(Task 6 验证使用)
|
||||
|
||||
- [x] Step 1: 更新 docker-compose.yml 的 build.context
|
||||
|
||||
将 `backend` 服务的 `build:` 从 `./backend` 改为 `./apps/server`:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
build: ./apps/server
|
||||
```
|
||||
|
||||
将 `frontend` 服务的 `build:` 从 `./frontend` 改为 `./apps/admin`:
|
||||
|
||||
```yaml
|
||||
frontend:
|
||||
build: ./apps/admin
|
||||
```
|
||||
|
||||
容器名 `dorm_billing_backend` 和 `dorm_billing_frontend` 保持不变。
|
||||
|
||||
- [x] Step 2: 验证 Docker Build
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
预期结果:两个镜像均构建成功,无错误。
|
||||
|
||||
- [x] Step 3: 提交 Task 5
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml
|
||||
git commit -m "feat(task5): update docker-compose build.context paths
|
||||
|
||||
- backend build.context: ./backend -> ./apps/server
|
||||
- frontend build.context: ./frontend -> ./apps/admin
|
||||
- Container names unchanged: dorm_billing_backend, dorm_billing_frontend"
|
||||
```
|
||||
|
||||
archived-with: 2026-07-02-migrate-to-turborepo
|
||||
---
|
||||
|
||||
### Task 6: 验证
|
||||
|
||||
**文件:**
|
||||
- 修改: `apps/server/package.json` — 添加 typecheck 脚本(如需要)
|
||||
- 修改: `apps/admin/package.json` — 添加 typecheck 脚本(如需要)
|
||||
|
||||
**接口:**
|
||||
- 消费: Task 1-5 的所有产出
|
||||
- 产出: 完整验证报告,确认迁移成功
|
||||
|
||||
- [x] Step 1: 添加 typecheck 脚本
|
||||
|
||||
在 `apps/server/package.json` 的 `scripts` 中添加:
|
||||
```json
|
||||
"typecheck": "tsc --noEmit"
|
||||
```
|
||||
|
||||
在 `apps/admin/package.json` 的 `scripts` 中添加:
|
||||
```json
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
```
|
||||
|
||||
- [x] Step 2: 验证 npm install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
验证 `@gongxue/typescript-config` 符号链接:
|
||||
```bash
|
||||
ls -la apps/server/node_modules/@gongxue/typescript-config
|
||||
ls -la apps/admin/node_modules/@gongxue/typescript-config
|
||||
```
|
||||
|
||||
- [x] Step 3: 验证 npm run build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
验证构建产物:
|
||||
```bash
|
||||
ls apps/server/dist/main.js
|
||||
ls apps/admin/dist/index.html
|
||||
```
|
||||
|
||||
- [x] Step 4: 验证 npm run lint
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
- [x] Step 5: 验证 npm run format
|
||||
|
||||
```bash
|
||||
npm run format
|
||||
```
|
||||
|
||||
- [x] Step 6: 验证 npm run test
|
||||
|
||||
```bash
|
||||
npm run test --workspace=apps/server
|
||||
```
|
||||
|
||||
- [x] Step 7: 验证 npm run dev
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
预期:server :3003 + admin :3002 并行启动,API 代理正常。
|
||||
|
||||
- [x] Step 8: 验证 npm run typecheck
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
- [x] Step 9: 提交 Task 6
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(task6): complete turborepo migration verification
|
||||
|
||||
Verification results:
|
||||
- [PASS] npm install (all workspaces)
|
||||
- [PASS] npm run build (server + admin)
|
||||
- [PASS] npm run lint (server ESLint + admin oxlint)
|
||||
- [PASS] npm run format (oxfmt)
|
||||
- [PASS] npm run test (server Jest)
|
||||
- [PASS] npm run dev (parallel server:3003 + admin:3002, API proxy OK)
|
||||
- [PASS] npm run typecheck"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,98 +0,0 @@
|
||||
# 列表页筛选补全 + 考勤预警 + 同步对接
|
||||
|
||||
> **For agentic workers:** Use subagent-driven-development. Steps use checkbox syntax.
|
||||
|
||||
**Goal:** Add missing filter dropdowns to 7 list pages, implement attendance anomaly detection, and wire up real DingTalk/WeCom sync API calls.
|
||||
|
||||
**Architecture:** Frontend: add Ant Design `<Select>` + `<DatePicker>` components to existing filter bars. Backend: add anomaly detection query to AttendanceService, implement sync stubs.
|
||||
|
||||
**Tech Stack:** React 19 + Ant Design 6 + NestJS 11 + TypeORM
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Follow existing filter patterns (Input.Search + Select + Space wrap)
|
||||
- Each filter change resets to page 1
|
||||
- Status filter options must match entity `status` field values
|
||||
- Sync stubs must check env vars before attempting API calls
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Students Page — Status + Tenant Filter
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Students/index.tsx`
|
||||
|
||||
- [ ] Add `filterStatus` state + `<Select>` dropdown (active/graduated/withdrawn/archived)
|
||||
- [ ] Add `filterTenantId` state + `<Select>` dropdown (loaded from `/tenants`)
|
||||
- [ ] Wire `fetchData()` to pass `status` + `tenantId` query params
|
||||
- [ ] Verify: select status → list filters, select tenant → list filters
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rooms Page — Status Filter
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Rooms/index.tsx`
|
||||
|
||||
- [ ] Add `filterStatus` + `<Select>` (available/in_use/maintenance/archived)
|
||||
- [ ] Wire to API query param
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Occupancies Page — Status + Date Range
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Occupancies/index.tsx`
|
||||
|
||||
- [ ] Add `filterStatus` (checked_in/checked_out)
|
||||
- [ ] Add `<RangePicker>` for check-in date range
|
||||
- [ ] Wire to API params
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remaining Pages — Status Filters
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Classrooms/index.tsx` — status dropdown
|
||||
- Modify: `apps/admin/src/pages/Expenses/index.tsx` — status filter
|
||||
- Modify: `apps/admin/src/pages/Tenants/index.tsx` — status dropdown
|
||||
|
||||
- [ ] Each page: add `<Select>` with appropriate status options
|
||||
- [ ] Wire to API params
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Bills Page — Expense Type Filter
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Bills/index.tsx`
|
||||
|
||||
- [ ] Add `filterExpenseType` + `<Select>` with water/electricity/cleaning/rent/other
|
||||
- [ ] Wire to API
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Attendance Anomaly Detection
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/attendance/attendance.service.ts` — add `getAnomalies()` method
|
||||
- Modify: `apps/server/src/attendance/attendance.controller.ts` — add `GET /attendance-records/anomalies`
|
||||
- Modify: `apps/admin/src/pages/Attendance/index.tsx` — add Alert banner
|
||||
|
||||
- [ ] Backend: `getAnomalies()` queries students with ≥3 consecutive absences or ≥5 lates in 7 days
|
||||
- [ ] Backend: returns `{ studentId, studentName, type: 'consecutive_absence'|'frequent_late', count, dateRange }`
|
||||
- [ ] Frontend: `<Alert>` banner at top of page showing anomaly count
|
||||
- [ ] Frontend: click to filter records for that student
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Sync Stubs → Real Implementation
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/sync/sync.service.ts`
|
||||
|
||||
- [ ] Check `process.env.DINGTALK_APP_KEY` before attempting ding sync
|
||||
- [ ] Check `process.env.WECOM_CORP_ID` before attempting wecom sync
|
||||
- [ ] Log actionable messages: "DingTalk not configured, set DINGTALK_APP_KEY"
|
||||
- [ ] If configured, attempt real API calls with proper error handling
|
||||
- [ ] Record sync counts in SyncLog
|
||||
@@ -1,586 +0,0 @@
|
||||
# P2 Remaining Tasks Implementation Plan
|
||||
|
||||
> **For agentic workers:** Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Complete the 4 genuinely remaining P2 items: dashboard classroom utilization stats, list page filter enhancements, deposit refund approval workflow, and scheduled sync implementation.
|
||||
|
||||
**Architecture:** Backend enhancements (UtilizationStats endpoint, deposit refund workflow status transitions, sync stubs → real API calls) plus frontend enhancements (dashboard utilization section, list page filter bars).
|
||||
|
||||
**Tech Stack:** NestJS 11 + TypeORM 0.3 (backend), React 19 + Ant Design 6 + ECharts (frontend), SQLite/MySQL, @nestjs/schedule (Cron).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All list pages ≥ 50 records MUST have comprehensive filter bars (class, date range, status, source, etc.)
|
||||
- Sensitive operations (refund approval) MUST log via OperationLogsService
|
||||
- Follow existing NestJS module structure
|
||||
- Dashboard stats MUST respect CampusScope data isolation
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Dashboard — Classroom Utilization Section
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/dashboard/dashboard.service.ts` (add `getClassroomUtilizationStats()`)
|
||||
- Modify: `apps/server/src/dashboard/dashboard.controller.ts` (add `GET /dashboard/classroom-utilization`)
|
||||
- Modify: `apps/admin/src/pages/Dashboard/index.tsx` (add utilization section below existing charts)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Classroom`, `ClassSchedule`, `ClassroomRental` repos already injected
|
||||
- Produces: `getClassroomUtilizationStats(): Promise<UtilizationStats>` where `UtilizationStats = { totalClassrooms: number; inUseCount: number; utilizationRate: string; scheduleHours: number; rentalDays: number }`
|
||||
|
||||
- [ ] **Step 1: Add `getClassroomUtilizationStats()` method to DashboardService**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/dashboard/dashboard.service.ts — add after getClassroomOccupancy()
|
||||
|
||||
async getClassroomUtilizationStats() {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const totalClassrooms = await this.classroomRepo.count({
|
||||
where: await this.scope.filter({ status: Not('archived') }),
|
||||
});
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Count classrooms with active schedules today
|
||||
const schedQb = this.scheduleRepo
|
||||
.createQueryBuilder('s')
|
||||
.select('COUNT(DISTINCT s.classroomId)', 'cnt')
|
||||
.where('s.status = :active', { active: 'active' })
|
||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today });
|
||||
if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const schedResult = await schedQb.getRawOne();
|
||||
|
||||
// Count classrooms with active rentals today
|
||||
const rentalQb = this.rentalRepo
|
||||
.createQueryBuilder('r')
|
||||
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
|
||||
if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const rentalResult = await rentalQb.getRawOne();
|
||||
|
||||
// Combine: use Set merge of both
|
||||
const combinedQb = this.scheduleRepo
|
||||
.createQueryBuilder('s')
|
||||
.select('s.classroomId')
|
||||
.where('s.status = :active', { active: 'active' })
|
||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today })
|
||||
.groupBy('s.classroomId');
|
||||
if (scopeIds) combinedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const schedIds = await combinedQb.getRawMany();
|
||||
|
||||
const combinedRentalQb = this.rentalRepo
|
||||
.createQueryBuilder('r')
|
||||
.select('r.classroomId')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||
.groupBy('r.classroomId');
|
||||
if (scopeIds) combinedRentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
const rentalIds = await combinedRentalQb.getRawMany();
|
||||
|
||||
const allInUseIds = new Set([
|
||||
...schedIds.map((s: any) => s.classroomId),
|
||||
...rentalIds.map((r: any) => r.classroomId),
|
||||
]);
|
||||
|
||||
const scheduleCount = parseInt(schedResult?.cnt || '0', 10);
|
||||
const rentalCount = parseInt(rentalResult?.cnt || '0', 10);
|
||||
const inUseCount = allInUseIds.size;
|
||||
const utilizationRate = totalClassrooms > 0
|
||||
? ((inUseCount / totalClassrooms) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
return {
|
||||
totalClassrooms,
|
||||
inUseCount,
|
||||
utilizationRate,
|
||||
scheduleCount,
|
||||
rentalCount,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add controller endpoint**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/dashboard/dashboard.controller.ts — add inside DashboardController
|
||||
|
||||
@Get('classroom-utilization')
|
||||
async getClassroomUtilization() {
|
||||
return this.service.getClassroomUtilizationStats();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add utilization section to Dashboard frontend**
|
||||
|
||||
Add after the existing stats cards row and classroom occupancy chart section in `apps/admin/src/pages/Dashboard/index.tsx`:
|
||||
|
||||
```typescript
|
||||
// Add state
|
||||
const [classroomUtil, setClassroomUtil] = useState<{
|
||||
totalClassrooms: number;
|
||||
inUseCount: number;
|
||||
utilizationRate: string;
|
||||
scheduleCount: number;
|
||||
rentalCount: number;
|
||||
} | null>(null);
|
||||
|
||||
// Add fetch in fetchData
|
||||
const cu = await api.get('/dashboard/classroom-utilization');
|
||||
setClassroomUtil(cu);
|
||||
|
||||
// Add a Card row after existing stat cards
|
||||
<Card title="教室利用率" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic title="教室总数" value={classroomUtil?.totalClassrooms ?? '-'} prefix={<ReadOutlined />} />
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic title="今日使用" value={classroomUtil?.inUseCount ?? '-'} prefix={<CheckCircleOutlined />} />
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="利用率"
|
||||
value={classroomUtil?.utilizationRate ?? '-'}
|
||||
suffix="%"
|
||||
prefix={<PercentageOutlined />}
|
||||
valueStyle={{ color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic title="内部排课" value={classroomUtil?.scheduleCount ?? '-'} prefix={<CalendarOutlined />} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run: `cd apps/server && npx jest --testPathPattern="dashboard" 2>/dev/null || echo "no tests yet"`
|
||||
Start dev server, open dashboard, confirm utilization section renders with correct data.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Deposit Refund Approval Workflow
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/entities/deposit.entity.ts` (add `refundStatus`, `refundRequestedAt`, `refundApprovedBy`, `refundApprovedAt`, `refundRejectedReason`)
|
||||
- Modify: `apps/server/src/deposits/deposits.service.ts` (add `requestRefund`, `approveRefund`, `rejectRefund` methods)
|
||||
- Modify: `apps/server/src/deposits/deposits.controller.ts` (add endpoints)
|
||||
- Modify: `apps/server/src/deposits/dto/deposit.dto.ts` (add DTOs)
|
||||
- Modify: `apps/admin/src/pages/Deposits/index.tsx` (add approval UI)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `POST /deposits/:id/request-refund`, `PUT /deposits/:id/approve-refund`, `PUT /deposits/:id/reject-refund`
|
||||
|
||||
- [ ] **Step 1: Add refund workflow fields to Deposit entity**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/entities/deposit.entity.ts — add fields inside Deposit class
|
||||
|
||||
@Column({ name: 'refund_status', length: 20, nullable: true })
|
||||
refundStatus: string; // 'pending_approval' | 'approved' | 'rejected'
|
||||
|
||||
@Column({ name: 'refund_requested_at', type: 'datetime', nullable: true })
|
||||
refundRequestedAt: Date;
|
||||
|
||||
@Column({ name: 'refund_approved_by', type: 'integer', nullable: true })
|
||||
refundApprovedBy: number;
|
||||
|
||||
@Column({ name: 'refund_approved_at', type: 'datetime', nullable: true })
|
||||
refundApprovedAt: Date;
|
||||
|
||||
@Column({ name: 'refund_rejected_reason', length: 500, nullable: true })
|
||||
refundRejectedReason: string;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add DTOs**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/deposits/dto/deposit.dto.ts — add exports
|
||||
|
||||
export class RequestRefundDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class ApproveRefundDto {
|
||||
@IsNumber()
|
||||
approvedBy: number;
|
||||
}
|
||||
|
||||
export class RejectRefundDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
reason: string;
|
||||
|
||||
@IsNumber()
|
||||
rejectedBy: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add service methods**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/deposits/deposits.service.ts — add methods
|
||||
|
||||
async requestRefund(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
if (deposit.refundStatus === 'pending_approval') {
|
||||
throw new BadRequestException('该押金已提交退还申请,等待审批中');
|
||||
}
|
||||
if (deposit.refundStatus === 'approved') {
|
||||
throw new BadRequestException('该押金已通过审批');
|
||||
}
|
||||
deposit.refundStatus = 'pending_approval';
|
||||
deposit.refundRequestedAt = new Date();
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
|
||||
async approveRefund(id: number, approvedBy: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
if (deposit.refundStatus !== 'pending_approval') {
|
||||
throw new BadRequestException('该押金不在待审批状态');
|
||||
}
|
||||
deposit.refundStatus = 'approved';
|
||||
deposit.refundApprovedBy = approvedBy;
|
||||
deposit.refundApprovedAt = new Date();
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
|
||||
async rejectRefund(id: number, reason: string, rejectedBy: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
if (deposit.refundStatus !== 'pending_approval') {
|
||||
throw new BadRequestException('该押金不在待审批状态');
|
||||
}
|
||||
deposit.refundStatus = 'rejected';
|
||||
deposit.refundApprovedBy = rejectedBy;
|
||||
deposit.refundApprovedAt = new Date();
|
||||
deposit.refundRejectedReason = reason;
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add controller endpoints**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/deposits/deposits.controller.ts — add endpoints
|
||||
|
||||
@Post(':id/request-refund')
|
||||
@RequirePermission('deposit:edit')
|
||||
async requestRefund(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.requestRefund(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '申请退还',
|
||||
targetId: +id,
|
||||
targetType: 'deposit',
|
||||
detail: `申请押金退还`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/approve-refund')
|
||||
@RequirePermission('deposit:edit')
|
||||
async approveRefund(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.approveRefund(+id, req.user?.id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '通过退还审批',
|
||||
targetId: +id,
|
||||
targetType: 'deposit',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/reject-refund')
|
||||
@RequirePermission('deposit:edit')
|
||||
async rejectRefund(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { reason: string },
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.rejectRefund(+id, body.reason, req.user?.id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '驳回退还申请',
|
||||
targetId: +id,
|
||||
targetType: 'deposit',
|
||||
detail: `驳回原因:${body.reason}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add frontend approval UI**
|
||||
|
||||
Modify `apps/admin/src/pages/Deposits/index.tsx` — in the deposits table, add a refund status column and action buttons:
|
||||
|
||||
- Add column: `refundStatus` with Tag rendering (pending_approval=orange '待审批', approved=green '已通过', rejected=red '已驳回')
|
||||
- Add action button "申请退还" (when refundStatus is null and not yet refunded)
|
||||
- Add action buttons "通过"/"驳回" (when refundStatus === 'pending_approval')
|
||||
|
||||
```typescript
|
||||
// In columns array, add:
|
||||
{
|
||||
title: '退还状态',
|
||||
dataIndex: 'refundStatus',
|
||||
key: 'refundStatus',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const m: Record<string, { text: string; color: string }> = {
|
||||
pending_approval: { text: '待审批', color: 'orange' },
|
||||
approved: { text: '已通过', color: 'green' },
|
||||
rejected: { text: '已驳回', color: 'red' },
|
||||
};
|
||||
const item = m[v];
|
||||
return item ? <Tag color={item.color}>{item.text}</Tag> : v || '-';
|
||||
},
|
||||
},
|
||||
// In action column, add conditional buttons:
|
||||
{record.refundStatus === 'pending_approval' && (
|
||||
<>
|
||||
<Popconfirm title="确认通过?" onConfirm={() => handleApproveRefund(record.id)}>
|
||||
<Button size="small" type="link" style={{ color: '#34C759' }}>通过</Button>
|
||||
</Popconfirm>
|
||||
<Button size="small" type="link" danger onClick={() => {
|
||||
setRejectTarget(record);
|
||||
setRejectModalOpen(true);
|
||||
}}>驳回</Button>
|
||||
</>
|
||||
)}
|
||||
{!record.refundStatus && !record.refundedAt && (
|
||||
<Popconfirm title="确认提交退还申请?" onConfirm={() => handleRequestRefund(record.id)}>
|
||||
<Button size="small" type="link">申请退还</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add reject reason modal**
|
||||
|
||||
```typescript
|
||||
// State
|
||||
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||
const [rejectTarget, setRejectTarget] = useState<any>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
// Handler
|
||||
const handleRejectRefund = async () => {
|
||||
await api.put(`/deposits/${rejectTarget.id}/reject-refund`, { reason: rejectReason });
|
||||
message.success('已驳回');
|
||||
setRejectModalOpen(false);
|
||||
setRejectReason('');
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// Modal
|
||||
<Modal
|
||||
title="驳回退还申请"
|
||||
open={rejectModalOpen}
|
||||
onOk={handleRejectRefund}
|
||||
onCancel={() => setRejectModalOpen(false)}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="请输入驳回原因"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Student Archive — Multi-Enrollment Comparison View
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Students/*` (add comparison view toggle to student detail)
|
||||
- Create: no new files; enhance existing Student detail view
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Existing `student_enrollments` data from student API response (already returns enrollments in detail view)
|
||||
- Produces: Side-by-side comparison cards for culture vs professional enrollments
|
||||
|
||||
- [ ] **Step 1: Add enrollment comparison component to Students page**
|
||||
|
||||
The student detail modal/expand already fetches enrollments. Add a comparison section when a student has 2+ enrollments:
|
||||
|
||||
```typescript
|
||||
// In the student detail modal (or table expanded row), after basic info:
|
||||
{student.enrollments && student.enrollments.length >= 2 && (
|
||||
<Card title="多班型对比" size="small" style={{ marginTop: 16 }}>
|
||||
<Row gutter={16}>
|
||||
{student.enrollments.map((enr: any, idx: number) => (
|
||||
<Col span={12} key={enr.id}>
|
||||
<Card
|
||||
size="small"
|
||||
title={enr.classType || `班型 ${idx + 1}`}
|
||||
style={{ background: idx === 0 ? '#f0f5ff' : '#f6ffed' }}
|
||||
>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="班级">{enr.className || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="课程类别">{enr.courseCategory || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开班日期">{enr.startDate || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="结课日期">{enr.endDate || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="班主任">{enr.headTeacher || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="任课教师">{enr.teacher || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
Open Students page, click on a student with multiple enrollments. Confirm comparison cards render side-by-side.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Scheduled Sync — Fill Integration Stubs
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/sync/sync.service.ts` (implement `performDingTalkSync` and `performWeComSync`)
|
||||
- Modify: `apps/server/src/sync/sync.controller.ts` (add sync status endpoint if not present)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Existing DINGTALK/WECOM integration modules (check `apps/server/src/` for existing API clients)
|
||||
- Produces: Real sync with record counts logged to SyncLog
|
||||
|
||||
- [ ] **Step 1: Check existing integration modules**
|
||||
|
||||
Run a quick scan to find existing DingTalk/WeCom API clients:
|
||||
|
||||
```bash
|
||||
grep -r "class.*DingTalk\|class.*WeCom\|dingtalk\|wecom" apps/server/src --include="*.ts" -l
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement performDingTalkSync**
|
||||
|
||||
If a DingTalk service exists, inject and use it:
|
||||
|
||||
```typescript
|
||||
// apps/server/src/sync/sync.service.ts
|
||||
// If DingTalkService exists:
|
||||
constructor(
|
||||
// ... existing repos
|
||||
private readonly dingTalkService?: DingTalkService, // optional injection
|
||||
) {}
|
||||
|
||||
private async performDingTalkSync(lastSyncAt: Date | null): Promise<number> {
|
||||
// Check if DingTalk integration is configured
|
||||
const config = process.env.DINGTALK_APP_KEY;
|
||||
if (!config) {
|
||||
this.logger.warn('DingTalk not configured, skipping sync');
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// Pull departments
|
||||
const depts = await this.dingTalkService?.fetchDepartments() ?? [];
|
||||
// Pull users
|
||||
const users = await this.dingTalkService?.fetchUsers() ?? [];
|
||||
// If incremental, filter by lastSyncAt
|
||||
|
||||
this.logger.log(`DingTalk sync: ${depts.length} departments, ${users.length} users`);
|
||||
return depts.length + users.length;
|
||||
} catch (err: any) {
|
||||
this.logger.error(`DingTalk sync failed: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If no DingTalk service exists yet, keep stubs but make them log meaningful warnings:
|
||||
|
||||
```typescript
|
||||
private async performDingTalkSync(_lastSyncAt: Date | null): Promise<number> {
|
||||
this.logger.warn(
|
||||
'DingTalk integration not yet implemented — add DingTalkService to SyncModule to enable real sync',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Same for performWeComSync**
|
||||
|
||||
```typescript
|
||||
private async performWeComSync(_lastSyncAt: Date | null): Promise<number> {
|
||||
const config = process.env.WECOM_CORP_ID;
|
||||
if (!config) {
|
||||
this.logger.warn('WeCom not configured, skipping sync');
|
||||
return 0;
|
||||
}
|
||||
// TODO: integrate with existing WeCom service
|
||||
this.logger.warn('WeCom sync not yet fully implemented');
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add sync status to SyncController**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/sync/sync.controller.ts — add endpoint
|
||||
@Get('status')
|
||||
@RequirePermission('log:view')
|
||||
async getStatus() {
|
||||
const lastDingTalk = await this.syncService.getLastSync('dingtalk');
|
||||
const lastWeCom = await this.syncService.getLastSync('wecom');
|
||||
return {
|
||||
dingTalk: lastDingTalk ? { lastSyncAt: lastDingTalk.endedAt, status: lastDingTalk.status } : null,
|
||||
weCom: lastWeCom ? { lastSyncAt: lastWeCom.endedAt, status: lastWeCom.status } : null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Add `getLastSync` to SyncService:
|
||||
|
||||
```typescript
|
||||
async getLastSync(platform: SyncPlatform) {
|
||||
return this.syncLogRepo.findOne({
|
||||
where: { platform },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify**
|
||||
|
||||
Run `npx jest --testPathPattern="sync" 2>/dev/null` if tests exist. Start server, verify the `/sync/status` endpoint returns data.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
1. **Spec coverage:**
|
||||
- Task 1 → PRD 6.3 教室利用率统计 ✅
|
||||
- Task 2 → PRD 11.2 押金退还审批流 ✅
|
||||
- Task 3 → PRD 2.2 多班型对比视图 ✅
|
||||
- Task 4 → PRD INT.1-3 定时/增量同步 ✅
|
||||
|
||||
2. **Placeholder scan:** All steps have concrete code, no TODOs.
|
||||
|
||||
3. **Type consistency:** All interfaces match existing service patterns. DTOs follow existing naming conventions.
|
||||
@@ -1,188 +0,0 @@
|
||||
# 学生档案子系统 Implementation Plan
|
||||
|
||||
> **For agentic workers:** Use subagent-driven-development. Steps use checkbox syntax.
|
||||
|
||||
**Goal:** Rebuild the student profile/report subsystem: 6 new entity tables, CRUD APIs, aggregate query, PDF report generation (pdfkit), frontend profile page, multi-enrollment comparison.
|
||||
|
||||
**Architecture:** New `ArchiveModule` aggregates 6 sub-entities under one API surface. `GET /archive/:studentId` returns the full profile. `GET /archive/:studentId/report` generates PDF. Use pdfkit (already in deps) for PDF; ECharts server-side SVG for charts.
|
||||
|
||||
**Tech Stack:** NestJS 11 + TypeORM + pdfkit + ECharts (SSR via `echarts` npm) + React 19 + Ant Design 6
|
||||
|
||||
## Global Constraints
|
||||
|
||||
+- All tables have `department_id` for campus scope isolation
|
||||
+- All write operations log via OperationLogsService
|
||||
+- PDF uses pdfkit (NOT puppeteer) — already in bill export
|
||||
+- Charts in PDF rendered as static SVG via echarts SSR
|
||||
+- Sensitive fields (phone/idNumber) masked in API responses, unmasked in PDF
|
||||
+- Follow existing NestJS module structure: `archive/` with entity/dto/service/controller
|
||||
+- Frontend follows existing page patterns (Students page as reference)
|
||||
|
||||
---
|
||||
|
||||
## Entities Design
|
||||
|
||||
### student_profiles — 扩展档案
|
||||
```sql
|
||||
id (PK), student_id FK UNIQUE, target_college, target_major, subject_direction,
|
||||
grade, campus_location, profile_date (建档日期), notes,
|
||||
department_id, created_at, updated_at
|
||||
```
|
||||
|
||||
### student_enrollments — 报读记录
|
||||
```sql
|
||||
id (PK), student_id FK, course_category (课程类别), class_type (班型: culture/professional/bootcamp),
|
||||
class_name, head_teacher, subject_teacher, start_date, end_date, status,
|
||||
department_id, created_at, updated_at
|
||||
```
|
||||
|
||||
### exam_scores — 考试成绩
|
||||
```sql
|
||||
id (PK), student_id FK, enrollment_id FK (nullable, links to enrollment),
|
||||
exam_type (周测/月测/模考/入学测), exam_name, subject, score (decimal),
|
||||
class_avg (decimal), rank, exam_date,
|
||||
department_id, created_at
|
||||
```
|
||||
|
||||
### learning_records — 学情记录
|
||||
```sql
|
||||
id (PK), student_id FK, record_date, record_type (课堂表现/作业/沟通/其他),
|
||||
content (text), follow_up_method, next_step,
|
||||
department_id, created_at
|
||||
```
|
||||
|
||||
### result_archives — 录取归档
|
||||
```sql
|
||||
id (PK), student_id FK, culture_final_score (decimal), professional_final_score (decimal),
|
||||
admission_status (已录取/未录取/待定), admitted_college, admitted_major,
|
||||
department_id, created_at, updated_at
|
||||
```
|
||||
|
||||
### archive_attachments — 附件
|
||||
```sql
|
||||
id (PK), student_id FK, category (成绩截图/录取截图/协议/其他),
|
||||
file_name, file_path, file_size, mime_type,
|
||||
department_id, created_at
|
||||
```
|
||||
|
||||
### student_reports — 报告版本
|
||||
```sql
|
||||
id (PK), student_id FK, snapshot_data (JSON — frozen copy of all profile data at generation time),
|
||||
html_content (text — rendered HTML), pdf_path, generated_at,
|
||||
department_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Entities + Module Skeleton
|
||||
|
||||
**Files:**
|
||||
+- Create: `apps/server/src/entities/student-profile.entity.ts`
|
||||
+- Create: `apps/server/src/entities/student-enrollment.entity.ts`
|
||||
+- Create: `apps/server/src/entities/exam-score.entity.ts`
|
||||
+- Create: `apps/server/src/entities/learning-record.entity.ts`
|
||||
+- Create: `apps/server/src/entities/result-archive.entity.ts`
|
||||
+- Create: `apps/server/src/entities/archive-attachment.entity.ts`
|
||||
+- Create: `apps/server/src/entities/student-report.entity.ts`
|
||||
+- Modify: `apps/server/src/entities/index.ts`
|
||||
+- Create: `apps/server/src/archive/archive.module.ts`
|
||||
+- Create: `apps/server/src/archive/archive.service.ts`
|
||||
+- Create: `apps/server/src/archive/archive.controller.ts`
|
||||
+- Create: `apps/server/src/archive/dto/archive.dto.ts`
|
||||
+- Modify: `apps/server/src/app.module.ts`
|
||||
|
||||
All entities follow existing TypeORM patterns with `@Entity`, `@PrimaryGeneratedColumn`, `@Column`, `@ManyToOne(Student)`, `@CreateDateColumn`.
|
||||
|
||||
ArchiveModule imports `TypeOrmModule.forFeature([all 6 entities])`, is registered in AppModule.
|
||||
|
||||
ArchiveService provides:
|
||||
- `getProfile(studentId)` — joins all 6 tables, returns aggregate
|
||||
- `saveProfile(studentId, dto)` — upsert student_profiles
|
||||
- CRUD for each sub-entity (enrollments, scores, records, results, attachments)
|
||||
- `generateReport(studentId)` — produces PDF
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: CRUD APIs
|
||||
|
||||
**ArchiveController endpoints:**
|
||||
|
||||
| Method | Path | Description |
|
||||
|------|------|------|
|
||||
| GET | `/archive/:studentId` | Full profile aggregate |
|
||||
| PUT | `/archive/:studentId/profile` | Upsert student_profiles |
|
||||
| POST | `/archive/:studentId/enrollments` | Add enrollment |
|
||||
| PUT | `/archive/enrollments/:id` | Edit enrollment |
|
||||
| DELETE | `/archive/enrollments/:id` | Delete enrollment |
|
||||
| POST | `/archive/:studentId/exam-scores` | Add exam score |
|
||||
| PUT | `/archive/exam-scores/:id` | Edit exam score |
|
||||
| DELETE | `/archive/exam-scores/:id` | Delete exam score |
|
||||
| POST | `/archive/:studentId/learning-records` | Add learning record |
|
||||
| PUT | `/archive/learning-records/:id` | Edit learning record |
|
||||
| DELETE | `/archive/learning-records/:id` | Delete learning record |
|
||||
| PUT | `/archive/:studentId/result` | Upsert result archive |
|
||||
| POST | `/archive/:studentId/attachments` | Upload attachment (multipart) |
|
||||
| DELETE | `/archive/attachments/:id` | Delete attachment |
|
||||
| GET | `/archive/:studentId/report` | Generate & download PDF |
|
||||
|
||||
All write endpoints log via OperationLogsService (`module: '学生档案'`).
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: PDF Report Generation
|
||||
|
||||
Use pdfkit. One file: `apps/server/src/archive/archive-report.service.ts`.
|
||||
|
||||
Report structure (multi-page A4):
|
||||
1. **封面** — name, studentNo, subjectDirection, targetCollege/targetMajor, headTeacher, profileDate
|
||||
2. **基础信息** — personal info table + enrollment comparison (culture vs professional side-by-side)
|
||||
3. **入学测评与阶段概览** — first exam scores, highest scores, improvement (bar chart via echarts SVG)
|
||||
4. **出勤记录** — attendance summary (pie: present/absent/late/leave), daily matrix
|
||||
5. **文化课测评** — all culture exam scores table, subject breakdown bar
|
||||
6. **专业课测评** — all professional exam scores table, learning records list
|
||||
|
||||
The multi-enrollment comparison (PRD 2.2): when student has 2+ enrollments (e.g., culture + professional), each gets its own column in the tables and its own chart section.
|
||||
|
||||
Key implementation:
|
||||
- `generateReport(studentId)` — orchestrates data gathering, builds PDF sections
|
||||
- Helper: `renderAttendancePie(records)` → SVG buffer → embedded in PDF
|
||||
- Helper: `renderScoreBar(scores)` → SVG buffer → embedded in PDF
|
||||
- Charts: use `echarts` npm package, render to SVG string, convert to buffer, embed via `doc.image()`
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Frontend Student Profile Page
|
||||
|
||||
**Files:**
|
||||
+- Create: `apps/admin/src/pages/StudentProfile/index.tsx`
|
||||
+- Modify: `apps/admin/src/App.tsx` (add route `/students/:id/profile`)
|
||||
|
||||
Page layout:
|
||||
- **顶部** — Student info card (name, phone masked, idNumber masked, status, tenant — reusing Students page data)
|
||||
- **Tabs**: 基础档案 / 报读记录 / 考试成绩 / 学情记录 / 录取结果 / 附件
|
||||
- **基础档案 Tab** — Form: 目标院校、目标专业、科类方向、年级、校区、建档日期
|
||||
- **报读记录 Tab** — Table + Add modal: 课程类别、班型、班级名、班主任、任课老师、开/结课日期
|
||||
- **考试成绩 Tab** — Table + Add/Edit modal: 类型、名称、科目、分数、班级平均、排名、日期
|
||||
- **学情记录 Tab** — Table + Add modal: 日期、类型、内容、跟进方式、下一步
|
||||
- **录取结果 Tab** — Form: 文化课最终成绩、专业课最终成绩、录取状态、录取院校、录取专业
|
||||
- **附件 Tab** — Upload list: 分类、文件名、大小、删除
|
||||
- **操作栏** — "生成档案报表" button → downloads PDF
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Multi-Enrollment PDF Comparison
|
||||
|
||||
PRD 2.2 specific: when 2+ enrollments exist, the PDF report must show them side-by-side:
|
||||
- Cover page: list all class_types
|
||||
- Score tables: columns per enrollment
|
||||
- Separate chart sections for culture vs professional
|
||||
|
||||
This is handled in the PDF generation logic — the archive-report.service.ts builds sections dynamically based on enrollment count.
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
Phase 1→2→4→3→5 (entities→CRUD→frontend→PDF→comparison). Phases 3+5 are combined in the report service.
|
||||
|
||||
**Total: 5 phases, ~12 files created, ~3 files modified.**
|
||||
@@ -1,611 +0,0 @@
|
||||
# 钉钉/企微同步对接 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace `sync.service.ts` stubs with real DingTalk/WeCom API calls — fetch departments and users, persist to DB, record sync logs.
|
||||
|
||||
**Architecture:** Add `source`/`sourceId`/`parentSourceId` to Department entity for idempotent sync matching. Create two integration services (`DingTalkService`, `WeComService`) under `src/integration/`, one shared `IntegrationModule`, wire into `SyncModule`. Each service self-checks env vars and degrades gracefully when not configured.
|
||||
|
||||
**Tech Stack:** NestJS 11 + TypeORM + `@nestjs/schedule` + native `fetch`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
+- MUST check `process.env.DINGTALK_APP_KEY` / `WECOM_CORP_ID` before attempting API calls
|
||||
+- When env vars missing: log warning, return 0 records, set sync log to `success` (not `failed` — "not configured" is not an error)
|
||||
+- Sync count returned from `perform*Sync` is the number of **new/updated records persisted**
|
||||
+- Follow existing NestJS module structure: one directory per concern
|
||||
+- Each service is independently injectable; `SyncModule` imports `IntegrationModule`
|
||||
+- Use native `fetch` (Node 18+) — no extra HTTP client dependency
|
||||
+- Department entity gains nullable `source` / `sourceId` / `parentSourceId` — existing records unaffected
|
||||
+- Sync preserves existing tree structure: departments matched by `sourceId`, users by `username`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Source Tracking to Department Entity
|
||||
|
||||
**Files:**
|
||||
+- Modify: `apps/server/src/entities/department.entity.ts`
|
||||
|
||||
**Interfaces:**
|
||||
+- Produces: Department entity with nullable `source`, `sourceId`, `parentSourceId` columns
|
||||
|
||||
+- [ ] **Step 1: Add columns to Department entity**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/entities/department.entity.ts — add after 'status' field (line ~42):
|
||||
@Column({ length: 20, nullable: true })
|
||||
source: string; // 'dingtalk' | 'wecom' | null (null = manual)
|
||||
|
||||
@Column({ name: 'source_id', length: 50, nullable: true })
|
||||
sourceId: string; // external dept ID for idempotent sync matching
|
||||
|
||||
@Column({ name: 'parent_source_id', length: 50, nullable: true })
|
||||
parentSourceId: string; // external parent dept ID (resolved in post-processing)
|
||||
```
|
||||
|
||||
+- [ ] **Step 2: Verify compilation**
|
||||
|
||||
Run: `cd apps/server && npx tsc --noEmit 2>&1 | head -20`
|
||||
Expected: No new errors from department.entity.ts
|
||||
|
||||
+- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/entities/department.entity.ts
|
||||
git commit -m "feat: add source tracking fields to Department for sync idempotency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: DingTalk Integration Service
|
||||
|
||||
**Files:**
|
||||
+- Create: `apps/server/src/integration/dingtalk.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
+- Produces: `DingTalkService` with `syncAll(): Promise<{ deptCount: number; userCount: number }>`
|
||||
+- Consumes: `Department` repo, `User` repo
|
||||
|
||||
+- [ ] **Step 1: Create the full DingTalkService**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/integration/dingtalk.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Department } from '../entities/department.entity';
|
||||
import { User } from '../entities/user.entity';
|
||||
|
||||
interface DingTalkTokenResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface DingTalkDeptListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result: Array<{ dept_id: number; name: string; parent_id: number }>;
|
||||
}
|
||||
|
||||
interface DingTalkUserListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result: {
|
||||
has_more: boolean;
|
||||
list: Array<{
|
||||
userid: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
dept_id_list: number[];
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DingTalkService {
|
||||
private readonly logger = new Logger(DingTalkService.name);
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiresAt = 0;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Department)
|
||||
private readonly deptRepo: Repository<Department>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
) {}
|
||||
|
||||
private get configured(): boolean {
|
||||
return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET);
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
const appKey = process.env.DINGTALK_APP_KEY!;
|
||||
const appSecret = process.env.DINGTALK_APP_SECRET!;
|
||||
const url = `https://oapi.dingtalk.com/gettoken?appkey=${appKey}&appsecret=${appSecret}`;
|
||||
const res = await fetch(url);
|
||||
const body: DingTalkTokenResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
throw new Error(`DingTalk gettoken failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
|
||||
this.accessToken = body.access_token;
|
||||
this.tokenExpiresAt = Date.now() + body.expires_in * 1000;
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
private async fetchDepartments(token: string): Promise<Array<{ dept_id: number; name: string; parent_id: number }>> {
|
||||
const url = `https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${token}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dept_id: 1 }),
|
||||
});
|
||||
const body: DingTalkDeptListResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
throw new Error(`DingTalk department list failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
return body.result;
|
||||
}
|
||||
|
||||
private async fetchUsers(
|
||||
token: string,
|
||||
deptId: number,
|
||||
): Promise<Array<{ userid: string; name: string; mobile: string; dept_id_list: number[] }>> {
|
||||
const allUsers: DingTalkUserListResponse['result']['list'] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (true) {
|
||||
const url = `https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dept_id: deptId, cursor, size: 100 }),
|
||||
});
|
||||
const body: DingTalkUserListResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
throw new Error(`DingTalk user list failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
|
||||
allUsers.push(...body.result.list);
|
||||
if (!body.result.has_more) break;
|
||||
cursor = allUsers.length;
|
||||
}
|
||||
|
||||
return allUsers;
|
||||
}
|
||||
|
||||
async syncAll(): Promise<{ deptCount: number; userCount: number }> {
|
||||
if (!this.configured) {
|
||||
this.logger.warn('DingTalk not configured (DINGTALK_APP_KEY / DINGTALK_APP_SECRET missing), skipping sync');
|
||||
return { deptCount: 0, userCount: 0 };
|
||||
}
|
||||
|
||||
const token = await this.getAccessToken();
|
||||
const dingDepts = await this.fetchDepartments(token);
|
||||
|
||||
// Upsert departments
|
||||
let deptCount = 0;
|
||||
for (const dd of dingDepts) {
|
||||
const sourceId = String(dd.dept_id);
|
||||
let dept = await this.deptRepo.findOne({ where: { source: 'dingtalk', sourceId } });
|
||||
|
||||
if (dept) {
|
||||
dept.name = dd.name;
|
||||
dept.parentSourceId = dd.parent_id ? String(dd.parent_id) : null;
|
||||
} else {
|
||||
dept = this.deptRepo.create({
|
||||
name: dd.name,
|
||||
source: 'dingtalk',
|
||||
sourceId,
|
||||
parentSourceId: dd.parent_id ? String(dd.parent_id) : null,
|
||||
type: 'department',
|
||||
});
|
||||
deptCount++;
|
||||
}
|
||||
await this.deptRepo.save(dept);
|
||||
}
|
||||
|
||||
// Resolve parentSourceId → parentId for tree linking
|
||||
const syncedDepts = await this.deptRepo.find({ where: { source: 'dingtalk' } });
|
||||
const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id]));
|
||||
for (const dept of syncedDepts) {
|
||||
if (dept.parentSourceId && idMap.has(dept.parentSourceId)) {
|
||||
dept.parentId = idMap.get(dept.parentSourceId)!;
|
||||
} else if (dept.parentSourceId === '1' || dept.parentSourceId === '0') {
|
||||
dept.parentId = null; // root
|
||||
}
|
||||
}
|
||||
await this.deptRepo.save(syncedDepts);
|
||||
|
||||
// Upsert users across all departments
|
||||
let userCount = 0;
|
||||
const seenUserIds = new Set<string>();
|
||||
for (const dd of dingDepts) {
|
||||
const dingUsers = await this.fetchUsers(token, dd.dept_id);
|
||||
for (const du of dingUsers) {
|
||||
if (seenUserIds.has(du.userid)) continue;
|
||||
seenUserIds.add(du.userid);
|
||||
|
||||
let user = await this.userRepo.findOne({ where: { username: du.userid } });
|
||||
if (user) {
|
||||
user.name = du.name;
|
||||
} else {
|
||||
user = this.userRepo.create({
|
||||
username: du.userid,
|
||||
name: du.name,
|
||||
passwordHash: '',
|
||||
isActive: true,
|
||||
});
|
||||
userCount++;
|
||||
}
|
||||
await this.userRepo.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`DingTalk sync done: ${deptCount} new depts, ${userCount} new users`);
|
||||
return { deptCount, userCount };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+- [ ] **Step 2: Verify file exists**
|
||||
|
||||
Run: `wc -l apps/server/src/integration/dingtalk.service.ts`
|
||||
Expected: ~160 lines
|
||||
|
||||
---
|
||||
|
||||
### Task 3: WeCom Integration Service
|
||||
|
||||
**Files:**
|
||||
+- Create: `apps/server/src/integration/wecom.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
+- Produces: `WeComService` with `syncAll(): Promise<{ deptCount: number; userCount: number }>`
|
||||
|
||||
+- [ ] **Step 1: Create the full WeComService**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/integration/wecom.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Department } from '../entities/department.entity';
|
||||
import { User } from '../entities/user.entity';
|
||||
|
||||
interface WeComTokenResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface WeComDeptListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
department: Array<{ id: number; name: string; parentid: number }>;
|
||||
}
|
||||
|
||||
interface WeComUserListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
userlist: Array<{
|
||||
userid: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
department: number[];
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WeComService {
|
||||
private readonly logger = new Logger(WeComService.name);
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiresAt = 0;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Department)
|
||||
private readonly deptRepo: Repository<Department>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
) {}
|
||||
|
||||
private get configured(): boolean {
|
||||
return !!(process.env.WECOM_CORP_ID && process.env.WECOM_CORP_SECRET);
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
const corpId = process.env.WECOM_CORP_ID!;
|
||||
const corpSecret = process.env.WECOM_CORP_SECRET!;
|
||||
const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${corpSecret}`;
|
||||
const res = await fetch(url);
|
||||
const body: WeComTokenResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
throw new Error(`WeCom gettoken failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
|
||||
this.accessToken = body.access_token;
|
||||
this.tokenExpiresAt = Date.now() + body.expires_in * 1000;
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
private async fetchDepartments(
|
||||
token: string,
|
||||
parentId = 1,
|
||||
): Promise<Array<{ id: number; name: string; parentid: number }>> {
|
||||
const all: WeComDeptListResponse['department'] = [];
|
||||
const url = `https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=${token}&id=${parentId}`;
|
||||
const res = await fetch(url);
|
||||
const body: WeComDeptListResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
// Empty department list is OK for leaf departments
|
||||
if (body.errcode === 60003) return all;
|
||||
throw new Error(`WeCom department list failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
|
||||
for (const dept of body.department) {
|
||||
all.push(dept);
|
||||
if (dept.id !== parentId) {
|
||||
const children = await this.fetchDepartments(token, dept.id);
|
||||
all.push(...children);
|
||||
}
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
private async fetchUsers(
|
||||
token: string,
|
||||
deptId: number,
|
||||
): Promise<Array<{ userid: string; name: string; mobile: string; department: number[] }>> {
|
||||
const url = `https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=${token}&department_id=${deptId}&fetch_child=1`;
|
||||
const res = await fetch(url);
|
||||
const body: WeComUserListResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
throw new Error(`WeCom user list failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
|
||||
return body.userlist;
|
||||
}
|
||||
|
||||
async syncAll(): Promise<{ deptCount: number; userCount: number }> {
|
||||
if (!this.configured) {
|
||||
this.logger.warn('WeCom not configured (WECOM_CORP_ID / WECOM_CORP_SECRET missing), skipping sync');
|
||||
return { deptCount: 0, userCount: 0 };
|
||||
}
|
||||
|
||||
const token = await this.getAccessToken();
|
||||
const wxDepts = await this.fetchDepartments(token);
|
||||
|
||||
// Upsert departments
|
||||
let deptCount = 0;
|
||||
for (const wd of wxDepts) {
|
||||
const sourceId = String(wd.id);
|
||||
let dept = await this.deptRepo.findOne({ where: { source: 'wecom', sourceId } });
|
||||
|
||||
if (dept) {
|
||||
dept.name = wd.name;
|
||||
dept.parentSourceId = wd.parentid ? String(wd.parentid) : null;
|
||||
} else {
|
||||
dept = this.deptRepo.create({
|
||||
name: wd.name,
|
||||
source: 'wecom',
|
||||
sourceId,
|
||||
parentSourceId: wd.parentid ? String(wd.parentid) : null,
|
||||
type: 'department',
|
||||
});
|
||||
deptCount++;
|
||||
}
|
||||
await this.deptRepo.save(dept);
|
||||
}
|
||||
|
||||
// Resolve parentSourceId → parentId
|
||||
const syncedDepts = await this.deptRepo.find({ where: { source: 'wecom' } });
|
||||
const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id]));
|
||||
for (const dept of syncedDepts) {
|
||||
if (dept.parentSourceId && idMap.has(dept.parentSourceId)) {
|
||||
dept.parentId = idMap.get(dept.parentSourceId)!;
|
||||
} else if (dept.parentSourceId === '0' || dept.parentSourceId === '1') {
|
||||
dept.parentId = null;
|
||||
}
|
||||
}
|
||||
await this.deptRepo.save(syncedDepts);
|
||||
|
||||
// Upsert users
|
||||
let userCount = 0;
|
||||
const seenUserIds = new Set<string>();
|
||||
for (const wd of wxDepts) {
|
||||
const wxUsers = await this.fetchUsers(token, wd.id);
|
||||
for (const wu of wxUsers) {
|
||||
if (seenUserIds.has(wu.userid)) continue;
|
||||
seenUserIds.add(wu.userid);
|
||||
|
||||
let user = await this.userRepo.findOne({ where: { username: wu.userid } });
|
||||
if (user) {
|
||||
user.name = wu.name;
|
||||
} else {
|
||||
user = this.userRepo.create({
|
||||
username: wu.userid,
|
||||
name: wu.name,
|
||||
passwordHash: '',
|
||||
isActive: true,
|
||||
});
|
||||
userCount++;
|
||||
}
|
||||
await this.userRepo.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`WeCom sync done: ${deptCount} new depts, ${userCount} new users`);
|
||||
return { deptCount, userCount };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+- [ ] **Step 2: Verify file exists**
|
||||
|
||||
Run: `wc -l apps/server/src/integration/wecom.service.ts`
|
||||
Expected: ~170 lines
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Integration Module + Wiring
|
||||
|
||||
**Files:**
|
||||
+- Create: `apps/server/src/integration/integration.module.ts`
|
||||
+- Modify: `apps/server/src/sync/sync.module.ts`
|
||||
+- Modify: `apps/server/src/sync/sync.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
+- Consumes: `DingTalkService.syncAll()`, `WeComService.syncAll()`
|
||||
+- Produces: `SyncService` with real sync calls replacing stubs
|
||||
|
||||
+- [ ] **Step 1: Create IntegrationModule**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/integration/integration.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Department, User } from '../entities';
|
||||
import { DingTalkService } from './dingtalk.service';
|
||||
import { WeComService } from './wecom.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Department, User])],
|
||||
providers: [DingTalkService, WeComService],
|
||||
exports: [DingTalkService, WeComService],
|
||||
})
|
||||
export class IntegrationModule {}
|
||||
```
|
||||
|
||||
+- [ ] **Step 2: Wire IntegrationModule into SyncModule**
|
||||
|
||||
In `apps/server/src/sync/sync.module.ts`: add `IntegrationModule` to the `imports` array and the import statement:
|
||||
|
||||
```typescript
|
||||
// Add at top:
|
||||
import { IntegrationModule } from '../integration/integration.module';
|
||||
|
||||
// In @Module decorator, add IntegrationModule to imports:
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forFeature([SyncLog, SyncState]),
|
||||
IntegrationModule,
|
||||
],
|
||||
// ... rest unchanged
|
||||
```
|
||||
|
||||
+- [ ] **Step 3: Inject services and replace stubs in SyncService**
|
||||
|
||||
In `apps/server/src/sync/sync.service.ts`:
|
||||
|
||||
Add imports:
|
||||
```typescript
|
||||
import { DingTalkService } from '../integration/dingtalk.service';
|
||||
import { WeComService } from '../integration/wecom.service';
|
||||
```
|
||||
|
||||
Add to constructor parameters:
|
||||
```typescript
|
||||
constructor(
|
||||
@InjectRepository(SyncLog) private readonly syncLogRepo: Repository<SyncLog>,
|
||||
@InjectRepository(SyncState) private readonly syncStateRepo: Repository<SyncState>,
|
||||
private readonly dingTalkService: DingTalkService,
|
||||
private readonly weComService: WeComService,
|
||||
) {}
|
||||
```
|
||||
|
||||
Replace `performDingTalkSync` (lines 154-166):
|
||||
```typescript
|
||||
private async performDingTalkSync(_lastSyncAt: Date | null): Promise<number> {
|
||||
const result = await this.dingTalkService.syncAll();
|
||||
return result.deptCount + result.userCount;
|
||||
}
|
||||
```
|
||||
|
||||
Replace `performWeComSync` (lines 168-179):
|
||||
```typescript
|
||||
private async performWeComSync(_lastSyncAt: Date | null): Promise<number> {
|
||||
const result = await this.weComService.syncAll();
|
||||
return result.deptCount + result.userCount;
|
||||
}
|
||||
```
|
||||
|
||||
Also remove the stale JSDoc comments above the old stubs.
|
||||
|
||||
+- [ ] **Step 4: Verify compilation**
|
||||
|
||||
Run: `cd apps/server && npx tsc --noEmit 2>&1 | head -30`
|
||||
Expected: No type errors from integration/ or sync/ modules
|
||||
|
||||
+- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/integration/
|
||||
git add apps/server/src/sync/sync.module.ts apps/server/src/sync/sync.service.ts
|
||||
git commit -m "feat: wire DingTalk/WeCom integration services into sync pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Verification
|
||||
|
||||
**Files:**
|
||||
+- _(none modified — verification only)_
|
||||
|
||||
+- [ ] **Step 1: Start dev server with missing env vars**
|
||||
|
||||
```bash
|
||||
cd apps/server && npm run start:dev &
|
||||
sleep 5
|
||||
```
|
||||
|
||||
Check logs: should show `DingTalk not configured... skipping sync` and `WeCom not configured... skipping sync` at startup (or wait for the 2 AM cron, or trigger manually).
|
||||
|
||||
+- [ ] **Step 2: Test manual trigger endpoint**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3002/api/sync/trigger | python3 -m json.tool 2>/dev/null || curl -s http://localhost:3002/api/sync/trigger
|
||||
```
|
||||
|
||||
Expected: JSON array of sync log objects with `status: "success"` and `recordsCount: 0`
|
||||
|
||||
+- [ ] **Step 3: Check sync logs endpoint**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3002/api/sync/logs | python3 -m json.tool 2>/dev/null | head -30
|
||||
```
|
||||
|
||||
Expected: Array of sync log entries with fields `platform`, `status`, `recordsCount`, `startedAt`, `finishedAt`
|
||||
|
||||
+- [ ] **Step 4: Verify server still serves other endpoints**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3002/api/students?pageSize=1 | python3 -m json.tool 2>/dev/null | head -10
|
||||
```
|
||||
|
||||
Expected: Normal student list response (no regression)
|
||||
|
||||
+- [ ] **Step 5: Stop server and commit verification**
|
||||
|
||||
```bash
|
||||
kill %1 2>/dev/null
|
||||
# If all checks passed, no additional commits needed
|
||||
```
|
||||
@@ -1,280 +0,0 @@
|
||||
# 教师管理页 Implementation Plan
|
||||
|
||||
> **For agentic workers:** Use subagent-driven-development. Steps use checkbox syntax.
|
||||
|
||||
**Goal:** Add admin-facing teacher management: backend teacher list/profile API + frontend Teachers page with list, filter, profile edit.
|
||||
|
||||
**Architecture:** Add `GET /teachers` and `PUT /teachers/:id/profile` to RBAC controller (teachers are RBAC-managed users). Frontend follows existing page pattern (Users page as template).
|
||||
|
||||
**Tech Stack:** NestJS 11 + TypeORM + React 19 + Ant Design 6
|
||||
|
||||
## Global Constraints
|
||||
|
||||
+- Follow existing patterns: Users page for frontend layout, RBAC controller for teacher endpoints
|
||||
+- Teacher = any user whose roles include teacher-adjacent roles (code: 'teacher', plus any with class_teacher assignments)
|
||||
+- Profile field is `simple-json` — edit via a text area or structured form
|
||||
+- Include class assignments from ClassTeacher join in the list response
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — Teacher List + Profile API
|
||||
|
||||
**Files:**
|
||||
+- Modify: `apps/server/src/rbac/rbac.controller.ts` (add endpoints)
|
||||
+- Modify: `apps/server/src/rbac/rbac.service.ts` (add queries)
|
||||
|
||||
**Interfaces:**
|
||||
+- Produces: `GET /teachers` → `{ list: TeacherRow[]; total: number }`
|
||||
+- Produces: `PUT /teachers/:id/profile` → updated User
|
||||
+- Consumes: User, Role, ClassTeacher repos
|
||||
|
||||
+- [ ] **Step 1: Add getTeachers() to RbacService**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/rbac/rbac.service.ts — add method
|
||||
|
||||
async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) {
|
||||
const qb = this.userRepo
|
||||
.createQueryBuilder('u')
|
||||
.leftJoin('u.roles', 'role')
|
||||
.leftJoin(ClassTeacher, 'ct', 'ct.userId = u.id')
|
||||
.leftJoin('ct.class', 'c')
|
||||
.select([
|
||||
'u.id', 'u.username', 'u.name', 'u.isActive', 'u.profile', 'u.lastLoginAt',
|
||||
'role.code', 'role.name',
|
||||
'ct.id', 'ct.roleType', 'ct.subject',
|
||||
'c.id', 'c.name',
|
||||
])
|
||||
.where('role.code IN (:...roles)', { roles: ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'] });
|
||||
|
||||
if (query?.search) {
|
||||
qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` });
|
||||
}
|
||||
|
||||
const total = await qb.getCount();
|
||||
const raw = await qb
|
||||
.orderBy('u.name', 'ASC')
|
||||
.skip(((query?.page || 1) - 1) * (query?.pageSize || 20))
|
||||
.take(query?.pageSize || 20)
|
||||
.getMany();
|
||||
|
||||
// Group class assignments per user
|
||||
const list = raw.map((u: any) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
isActive: u.isActive,
|
||||
profile: u.profile,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
roles: (u.roles || []).map((r: any) => ({ code: r.code, name: r.name })),
|
||||
classAssignments: (u.__ct__ || []).map((ct: any) => ({
|
||||
roleType: ct.roleType,
|
||||
subject: ct.subject,
|
||||
className: ct.__class__?.name || null,
|
||||
})),
|
||||
}));
|
||||
|
||||
return { list, total };
|
||||
}
|
||||
```
|
||||
|
||||
+- [ ] **Step 2: Add updateTeacherProfile() to RbacService**
|
||||
|
||||
```typescript
|
||||
async updateTeacherProfile(id: number, profile: { subjects?: string[]; joinedAt?: string; qualifications?: string }) {
|
||||
const user = await this.userRepo.findOne({ where: { id } });
|
||||
if (!user) throw new NotFoundException('用户不存在');
|
||||
user.profile = { ...user.profile, ...profile };
|
||||
return this.userRepo.save(user);
|
||||
}
|
||||
```
|
||||
|
||||
+- [ ] **Step 3: Add controller endpoints**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/rbac/rbac.controller.ts — add endpoints
|
||||
|
||||
@Get('teachers')
|
||||
@RequirePermission('user:view')
|
||||
async getTeachers(@Query('search') search?: string, @Query('page') page?: number, @Query('pageSize') pageSize?: number) {
|
||||
return this.rbacService.getTeachers({ search, page: page ? +page : undefined, pageSize: pageSize ? +pageSize : undefined });
|
||||
}
|
||||
|
||||
@Put('teachers/:id/profile')
|
||||
@RequirePermission('user:edit')
|
||||
async updateTeacherProfile(@Param('id') id: string, @Body() profile: any, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.rbacService.updateTeacherProfile(+id, profile);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id, username: req.user?.username,
|
||||
module: '教师管理', action: '编辑档案',
|
||||
targetId: +id, targetType: 'user',
|
||||
detail: `更新教师档案`,
|
||||
ipAddress, userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
+- [ ] **Step 4: Verify**
|
||||
|
||||
`cd apps/server && npx tsc --noEmit 2>&1 | grep -v spec.ts | grep "error TS" | head -5`
|
||||
Expected: no errors
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — Teachers Management Page
|
||||
|
||||
**Files:**
|
||||
+- Create: `apps/admin/src/pages/Teachers/index.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
+- Consumes: `GET /teachers`, `PUT /teachers/:id/profile`
|
||||
+- Produces: Full page with search, table, profile edit modal
|
||||
|
||||
+- [ ] **Step 1: Create the Teachers page**
|
||||
|
||||
Follow the Users page pattern. Key elements:
|
||||
- Search bar (name/username)
|
||||
- Table columns: 姓名, 用户名, 角色(多个Tag), 任课班级(多个Tag), 科目, 入职日期, 状态, 最后登录, 操作
|
||||
- Click "编辑档案" → modal with form fields: subjects (Select mode="tags"), joinedAt (DatePicker), qualifications (Input.TextArea)
|
||||
- Click row → expand to show class assignments detail
|
||||
|
||||
Core structure (abbreviated — implement full component):
|
||||
|
||||
```tsx
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Input, Button, Modal, Form, Select, DatePicker, Tag, Space, message } from 'antd';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
|
||||
interface TeacherRow {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
profile: { subjects?: string[]; joinedAt?: string; qualifications?: string } | null;
|
||||
lastLoginAt: string;
|
||||
roles: { code: string; name: string }[];
|
||||
classAssignments: { roleType: string; subject: string; className: string | null }[];
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
super_admin: '超管', teacher: '老师', class_teacher: '班主任',
|
||||
dormitory_supervisor: '宿管', institution_head: '机构负责人',
|
||||
};
|
||||
|
||||
const TeachersPage: React.FC = () => {
|
||||
const [data, setData] = useState<TeacherRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [profileModal, setProfileModal] = useState<TeacherRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ list: TeacherRow[]; total: number }>('/rbac/teachers', { params: { search: search || undefined, page, pageSize: 20 } });
|
||||
setData(res.list);
|
||||
setTotal(res.total);
|
||||
} catch { /* silent */ }
|
||||
setLoading(false);
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
const values = await form.validateFields();
|
||||
await api.put(`/rbac/teachers/${profileModal!.id}/profile`, {
|
||||
subjects: values.subjects || [],
|
||||
joinedAt: values.joinedAt?.format('YYYY-MM-DD'),
|
||||
qualifications: values.qualifications,
|
||||
});
|
||||
message.success('已更新');
|
||||
setProfileModal(null);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '用户名', dataIndex: 'username', width: 120 },
|
||||
{
|
||||
title: '角色', dataIndex: 'roles', width: 200,
|
||||
render: (roles: TeacherRow['roles']) => roles.map(r => <Tag key={r.code}>{ROLE_LABELS[r.code] || r.name}</Tag>),
|
||||
},
|
||||
{
|
||||
title: '任课班级', dataIndex: 'classAssignments', width: 200,
|
||||
render: (ca: TeacherRow['classAssignments']) => ca?.length ? ca.map((a, i) => <Tag key={i}>{a.className || '-'}</Tag>) : '-',
|
||||
},
|
||||
{
|
||||
title: '科目', dataIndex: 'profile', width: 120,
|
||||
render: (p: TeacherRow['profile']) => p?.subjects?.join('、') || '-',
|
||||
},
|
||||
{
|
||||
title: '入职日期', dataIndex: 'profile', width: 110,
|
||||
render: (p: TeacherRow['profile']) => p?.joinedAt || '-',
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '在职' : '停用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最后登录', dataIndex: 'lastLoginAt', width: 160,
|
||||
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-',
|
||||
},
|
||||
{
|
||||
title: '操作', width: 100,
|
||||
render: (_: unknown, r: TeacherRow) => (
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => { setProfileModal(r); form.setFieldsValue({ subjects: r.profile?.subjects || [], joinedAt: r.profile?.joinedAt ? dayjs(r.profile.joinedAt) : null, qualifications: r.profile?.qualifications || '' }); }}>
|
||||
档案
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 16 }}>教师管理</h2>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Input.Search placeholder="搜索姓名/用户名" allowClear onSearch={setSearch} style={{ width: 200 }} />
|
||||
</Space>
|
||||
<Table columns={columns} dataSource={data} rowKey="id" loading={loading}
|
||||
pagination={{ current: page, pageSize: 20, total, onChange: setPage }} />
|
||||
<Modal title="编辑教师档案" open={!!profileModal} onOk={handleSaveProfile} onCancel={() => setProfileModal(null)}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="subjects" label="任教学科">
|
||||
<Select mode="tags" placeholder="输入学科后回车" />
|
||||
</Form.Item>
|
||||
<Form.Item name="joinedAt" label="入职日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="qualifications" label="资质/备注">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeachersPage;
|
||||
```
|
||||
|
||||
+- [ ] **Step 2: Add route in App.tsx**
|
||||
|
||||
In `apps/admin/src/App.tsx`, add route for `/teachers` pointing to `TeachersPage`.
|
||||
|
||||
+- [ ] **Step 3: Verify frontend compiles**
|
||||
|
||||
`cd apps/admin && npx tsc --noEmit 2>&1 | head -10`
|
||||
Expected: no new errors
|
||||
|
||||
+- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/rbac/ apps/admin/src/pages/Teachers/ apps/admin/src/App.tsx
|
||||
git commit -m "feat: add teacher management page with profile editing"
|
||||
```
|
||||
@@ -1,268 +0,0 @@
|
||||
# 学生档案报告前端预览 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将档案报告从后端 Puppeteer 生成 PDF 改为后端生成 HTML、前端新窗口预览、浏览器打印出 PDF。
|
||||
|
||||
**Architecture:** 后端新增 `/archive/:studentId/report-html` 返回 HTML 字符串(复用现有 `buildHtml()` 方法),前端 `fetch` 后在新窗口渲染。移除 Puppeteer 依赖和原 PDF 下载端点。
|
||||
|
||||
**Tech Stack:** NestJS 11 + TypeORM + React 19 + Ant Design 6
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 复用现有 `buildHtml()` / `buildCover()` / `css()` 等 HTML 构造方法,不做任何样式改动
|
||||
- 所有写操作记录日志
|
||||
- 前端遵循现有页面模式
|
||||
- Docker 镜像需移除 Chromium 相关依赖
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端新增 report-html 接口
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/archive/archive-report.service.ts`
|
||||
- Modify: `apps/server/src/archive/archive.controller.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ArchiveReportService.generateReportHtml(studentId: number): Promise<string>`
|
||||
- Produces: `GET /archive/:studentId/report-html` → `{ html: string }`
|
||||
|
||||
- [ ] **Step 1: 在 ArchiveReportService 新增 generateReportHtml 方法**
|
||||
|
||||
在 `archive-report.service.ts` 的 `generateReport` 方法之后,新增:
|
||||
|
||||
```typescript
|
||||
async generateReportHtml(studentId: number): Promise<string> {
|
||||
const [student, profile, enrollments, exams, learnings, result, attendances] =
|
||||
await Promise.all([
|
||||
this.studentRepo.findOne({ where: { id: studentId } }),
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { startDate: 'ASC' } }),
|
||||
this.examRepo.find({ where: { studentId }, order: { examDate: 'ASC' } }),
|
||||
this.learningRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attendanceRepo.find({ where: { studentId }, order: { attendanceDate: 'ASC' } }),
|
||||
]);
|
||||
|
||||
if (!student) throw new Error('学生不存在');
|
||||
|
||||
const data: ReportData = {
|
||||
student,
|
||||
profile,
|
||||
enrollments,
|
||||
exams,
|
||||
learnings,
|
||||
result,
|
||||
attendances,
|
||||
};
|
||||
|
||||
return this.buildHtml(data);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 ArchiveController 新增 report-html 端点**
|
||||
|
||||
在 `archive.controller.ts` 中,`generateReport` 方法之后新增:
|
||||
|
||||
```typescript
|
||||
@Get(':studentId/report-html')
|
||||
@RequirePermission('student:view')
|
||||
async getReportHtml(
|
||||
@Param('studentId') studentId: string,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: 'archive',
|
||||
action: 'preview_report',
|
||||
targetId: +studentId,
|
||||
targetType: 'student',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
const html = await this.reportService.generateReportHtml(+studentId);
|
||||
return { html };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证后端接口**
|
||||
|
||||
```bash
|
||||
# 启动后端后测试
|
||||
curl -H "Authorization: Bearer <token>" http://localhost:3000/api/archive/1/report-html
|
||||
```
|
||||
|
||||
预期返回 `{ "html": "<!DOCTYPE html>..." }`,HTML 内容完整。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/archive/archive-report.service.ts apps/server/src/archive/archive.controller.ts
|
||||
git commit -m "feat: add GET /archive/:studentId/report-html endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 前端按钮改为预览报告
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/StudentProfile/index.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /archive/:studentId/report-html` → `{ html: string }`
|
||||
|
||||
- [ ] **Step 1: 修改按钮行为**
|
||||
|
||||
将 `handleDownloadReport` 替换为 `handlePreviewReport`,打开新窗口渲染 HTML:
|
||||
|
||||
```typescript
|
||||
const handlePreviewReport = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch(`/api/archive/${studentId}/report-html`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const { html } = await res.json();
|
||||
const w = window.open('', '_blank');
|
||||
if (w) {
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
将按钮文本和图标改为预览:
|
||||
|
||||
```tsx
|
||||
extra={
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
type="primary"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={handlePreviewReport}
|
||||
>
|
||||
预览报告
|
||||
</PermissionButton>
|
||||
}
|
||||
```
|
||||
|
||||
需要在文件顶部 import 添加 `EyeOutlined`(已有 `DownloadOutlined` 可删除)。
|
||||
|
||||
- [ ] **Step 2: 浏览器验证**
|
||||
|
||||
```bash
|
||||
# 启动前端 dev server
|
||||
cd apps/admin && npm run dev
|
||||
```
|
||||
|
||||
1. 打开学生档案页面
|
||||
2. 点击「预览报告」按钮
|
||||
3. 确认新窗口打开完整报告,样式正确
|
||||
4. 新窗口 Ctrl+P → 确认打印预览分页正常
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/admin/src/pages/StudentProfile/index.tsx
|
||||
git commit -m "feat: change report button from download to preview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 移除 Puppeteer 和旧 PDF 下载端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/archive/archive-report.service.ts`
|
||||
- Modify: `apps/server/src/archive/archive.controller.ts`
|
||||
- Modify: `apps/server/package.json`
|
||||
- Modify: `apps/server/Dockerfile`
|
||||
|
||||
**Interfaces:**
|
||||
- Removes: `ArchiveReportService.generateReport(studentId, res)` — Puppeteer PDF 生成
|
||||
- Removes: `GET /archive/:studentId/report` — PDF 下载端点
|
||||
- Removes: `puppeteer` npm 依赖
|
||||
|
||||
- [ ] **Step 1: 删除 generateReport 方法**
|
||||
|
||||
在 `archive-report.service.ts` 中删除 `generateReport(res: Response)` 方法(第 36-85 行),包括方法内所有 Puppeteer 相关逻辑。
|
||||
|
||||
同步删除文件顶部的两个不再需要的 import:
|
||||
|
||||
```typescript
|
||||
// 删除这两行
|
||||
import puppeteer from 'puppeteer';
|
||||
import { Response } from 'express';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 删除旧的 report 端点**
|
||||
|
||||
在 `archive.controller.ts` 中删除 `GET /archive/:studentId/report` 的 `generateReport` 方法(第 343-362 行)。
|
||||
|
||||
同步删除 `@Res` 装饰器的 import(检查 `@Res` 是否被其他地方使用,如果只在 `generateReport` 中使用,则一并移除)。
|
||||
|
||||
- [ ] **Step 3: 移除 puppeteer 依赖**
|
||||
|
||||
```bash
|
||||
cd apps/server && npm uninstall puppeteer
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 清理 Dockerfile**
|
||||
|
||||
读取 `apps/server/Dockerfile`,移除 Chromium/ Puppeteer 相关依赖安装。常见需要移除的:
|
||||
- `chromium` / `chromium-browser` 等包
|
||||
- Puppeteer 相关环境变量如 `PUPPETEER_SKIP_CHROMIUM_DOWNLOAD`
|
||||
|
||||
- [ ] **Step 5: 验证构建**
|
||||
|
||||
```bash
|
||||
cd apps/server && npm run build
|
||||
```
|
||||
|
||||
确认编译通过,无 puppeteer 相关 import 错误。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/archive/archive-report.service.ts apps/server/src/archive/archive.controller.ts apps/server/package.json apps/server/package-lock.json apps/server/Dockerfile
|
||||
git commit -m "refactor: remove Puppeteer, use frontend browser print for PDF"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 端到端验证
|
||||
|
||||
- [ ] **Step 1: 启动完整环境**
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证功能**
|
||||
|
||||
1. 登录系统 → 学生列表 → 进入某个学生档案
|
||||
2. 点击「预览报告」→ 新窗口打开
|
||||
3. 检查报告完整性:封面、基础信息、考试成绩总览(含 SVG 趋势图)、出勤记录(含 SVG 柱状图)、文化课明细、学情记录与录取归档
|
||||
4. 新窗口 Ctrl+P → 另存为 PDF
|
||||
5. 确认 PDF 内容与预览一致
|
||||
|
||||
- [ ] **Step 3: 验证无回归**
|
||||
|
||||
- 基础档案 Tab CRUD 正常
|
||||
- 报读记录/考试成绩/学情记录/录取结果 增删改正常
|
||||
- 附件上传/删除正常
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
- [x] 新增 `/report-html` 端点 → Task 1 Step 2
|
||||
- [x] 复用现有 buildHtml → Task 1 Step 1
|
||||
- [x] 删除 Puppeteer 和旧端点 → Task 3
|
||||
- [x] 前端按钮改为预览 → Task 2
|
||||
- [x] 验收标准全部覆盖 → Task 4
|
||||
|
||||
**2. Placeholder scan:** 无 TBD/TODO/占位符。
|
||||
|
||||
**3. Type consistency:** `generateReportHtml` 签名在三处一致:Service 定义、Controller 调用、接口文档。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,805 +0,0 @@
|
||||
# 钉钉导入标记班级 — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在钉钉组织导入抽屉中支持标记部门为班级,导入时自动创建班级并建立师生关联。
|
||||
|
||||
**Architecture:** 前端在 IntegrationConfig 抽屉中添加部门级「标为班级」按钮和 Modal 表单;后端 `importDingTalkUsers` 方法接收可选的 `classes[]` 参数,在单事务中先建班、再导人、最后建关联。导入时勾选的用户分配角色后即为老师,所有老师统一写入 `ClassTeacher`(`roleType='teacher'`),班级对老师为多对多。
|
||||
|
||||
**Tech Stack:** React 19 + Ant Design 6 (前端), NestJS 11 + TypeORM 0.3 (后端), SQLite/MySQL
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 规范约束自 `CLAUDE.md`(恭学教育学生管理系统 — 项目约束)
|
||||
- 前端编码 agent 需注入 `ui-ux-pro-max`(Ant Design 6 交互规范)和 `vercel-react-best-practices`(性能优化)
|
||||
- 后端编码 agent 需注入 `nestjs-best-practices`
|
||||
- 所有编辑遵循现有 NestJS 模块结构
|
||||
- 敏感信息脱敏规则照旧(不涉及本次改动)
|
||||
- 遵循 skil `ponytail` full 级别约束:最简实现,不引入新依赖,不创建不必要的抽象
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 — 扩展 DTO 和钉钉接口返回 deptIds
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/sync/dto/import-users.dto.ts`
|
||||
- Modify: `apps/server/src/integration/dingtalk.service.ts:486-499`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 现有 `ImportUserItemDto`, `DingOrgTreeNodeWithUsers`
|
||||
- Produces: `ImportClassItemDto`, `ImportUserItemDto.dingDeptIds`, `DingOrgTreeNodeWithUsers.users[].deptIds`
|
||||
|
||||
- [ ] **Step 1: 扩展 ImportUsersDto,新增 ImportClassItemDto**
|
||||
|
||||
编辑 `apps/server/src/sync/dto/import-users.dto.ts`,在现有 `ImportUserItemDto` 中加 `dingDeptIds` 字段,新增 `ImportClassItemDto` 和 `ImportUsersDto.classes`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
IsArray,
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class ImportUserItemDto {
|
||||
@IsString()
|
||||
dingUserId: string;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
mobile: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
roleId: number | null;
|
||||
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
dingDeptIds: number[];
|
||||
}
|
||||
|
||||
export class ImportClassItemDto {
|
||||
@IsNumber()
|
||||
deptId: number;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
classType: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
maxStudents?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class ImportUsersDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImportClassItemDto)
|
||||
classes?: ImportClassItemDto[];
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImportUserItemDto)
|
||||
users: ImportUserItemDto[];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: dingtalk.service.ts — fetchOrgTreeWithUsers 返回 deptIds**
|
||||
|
||||
编辑 `apps/server/src/integration/dingtalk.service.ts`,在 `fetchOrgTreeWithUsers` 方法中保留 `dept_id_list` 到每个 user。
|
||||
|
||||
找到第 493-498 行的 users mapping,改为:
|
||||
|
||||
```ts
|
||||
// Before dedup: collect deptIds per user
|
||||
const userDeptMap = new Map<string, number[]>();
|
||||
|
||||
nodes.push({
|
||||
id: detail.dept_id,
|
||||
name: detail.name,
|
||||
parentId: detail.parent_id,
|
||||
children: [],
|
||||
users: dingUsers.map((u) => ({
|
||||
userid: u.userid,
|
||||
name: u.name,
|
||||
mobile: u.mobile,
|
||||
})),
|
||||
});
|
||||
|
||||
// Record which departments each user belongs to
|
||||
for (const u of dingUsers) {
|
||||
if (!userDeptMap.has(u.userid)) {
|
||||
userDeptMap.set(u.userid, []);
|
||||
}
|
||||
userDeptMap.get(u.userid)!.push(detail.dept_id);
|
||||
}
|
||||
```
|
||||
|
||||
然后在去重循环后(第 503-509 行),为每个 user 附加 deptIds:
|
||||
|
||||
```ts
|
||||
for (const node of nodes) {
|
||||
node.users = node.users
|
||||
.filter((u) => {
|
||||
if (seenUserIds.has(u.userid)) return false;
|
||||
seenUserIds.add(u.userid);
|
||||
return true;
|
||||
})
|
||||
.map((u) => ({
|
||||
...u,
|
||||
deptIds: userDeptMap.get(u.userid) || [],
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
同步更新 `DingOrgTreeNodeWithUsers` interface:
|
||||
|
||||
```ts
|
||||
export interface DingOrgTreeNodeWithUsers {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNodeWithUsers[];
|
||||
users: Array<{
|
||||
userid: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
deptIds: number[];
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no new type errors from the modified files.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/sync/dto/import-users.dto.ts apps/server/src/integration/dingtalk.service.ts
|
||||
git commit -m "feat(sync): add ImportClassItemDto and expose deptIds in org-tree-with-users"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端 — 改造 importDingTalkUsers 支持班级关联
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/sync/sync.service.ts:122-199`
|
||||
- Modify: `apps/server/src/sync/sync.module.ts:16-34`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ImportClassItemDto`, `ImportUserItemDto.dingDeptIds` (from Task 1)
|
||||
- Produces: 改造后的 `importDingTalkUsers(classes?: ImportClassItemDto[], users: ImportUserItemDto[])`,返回增加 `classCount`
|
||||
|
||||
- [ ] **Step 1: sync.module.ts — 注入 Class 和 ClassStudent Repository**
|
||||
|
||||
SyncModule 当前未导入 `Class` 和 `ClassStudent` entity。编辑 `apps/server/src/sync/sync.module.ts`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
SyncLog, SyncState, UserDingMapping, ClassSchedule, Department,
|
||||
UserDepartment, ClassTeacher, User, Student, Role,
|
||||
Class, // 新增
|
||||
ClassStudent, // 新增
|
||||
} from '../entities';
|
||||
```
|
||||
|
||||
并在 `TypeOrmModule.forFeature` 数组中添加 `Class, ClassStudent`。
|
||||
|
||||
- [ ] **Step 2: sync.service.ts — constructor 注入新 repo**
|
||||
|
||||
编辑 `apps/server/src/sync/sync.service.ts`:
|
||||
|
||||
```ts
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
import type { ImportClassItemDto } from './dto/import-users.dto';
|
||||
```
|
||||
|
||||
Constructor 添加:
|
||||
|
||||
```ts
|
||||
@InjectRepository(Class)
|
||||
private readonly classRepo: Repository<Class>,
|
||||
@InjectRepository(ClassStudent)
|
||||
private readonly classStudentRepo: Repository<ClassStudent>,
|
||||
```
|
||||
|
||||
更新 `ImportUserDto` 接口以包含 `dingDeptIds`:
|
||||
|
||||
```ts
|
||||
export interface ImportUserDto {
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
roleId: number | null;
|
||||
dingDeptIds: number[];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 改写 importDingTalkUsers 方法签名和逻辑**
|
||||
|
||||
将方法签名改为:
|
||||
|
||||
```ts
|
||||
async importDingTalkUsers(
|
||||
users: ImportUserDto[],
|
||||
classes?: ImportClassItemDto[],
|
||||
): Promise<{
|
||||
teacherCount: number;
|
||||
studentCount: number;
|
||||
classCount: number;
|
||||
skipped: number;
|
||||
warnings: string[];
|
||||
}>
|
||||
```
|
||||
|
||||
完整方法体替换为单事务版本:
|
||||
|
||||
```ts
|
||||
async importDingTalkUsers(
|
||||
users: ImportUserDto[],
|
||||
classes?: ImportClassItemDto[],
|
||||
): Promise<{
|
||||
teacherCount: number;
|
||||
studentCount: number;
|
||||
classCount: number;
|
||||
skipped: number;
|
||||
warnings: string[];
|
||||
}> {
|
||||
const classItems = classes ?? [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 预检查班级编码重复
|
||||
if (classItems.length > 0) {
|
||||
const codes = classItems.map((c) => c.code);
|
||||
const existing = await this.classRepo.find({ where: codes.map((code) => ({ code } as any)) });
|
||||
if (existing.length > 0) {
|
||||
const dup = existing.map((c) => c.code).join(', ');
|
||||
throw new BadRequestException(`班级编码已存在: ${dup}`);
|
||||
}
|
||||
}
|
||||
|
||||
let teacherCount = 0;
|
||||
let studentCount = 0;
|
||||
let skipped = 0;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
// 1. 创建班级
|
||||
const deptClassMap = new Map<number, number>(); // deptId -> classId
|
||||
for (const c of classItems) {
|
||||
const cls = manager.create(Class, {
|
||||
name: c.name,
|
||||
code: c.code,
|
||||
classType: c.classType,
|
||||
startDate: c.startDate ?? null,
|
||||
endDate: c.endDate ?? null,
|
||||
maxStudents: c.maxStudents ?? 0,
|
||||
notes: c.notes ?? null,
|
||||
} as any);
|
||||
await manager.save(cls);
|
||||
deptClassMap.set(c.deptId, cls.id);
|
||||
}
|
||||
|
||||
// 2. 导入用户(逐用户)
|
||||
for (const u of users) {
|
||||
const existingMapping = await manager.findOne(UserDingMapping, {
|
||||
where: { dingUserId: u.dingUserId },
|
||||
});
|
||||
if (existingMapping) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const username = `dd_${u.dingUserId}`;
|
||||
const passwordHash = await bcrypt.hash('123456', 10);
|
||||
|
||||
const user = manager.create(User, {
|
||||
username,
|
||||
name: u.name,
|
||||
passwordHash,
|
||||
isActive: true,
|
||||
});
|
||||
await manager.save(user);
|
||||
|
||||
let isTeacher = false;
|
||||
let isHeadTeacher = false;
|
||||
|
||||
if (u.roleId != null) {
|
||||
const role = await manager.findOne(Role, { where: { id: u.roleId } });
|
||||
if (!role) {
|
||||
throw new BadRequestException(`角色 id=${u.roleId} 不存在`);
|
||||
}
|
||||
user.roles = [role];
|
||||
let isTeacher = false;
|
||||
|
||||
if (u.roleId != null) {
|
||||
const role = await manager.findOne(Role, { where: { id: u.roleId } });
|
||||
if (!role) {
|
||||
throw new BadRequestException(`角色 id=${u.roleId} 不存在`);
|
||||
}
|
||||
user.roles = [role];
|
||||
await manager.save(user);
|
||||
isTeacher = true;
|
||||
teacherCount++;
|
||||
} else {
|
||||
const student = manager.create(Student, {
|
||||
name: u.name,
|
||||
phone: u.mobile || undefined,
|
||||
userId: user.id,
|
||||
status: 'active',
|
||||
});
|
||||
await manager.save(student);
|
||||
studentCount++;
|
||||
}
|
||||
|
||||
// 钉钉映射
|
||||
const mapping = manager.create(UserDingMapping, {
|
||||
dingUserId: u.dingUserId,
|
||||
userId: user.id,
|
||||
dingName: u.name,
|
||||
dingMobile: u.mobile,
|
||||
});
|
||||
await manager.save(mapping);
|
||||
|
||||
// 3. 建立班级关联
|
||||
if (classItems.length > 0 && u.dingDeptIds?.length > 0) {
|
||||
for (const deptId of u.dingDeptIds) {
|
||||
const classId = deptClassMap.get(deptId);
|
||||
if (!classId) continue;
|
||||
|
||||
if (isTeacher) {
|
||||
const ct = manager.create(ClassTeacher, {
|
||||
classId,
|
||||
userId: user.id,
|
||||
roleType: 'teacher',
|
||||
} as any);
|
||||
await manager.save(ct);
|
||||
} else {
|
||||
const cs = manager.create(ClassStudent, {
|
||||
classId,
|
||||
studentId: (await manager.findOne(Student, { where: { userId: user.id } }))?.id,
|
||||
status: 'active',
|
||||
} as any);
|
||||
await manager.save(cs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检查空班级
|
||||
for (const [deptId, classId] of deptClassMap) {
|
||||
const tc = await manager.count(ClassTeacher, { where: { classId } });
|
||||
const sc = await manager.count(ClassStudent, { where: { classId } });
|
||||
if (tc === 0 && sc === 0) {
|
||||
const cls = await manager.findOne(Class, { where: { id: classId } });
|
||||
warnings.push(`班级 "${cls?.name}" (deptId=${deptId}) 无任何师生`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${classItems.length} 个班级, ${skipped} 跳过`,
|
||||
);
|
||||
return { teacherCount, studentCount, classCount: classItems.length, skipped, warnings };
|
||||
}
|
||||
```
|
||||
|
||||
需要新增 import:
|
||||
|
||||
```ts
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编译验证**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 5: 更新 sync.controller.ts 调用方式**
|
||||
|
||||
`apps/server/src/sync/sync.controller.ts` 第 57 行:
|
||||
|
||||
```ts
|
||||
const result = await this.syncService.importDingTalkUsers(body.users);
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```ts
|
||||
const result = await this.syncService.importDingTalkUsers(body.users, body.classes);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/sync/sync.module.ts apps/server/src/sync/sync.service.ts apps/server/src/sync/sync.controller.ts
|
||||
git commit -m "feat(sync): importDingTalkUsers supports class creation and teacher/student linking"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 前端 — IntegrationConfig 树节点和班级标记 Modal
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 改造后的 `POST /sync/dingtalk/import-users` (classes + users),DingOrgTreeNodeExt 新增 deptIds
|
||||
- Produces: 树中部门节点可标为班级,Modal 表单,导入 payload 含 classes
|
||||
|
||||
**Skills to load before coding:**
|
||||
- `ui-ux-pro-max` — Ant Design 6 组件选型、交互细节
|
||||
- `vercel-react-best-practices` — memo、useMemo 避免无意义重渲染
|
||||
|
||||
- [ ] **Step 1: 扩展前端类型定义**
|
||||
|
||||
在 `IntegrationConfig/index.tsx` 的 interface 定义区域,修改 `DingOrgTreeNodeExt`:
|
||||
|
||||
```ts
|
||||
interface DingOrgTreeNodeExt {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNodeExt[];
|
||||
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
|
||||
}
|
||||
```
|
||||
|
||||
新增 class mark 表单类型和状态:
|
||||
|
||||
```ts
|
||||
interface ClassMarkForm {
|
||||
deptId: number;
|
||||
name: string;
|
||||
code: string;
|
||||
classType: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
maxStudents?: number;
|
||||
notes?: string;
|
||||
}
|
||||
```
|
||||
|
||||
在组件 state 区域(第 91-101 行附近)新增:
|
||||
|
||||
```ts
|
||||
const [classMarks, setClassMarks] = useState<Record<number, ClassMarkForm>>({});
|
||||
const [classModalOpen, setClassModalOpen] = useState(false);
|
||||
const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null);
|
||||
const [classForm] = Form.useForm<ClassMarkForm>();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 标记班级 Modal 组件**
|
||||
|
||||
在组件内部(`handleImportUsers` 之前)添加 Modal 处理函数:
|
||||
|
||||
```ts
|
||||
const openClassModal = (deptId: number, deptName: string) => {
|
||||
const existing = classMarks[deptId];
|
||||
if (existing) {
|
||||
classForm.setFieldsValue(existing);
|
||||
} else {
|
||||
classForm.setFieldsValue({
|
||||
deptId,
|
||||
name: deptName,
|
||||
code: '',
|
||||
classType: 'culture',
|
||||
});
|
||||
}
|
||||
setClassModalDept({ id: deptId, name: deptName });
|
||||
setClassModalOpen(true);
|
||||
};
|
||||
|
||||
const handleClassModalOk = async () => {
|
||||
const values = await classForm.validateFields();
|
||||
setClassMarks((prev) => ({
|
||||
...prev,
|
||||
[values.deptId]: values,
|
||||
}));
|
||||
setClassModalOpen(false);
|
||||
setClassModalDept(null);
|
||||
};
|
||||
|
||||
const handleClassModalCancel = () => {
|
||||
setClassModalOpen(false);
|
||||
setClassModalDept(null);
|
||||
};
|
||||
```
|
||||
|
||||
Modal JSX(放在 Drawer 之前或之后):
|
||||
|
||||
```tsx
|
||||
<Modal
|
||||
title={classMarks[classModalDept?.id ?? -1] ? '修改班级信息' : '标记为班级'}
|
||||
open={classModalOpen}
|
||||
onOk={handleClassModalOk}
|
||||
onCancel={handleClassModalCancel}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={classForm} layout="vertical">
|
||||
<Form.Item name="deptId" hidden><Input /></Form.Item>
|
||||
<Form.Item name="name" label="班级名称" rules={[{ required: true, message: '请输入班级名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="班级编码" rules={[{ required: true, message: '请输入班级编码' }]}>
|
||||
<Input placeholder="如 CS2024-01" />
|
||||
</Form.Item>
|
||||
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'culture', label: '文化课' },
|
||||
{ value: 'professional', label: '专业课' },
|
||||
{ value: 'bootcamp', label: '集训营' },
|
||||
{ value: 'sprint', label: '冲刺班' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="startDate" label="开班日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="endDate" label="结束日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxStudents" label="最大人数">
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0 表示不限制" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
需要新增 import:`Modal, DatePicker, InputNumber` from antd(已有 Modal、Drawer、Select、Input,检查是否缺少)。
|
||||
|
||||
- [ ] **Step 3: 树节点中嵌入班级操作**
|
||||
|
||||
在 `buildTreeData` 函数(约第 251 行)中,修改部门节点的 `title` 显示:
|
||||
|
||||
```ts
|
||||
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||||
return nodes.map((node) => ({
|
||||
title: (
|
||||
<Space size="small">
|
||||
<span>{node.name}</span>
|
||||
{classMarks[node.id] ? (
|
||||
<Tag
|
||||
color="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => openClassModal(node.id, node.name)}
|
||||
>
|
||||
班级: {classMarks[node.id].name} [已标记]
|
||||
</Tag>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<span>🏫</span>}
|
||||
onClick={() => openClassModal(node.id, node.name)}
|
||||
>
|
||||
标为班级
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
key: `dept-${node.id}`,
|
||||
children: [
|
||||
...buildTreeData(node.children),
|
||||
...node.users.map((u) => ({
|
||||
title: (
|
||||
<UserTreeNode
|
||||
key={u.userid}
|
||||
u={{ userid: u.userid, name: u.name, mobile: u.mobile }}
|
||||
isTeacher={!!teacherChecks[u.userid]}
|
||||
onToggle={() => { /* ... existing toggle logic ... */ }}
|
||||
roleId={teacherRoles[u.userid]}
|
||||
defaultRoleId={defaultTeacherRoleId}
|
||||
roles={roles}
|
||||
onRoleChange={(newRoleId) => { /* ... existing role change logic ... */ }}
|
||||
/>
|
||||
),
|
||||
key: `user-${u.userid}`,
|
||||
isLeaf: true,
|
||||
})),
|
||||
],
|
||||
}));
|
||||
}, [classMarks, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 修改 handleImportUsers payload**
|
||||
|
||||
编辑 `handleImportUsers`(约第 209 行),在 payload 中加上 classes:
|
||||
|
||||
```ts
|
||||
const payload = {
|
||||
classes: Object.values(classMarks),
|
||||
users: allUsers.map((u) => ({
|
||||
dingUserId: u.userid,
|
||||
name: u.name,
|
||||
mobile: u.mobile,
|
||||
roleId: teacherChecks[u.userid]
|
||||
? (teacherRoles[u.userid] || defaultTeacherRoleId)
|
||||
: null,
|
||||
dingDeptIds: u.deptIds || [], // 新增
|
||||
})),
|
||||
};
|
||||
```
|
||||
|
||||
注意:`allUsers` 需要在 flatten 时也收集 deptIds。修改 flatten:
|
||||
|
||||
```ts
|
||||
const flatten = (nodes: DingOrgTreeNodeExt[]) => {
|
||||
for (const node of nodes) {
|
||||
allUsers.push(...node.users);
|
||||
flatten(node.children);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
由于 `node.users` 现在包含 `deptIds`,`allUsers` 的类型需调整。修改 `allUsers` 声明:
|
||||
|
||||
```ts
|
||||
const allUsers: Array<{
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
deptIds: number[];
|
||||
}> = [];
|
||||
```
|
||||
|
||||
在 flatten 中 push 时展开正确字段:
|
||||
|
||||
```ts
|
||||
allUsers.push(
|
||||
...node.users.map((u) => ({
|
||||
dingUserId: u.userid,
|
||||
name: u.name,
|
||||
mobile: u.mobile,
|
||||
deptIds: u.deptIds || [],
|
||||
})),
|
||||
);
|
||||
```
|
||||
|
||||
然后 payload 中 `u.deptIds` 可用。
|
||||
|
||||
- [ ] **Step 5: 关闭抽屉时清理 classMarks**
|
||||
|
||||
在 `onClose` 处理中(已有 `setDrawerOpen(false)` 的地方)加:
|
||||
|
||||
```ts
|
||||
setClassMarks({});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 编译前端验证**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
修正所有类型错误。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/admin/src/pages/IntegrationConfig/index.tsx
|
||||
git commit -m "feat(admin): add class marking modal in DingTalk org import drawer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 后端 — 编写测试
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/sync/sync.service.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 改造后的 `importDingTalkUsers` (Task 2)
|
||||
- Produces: 7 个新测试用例
|
||||
|
||||
**Skills to load before coding:**
|
||||
- Tester agent — 但不在此处写测试,而是委托给 Tester 子代理
|
||||
|
||||
- [ ] **Step 1: 委托 Tester 子代理编写测试**
|
||||
|
||||
由于同步服务已有完善的 mock 基础设施,直接委托 Tester 代理根据 spec 编写以下用例:
|
||||
|
||||
1. **单部门标班级 + 1老师 + 1学生** — 验证 Class, ClassTeacher(roleType='teacher'), ClassStudent 均创建
|
||||
2. **单部门多老师** — 所有老师均写入 ClassTeacher
|
||||
3. **用户在多个被标记部门** — 同时加入多个班级的 ClassStudent 或 ClassTeacher
|
||||
4. **班级编码重复** — 事务回滚,抛出 BadRequestException
|
||||
5. **空部门(无人)** — 班级创建,返回 warning
|
||||
6. **纯学生无老师** — 班级创建,ClassStudent 正确
|
||||
7. **无 classes 参数** — 向下兼容,返回结果中 classCount=0
|
||||
|
||||
代理需扩展 mock Manager 以支持 `Class.create/save/findOne/count` 和 `ClassStudent.create/save/count`。
|
||||
|
||||
- [ ] **Step 2: 运行全部 sync 测试**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx jest --testPathPattern='sync.service.spec' --no-coverage
|
||||
```
|
||||
|
||||
Expected: 全部通过。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/sync/sync.service.spec.ts
|
||||
git commit -m "test(sync): add class-marking import test cases"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 端到端验证 & 清理
|
||||
|
||||
**Files:** 无新增,仅验证
|
||||
|
||||
- [ ] **Step 1: 启动后端**
|
||||
|
||||
```bash
|
||||
cd apps/server && npm run start:dev
|
||||
```
|
||||
|
||||
确认无启动错误。
|
||||
|
||||
- [ ] **Step 2: 启动前端**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 手动验证流程**
|
||||
|
||||
1. 打开浏览器 → 钉钉集成配置页
|
||||
2. 点击「获取组织架构」→ 确认部门节点显示「🏫 标为班级」按钮
|
||||
3. 点击按钮 → Modal 弹出,部门名已预填
|
||||
4. 填写编码、班型 → 确定 → 节点显示 `🏫 班级: XXX [已标记]`
|
||||
5. 在该部门下勾选一个用户为「老师」角色
|
||||
6. 点击「导入」→ 确认成功
|
||||
7. 到班级管理页验证:班级存在、老师关联正确、学生归属正确
|
||||
|
||||
- [ ] **Step 4: 验证向下兼容**
|
||||
|
||||
不标记任何班级,仅勾选老师学生 → 导入 → 确认行为不变。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
1. **Spec coverage**: DTO 扩展 ✓, 服务层逻辑 ✓, 前端树节点 ✓, Modal 表单 ✓, 导入 payload ✓, 错误场景 ✓, 测试策略 ✓
|
||||
2. **Placeholder scan**: 无 TBD/TODO,所有代码块均为具体实现
|
||||
3. **Type consistency**: `ImportUserDto.dingDeptIds` 在 Task 1 DTO 和 Task 2 service 中类型一致;`ClassMarkForm` 在 Task 3 定义和使用一致
|
||||
@@ -1,324 +0,0 @@
|
||||
# DingTalk 导入链路修复 — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 修复钉钉导入链路的两个阻塞 bug:前端叶子节点判断、后端 CampusScope 过滤导致学生/考勤数据不可见。
|
||||
|
||||
**Architecture:** 教学域(学生、考勤)移除 CampusScope 部门过滤,改为依赖 RBAC 权限码控制访问;管理域(宿舍、财务)保留 CampusScope 不变。
|
||||
|
||||
**Tech Stack:** React 19 + TypeScript + NestJS 11 + TypeORM
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 遵循现有 NestJS 模块结构
|
||||
- 后端编译检查:`npx tsc --noEmit -p tsconfig.build.json`
|
||||
- 前端编译检查:`npx tsc --noEmit`
|
||||
- 不改动 `syncAll`、`importDingTalkUsers`、RBAC 权限码
|
||||
- 不改动 `OccupanciesService`、`RoomsService`、`BillsService`、`ExpensesService`(保留 CampusScope)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 前端 — 叶子节点判断
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx:375-393`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DingOrgTreeNodeExt` (has `children: DingOrgTreeNodeExt[]`, `users: Array<...>`)
|
||||
- Produces: `DataNode[]` tree nodes with conditional class-mark button
|
||||
|
||||
- [ ] **Step 1: Read current code to confirm line numbers**
|
||||
|
||||
Run: `read apps/admin/src/pages/IntegrationConfig/index.tsx:373-395`
|
||||
|
||||
- [ ] **Step 2: Modify buildTreeData — add leaf-node condition**
|
||||
|
||||
当前 `node.children` 和 `node.users` 渲染逻辑不需要改,只需要在 `title` 渲染部分(第 373–395 行)用条件包裹"标为班级"按钮:
|
||||
|
||||
```tsx
|
||||
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||||
return nodes.map((node) => ({
|
||||
title: (
|
||||
<Space size="small">
|
||||
<span>{node.name}</span>
|
||||
{node.children.length === 0 && node.users.length > 0 && (
|
||||
classMarks[node.id] ? (
|
||||
<Tag
|
||||
color="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => openClassModal(node.id, node.name)}
|
||||
>
|
||||
班级: {classMarks[node.id].name} [已标记]
|
||||
</Tag>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<span>🏫</span>}
|
||||
onClick={() => openClassModal(node.id, node.name)}
|
||||
>
|
||||
标为班级
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
key: `dept-${node.id}`,
|
||||
children: [
|
||||
...buildTreeData(node.children),
|
||||
...node.users.map((u) => ({ ... })),
|
||||
],
|
||||
}));
|
||||
}, [classMarks, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
|
||||
```
|
||||
|
||||
改动要点:用 `{node.children.length === 0 && node.users.length > 0 && ( ... )}` 包裹原来的三元表达式。只有无子部门且有用户的叶子节点才显示按钮。
|
||||
|
||||
- [ ] **Step 3: 验证 TypeScript 编译**
|
||||
|
||||
Run: `cd /Users/tiku1/code/gongxue-base/apps/admin && npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/admin/src/pages/IntegrationConfig/index.tsx
|
||||
git commit -m "fix(admin): only show class-mark button on leaf departments in DingTalk org import"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端 — StudentsService 移除 CampusScope
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/students/students.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: (none external — internal change only)
|
||||
- Produces: `findAll(query)` returns `Promise<Student[]>` — same signature, no scope filtering
|
||||
|
||||
- [ ] **Step 1: Read current code**
|
||||
|
||||
Run: `read apps/server/src/students/students.service.ts`
|
||||
|
||||
- [ ] **Step 2: Remove CampusScope import**
|
||||
|
||||
Delete line 4:
|
||||
```typescript
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove constructor parameter**
|
||||
|
||||
SWAP lines 14-20 — remove `private readonly scope: CampusScope` and trailing comma on line 18:
|
||||
|
||||
```typescript
|
||||
constructor(
|
||||
@InjectRepository(Student) private repo: Repository<Student>,
|
||||
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||
) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove scope.filter call in findAll**
|
||||
|
||||
SWAP lines 22-33 — remove the `filteredWhere` intermediate. `findAll` now uses `where` directly:
|
||||
|
||||
```typescript
|
||||
async findAll(query?: { name?: string; status?: string; includeArchived?: boolean; tenantId?: number | string }) {
|
||||
const where: FindOptionsWhere<Student> = {};
|
||||
if (query?.name) where.name = Like(`%${query.name}%`);
|
||||
if (query?.tenantId) where.tenantId = Number(query.tenantId);
|
||||
if (query?.status) {
|
||||
where.status = query.status;
|
||||
} else if (!query?.includeArchived) {
|
||||
where.status = Not(In(['archived']));
|
||||
}
|
||||
return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['tenant'] });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify TypeScript compile**
|
||||
|
||||
Run: `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json`
|
||||
Expected: no errors (ignore pre-existing errors in files not touched)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/students/students.service.ts
|
||||
git commit -m "fix(server): remove CampusScope from StudentsService, classes are teacher-managed not dept-filtered"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端 — AttendanceService 移除 CampusScope
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/attendance/attendance.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: (none external — internal change only)
|
||||
- Produces: all public method signatures unchanged, no scope filtering applied
|
||||
|
||||
- [ ] **Step 1: Read current code to confirm line numbers**
|
||||
|
||||
Run: `read apps/server/src/attendance/attendance.service.ts:1-40`
|
||||
|
||||
- [ ] **Step 2: Remove CampusScope import (line 9)**
|
||||
|
||||
```typescript
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
```
|
||||
→ Delete this line.
|
||||
|
||||
- [ ] **Step 3: Remove constructor parameter (line 39)**
|
||||
|
||||
SWAP the constructor body — remove `private readonly scope: CampusScope`:
|
||||
|
||||
```typescript
|
||||
constructor(
|
||||
@InjectRepository(AttendanceRecord)
|
||||
private attendanceRepo: Repository<AttendanceRecord>,
|
||||
@InjectRepository(DingAttendanceRaw)
|
||||
private dingRawRepo: Repository<DingAttendanceRaw>,
|
||||
@InjectRepository(Class)
|
||||
private classRepo: Repository<Class>,
|
||||
@InjectRepository(Student)
|
||||
private studentRepo: Repository<Student>,
|
||||
@InjectRepository(ClassSchedule)
|
||||
private scheduleRepo: Repository<ClassSchedule>,
|
||||
@InjectRepository(ClassStudent)
|
||||
private classStudentRepo: Repository<ClassStudent>,
|
||||
@InjectRepository(UserDingMapping)
|
||||
private userDingMappingRepo: Repository<UserDingMapping>,
|
||||
) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove scope block in getSummary (lines 182-185)**
|
||||
|
||||
Delete lines 182-185:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Remove scope block in findAll (lines 287-290)**
|
||||
|
||||
Delete lines 287-290:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Remove scope blocks in getClasses (lines 327-330 and 339)**
|
||||
|
||||
Delete lines 327-330:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
SWAP line 339 — replace `await this.scope.filter({ id: In(classIds) })` with `{ id: In(classIds) }`:
|
||||
```typescript
|
||||
const classes = await this.classRepo.find({ where: { id: In(classIds) } });
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Remove scope block in findAllForExport (lines 422-425)**
|
||||
|
||||
Delete lines 422-425:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Remove scope guard in update (lines 460-463)**
|
||||
|
||||
Delete lines 460-463:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds && !scopeIds.includes(record.departmentId)) {
|
||||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Remove scope guard in remove (lines 482-485)**
|
||||
|
||||
Delete lines 482-485:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds && !scopeIds.includes(record.departmentId)) {
|
||||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Remove scope block in getReport (lines 494-497)**
|
||||
|
||||
Delete lines 494-497:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Remove scope block in getAlerts (lines 570-573)**
|
||||
|
||||
Delete lines 570-573:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 12: Verify TypeScript compile**
|
||||
|
||||
Run: `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json`
|
||||
Expected: no errors (ignore pre-existing errors in files not touched)
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/attendance/attendance.service.ts
|
||||
git commit -m "fix(server): remove CampusScope from AttendanceService, attendance is teacher-managed not dept-filtered"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 端到端验证
|
||||
|
||||
- [ ] **Step 1: 启动后端**
|
||||
|
||||
```bash
|
||||
cd /Users/tiku1/code/gongxue-base/apps/server && npm run start:dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 启动前端**
|
||||
|
||||
```bash
|
||||
cd /Users/tiku1/code/gongxue-base/apps/admin && npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证场景**
|
||||
|
||||
1. 以非超管老师身份登录,打开集成配置页面
|
||||
2. 获取钉钉组织架构 → 确认父部门没有"标为班级"按钮,叶子部门有
|
||||
3. 标记班级、勾选老师、执行导入 → 确认导入成功
|
||||
4. 导航到学生管理页面 → 确认能看到导入的学生
|
||||
5. 导航到考勤管理页面 → 确认有数据
|
||||
|
||||
- [ ] **Step 4: Commit verification notes**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "chore: end-to-end verification notes"
|
||||
```
|
||||
@@ -1,876 +0,0 @@
|
||||
# DingTalk Sync Role Selection — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move DingTalk user sync from Users page to IntegrationConfig page with a Drawer-based tree UI for selecting which synced users are teachers (with role assignment) vs students.
|
||||
|
||||
**Architecture:** Backend adds two endpoints: one to fetch the DingTalk org tree with users attached, one to import users with role/student assignment. Frontend adds a "同步用户" Tab on IntegrationConfig page with a Drawer tree; removes the old sync button and mark-staff/mark-student buttons from Users page.
|
||||
|
||||
**Tech Stack:** NestJS 11 + TypeORM 0.3 (backend), React 19 + Vite + Ant Design 6 (frontend)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- MUST inject superpowers:ui-ux-pro-max and superpowers:vercel-react-best-practices during implementation
|
||||
- Follow existing NestJS module patterns: entity/dto/service/controller
|
||||
- Frontend pages in `apps/admin/src/pages/` with independent directories
|
||||
- RBAC permission decorators on all new endpoints
|
||||
- Use existing `api` axios instance for frontend API calls
|
||||
- Default teacher role: "班主任" (look up by name from `/rbac/roles`)
|
||||
- `roleId: null` (not undefined) marks a user as student
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — New type and fetchOrgTreeWithUsers
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/integration/dingtalk.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `DingOrgTreeNodeWithUsers` (exported interface), `fetchOrgTreeWithUsers(rootDeptId?: number): Promise<DingOrgTreeNodeWithUsers[]>`
|
||||
|
||||
- [ ] **Step 1: Add DingOrgTreeNodeWithUsers type**
|
||||
|
||||
After the existing `DingOrgTreeNode` interface (line ~73), add:
|
||||
|
||||
```typescript
|
||||
/** 钉钉部门树节点(含用户),供同步用户选择器使用 */
|
||||
export interface DingOrgTreeNodeWithUsers {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNodeWithUsers[];
|
||||
users: Array<{ userid: string; name: string; mobile: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add fetchOrgTreeWithUsers method**
|
||||
|
||||
After `fetchOrgTree` method (line ~456), add:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 获取钉钉组织部门树(含用户),供前端同步用户选择器使用。
|
||||
* 返回从指定 rootDeptId 开始的树,每个部门节点含 users 数组。
|
||||
*/
|
||||
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<DingOrgTreeNodeWithUsers[]> {
|
||||
if (!this.configured) {
|
||||
throw new ServiceUnavailableException('钉钉未配置');
|
||||
}
|
||||
const token = await this.getAccessToken();
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
|
||||
// 拉每个部门详情
|
||||
const nodes: DingOrgTreeNodeWithUsers[] = [];
|
||||
for (let i = 0; i < deptIds.length; i++) {
|
||||
if (i > 0) await this.delay(i);
|
||||
const detail = await this.getDeptDetail(token, deptIds[i]);
|
||||
if (!detail) continue;
|
||||
|
||||
// 拉该部门下的用户
|
||||
const dingUsers = await this.getDeptUsers(token, deptIds[i]);
|
||||
|
||||
nodes.push({
|
||||
id: detail.dept_id,
|
||||
name: detail.name,
|
||||
parentId: detail.parent_id,
|
||||
children: [],
|
||||
users: dingUsers.map((u) => ({
|
||||
userid: u.userid,
|
||||
name: u.name,
|
||||
mobile: u.mobile,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// 全局去重:同一个 dingUserId 可能在多个部门出现
|
||||
const seenUserIds = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
node.users = node.users.filter((u) => {
|
||||
if (seenUserIds.has(u.userid)) return false;
|
||||
seenUserIds.add(u.userid);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// 组装成树
|
||||
const map = new Map<number, DingOrgTreeNodeWithUsers>();
|
||||
nodes.forEach((n) => map.set(n.id, n));
|
||||
const roots: DingOrgTreeNodeWithUsers[] = [];
|
||||
for (const node of nodes) {
|
||||
const parent = map.get(node.parentId);
|
||||
if (parent && node.id !== rootDeptId) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build check**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
|
||||
```
|
||||
|
||||
Expected: no new errors (existing pre-existing errors may remain).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/integration/dingtalk.service.ts
|
||||
git commit -m "feat: add fetchOrgTreeWithUsers to DingTalkService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend — SyncService new methods
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/sync/sync.service.ts`
|
||||
- Modify: `apps/server/src/sync/sync.module.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DingOrgTreeNodeWithUsers` from Task 1
|
||||
- Produces: `getDingTalkOrgTreeWithUsers(rootDeptId?: number): Promise<DingOrgTreeNodeWithUsers[]>`, `importDingTalkUsers(users: ImportUserDto[]): Promise<{ teacherCount: number; studentCount: number; skipped: number }>`
|
||||
|
||||
- [ ] **Step 1: Add ImportUserDto and inject new repos**
|
||||
|
||||
In `sync.service.ts`, after existing imports, add:
|
||||
|
||||
```typescript
|
||||
import { User } from '../entities/user.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Role } from '../entities/role.entity';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
```
|
||||
|
||||
Add to constructor injection (after existing `mappingRepo`):
|
||||
|
||||
```typescript
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
@InjectRepository(Student)
|
||||
private readonly studentRepo: Repository<Student>,
|
||||
@InjectRepository(Role)
|
||||
private readonly roleRepo: Repository<Role>,
|
||||
```
|
||||
|
||||
Add DTO interface at top of file (before class):
|
||||
|
||||
```typescript
|
||||
export interface ImportUserDto {
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
roleId: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add getDingTalkOrgTreeWithUsers method**
|
||||
|
||||
After existing `getDingTalkOrgTree` method (line ~93), add:
|
||||
|
||||
```typescript
|
||||
/** 获取钉钉组织部门树(含用户),供前端同步用户选择器使用 */
|
||||
async getDingTalkOrgTreeWithUsers(rootDeptId = 1) {
|
||||
return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add importDingTalkUsers method**
|
||||
|
||||
After the new `getDingTalkOrgTreeWithUsers`, add:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 从钉钉导入用户:roleId 非 null → 老师(User + 指定角色),roleId null → 学生(User + Student)。
|
||||
* 已存在 UserDingMapping 的记录跳过。
|
||||
*/
|
||||
async importDingTalkUsers(users: ImportUserDto[]): Promise<{
|
||||
teacherCount: number;
|
||||
studentCount: number;
|
||||
skipped: number;
|
||||
}> {
|
||||
let teacherCount = 0;
|
||||
let studentCount = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const u of users) {
|
||||
// 检查是否已存在映射
|
||||
const existing = await this.mappingRepo.findOne({
|
||||
where: { dingUserId: u.dingUserId },
|
||||
});
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const username = u.mobile || `dd_${u.dingUserId}`;
|
||||
const passwordHash = await bcrypt.hash('123456', 10);
|
||||
|
||||
const user = this.userRepo.create({
|
||||
username,
|
||||
name: u.name,
|
||||
passwordHash,
|
||||
isActive: true,
|
||||
});
|
||||
await this.userRepo.save(user);
|
||||
|
||||
if (u.roleId != null) {
|
||||
// 老师:分配角色
|
||||
const role = await this.roleRepo.findOne({ where: { id: u.roleId } });
|
||||
if (role) {
|
||||
user.roles = [role];
|
||||
await this.userRepo.save(user);
|
||||
} else {
|
||||
this.logger.warn(`角色 id=${u.roleId} 不存在,用户 ${u.name} 未分配角色`);
|
||||
}
|
||||
teacherCount++;
|
||||
} else {
|
||||
// 学生:创建 Student 记录
|
||||
const student = this.studentRepo.create({
|
||||
name: u.name,
|
||||
phone: u.mobile || undefined,
|
||||
userId: user.id,
|
||||
status: 'active',
|
||||
});
|
||||
await this.studentRepo.save(student);
|
||||
studentCount++;
|
||||
}
|
||||
|
||||
// 创建映射
|
||||
const mapping = this.mappingRepo.create({
|
||||
dingUserId: u.dingUserId,
|
||||
userId: user.id,
|
||||
dingName: u.name,
|
||||
dingMobile: u.mobile,
|
||||
});
|
||||
await this.mappingRepo.save(mapping);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`导入用户 ${u.name}(${u.dingUserId}) 失败: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${skipped} 跳过`,
|
||||
);
|
||||
return { teacherCount, studentCount, skipped };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update sync.module.ts — add User, Student, Role entities**
|
||||
|
||||
In `TypeOrmModule.forFeature([...])` array, add `User`, `Student`, `Role` to the imports. Also add the import for them at the top:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
SyncLog,
|
||||
SyncState,
|
||||
UserDingMapping,
|
||||
ClassSchedule,
|
||||
Department,
|
||||
UserDepartment,
|
||||
ClassTeacher,
|
||||
User,
|
||||
Student,
|
||||
Role,
|
||||
} from '../entities';
|
||||
```
|
||||
|
||||
And in the `forFeature` array after `ClassTeacher`:
|
||||
|
||||
```typescript
|
||||
User,
|
||||
Student,
|
||||
Role,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build check**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
|
||||
```
|
||||
|
||||
Expected: no new errors from modified files.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/sync/sync.service.ts apps/server/src/sync/sync.module.ts
|
||||
git commit -m "feat: add importDingTalkUsers and org-tree-with-users to SyncService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backend — SyncController new endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/sync/sync.controller.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getDingTalkOrgTreeWithUsers`, `importDingTalkUsers` from Task 2
|
||||
|
||||
- [ ] **Step 1: Add org-tree-with-users endpoint**
|
||||
|
||||
After `getDingTalkOrgTree` endpoint (line ~41), add:
|
||||
|
||||
```typescript
|
||||
/** 获取钉钉组织部门树(含用户),供同步用户选择器使用 */
|
||||
@Get('dingtalk/org-tree-with-users')
|
||||
@RequirePermission('sync:read')
|
||||
async getDingTalkOrgTreeWithUsers(@Query('rootDeptId') rootDeptId?: string) {
|
||||
const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;
|
||||
const tree = await this.syncService.getDingTalkOrgTreeWithUsers(rootId);
|
||||
return { success: true, data: tree };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add import-users endpoint**
|
||||
|
||||
After the new endpoint above, add:
|
||||
|
||||
```typescript
|
||||
/** 导入钉钉用户:老师分配角色,学生创建 Student */
|
||||
@Post('dingtalk/import-users')
|
||||
@RequirePermission('sync:trigger')
|
||||
async importDingTalkUsers(@Body() body: { users: Array<{ dingUserId: string; name: string; mobile: string; roleId: number | null }> }) {
|
||||
const result = await this.syncService.importDingTalkUsers(body.users);
|
||||
return { success: true, ...result };
|
||||
}
|
||||
```
|
||||
|
||||
Add `Body` to the imports from `@nestjs/common` at the top if not already present (check line 1 — `BadRequestException` is there, add `Body`):
|
||||
|
||||
```typescript
|
||||
import { BadRequestException, Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build check**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
|
||||
```
|
||||
|
||||
Expected: no new errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/sync/sync.controller.ts
|
||||
git commit -m "feat: add org-tree-with-users and import-users endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — Users page cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Users/index.tsx`
|
||||
|
||||
- [ ] **Step 1: Remove imports**
|
||||
|
||||
Remove from import line (line 1): `useCallback` (if only used by sync), `CloudDownloadOutlined` (line 15), `TreeSelect` (from antd imports).
|
||||
|
||||
Check: `useCallback` is also used by `fetchData` (line 81), so keep it. Only remove `CloudDownloadOutlined` and `TreeSelect` from imports.
|
||||
|
||||
Change antd import line (line 2-14):
|
||||
- Remove `TreeSelect` from the destructured import list.
|
||||
|
||||
Change icons import line 15:
|
||||
- Remove `CloudDownloadOutlined` from the import.
|
||||
|
||||
- [ ] **Step 2: Remove sync-related state and functions**
|
||||
|
||||
Remove these state declarations (lines ~33, 37-38):
|
||||
- `const [syncing, setSyncing] = useState(false);`
|
||||
- `const [syncDeptId, setSyncDeptId] = useState<number | undefined>(undefined);`
|
||||
- `const [orgTree, setOrgTree] = useState<...>([]);`
|
||||
|
||||
Remove these functions:
|
||||
- `loadOrgTree` (lines ~40-53)
|
||||
- `handleSyncDingTalk` (lines ~96-113)
|
||||
- `handleMarkStaff` (lines ~187-196)
|
||||
|
||||
- [ ] **Step 3: Remove sync button and TreeSelect from JSX**
|
||||
|
||||
In the toolbar `<Space wrap>` (lines ~332-368):
|
||||
- Remove the `TreeSelect` block (lines ~333-342)
|
||||
- Remove the `PermissionButton` with `permission="sync:trigger"` (lines ~351-358)
|
||||
|
||||
- [ ] **Step 4: Remove mark-staff/mark-student buttons from columns**
|
||||
|
||||
In the `columns` useMemo (lines ~298-307), remove the two conditional blocks:
|
||||
- Remove lines ~298-302: `record.studentStatus === 'active'` → "标记教职工" button
|
||||
- Remove lines ~303-307: `record.studentStatus === 'staff'` → "恢复学员" button
|
||||
|
||||
Adjust the `width` of the 操作 column from `320` to `240` since we're removing two buttons.
|
||||
|
||||
- [ ] **Step 5: Build check**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no new errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/admin/src/pages/Users/index.tsx
|
||||
git commit -m "refactor: remove sync and mark-staff buttons from Users page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — IntegrationConfig sync users Tab + Drawer
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /sync/dingtalk/org-tree-with-users`, `POST /sync/dingtalk/import-users`, `GET /rbac/roles`
|
||||
- Produces: SyncUsersTab component with org tree Drawer, teacher selection, role assignment
|
||||
|
||||
- [ ] **Step 1: Add new imports**
|
||||
|
||||
Add to existing antd imports: `Tabs`, `Drawer`, `Tree`, `Checkbox`, `Select`, `TreeSelect`.
|
||||
|
||||
Add icons: `SyncOutlined`, `ReloadOutlined`.
|
||||
|
||||
Current import block (lines 1-8):
|
||||
|
||||
```typescript
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```typescript
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
|
||||
Tabs, Drawer, Tree, Checkbox, Select, TreeSelect,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
SyncOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import api from '../../api';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add types and state for sync tab**
|
||||
|
||||
After the existing `IntegrationConfigPage` component declaration, add new state:
|
||||
|
||||
```typescript
|
||||
// ── Sync Users Tab ──
|
||||
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
|
||||
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [fetchingTree, setFetchingTree] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [roles, setRoles] = useState<Array<{ id: number; name: string }>>([]);
|
||||
const [defaultTeacherRoleId, setDefaultTeacherRoleId] = useState<number | null>(null);
|
||||
// Department tree for the picker (no users)
|
||||
const [deptPickerTree, setDeptPickerTree] = useState<Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }>>([]);
|
||||
```
|
||||
|
||||
Add the extended tree node type before the component:
|
||||
|
||||
```typescript
|
||||
interface DingOrgTreeNodeExt {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNodeExt[];
|
||||
users: Array<{ userid: string; name: string; mobile: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add fetch roles and fetch org tree handlers**
|
||||
|
||||
```typescript
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/rbac/roles');
|
||||
const activeRoles = res.filter((r: any) => r.status !== 0);
|
||||
setRoles(activeRoles);
|
||||
const teacherRole = activeRoles.find((r: any) => r.name === '班主任');
|
||||
setDefaultTeacherRoleId(teacherRole?.id || activeRoles[0]?.id || null);
|
||||
} catch {
|
||||
// ignore — roles will be empty
|
||||
}
|
||||
};
|
||||
|
||||
const loadDeptTree = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/sync/dingtalk/org-tree');
|
||||
if (res.success && res.data) {
|
||||
const toTreeNode = (nodes: any[]): any[] =>
|
||||
nodes.map((n: any) => ({
|
||||
title: n.name,
|
||||
value: n.id,
|
||||
children: n.children ? toTreeNode(n.children) : undefined,
|
||||
}));
|
||||
setDeptPickerTree(toTreeNode(res.data));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchOrgTree = async () => {
|
||||
setFetchingTree(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId);
|
||||
const res: any = await api.get('/sync/dingtalk/org-tree-with-users', { params });
|
||||
if (res.success && res.data) {
|
||||
setOrgTree(res.data);
|
||||
setTeacherChecks({});
|
||||
setTeacherRoles({});
|
||||
setDrawerOpen(true);
|
||||
} else {
|
||||
message.error('获取组织架构失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '获取组织架构失败');
|
||||
} finally {
|
||||
setFetchingTree(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add import handler**
|
||||
|
||||
```typescript
|
||||
const handleImportUsers = async () => {
|
||||
setImporting(true);
|
||||
try {
|
||||
// Flatten all users from tree
|
||||
const allUsers: Array<{
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
}> = [];
|
||||
|
||||
const flatten = (nodes: DingOrgTreeNodeExt[]) => {
|
||||
for (const node of nodes) {
|
||||
allUsers.push(...node.users);
|
||||
flatten(node.children);
|
||||
}
|
||||
};
|
||||
flatten(orgTree);
|
||||
|
||||
const payload = {
|
||||
users: allUsers.map((u) => ({
|
||||
dingUserId: u.userid,
|
||||
name: u.name,
|
||||
mobile: u.mobile,
|
||||
roleId: teacherChecks[u.userid]
|
||||
? (teacherRoles[u.userid] || defaultTeacherRoleId)
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
|
||||
const res: any = await api.post('/sync/dingtalk/import-users', payload);
|
||||
message.success(
|
||||
`导入完成:${res.teacherCount} 位老师,${res.studentCount} 位学生` +
|
||||
(res.skipped > 0 ? `,${res.skipped} 已跳过` : ''),
|
||||
);
|
||||
setDrawerOpen(false);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '导入失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build tree data for Drawer**
|
||||
|
||||
```typescript
|
||||
const buildTreeData = (nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||||
return nodes.map((node) => ({
|
||||
title: node.name,
|
||||
key: `dept-${node.id}`,
|
||||
children: [
|
||||
// Sub-departments
|
||||
...buildTreeData(node.children),
|
||||
// Users in this department
|
||||
...node.users.map((u) => ({
|
||||
title: (
|
||||
<Space size="small">
|
||||
<Checkbox
|
||||
checked={!!teacherChecks[u.userid]}
|
||||
onChange={(e) => {
|
||||
setTeacherChecks((prev) => ({
|
||||
...prev,
|
||||
[u.userid]: e.target.checked,
|
||||
}));
|
||||
if (!e.target.checked) {
|
||||
setTeacherRoles((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[u.userid];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
老师
|
||||
</Checkbox>
|
||||
<span style={{ fontWeight: 500 }}>{u.name}</span>
|
||||
{u.mobile && (
|
||||
<Tag style={{ marginLeft: 4 }}>{u.mobile}</Tag>
|
||||
)}
|
||||
{teacherChecks[u.userid] && (
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 100, marginLeft: 8 }}
|
||||
value={teacherRoles[u.userid] || defaultTeacherRoleId}
|
||||
onChange={(roleId: number) =>
|
||||
setTeacherRoles((prev) => ({ ...prev, [u.userid]: roleId }))
|
||||
}
|
||||
options={roles.map((r) => ({ label: r.name, value: r.id }))}
|
||||
placeholder="选择角色"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
key: `user-${u.userid}`,
|
||||
selectable: false,
|
||||
})),
|
||||
],
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build tree data memoized**
|
||||
|
||||
```typescript
|
||||
const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Replace page root with Tabs**
|
||||
|
||||
Replace the entire `return (...)` block. The page root becomes:
|
||||
|
||||
```typescript
|
||||
const syncTabItems = config
|
||||
? [
|
||||
{
|
||||
key: 'sync-users',
|
||||
label: '同步用户',
|
||||
children: (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
message="从钉钉获取组织架构,勾选老师并分配角色,其余用户将作为学生导入。"
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
<Space>
|
||||
<TreeSelect
|
||||
treeData={deptPickerTree}
|
||||
value={syncRootDeptId}
|
||||
onChange={(v) => setSyncRootDeptId(v)}
|
||||
placeholder="选择起始部门(不选=全部)"
|
||||
allowClear
|
||||
treeDefaultExpandAll
|
||||
style={{ minWidth: 240 }}
|
||||
onDropdownVisibleChange={(open) => { if (open) loadDeptTree(); }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
loading={fetchingTree}
|
||||
onClick={handleFetchOrgTree}
|
||||
>
|
||||
获取组织架构
|
||||
</Button>
|
||||
</Space>
|
||||
{drawerOpen && (
|
||||
<Drawer
|
||||
title="钉钉组织架构 — 勾选老师"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={520}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={importing}
|
||||
onClick={handleImportUsers}
|
||||
>
|
||||
导入
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{treeData.length > 0 ? (
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
defaultExpandAll
|
||||
blockNode
|
||||
showLine={{ showLeafIcon: false }}
|
||||
/>
|
||||
) : (
|
||||
<Spin />
|
||||
)}
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'config',
|
||||
children: (
|
||||
<Spin spinning={loading}>
|
||||
{config && (
|
||||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="启用同步">
|
||||
<Tag color={config.startEnable ? 'green' : 'default'}>
|
||||
{config.startEnable ? '已启用' : '未启用'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
|
||||
style={{ marginBottom: 24 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
|
||||
<Form.Item name="corpId" label="CorpId(企业ID)" rules={[{ required: true, message: '请输入 CorpId' }]}>
|
||||
<Input placeholder="dingxxxxxxxx" />
|
||||
</Form.Item>
|
||||
<Form.Item name="agentId" label="AppKey(应用凭证)" rules={[{ required: true, message: '请输入 AppKey' }]}>
|
||||
<Input placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="appSecret"
|
||||
label="AppSecret(应用密钥)"
|
||||
rules={[{ required: true, message: '请输入 AppSecret' }]}
|
||||
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
|
||||
>
|
||||
<Input.Password placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
||||
测试连接
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Spin>
|
||||
),
|
||||
},
|
||||
...syncTabItems,
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="钉钉集成配置" extra={...}>
|
||||
<Tabs items={tabItems} />
|
||||
</Card>
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Add fetchRoles to useEffect**
|
||||
|
||||
In the existing `useEffect` (line ~42), add `fetchRoles()` call:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
fetchRoles();
|
||||
}, []);
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Build check**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 10: E2E smoke test**
|
||||
|
||||
Start the dev server and verify:
|
||||
1. Navigate to IntegrationConfig page
|
||||
2. "同步用户" Tab only visible when DingTalk is configured
|
||||
3. Click "获取组织架构" → Drawer opens with department tree
|
||||
4. Check users as teachers → role select appears (defaults to 班主任)
|
||||
5. Click "导入" → success message with counts
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx vite --port 5173 &
|
||||
cd apps/server && npm run start:dev &
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/admin/src/pages/IntegrationConfig/index.tsx
|
||||
git commit -m "feat: add sync users Tab with Drawer tree to IntegrationConfig"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final verification & cleanup
|
||||
|
||||
- [ ] **Step 1: Run full type check**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify Users page no longer shows sync/mark buttons**
|
||||
|
||||
Smoke test Users page — confirm no "同步钉钉用户" button, no TreeSelect, and no "标记教职工"/"恢复学员" in the actions column.
|
||||
|
||||
- [ ] **Step 3: Verify IntegrationConfig sync flow end-to-end**
|
||||
|
||||
Run through the full flow:
|
||||
1. Config page → Sync Users tab
|
||||
2. Fetch org tree → Drawer shows tree
|
||||
3. Check teachers → role dropdown works
|
||||
4. Import → correct counts returned
|
||||
5. Verify in DB: teachers have roles, students have Student records
|
||||
|
||||
- [ ] **Step 4: Commit any remaining changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: final verification and cleanup for dingtalk sync role selection"
|
||||
```
|
||||
@@ -1,174 +0,0 @@
|
||||
# Org Tree Deepest Levels — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
> **Required skills per agent:** `superpowers:vercel-react-best-practices`, `superpowers:ui-ux-pro-max`
|
||||
|
||||
**Goal:** `fetchOrgTreeWithUsers` only returns departments at the deepest 2 levels of the global org tree.
|
||||
|
||||
**Architecture:** Add a `getDeptDepthMap` BFS method that mirrors `getAllDeptIds` but tracks level. Insert depth filtering in `fetchOrgTreeWithUsers` before the detail+user fetch loop, slashing API calls.
|
||||
|
||||
**Tech Stack:** NestJS + TypeScript, DingTalk Open API v2
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Depth is computed from rootDeptId=1 globally, regardless of the user's `rootDeptId` param
|
||||
- `fetchOrgTree` (dept picker) is NOT modified
|
||||
- `syncAll` is NOT modified
|
||||
- Frontend receives the same `DingOrgTreeNodeWithUsers[]` shape, zero frontend changes
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `getDeptDepthMap` method
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/integration/dingtalk.service.ts` (insert after `getAllDeptIds`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new (uses existing `rateLimit()`, `SubDeptIdListResponse`, DingTalk `listsubid` API)
|
||||
- Produces: `private async getDeptDepthMap(token: string): Promise<Map<number, number>>` — key=deptId, value=1-based depth
|
||||
|
||||
- [ ] **Step 1: Add `getDeptDepthMap` after `getAllDeptIds` (after line 263)**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* BFS from rootDeptId=1, returns depth of every department.
|
||||
* Depth is 1-based (root=1). Uses listsubid API only, no detail fetches.
|
||||
*/
|
||||
private async getDeptDepthMap(token: string): Promise<Map<number, number>> {
|
||||
const depthMap = new Map<number, number>();
|
||||
const queue: Array<{ id: number; depth: number }> = [{ id: 1, depth: 1 }];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { id, depth } = queue.shift()!;
|
||||
depthMap.set(id, depth);
|
||||
|
||||
try {
|
||||
await this.rateLimit();
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/v2/department/listsubid?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dept_id: id }),
|
||||
},
|
||||
);
|
||||
const body: SubDeptIdListResponse = await res.json();
|
||||
if (body.errcode === 0 && body.result?.dept_id_list) {
|
||||
for (const childId of body.result.dept_id_list) {
|
||||
queue.push({ id: childId, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`getDeptDepthMap 获取部门 ${id} 子部门失败: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return depthMap;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `cd apps/server && npx tsc --noEmit`
|
||||
Expected: no new errors (may have pre-existing ones in other files)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/integration/dingtalk.service.ts
|
||||
git commit -m "feat: add getDeptDepthMap BFS method for org tree depth tracking"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Filter `fetchOrgTreeWithUsers` to deepest 2 levels
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/integration/dingtalk.service.ts:476-547`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getDeptDepthMap(token)` from Task 1
|
||||
- Produces: same `Promise<DingOrgTreeNodeWithUsers[]>` return type, now filtered
|
||||
|
||||
- [ ] **Step 1: Insert depth filtering before the detail+user fetch loop**
|
||||
|
||||
Replace lines 480-481 (`const token = ...; const deptIds = ...;`) with the depth-aware version, and wrap the loop to use `filteredIds`:
|
||||
|
||||
```typescript
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// Compute global depth map (always from rootDeptId=1)
|
||||
const depthMap = await this.getDeptDepthMap(token);
|
||||
const maxDepth = Math.max(...depthMap.values());
|
||||
|
||||
// Get subtree IDs for the user's selected root
|
||||
const subtreeIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
|
||||
// Filter: only keep departments at the deepest 2 levels of the global tree
|
||||
const filteredIds = subtreeIds.filter((id) => {
|
||||
const d = depthMap.get(id) ?? -1;
|
||||
return d === maxDepth || d === maxDepth - 1;
|
||||
});
|
||||
|
||||
if (filteredIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 拉每个部门的详情(仅过滤后的)
|
||||
const nodes: DingOrgTreeNodeWithUsers[] = [];
|
||||
const userDeptMap = new Map<string, number[]>();
|
||||
|
||||
for (let i = 0; i < filteredIds.length; i++) {
|
||||
if (i > 0) await this.delay(i);
|
||||
const detail = await this.getDeptDetail(token, filteredIds[i]);
|
||||
```
|
||||
|
||||
And update the deptId reference inside the loop body — line 494 uses `deptIds[i]`:
|
||||
|
||||
Change line 494:
|
||||
```typescript
|
||||
// Before:
|
||||
this.logger.log(`[dingtalk] dept ${deptIds[i]} (${detail.name}): ${dingUsers.length} users`);
|
||||
// After:
|
||||
this.logger.log(`[dingtalk] dept ${filteredIds[i]} (${detail.name}): ${dingUsers.length} users`);
|
||||
```
|
||||
|
||||
And the `getDeptUsers` call on line 493 must also use `filteredIds[i]` instead of `deptIds[i]` — it already does since we're iterating `filteredIds`.
|
||||
|
||||
- [ ] **Step 2: Update tree assembly to handle missing parent nodes**
|
||||
|
||||
The tree assembly at lines 534-546 currently checks `node.id !== rootDeptId` to decide if a node is a root. Since intermediate levels are filtered out, a maxDepth-1 node's parent won't be in the `map`. Update the root detection:
|
||||
|
||||
```typescript
|
||||
// 组装成树(父节点可能已被过滤,缺失的父节点 → 节点提升为根)
|
||||
const map = new Map<number, DingOrgTreeNodeWithUsers>();
|
||||
nodes.forEach((n) => map.set(n.id, n));
|
||||
const roots: DingOrgTreeNodeWithUsers[] = [];
|
||||
for (const node of nodes) {
|
||||
const parent = map.get(node.parentId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: removed the `&& node.id !== rootDeptId` condition — it's redundant with the `map.get` check when intermediate levels are filtered. Nodes whose parent exists in `map` get attached; those whose parent was filtered out become roots. Same behavior, simpler logic.
|
||||
|
||||
- [ ] **Step 3: Verify it compiles**
|
||||
|
||||
Run: `cd apps/server && npx tsc --noEmit`
|
||||
Expected: no new errors
|
||||
|
||||
- [ ] **Step 4: Smoke test with a real DingTalk config (if available)**
|
||||
|
||||
Run: `curl -s http://localhost:3000/api/sync/dingtalk/org-tree-with-users | jq '. | length'`
|
||||
Expected: returns departments; check that the response depth is at most 2 levels (manual inspection of `parentId` chains, or verify response size is smaller than before).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/integration/dingtalk.service.ts
|
||||
git commit -m "feat: filter org-tree-with-users to deepest 2 levels"
|
||||
```
|
||||
@@ -1,636 +0,0 @@
|
||||
# Student Ding Mapping + Drawer Batch Operations — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
> **Required skills per agent:** `superpowers:vercel-react-best-practices`, `superpowers:ui-ux-pro-max`
|
||||
|
||||
**Goal:** Replace UserDingMapping with StudentDingMapping, rewrite syncAll to create Students directly, add class batch-import endpoint, rebuild Drawer with checkable Tree + class list.
|
||||
|
||||
**Architecture:** Student becomes independent from User — sync creates Student + StudentDingMapping directly. Frontend Drawer uses Ant Design `<Tree checkable>` with left-right split layout for batch user selection and class operations.
|
||||
|
||||
**Tech Stack:** NestJS 11 + TypeORM 0.3 + Ant Design 6
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Student is independent of User (no userId, no RBAC)
|
||||
- StudentDingMapping replaces UserDingMapping everywhere
|
||||
- fetchOrgTree (dept picker) is NOT modified
|
||||
- syncAll still syncs all departments — only user handling changes
|
||||
- Depth filtering (getDeptDepthMap) is removed entirely
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create StudentDingMapping entity + swap in all modules
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/server/src/entities/student-ding-mapping.entity.ts`
|
||||
- Modify: `apps/server/src/entities/index.ts`
|
||||
- Modify: `apps/server/src/app.module.ts`
|
||||
- Modify: `apps/server/src/integration/dingtalk.service.ts` — import + repo injection
|
||||
- Modify: `apps/server/src/integration/integration.module.ts` — TypeOrmModule.forFeature
|
||||
- Modify: `apps/server/src/sync/sync.service.ts` — import + repo injection
|
||||
- Modify: `apps/server/src/sync/sync.module.ts` — TypeOrmModule.forFeature
|
||||
- Modify: `apps/server/src/sync/schedule-sync.service.ts` — import + repo injection
|
||||
- Modify: `apps/server/src/attendance/attendance-import.service.ts` — import + repo injection
|
||||
- Modify: `apps/server/src/attendance/attendance.module.ts` — TypeOrmModule.forFeature
|
||||
- Modify: `apps/server/src/attendance/attendance.service.ts` — import + repo injection
|
||||
- Modify: `apps/server/src/rbac/rbac.service.ts` — import + repo injection
|
||||
- Modify: `apps/server/src/rbac/rbac.module.ts` — TypeOrmModule.forFeature
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `StudentDingMapping` entity with `id`, `dingUserId`(unique), `studentId`(unique), `student`(ManyToOne), `createdAt`
|
||||
- Note: rbac controller endpoints (getUserDingMappings etc.) removed in Task 4
|
||||
|
||||
- [ ] **Step 1: Create `student-ding-mapping.entity.ts`**
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Entity, PrimaryGeneratedColumn, Column, CreateDateColumn,
|
||||
ManyToOne, JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
|
||||
@Entity('student_ding_mapping')
|
||||
export class StudentDingMapping {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'ding_user_id', length: 100, unique: true })
|
||||
dingUserId: string;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer', unique: true })
|
||||
studentId: number;
|
||||
|
||||
@ManyToOne(() => Student, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Swap exports in `entities/index.ts`**
|
||||
|
||||
Remove: `export { UserDingMapping } from './user-ding-mapping.entity';`
|
||||
Add: `export { StudentDingMapping } from './student-ding-mapping.entity';`
|
||||
|
||||
- [ ] **Step 3: Swap in `app.module.ts`**
|
||||
|
||||
In the import from `'./entities'`: replace `UserDingMapping` with `StudentDingMapping`.
|
||||
In the `TypeOrmModule.forRoot` entities array: same replacement.
|
||||
|
||||
- [ ] **Step 4: Swap in every module file**
|
||||
|
||||
For each file listed above, replace mechanically:
|
||||
- `UserDingMapping` → `StudentDingMapping`
|
||||
- `user_ding_mapping` → `student_ding_mapping`
|
||||
- `mappingRepo` / `userDingMappingRepo` → `studentDingMappingRepo`
|
||||
|
||||
- [ ] **Step 5: Delete `user-ding-mapping.entity.ts`**
|
||||
|
||||
- [ ] **Step 6: Verify compile**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: zero new errors (pre-existing spec-file errors ignored).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/entities/ apps/server/src/app.module.ts apps/server/src/integration/ apps/server/src/sync/ apps/server/src/attendance/ apps/server/src/rbac/
|
||||
git commit -m "refactor: replace UserDingMapping with StudentDingMapping entity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewrite syncAll to create Student directly
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/integration/dingtalk.service.ts` — `syncAll` and `syncOneUser`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `StudentDingMapping` entity (Task 1)
|
||||
- Produces: same `{ deptCount, userCount }` return type
|
||||
|
||||
- [ ] **Step 1: Rewrite `syncOneUser`**
|
||||
|
||||
Replace existing `syncOneUser` (lines ~553-624) with:
|
||||
|
||||
```typescript
|
||||
private async syncOneUser(du: {
|
||||
userid: string; name: string; mobile: string;
|
||||
}): Promise<void> {
|
||||
let mapping = await this.studentDingMappingRepo.findOne({
|
||||
where: { dingUserId: du.userid },
|
||||
});
|
||||
if (mapping) {
|
||||
const student = await this.studentRepo.findOne({
|
||||
where: { id: mapping.studentId },
|
||||
});
|
||||
if (student) {
|
||||
student.name = du.name;
|
||||
if (du.mobile) student.phone = du.mobile;
|
||||
await this.studentRepo.save(student);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const student = this.studentRepo.create({
|
||||
name: du.name,
|
||||
phone: du.mobile || undefined,
|
||||
status: 'active',
|
||||
});
|
||||
await this.studentRepo.save(student);
|
||||
|
||||
mapping = this.studentDingMappingRepo.create({
|
||||
dingUserId: du.userid,
|
||||
studentId: student.id,
|
||||
});
|
||||
await this.studentDingMappingRepo.save(mapping);
|
||||
}
|
||||
```
|
||||
|
||||
Remove unused imports: `bcrypt`, `User`, `userRepo` injection. `studentDingMappingRepo` already injected from Task 1.
|
||||
|
||||
- [ ] **Step 2: Verify compile**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/integration/dingtalk.service.ts
|
||||
git commit -m "refactor: syncAll creates Student + StudentDingMapping directly"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Revert deepest-2-levels filtering
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/integration/dingtalk.service.ts` — `fetchOrgTreeWithUsers` + remove `getDeptDepthMap`
|
||||
|
||||
- [ ] **Step 1: Remove `getDeptDepthMap` method** (the ~37 lines added in commit 0509175)
|
||||
|
||||
- [ ] **Step 2: Restore `fetchOrgTreeWithUsers` loop**
|
||||
|
||||
Remove depthMap/maxDepth/filteredIds. Restore:
|
||||
```typescript
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
```
|
||||
And `for (let i = 0; i < deptIds.length; i++)` instead of `filteredIds`.
|
||||
|
||||
- [ ] **Step 3: Restore tree assembly condition**
|
||||
|
||||
Add back `&& node.id !== rootDeptId` in the root-detection line.
|
||||
|
||||
- [ ] **Step 4: Verify compile + commit**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit
|
||||
git add apps/server/src/integration/dingtalk.service.ts
|
||||
git commit -m "revert: remove deepest-2-levels filter, restore full org tree"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remove User-based import + RBAC UserDingMapping endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/sync/sync.service.ts` — remove `importDingTalkUsers`, `ImportUserDto`
|
||||
- Modify: `apps/server/src/sync/sync.controller.ts` — remove `POST /dingtalk/import-users`
|
||||
- Modify: `apps/server/src/sync/dto/import-users.dto.ts` — remove `ImportUsersDto`
|
||||
- Modify: `apps/server/src/rbac/rbac.service.ts` — remove `getUserDingMappings`, `createUserDingMapping`, `deleteUserDingMapping`, unbound-users query
|
||||
- Modify: `apps/server/src/rbac/rbac.controller.ts` — remove corresponding endpoints + imports
|
||||
- Modify: `apps/server/src/rbac/dto/rbac.dto.ts` — remove `CreateUserDingMappingDto`
|
||||
|
||||
- [ ] **Step 1: Remove importDingTalkUsers from sync.service.ts**
|
||||
|
||||
Delete the entire `importDingTalkUsers` method and the `ImportUserDto` interface.
|
||||
Remove now-unused imports: `Role`, `bcrypt`, `ClassTeacher`, `ClassStudent`, `Department`, `ClassEntity`, `BadRequestException`.
|
||||
|
||||
- [ ] **Step 2: Remove import endpoint from sync.controller.ts**
|
||||
|
||||
Delete the `POST /dingtalk/import-users` handler and `ImportUsersDto` import.
|
||||
|
||||
- [ ] **Step 3: Remove RBAC UserDingMapping methods**
|
||||
|
||||
In `rbac.service.ts`: delete `getUserDingMappings()`, `createUserDingMapping()`, `deleteUserDingMapping()`, and the unbound-users query.
|
||||
In `rbac.controller.ts`: delete `GET /user-ding-mappings`, `POST /user-ding-mappings`, `DELETE /user-ding-mappings/:id`, `GET /user-ding-mappings/unbound-users`.
|
||||
In `rbac.dto.ts`: delete `CreateUserDingMappingDto`.
|
||||
|
||||
- [ ] **Step 4: Verify compile**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/sync/ apps/server/src/rbac/
|
||||
git commit -m "refactor: remove User-based import and RBAC UserDingMapping endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add class batch-import + extend class create
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/classes/classes.service.ts` — new `batchImportStudents`, extend `create`
|
||||
- Modify: `apps/server/src/classes/classes.controller.ts` — new endpoint
|
||||
- Modify: `apps/server/src/classes/dto/class.dto.ts` — new DTOs
|
||||
- Modify: `apps/server/src/classes/classes.module.ts` — add StudentDingMapping
|
||||
|
||||
**Interfaces:**
|
||||
- `POST /classes/:id/students/import` — body `{ dingUserIds: string[] }` → `{ imported: number, skipped: number }`
|
||||
- `POST /classes` — extended: optional `dingUserIds: string[]`
|
||||
|
||||
- [ ] **Step 1: Add DTOs in `class.dto.ts`**
|
||||
|
||||
```typescript
|
||||
import { IsArray, IsString, ArrayNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class BatchImportStudentsDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayNotEmpty()
|
||||
dingUserIds: string[];
|
||||
}
|
||||
```
|
||||
|
||||
In existing `CreateClassDto`, add:
|
||||
```typescript
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
dingUserIds?: string[];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Inject `StudentDingMapping` in classes.service.ts**
|
||||
|
||||
```typescript
|
||||
import { StudentDingMapping } from '../entities';
|
||||
// ...
|
||||
@InjectRepository(StudentDingMapping)
|
||||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `batchImportStudents` method**
|
||||
|
||||
```typescript
|
||||
async batchImportStudents(classId: number, dingUserIds: string[]): Promise<{ imported: number; skipped: number }> {
|
||||
const classEntity = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!classEntity) throw new NotFoundException('班级不存在');
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const dingUserId of dingUserIds) {
|
||||
let mapping = await this.studentDingMappingRepo.findOne({ where: { dingUserId } });
|
||||
let studentId: number;
|
||||
|
||||
if (mapping) {
|
||||
studentId = mapping.studentId;
|
||||
} else {
|
||||
const student = this.studentRepo.create({
|
||||
name: `dd_${dingUserId}`,
|
||||
status: 'active',
|
||||
departmentId: classEntity.departmentId ?? undefined,
|
||||
});
|
||||
const saved = await this.studentRepo.save(student);
|
||||
studentId = saved.id;
|
||||
mapping = this.studentDingMappingRepo.create({ dingUserId, studentId });
|
||||
await this.studentDingMappingRepo.save(mapping);
|
||||
}
|
||||
|
||||
const existing = await this.classStudentRepo.findOne({
|
||||
where: { classId, studentId },
|
||||
});
|
||||
if (existing) { skipped++; continue; }
|
||||
|
||||
await this.classStudentRepo.save(
|
||||
this.classStudentRepo.create({
|
||||
classId, studentId, status: 'active',
|
||||
joinDate: new Date().toISOString().slice(0, 10),
|
||||
}),
|
||||
);
|
||||
imported++;
|
||||
}
|
||||
|
||||
return { imported, skipped };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Extend `create` method**
|
||||
|
||||
At end of `create`, before return:
|
||||
```typescript
|
||||
if (dto.dingUserIds?.length) {
|
||||
await this.batchImportStudents(saved.id, dto.dingUserIds);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add endpoint in classes.controller.ts**
|
||||
|
||||
```typescript
|
||||
import { BatchImportStudentsDto } from './dto/class.dto';
|
||||
|
||||
@Post(':id/students/import')
|
||||
@RequirePermission('class:edit')
|
||||
async batchImportStudents(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: BatchImportStudentsDto,
|
||||
) {
|
||||
return this.classesService.batchImportStudents(+id, dto.dingUserIds);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add StudentDingMapping to classes.module.ts**
|
||||
|
||||
```typescript
|
||||
TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping]),
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify compile + commit**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit
|
||||
git add apps/server/src/classes/
|
||||
git commit -m "feat: add batch-import students to class endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — rewrite Drawer with checkable Tree + class list
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /sync/dingtalk/org-tree-with-users`, `GET /classes`, `POST /classes/:id/students/import`, `POST /classes`
|
||||
- Produces: redesigned Drawer with left-right split layout
|
||||
|
||||
- [ ] **Step 1: Update imports**
|
||||
|
||||
```typescript
|
||||
import { Tree, Row, Col, Card, List, Button, Space, Drawer, Alert, TreeSelect, message, Modal, Form, Input, DatePicker, InputNumber, Select, Tag } from 'antd';
|
||||
import { SyncOutlined, BankOutlined, UserOutlined } from '@ant-design/icons';
|
||||
```
|
||||
|
||||
Remove: `ReloadOutlined` (no longer used), `Spin`, `Descriptions`, etc.
|
||||
|
||||
- [ ] **Step 2: Replace state**
|
||||
|
||||
```typescript
|
||||
// Remove: teacherChecks, teacherRoles, defaultTeacherRoleId, roles, classMarks, classModalDept
|
||||
// Add:
|
||||
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
|
||||
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
|
||||
const [classes, setClasses] = useState<any[]>([]);
|
||||
const [classForm] = Form.useForm();
|
||||
const [classModalOpen, setClassModalOpen] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `buildTreeData` (checkable, no toggles)**
|
||||
|
||||
```typescript
|
||||
const buildTreeData = useCallback((nodes: any[]): any[] => {
|
||||
return nodes.map((node) => ({
|
||||
title: (
|
||||
<Space size="small">
|
||||
<BankOutlined />
|
||||
<span>{node.name}</span>
|
||||
<Tag>{node.users.length}人</Tag>
|
||||
</Space>
|
||||
),
|
||||
key: `dept-${node.id}`,
|
||||
children: [
|
||||
...buildTreeData(node.children),
|
||||
...node.users.map((u: any) => ({
|
||||
title: <Space><UserOutlined /><span>{u.name}</span><Tag>{u.mobile}</Tag></Space>,
|
||||
key: `user-${u.userid}`,
|
||||
})),
|
||||
],
|
||||
}));
|
||||
}, []);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `fetchClasses`**
|
||||
|
||||
```typescript
|
||||
const fetchClasses = async () => {
|
||||
try {
|
||||
const res = await api.get('/classes');
|
||||
setClasses(Array.isArray(res) ? res : res.data ?? []);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
```
|
||||
|
||||
Call `fetchClasses()` inside `handleFetchOrgTree`.
|
||||
|
||||
- [ ] **Step 5: `handleJoinClass`**
|
||||
|
||||
```typescript
|
||||
const handleJoinClass = async () => {
|
||||
if (selectedClassId === null) return message.warning('请先选择一个班级');
|
||||
const userIds = checkedKeys
|
||||
.filter((k) => String(k).startsWith('user-'))
|
||||
.map((k) => String(k).replace('user-', ''));
|
||||
if (userIds.length === 0) return message.warning('请勾选要导入的用户');
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await api.post(`/classes/${selectedClassId}/students/import`, { dingUserIds: userIds });
|
||||
message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`);
|
||||
setCheckedKeys([]);
|
||||
setSelectedClassId(null);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '导入失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 6: `handleCreateClass` modal**
|
||||
|
||||
```typescript
|
||||
const handleCreateClass = async () => {
|
||||
try {
|
||||
const values = await classForm.validateFields();
|
||||
const userIds = checkedKeys
|
||||
.filter((k) => String(k).startsWith('user-'))
|
||||
.map((k) => String(k).replace('user-', ''));
|
||||
const deptKeys = checkedKeys.filter((k) => String(k).startsWith('dept-'));
|
||||
const deptId = deptKeys.length > 0
|
||||
? Number(String(deptKeys[0]).replace('dept-', ''))
|
||||
: undefined;
|
||||
|
||||
setImporting(true);
|
||||
await api.post('/classes', { ...values, dingUserIds: userIds, departmentId: deptId });
|
||||
message.success('班级创建成功');
|
||||
setClassModalOpen(false);
|
||||
classForm.resetFields();
|
||||
setCheckedKeys([]);
|
||||
fetchClasses();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '创建失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Render Drawer with left-right layout**
|
||||
|
||||
```tsx
|
||||
<Drawer
|
||||
title="钉钉组织架构 — 批量导入"
|
||||
open={drawerOpen}
|
||||
onClose={() => { setDrawerOpen(false); }}
|
||||
width={900}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={() => { setDrawerOpen(false); }}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={importing}
|
||||
disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0 || selectedClassId === null}
|
||||
onClick={handleJoinClass}
|
||||
>加入选中的班级</Button>
|
||||
<Button
|
||||
disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0}
|
||||
onClick={() => setClassModalOpen(true)}
|
||||
>创建班级</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<div style={{ maxHeight: '60vh', overflow: 'auto' }}>
|
||||
<Tree
|
||||
checkable
|
||||
treeData={treeData}
|
||||
defaultExpandAll
|
||||
showLine={{ showLeafIcon: false }}
|
||||
checkedKeys={checkedKeys}
|
||||
onCheck={(checked) => setCheckedKeys(checked as React.Key[])}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Card title="班级列表" size="small"
|
||||
extra={<Button size="small" onClick={() => setClassModalOpen(true)}>+ 创建班级</Button>}>
|
||||
<List
|
||||
dataSource={classes}
|
||||
renderItem={(cls: any) => (
|
||||
<List.Item
|
||||
onClick={() => setSelectedClassId(cls.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: selectedClassId === cls.id ? '#e6f4ff' : undefined,
|
||||
borderRadius: 4,
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta title={cls.name} description={`${cls.code} ${cls.classType || ''}`} />
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 创建班级 Modal */}
|
||||
<Modal
|
||||
title="创建班级"
|
||||
open={classModalOpen}
|
||||
onOk={handleCreateClass}
|
||||
onCancel={() => { setClassModalOpen(false); classForm.resetFields(); }}
|
||||
confirmLoading={importing}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={classForm} layout="vertical">
|
||||
<Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 CS2024-01" />
|
||||
</Form.Item>
|
||||
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ value: 'culture', label: '文化课' },
|
||||
{ value: 'professional', label: '专业课' },
|
||||
{ value: 'bootcamp', label: '集训营' },
|
||||
{ value: 'sprint', label: '冲刺班' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="startDate" label="开班日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="endDate" label="结束日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxStudents" label="最大人数">
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0=不限制" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Delete old code**
|
||||
|
||||
Remove: `UserTreeNode` component, `teacherChecks`/`teacherRoles`/`classMarks`/`classModalDept` state, `handleToggleTeacher`/`handleClassModalOk`/`handleClassModalCancel`/`handleImportUsers` callbacks, `buildTreeData` (old version), per-department class mark modal, role fetch logic.
|
||||
|
||||
- [ ] **Step 9: Verify compile**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/admin/src/pages/IntegrationConfig/index.tsx
|
||||
git commit -m "feat: rewrite sync drawer with checkable tree + class batch import"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Cleanup tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/sync/sync.service.spec.ts` — swap UserDingMapping → StudentDingMapping, remove User-related mocks
|
||||
- Modify: `apps/server/src/attendance/attendance.service.spec.ts` — same swap
|
||||
|
||||
- [ ] **Step 1: Fix sync.service.spec.ts**
|
||||
|
||||
Replace all `UserDingMapping` with `StudentDingMapping`. Remove `userRepo`, `roleRepo`, `bcrypt` mocks. Update test cases that tested `importDingTalkUsers` — those tests are removed (the method is gone in Task 4). Keep only remaining tests.
|
||||
|
||||
- [ ] **Step 2: Fix attendance.service.spec.ts**
|
||||
|
||||
Replace `UserDingMapping` with `StudentDingMapping` in imports and mock providers.
|
||||
|
||||
- [ ] **Step 3: Verify compile**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/sync/sync.service.spec.ts apps/server/src/attendance/attendance.service.spec.ts
|
||||
git commit -m "chore: update tests for StudentDingMapping"
|
||||
```
|
||||
@@ -1,334 +0,0 @@
|
||||
# Student 角色分离 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 允许管理员将钉钉同步的用户标记为"教职工"(摘掉 Student),使其不在学员管理中出现,且下次同步不恢复。
|
||||
|
||||
**Architecture:** 利用 Student.status 新增值 `'staff'`,rbac 模块加两个端点,syncOneUser 加防护逻辑,前端加操作按钮。
|
||||
|
||||
**Tech Stack:** NestJS + TypeORM + React 19 + Ant Design 6
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Student.status 是 varchar,`'staff'` 是新增有效值,无需数据库迁移
|
||||
- 物理删除 User 已禁止(必须先归档),本方案不涉及 User CRUD 修改
|
||||
- 同步时不覆盖 `status != 'active'` 的 Student
|
||||
|
||||
---
|
||||
|
||||
### Task 1: syncOneUser 防护 — 不覆盖非 active 的 Student
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/integration/dingtalk.service.ts` (syncOneUser 方法内)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Student.status` (现有字段)
|
||||
- Produces: 无新增接口,行为变更
|
||||
|
||||
- [ ] **Step 1: 定位 syncOneUser 中创建/更新 Student 的代码**
|
||||
|
||||
当前逻辑(约 line 497-520):新用户创建 Student,已有用户 backfill Student。
|
||||
|
||||
- [ ] **Step 2: 在创建/更新 Student 前加防护**
|
||||
|
||||
在 `syncOneUser` 中,创建新 Student 和 backfill 已有 Student 之前,都先检查是否已有 status 为非 active 的记录:
|
||||
|
||||
```typescript
|
||||
// 在创建 Student 之前(约 line 497):
|
||||
// 检查是否已被手动标记为教职工/毕业/退训
|
||||
const existingStudent = await this.studentRepo.findOne({ where: { userId: user.id } });
|
||||
if (existingStudent && existingStudent.status !== 'active') {
|
||||
// 用户已被标记为非学员状态,不覆盖
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建新 Student(原有逻辑,在防护之后)
|
||||
const student = this.studentRepo.create({
|
||||
name: du.name,
|
||||
phone: du.mobile || undefined,
|
||||
userId: user.id,
|
||||
status: 'active',
|
||||
});
|
||||
await this.studentRepo.save(student);
|
||||
```
|
||||
|
||||
同样在 backfill 分支(约 line 510)也加相同检查:
|
||||
|
||||
```typescript
|
||||
// backfill 分支:已有 User 但没有 Student
|
||||
const existingStudent = await this.studentRepo.findOne({ where: { userId: user.id } });
|
||||
if (!existingStudent || existingStudent.status === 'active') {
|
||||
// 只在没有 Student 或 Student 为 active 时才 backfill
|
||||
if (!existingStudent) {
|
||||
const student = this.studentRepo.create({ ... });
|
||||
await this.studentRepo.save(student);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译检查**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/integration/dingtalk.service.ts
|
||||
git commit -m "feat: syncOneUser skips Students with non-active status"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: RBAC mark-staff / mark-student 端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/rbac/rbac.service.ts`
|
||||
- Modify: `apps/server/src/rbac/rbac.controller.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `PUT /rbac/users/:id/mark-staff` → `{ message: string }`
|
||||
- Produces: `PUT /rbac/users/:id/mark-student` → `{ message: string }`
|
||||
|
||||
- [ ] **Step 1: 注入 Student repo 到 RbacService**
|
||||
|
||||
在 `apps/server/src/rbac/rbac.service.ts`:
|
||||
|
||||
```typescript
|
||||
import { Student } from '../entities';
|
||||
|
||||
// constructor 中新增:
|
||||
@InjectRepository(Student)
|
||||
private readonly studentRepo: Repository<Student>,
|
||||
```
|
||||
|
||||
在 `apps/server/src/rbac/rbac.module.ts` 中 `TypeOrmModule.forFeature` 加入 `Student`。
|
||||
|
||||
- [ ] **Step 2: 添加 markAsStaff 方法**
|
||||
|
||||
```typescript
|
||||
async markAsStaff(userId: number) {
|
||||
const student = await this.studentRepo.findOne({ where: { userId } });
|
||||
if (!student) throw new Error('该用户没有学员记录');
|
||||
await this.studentRepo.update(student.id, { status: 'staff' });
|
||||
return { message: '已标记为教职工' };
|
||||
}
|
||||
|
||||
async markAsStudent(userId: number) {
|
||||
const student = await this.studentRepo.findOne({ where: { userId } });
|
||||
if (!student) throw new Error('该用户没有学员记录');
|
||||
await this.studentRepo.update(student.id, { status: 'active' });
|
||||
return { message: '已恢复为学员' };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加 Controller 端点**
|
||||
|
||||
在 `apps/server/src/rbac/rbac.controller.ts` 中的用户管理区域添加:
|
||||
|
||||
```typescript
|
||||
@Put('users/:id/mark-staff')
|
||||
@RequirePermission('user:edit')
|
||||
async markAsStaff(@Param('id') id: string) {
|
||||
try {
|
||||
return await this.rbacService.markAsStaff(+id);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
throw new BadRequestException(err?.message);
|
||||
}
|
||||
}
|
||||
|
||||
@Put('users/:id/mark-student')
|
||||
@RequirePermission('user:edit')
|
||||
async markAsStudent(@Param('id') id: string) {
|
||||
try {
|
||||
return await this.rbacService.markAsStudent(+id);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
throw new BadRequestException(err?.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
路由顺序检查:`users/:id/mark-staff` 在 `users/:id/password` 之后,`users/:id/archive` 之前,无冲突。
|
||||
|
||||
- [ ] **Step 4: 更新 findAllUsers 返回 Student.status**
|
||||
|
||||
在 `findAllUsers` 的返回映射中加入 student status:
|
||||
|
||||
```typescript
|
||||
// 在 findAllUsers 方法中,先批量查 Student
|
||||
const userIds = users.map((u) => u.id);
|
||||
const students = await this.studentRepo.find({
|
||||
where: { userId: In(userIds) },
|
||||
select: ['userId', 'status'],
|
||||
});
|
||||
const statusMap = new Map(students.map((s) => [s.userId, s.status]));
|
||||
|
||||
return users.map((u) => ({
|
||||
// ...原有字段...
|
||||
studentStatus: statusMap.get(u.id) || null,
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 编译检查**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/rbac/
|
||||
git commit -m "feat: add mark-staff/mark-student endpoints for role separation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 学员列表默认隐藏 staff
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/students/students.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Student.status` (现有字段)
|
||||
- Produces: `GET /students` 默认排除 status='staff'
|
||||
|
||||
- [ ] **Step 1: 修改 findAll 默认过滤条件**
|
||||
|
||||
在 `apps/server/src/students/students.service.ts` 的 `findAll` 方法中:
|
||||
|
||||
```typescript
|
||||
// 在 where 条件中默认排除 staff,除非明确传了 status=staff
|
||||
if (!query.status) {
|
||||
where.status = Not('staff');
|
||||
}
|
||||
// 如果明确传了 status=staff,则按传入值查询
|
||||
```
|
||||
|
||||
如果 `query.status` 传了 `'staff'`,则按 `staff` 过滤,否则默认排除。
|
||||
|
||||
- [ ] **Step 2: 编译检查 + Commit**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
|
||||
git add apps/server/src/students/students.service.ts
|
||||
git commit -m "feat: Students list excludes staff by default"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 — 用户管理页面加标记/恢复按钮
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Users/index.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /rbac/users?isArchived=` 返回 `studentStatus` 字段
|
||||
- Consumes: `PUT /rbac/users/:id/mark-staff`, `PUT /rbac/users/:id/mark-student`
|
||||
|
||||
- [ ] **Step 1: 添加 handleMarkStaff 函数**
|
||||
|
||||
```typescript
|
||||
const handleMarkStaff = async (id: number, toStaff: boolean) => {
|
||||
try {
|
||||
await api.put(`/rbac/users/${id}/${toStaff ? 'mark-staff' : 'mark-student'}`);
|
||||
message.success(toStaff ? '已标记为教职工' : '已恢复为学员');
|
||||
fetchData();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在表格操作列添加按钮**
|
||||
|
||||
在操作列中,归档按钮之后添加:
|
||||
|
||||
```tsx
|
||||
{record.studentStatus === 'active' && (
|
||||
<Popconfirm title="确认标记为教职工?" onConfirm={() => handleMarkStaff(record.id, true)}>
|
||||
<PermissionButton permission="user:edit" type="link" size="small">
|
||||
标记教职工
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{record.studentStatus === 'staff' && (
|
||||
<Popconfirm title="确认恢复为学员?" onConfirm={() => handleMarkStaff(record.id, false)}>
|
||||
<PermissionButton permission="user:edit" type="link" size="small">
|
||||
恢复学员
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译检查 + Commit**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
git add apps/admin/src/pages/Users/index.tsx
|
||||
git commit -m "feat: add mark-staff/restore-student buttons in user management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 前端 — 学员管理页面加 staff 筛选
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Students/index.tsx`
|
||||
|
||||
- [ ] **Step 1: 检查 Students 页面现有筛选**
|
||||
|
||||
`apps/admin/src/pages/Students/index.tsx` 已有 `statusMap` 包含 active/graduated/withdrawn/archived。
|
||||
|
||||
- [ ] **Step 2: 添加 staff 状态到 statusMap**
|
||||
|
||||
```typescript
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '在读', color: 'green' },
|
||||
graduated: { text: '已毕业', color: 'blue' },
|
||||
withdrawn: { text: '已退训', color: 'red' },
|
||||
archived: { text: '已归档', color: '#999' },
|
||||
staff: { text: '教职工', color: 'purple' },
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 确认可搜索 staff 状态**
|
||||
|
||||
因为 Student findAll 已经支持 `status` 查询参数(传 `staff` 即可),前端只需把 staff 加入 Select options 即可。staff 不在默认显示中,需要主动切换状态筛选才能看到。
|
||||
|
||||
- [ ] **Step 4: 编译检查 + Commit**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
git add apps/admin/src/pages/Students/index.tsx
|
||||
git commit -m "feat: add staff status filter in student management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 端到端验证
|
||||
|
||||
- [ ] **Step 1: 启动服务**
|
||||
|
||||
```bash
|
||||
cd apps/server && npm run start:dev
|
||||
cd apps/admin && npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证流程**
|
||||
|
||||
1. 打开「账号管理」→ 确认列表中有 `studentStatus` 显示
|
||||
2. 找到一个 studentStatus='active' 的用户 → 点「标记教职工」
|
||||
3. 确认提示 "已标记为教职工",刷新后状态变为 staff
|
||||
4. 打开「学员管理」→ 默认列表不再显示该用户
|
||||
5. 切换状态筛选到"教职工"→ 能看到该用户
|
||||
6. 回到「账号管理」→ 点「恢复学员」→ 确认恢复
|
||||
7. 模拟同步:触发 `POST /api/sync/trigger` → 确认 staff 状态的学员不被覆盖
|
||||
|
||||
- [ ] **Step 3: Commit(如有修复)**
|
||||
@@ -1,390 +0,0 @@
|
||||
# 修复学生导入姓名与账号 — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 修复批量导入学生时 name 填占位符 `dd_xxx`、phone 未设置的问题,改为使用前端传入的真实姓名和手机号。
|
||||
|
||||
**Architecture:** 前端已有钉钉用户 name/mobile 数据,导入时随请求传给后端,后端直接用于创建 Student,零额外钉钉 API 调用。
|
||||
|
||||
**Tech Stack:** NestJS + TypeORM + React + Ant Design
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 不改 Student entity / 数据库 schema
|
||||
- 不改 DingTalkService
|
||||
- 不改 StudentDingMapping
|
||||
- `tsc --noEmit` 编译通过
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 DTO — 字段改名
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/classes/dto/class.dto.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing
|
||||
- Produces: `CreateClassDto.users`, `BatchImportStudentsDto.users`
|
||||
|
||||
- [ ] **Step 1: 改 BatchImportStudentsDto**
|
||||
|
||||
`class.dto.ts` 第 136-141 行:
|
||||
|
||||
```typescript
|
||||
export class BatchImportStudentsDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayNotEmpty()
|
||||
dingUserIds: string[];
|
||||
}
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```typescript
|
||||
export class BatchImportStudentsDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImportUserItem)
|
||||
users: ImportUserItem[];
|
||||
}
|
||||
|
||||
export class ImportUserItem {
|
||||
@IsString() @IsNotEmpty()
|
||||
dingUserId: string;
|
||||
|
||||
@IsString() @IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
mobile?: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 改 CreateClassDto**
|
||||
|
||||
第 43-46 行,`dingUserIds` 字段:
|
||||
|
||||
```typescript
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
dingUserIds?: string[];
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```typescript
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImportUserItem)
|
||||
users?: ImportUserItem[];
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 补 import**
|
||||
|
||||
文件头部 import 行(第 1 行),追加 `ValidateNested`:
|
||||
|
||||
```typescript
|
||||
import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString, IsEnum, ArrayNotEmpty, ValidateNested } from 'class-validator';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编译验证**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit 2>&1 | head -20
|
||||
```
|
||||
|
||||
预期:Pass(如果后续 Task 没改完会有 type error,属于预期)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/classes/dto/class.dto.ts
|
||||
git commit -m "feat: change dingUserIds to users array with name/mobile in DTOs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端 Service — 使用真实 name 和 phone
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/classes/classes.service.ts:96-191`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `BatchImportStudentsDto.users`, `CreateClassDto.users` (from Task 1)
|
||||
- Produces: updated `batchImportStudents` signature
|
||||
|
||||
- [ ] **Step 1: 改 create() 方法**
|
||||
|
||||
第 96-128 行,两处 `dingUserIds` → `users`:
|
||||
|
||||
```typescript
|
||||
async create(dto: CreateClassDto) {
|
||||
const { studentIds, teachers, users, ...classData } = dto;
|
||||
…
|
||||
// batch import students by dingUserIds
|
||||
if (users?.length) {
|
||||
await this.batchImportStudents(saved.id, users);
|
||||
}
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 改 batchImportStudents 签名和逻辑**
|
||||
|
||||
第 131-191 行:
|
||||
|
||||
```typescript
|
||||
async batchImportStudents(classId: number, users: Array<{
|
||||
dingUserId: string; name: string; mobile?: string;
|
||||
}>): Promise<{ imported: number; skipped: number }> {
|
||||
const classEntity = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!classEntity) throw new NotFoundException('班级不存在');
|
||||
|
||||
if (users.length === 0) return { imported: 0, skipped: 0 };
|
||||
|
||||
const dingUserIds = users.map(u => u.dingUserId);
|
||||
|
||||
// 1. Fetch all existing ding mappings in one query
|
||||
const existingMappings = await this.studentDingMappingRepo.find({
|
||||
where: { dingUserId: In(dingUserIds) },
|
||||
});
|
||||
const dingToStudentId = new Map(existingMappings.map(m => [m.dingUserId, m.studentId]));
|
||||
|
||||
// 2. Batch create students for new dingUserIds
|
||||
const newUsers = users.filter(u => !dingToStudentId.has(u.dingUserId));
|
||||
if (newUsers.length > 0) {
|
||||
const newStudents = newUsers.map(u =>
|
||||
this.studentRepo.create({
|
||||
name: u.name,
|
||||
phone: u.mobile || `dt_${u.dingUserId}`,
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
const savedStudents = await this.studentRepo.save(newStudents);
|
||||
|
||||
const newMappings = savedStudents.map((s, i) =>
|
||||
this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id })
|
||||
);
|
||||
await this.studentDingMappingRepo.save(newMappings);
|
||||
|
||||
for (let i = 0; i < newUsers.length; i++) {
|
||||
dingToStudentId.set(newUsers[i].dingUserId, savedStudents[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch existing class-student links in one query
|
||||
const allStudentIds = Array.from(dingToStudentId.values());
|
||||
… // 不变
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit 2>&1 | head -20
|
||||
```
|
||||
|
||||
预期:Pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/classes/classes.service.ts
|
||||
git commit -m "fix: use real name and phone from frontend when importing students"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端 Controller — 传参修正
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/classes/classes.controller.ts:93-98`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `BatchImportStudentsDto.users` (from Task 1)
|
||||
- Produces: nothing new
|
||||
|
||||
- [ ] **Step 1: 改 controller 传参**
|
||||
|
||||
第 97 行:
|
||||
|
||||
```typescript
|
||||
return this.service.batchImportStudents(+id, dto.users);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编译验证**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit 2>&1 | head -20
|
||||
```
|
||||
|
||||
预期:Pass
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/classes/classes.controller.ts
|
||||
git commit -m "fix: pass dto.users instead of dto.dingUserIds in controller"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 — 传用户信息而非仅 ID
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `orgTree` (state), `checkedKeys` (state)
|
||||
- Produces: updated API call payloads
|
||||
|
||||
- [ ] **Step 1: 写 extractCheckedUsers 工具函数**
|
||||
|
||||
在 `handleJoinClass` 上方插入:
|
||||
|
||||
```typescript
|
||||
const extractCheckedUsers = useCallback((): Array<{ dingUserId: string; name: string; mobile?: string }> => {
|
||||
const result: Array<{ dingUserId: string; name: string; mobile?: string }> = [];
|
||||
const walk = (nodes: DingOrgTreeNodeExt[]) => {
|
||||
for (const node of nodes) {
|
||||
for (const u of node.users) {
|
||||
if (checkedKeys.includes(`user-${u.userid}`)) {
|
||||
result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });
|
||||
}
|
||||
}
|
||||
walk(node.children);
|
||||
}
|
||||
};
|
||||
walk(orgTree);
|
||||
return result;
|
||||
}, [checkedKeys, orgTree]);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 改 handleJoinClass**
|
||||
|
||||
第 212-231 行,旧代码:
|
||||
|
||||
```typescript
|
||||
const handleJoinClass = async () => {
|
||||
if (selectedClassId === null) return message.warning('请先选择一个班级');
|
||||
const userIds = checkedKeys
|
||||
.filter((k) => String(k).startsWith('user-'))
|
||||
.map((k) => String(k).replace('user-', ''));
|
||||
if (userIds.length === 0) return message.warning('请勾选要导入的用户');
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await api.post<ImportResult>(`/classes/${selectedClassId}/students/import`, { dingUserIds: userIds });
|
||||
message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`);
|
||||
setCheckedKeys([]);
|
||||
setSelectedClassId(null);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '导入失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```typescript
|
||||
const handleJoinClass = async () => {
|
||||
if (selectedClassId === null) return message.warning('请先选择一个班级');
|
||||
const users = extractCheckedUsers();
|
||||
if (users.length === 0) return message.warning('请勾选要导入的用户');
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await api.post<ImportResult>(`/classes/${selectedClassId}/students/import`, { users });
|
||||
message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`);
|
||||
setCheckedKeys([]);
|
||||
setSelectedClassId(null);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '导入失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 改 handleCreateClass**
|
||||
|
||||
第 233-251 行:
|
||||
|
||||
```typescript
|
||||
const handleCreateClass = async () => {
|
||||
try {
|
||||
const values = await classForm.validateFields();
|
||||
const users = extractCheckedUsers();
|
||||
await api.post('/classes', { ...values, users });
|
||||
message.success('班级创建成功');
|
||||
setClassModalOpen(false);
|
||||
classForm.resetFields();
|
||||
setCheckedKeys([]);
|
||||
fetchClasses();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '创建失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
(关键变更:`dingUserIds: userIds` → `users`,删除手动的 `.filter().map()` 逻辑)
|
||||
|
||||
- [ ] **Step 4: 前端编译验证**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx tsc --noEmit 2>&1 | head -20
|
||||
```
|
||||
|
||||
预期:Pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/admin/src/pages/IntegrationConfig/index.tsx
|
||||
git commit -m "fix: pass user name and mobile to backend when importing students"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 端到端验证
|
||||
|
||||
- [ ] **Step 1: 后端全量编译**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit
|
||||
```
|
||||
|
||||
预期:0 errors
|
||||
|
||||
- [ ] **Step 2: 前端全量编译**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
预期:0 errors
|
||||
|
||||
- [ ] **Step 3: 后端测试**
|
||||
|
||||
```bash
|
||||
cd apps/server && npm test 2>&1 | tail -20
|
||||
```
|
||||
|
||||
预期:现有测试全部通过
|
||||
|
||||
- [ ] **Step 4: Commit(如有遗漏)**
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
@@ -1,76 +0,0 @@
|
||||
# 课程二态考勤与截止自动结算 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 教师课程考勤只显示已打卡/未打卡,并在课程截止后自动最终拉取、落库和完成场次。
|
||||
|
||||
**Architecture:** 保留钉钉原始迟到状态;课程服务根据“是否存在实际打卡时间”生成临时二态结果和最终 `present/absent`。新增 Attendance 模块内的 NestJS 定时结算服务,每分钟扫描到期课程并复用导入与课程考勤服务,失败留待下一轮补偿。
|
||||
|
||||
**Tech Stack:** NestJS 11、@nestjs/schedule 6、TypeORM 0.3、Jest、React 19、Ant Design 6。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 仅调整排课关联的课程考勤。
|
||||
- 不迁移历史记录,不改变钉钉原始记录。
|
||||
- 不新增依赖、队列、兼容层或重复状态模型。
|
||||
- 课程截止后有实际打卡写 `present`,无实际打卡写 `absent`。
|
||||
- 单节失败不得阻断其他课程,后续扫描必须可补偿。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 课程二态映射
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/attendance/attendance.service.ts`
|
||||
- Test: `apps/server/src/attendance/attendance.lesson-session.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `createLessonAttendanceFromDingTalk(scheduleId, lessonDate, userId, finalize?)`;`finalize=false` 返回临时二态,`finalize=true` 写最终二态并完成场次。
|
||||
|
||||
- [ ] 添加失败测试:`Late` 且有 `checkInTime` 应为 `present`;无实际时间应为 `pending`;最终结算时无时间应为 `absent` 且 session 为 `completed`。
|
||||
- [ ] 运行 `npm test -- attendance.lesson-session.spec.ts --runInBand`,确认新增断言按预期失败。
|
||||
- [ ] 将课程状态映射改为只检查课程窗口内是否存在 `checkInTime` 或 `checkOutTime`;最终结算参数控制无打卡为 `absent`,并在同一事务完成场次。
|
||||
- [ ] 再次运行相同测试,确认通过。
|
||||
|
||||
### Task 2: 截止自动结算
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/server/src/attendance/attendance-settlement.service.ts`
|
||||
- Create: `apps/server/src/attendance/attendance-settlement.service.spec.ts`
|
||||
- Modify: `apps/server/src/attendance/attendance.module.ts`
|
||||
- Modify: `apps/server/src/app.module.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AttendanceImportService.importFromDingTalk(...)`、`AttendanceService.getTeacherClassDingUserIds(...)`、`AttendanceService.createLessonAttendanceFromDingTalk(..., true)`。
|
||||
- Produces: `AttendanceSettlementService.settleEndedLessons(now?: Date): Promise<void>`,由 `@Cron('* * * * *')` 调用。
|
||||
|
||||
- [ ] 添加失败测试:未截止不处理、已完成不处理、到期课程最终拉取并结算、一个课程失败后继续处理下一个、昨日跨午夜课程可结算。
|
||||
- [ ] 运行 `npm test -- attendance-settlement.service.spec.ts --runInBand`,确认因服务不存在而失败。
|
||||
- [ ] 实现每分钟扫描今天普通到期课程及昨日跨午夜到期课程;逐课程捕获异常并记录;使用课程 `teacherId` 作为自动导入审计用户。
|
||||
- [ ] 在 `AttendanceModule` 注册服务,在根模块启用 `ScheduleModule.forRoot()`。
|
||||
- [ ] 再次运行相同测试,确认通过。
|
||||
|
||||
### Task 3: 教师二态界面
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Attendance/attendance-workspace.ts`
|
||||
- Modify: `apps/admin/src/pages/Attendance/index.tsx`
|
||||
- Test: `apps/admin/src/pages/Attendance/attendance-workspace.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `summarizeLessonCheckins(records)`,将 `present`/`late` 归为已打卡,将其余归为未打卡。
|
||||
|
||||
- [ ] 添加失败测试:`present` 与遗留 `late` 均计入已打卡,`pending`/`absent` 计入未打卡。
|
||||
- [ ] 运行 admin 的定向测试命令并确认失败。
|
||||
- [ ] 教师抽屉改为“已打卡 / 未打卡”标签、汇总和手动修改选项;管理员档案保持原五态。
|
||||
- [ ] 再次运行定向测试,确认通过。
|
||||
|
||||
### Task 4: 聚焦验证
|
||||
|
||||
**Files:**
|
||||
- Verify only; no planned production edits.
|
||||
|
||||
- [ ] 运行 server 两个定向 Jest 测试文件。
|
||||
- [ ] 运行 server `npm run typecheck`。
|
||||
- [ ] 运行 admin 定向测试与 `npm run typecheck`。
|
||||
- [ ] 用现有数据库场景确认王子琪的 `Late` 在教师视图归为已打卡、陈浩无记录归为未打卡;不修改数据库数据。
|
||||
@@ -1,225 +0,0 @@
|
||||
# Classroom Field Simplification Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove the unused classroom `courseType` and `supervisor` fields from the admin UI, API contract, persistence model, and Excel import flow.
|
||||
|
||||
**Architecture:** Perform a clean cutover across every classroom caller. Add an idempotent bootstrap migration that drops the legacy columns on SQLite and MySQL, while leaving scheduling unchanged because schedule `subject` and class `classType` already own those concepts.
|
||||
|
||||
**Tech Stack:** React 19, Ant Design 6, NestJS 11, TypeORM 0.3, Jest, Vitest, SQLite, MySQL 8
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Do not add a replacement abstraction or compatibility alias.
|
||||
- Preserve classroom name, building, floor, capacity, room type, status, and notes behavior.
|
||||
- Do not change schedule form fields or schedule conflict behavior.
|
||||
- Use test-first changes and run focused tests before package builds.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Drop Legacy Classroom Columns
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/database/database-migrations.spec.ts`
|
||||
- Modify: `apps/server/src/database/database-migrations.service.ts:11`
|
||||
- Modify: `apps/server/src/entities/classroom.entity.ts:23`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: TypeORM `DataSource`, `QueryRunner`, and the existing bootstrap migration sequence.
|
||||
- Produces: private `removeUnusedClassroomColumns(): Promise<void>` called during application bootstrap.
|
||||
|
||||
- [ ] **Step 1: Write failing migration tests**
|
||||
|
||||
Extend `MigrationsPrivate` with `removeUnusedClassroomColumns(): Promise<void>`. Add focused tests proving that the method:
|
||||
|
||||
```typescript
|
||||
it('drops legacy classroom fields when present', async () => {
|
||||
const runner = mockRunner({
|
||||
getTable: {
|
||||
name: 'classrooms',
|
||||
columns: [{ name: 'id' }, { name: 'course_type' }, { name: 'supervisor' }],
|
||||
},
|
||||
});
|
||||
await bootstrapClassroomCleanup(runner);
|
||||
|
||||
await service.removeUnusedClassroomColumns();
|
||||
|
||||
expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN course_type');
|
||||
expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN supervisor');
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when classrooms or legacy fields are absent', async () => {
|
||||
const runner = mockRunner({ getTables: [], getTable: { name: 'classrooms', columns: [] } });
|
||||
await bootstrapClassroomCleanup(runner);
|
||||
|
||||
await service.removeUnusedClassroomColumns();
|
||||
|
||||
expect(runner.query).not.toHaveBeenCalled();
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
Use a local `bootstrapClassroomCleanup` helper matching the existing Nest testing-module setup.
|
||||
|
||||
- [ ] **Step 2: Run tests and verify RED**
|
||||
|
||||
Run: `npm test -w apps/server -- database/database-migrations.spec.ts --runInBand`
|
||||
|
||||
Expected: FAIL because `removeUnusedClassroomColumns` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the idempotent migration**
|
||||
|
||||
Call `await this.removeUnusedClassroomColumns();` from `onApplicationBootstrap()`. Implement the method with one query runner: inspect `classrooms`, return when absent, and issue one `ALTER TABLE classrooms DROP COLUMN <name>` per present legacy column. Always release the runner in `finally`.
|
||||
|
||||
Remove these entity properties and decorators:
|
||||
|
||||
```typescript
|
||||
courseType: string;
|
||||
supervisor: string;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run migration tests and verify GREEN**
|
||||
|
||||
Run: `npm test -w apps/server -- database/database-migrations.spec.ts --runInBand`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Remove Fields From API and Excel Import
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/classrooms/dto/classroom.dto.ts:3`
|
||||
- Modify: `apps/server/src/classrooms/classrooms.controller.ts:48`
|
||||
- Modify: `apps/server/src/classrooms/classrooms.service.ts:126`
|
||||
- Create: `apps/server/src/classrooms/classrooms.controller.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CreateClassroomDto`, `UpdateClassroomDto`, classroom Excel template/import endpoints.
|
||||
- Produces: classroom requests and spreadsheets containing only `name`, `building`, `floor`, `roomType`, `capacity`, and optional `notes` where applicable.
|
||||
|
||||
- [ ] **Step 1: Write a failing Excel contract test**
|
||||
|
||||
Instantiate `ClassroomsController` with mocked service/log dependencies, invoke `downloadTemplate`, capture the workbook response buffer, load it with ExcelJS, and assert:
|
||||
|
||||
```typescript
|
||||
expect(worksheet.getRow(1).values).toEqual([
|
||||
undefined,
|
||||
'教室名',
|
||||
'楼栋',
|
||||
'楼层',
|
||||
'类型',
|
||||
'容量',
|
||||
]);
|
||||
```
|
||||
|
||||
Also assert the usage notes no longer mention course type or supervisor.
|
||||
|
||||
- [ ] **Step 2: Run test and verify RED**
|
||||
|
||||
Run: `npm test -w apps/server -- classrooms/classrooms.controller.spec.ts --runInBand`
|
||||
|
||||
Expected: FAIL because the generated template still includes `课程类型` and `负责人`.
|
||||
|
||||
- [ ] **Step 3: Remove fields from server contracts**
|
||||
|
||||
Delete `courseType` and `supervisor` from both DTO classes. Remove them from:
|
||||
|
||||
- template columns, example rows, and usage notes;
|
||||
- parsed import rows;
|
||||
- `batchImport` row type.
|
||||
|
||||
Keep the existing field order `name`, `building`, `floor`, `roomType`, `capacity` consistent between export and import.
|
||||
|
||||
- [ ] **Step 4: Run focused server tests**
|
||||
|
||||
Run: `npm test -w apps/server -- classrooms/classrooms.controller.spec.ts database/database-migrations.spec.ts --runInBand`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Simplify Classroom Admin UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Classrooms/index.tsx:143`
|
||||
- Create: `apps/admin/src/pages/Classrooms/classroom-fields.ts`
|
||||
- Create: `apps/admin/src/pages/Classrooms/classroom-fields.integration.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: classroom records returned by `/classrooms`.
|
||||
- Produces: exported `CLASSROOM_VISIBLE_FIELDS` used to document/test the UI contract; list and form without `courseType` or `supervisor`.
|
||||
|
||||
- [ ] **Step 1: Write a failing UI contract test**
|
||||
|
||||
Create a small contract test:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CLASSROOM_VISIBLE_FIELDS } from './classroom-fields';
|
||||
|
||||
describe('classroom visible fields', () => {
|
||||
it('excludes obsolete course and supervisor metadata', () => {
|
||||
expect(CLASSROOM_VISIBLE_FIELDS).toEqual([
|
||||
'name',
|
||||
'building',
|
||||
'floor',
|
||||
'roomType',
|
||||
'capacity',
|
||||
'status',
|
||||
'notes',
|
||||
]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test and verify RED**
|
||||
|
||||
Run: `npm test -w apps/admin -- src/pages/Classrooms/classroom-fields.integration.test.ts`
|
||||
|
||||
Expected: FAIL because `classroom-fields.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement minimal UI cleanup**
|
||||
|
||||
Create the constant exactly as asserted. Remove the `课程类型` and `负责人` table columns and the corresponding two `Form.Item` blocks. Do not alter scheduling pages.
|
||||
|
||||
- [ ] **Step 4: Run focused admin test**
|
||||
|
||||
Run: `npm test -w apps/admin -- src/pages/Classrooms/classroom-fields.integration.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Verify Clean Cutover
|
||||
|
||||
**Files:**
|
||||
- Verify: `apps/server/src/entities/classroom.entity.ts`
|
||||
- Verify: `apps/server/src/classrooms/`
|
||||
- Verify: `apps/admin/src/pages/Classrooms/`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: completed Tasks 1–3.
|
||||
- Produces: buildable admin/server packages with no classroom `courseType` or `supervisor` path.
|
||||
|
||||
- [ ] **Step 1: Run focused tests together**
|
||||
|
||||
Run: `npm test -w apps/server -- classrooms/classrooms.controller.spec.ts database/database-migrations.spec.ts --runInBand`
|
||||
|
||||
Run: `npm test -w apps/admin -- src/pages/Classrooms/classroom-fields.integration.test.ts`
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 2: Run package typechecks/builds**
|
||||
|
||||
Run: `npm run typecheck -w apps/server`
|
||||
|
||||
Run: `npm run build -w apps/admin`
|
||||
|
||||
Expected: both exit successfully.
|
||||
|
||||
- [ ] **Step 3: Smoke-test observable contracts**
|
||||
|
||||
Start the existing app and verify:
|
||||
|
||||
1. Adding/editing a classroom shows no course type or supervisor field.
|
||||
2. Classroom list shows neither obsolete column.
|
||||
3. Downloaded import template has five headers: 教室名、楼栋、楼层、类型、容量.
|
||||
4. Creating a schedule still selects and saves a classroom normally.
|
||||
|
||||
Expected: all four scenarios succeed without console or API errors.
|
||||
@@ -1,282 +0,0 @@
|
||||
# CASL 授权体系迁移文档
|
||||
|
||||
## 概述
|
||||
|
||||
NestJS 后端授权已从基于 `permissions.includes()` 的字符串匹配迁移到 CASL(`@casl/ability`)基于能力的 ABAC 授权模型。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ AuthorizationModule (@Global) │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌────────────────────────┐ │
|
||||
│ │ CaslAbilityFactory │ │ AuthorizationService │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ createForUser(user) │ │ can(req, action, subj) │ │
|
||||
│ │ → AppAbility │ │ assert(req, ...) │ │
|
||||
│ │ │ │ canAbility(ab, ...) │ │
|
||||
│ └──────────┬───────────┘ │ assertAbility(ab, ...) │ │
|
||||
│ │ └────────────────────────┘ │
|
||||
│ ┌──────────▼───────────┐ ┌────────────────────────┐ │
|
||||
│ │ casl.constants.ts │ │ PoliciesGuard │ │
|
||||
│ │ mapPermissionCode() │ │ @CheckPolicies(…) │ │
|
||||
│ │ CaslAction/Subject │ └────────────────────────┘ │
|
||||
│ └──────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 核心类型
|
||||
|
||||
|概念|类型|说明|
|
||||
|---|---|---|
|
||||
|Action|`CaslAction`|`'manage' \| 'create' \| 'read' \| 'update' \| 'delete'`|
|
||||
|Subject|`SubjectName`|`'Student' \| 'Room' \| 'Class' \| …` (所有实体)|
|
||||
|Ability|`AppAbility`|`MongoAbility<[CaslAction, AppSubject]>`|
|
||||
|User|`AuthenticatedUser`|`{ id, username, permissions, isSuperAdmin, roles }`|
|
||||
|
||||
### 权限码映射
|
||||
|
||||
|旧权限码|CASL Action|CASL Subject|
|
||||
|---|---|---|
|
||||
|`student:view`|`read`|`Student`|
|
||||
|`student:create` / `student:import`|`create`|`Student`|
|
||||
|`student:edit`|`update`|`Student`|
|
||||
|`student:delete`|`delete`|`Student`|
|
||||
|`student:export`|`read`|`Student`|
|
||||
|`occupancy:checkin`|`create`|`Occupancy`|
|
||||
|`occupancy:checkout`|`update`|`Occupancy`|
|
||||
|`occupancy:transfer`|`update`|`Occupancy`|
|
||||
|`bill:generate` / `bill:confirm`|`update`|`Bill`|
|
||||
|`bill:export-excel` / `bill:export-pdf`|`read`|`Bill`|
|
||||
|`deposit:approve`|`update`|`Deposit`|
|
||||
|`sync:trigger` / `integration:trigger`|`update`|`Sync` / `Integration`|
|
||||
|
||||
完整映射见 `apps/server/src/authorization/casl.constants.ts`。
|
||||
|
||||
### 超管处理
|
||||
|
||||
`isSuperAdmin === true` → `ability.can('manage', 'all')` → 所有操作全部放行。
|
||||
|
||||
### 未知权限码处理
|
||||
|
||||
未知/无法映射的权限码(如 `ghost:action`)→ **不产生任何 CASL ability → deny-by-default**。用户对象上仍保留完整的 `permissions` 数组用于前端菜单/日志,但授权判断拒绝未知码。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
### 新增文件
|
||||
|
||||
|文件|说明|
|
||||
|---|---|
|
||||
|`apps/server/src/authorization/casl.constants.ts`|Action/Subject 定义、权限码映射函数|
|
||||
|`apps/server/src/authorization/interfaces.ts`|`AppAbility`, `AuthenticatedUser`, `PolicyHandler` 类型|
|
||||
|`apps/server/src/authorization/casl-ability.factory.ts`|CASL Ability 构建工厂|
|
||||
|`apps/server/src/authorization/authorization.service.ts`|通用授权服务(HTTP + 非 HTTP)|
|
||||
|`apps/server/src/authorization/authorization.module.ts`|@Global 模块|
|
||||
|`apps/server/src/authorization/index.ts`|桶导出|
|
||||
|`apps/server/src/authorization/decorators/check-policies.decorator.ts`|`@CheckPolicies()` 装饰器|
|
||||
|`apps/server/src/authorization/guards/policies.guard.ts`|`PoliciesGuard` CASL 策略守卫|
|
||||
|`apps/server/src/authorization/casl-ability.factory.spec.ts`|工厂测试(17 用例)|
|
||||
|`apps/server/src/authorization/authorization.service.spec.ts`|服务测试(12 用例)|
|
||||
|`apps/server/src/authorization/guards/policies.guard.spec.ts`|策略守卫测试(7 用例)|
|
||||
|
||||
### 修改文件
|
||||
|
||||
|文件|变更|
|
||||
|---|---|
|
||||
|`apps/server/src/auth/guards/permission.guard.ts`|注入 `CaslAbilityFactory`,用 `ability.can()` 替代 `permissions.includes()`|
|
||||
|`apps/server/src/auth/guards/permission.guard.spec.ts`|新增 CASL 授权测试(7 用例)|
|
||||
|`apps/server/src/app.module.ts`|导入 `AuthorizationModule`|
|
||||
|`apps/server/package.json`|新增 `@casl/ability` 依赖|
|
||||
|`apps/server/src/students/students.controller.ts`|注入 `AuthorizationService`,用 CASL 替代 `isSuperAdmin` 检查|
|
||||
|`apps/server/src/classes/classes.controller.ts`|同上|
|
||||
|`apps/server/src/attendance/attendance.controller.ts`|同上,修复测试兼容|
|
||||
|`apps/server/src/schedules/schedules.controller.ts`|同上|
|
||||
|`apps/server/src/dashboard/dashboard.controller.ts`|同上|
|
||||
|
||||
## Agent Tool 使用指南
|
||||
|
||||
CASL 授权服务**不依赖 HTTP ExecutionContext**,可在 Agent Tool、后台任务、CLI 等场景直接使用:
|
||||
|
||||
```typescript
|
||||
import { CaslAbilityFactory } from './authorization';
|
||||
import { AuthorizationService } from './authorization';
|
||||
import { CaslAction, SubjectName } from './authorization';
|
||||
|
||||
// 方式 1: 只构建 Ability
|
||||
const factory = app.get(CaslAbilityFactory);
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['attendance:view', 'attendance:create'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
if (ability.can(CaslAction.Read, SubjectName.Attendance)) {
|
||||
// 执行考勤查询
|
||||
}
|
||||
|
||||
// 方式 2: 使用 AuthorizationService
|
||||
const authz = app.get(AuthorizationService);
|
||||
const toolAbility = factory.createForUser(user);
|
||||
authz.assertAbility(toolAbility, CaslAction.Create, SubjectName.Attendance);
|
||||
// 如果无权限,抛出 ForbiddenException
|
||||
|
||||
// 方式 3: 通过 request-like 对象(适用于有 request 模拟的场景)
|
||||
authz.assert(
|
||||
{ user: { permissions: ['student:view'], isSuperAdmin: false } },
|
||||
CaslAction.Read,
|
||||
SubjectName.Student,
|
||||
);
|
||||
```
|
||||
|
||||
推荐 Agent Tool 使用 **方式 1+2**:先用 `factory.createForUser(user)` 构建 ability,再用 `service.canAbility/assertAbility` 检查。这种方式完全独立于 NestJS 请求生命周期。
|
||||
|
||||
## 测试命令与结果
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
|
||||
# 全部测试
|
||||
npx jest --no-coverage
|
||||
# 结果: 27 passed, 127 passed, 3 skipped
|
||||
|
||||
# 仅 CASL 相关测试
|
||||
npx jest --no-coverage authorization/ auth/guards/permission.guard.spec.ts
|
||||
# 结果: 54 passed
|
||||
|
||||
# 类型检查
|
||||
npx tsc -p tsconfig.build.json --noEmit
|
||||
# 结果: clean (无错误)
|
||||
```
|
||||
|
||||
## 遗留风险 / TODO
|
||||
|
||||
1. **`class:edit` 宽泛授权**(ponytail 标记):拥有 `class:edit` 权限的教师目前获得全量学生/排课/考勤管理权限。理想情况下应通过 CASL conditions 限制为仅自己班级的学生。当前数据模型(需查询 `class_teacher` 关联表确定 scope)无法直接在 CASL Ability 中表达。**未降低现有权限**,保留现状并加 TODO。
|
||||
|
||||
2. **前端权限守卫**:前端 `PermissionRoute` 组件(`apps/admin/src/auth/permission-store.ts`)仍然使用 `permissions.includes()` 检查。不影响安全性(后端是真实授权源),但可在后续迭代中统一。
|
||||
|
||||
3. **操作日志中的权限上下文**:当前操作日志记录仍使用 `user.permissions` 数组。CASL 迁移未改变日志格式。
|
||||
|
||||
4. **`dashboard:manage` 权限**:`dashboard` subject 在 preset permissions 中仅有 `dashboard:view`,但 dashboard.controller 检查了 `dashboard:manage`。CASL 映射将 `dashboard:manage` 的未知 action 映射为 `read`(保守),非 super_admin 用户理论上无法通过此检查。但实际上 controller 的权限守卫用的是 `@RequirePermission('dashboard:view')`,CASL 映射正常。`dashboard:manage` 仅出现在内部方法 `canManageAllDashboard` 的 permissions.includes 检查中,现已被 CASL 替代。
|
||||
|
||||
5. **构建验证**:`npx nest build` 未在迁移中执行(jest + tsc 已覆盖编译和类型检查)。Docker 部署前建议执行一次完整构建。
|
||||
|
||||
## Agent Tool 只读数据安全执行框架
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ AgentToolsModule (NON-HTTP — no controller) │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌─────────────────────────────┐ │
|
||||
│ │ AgentToolRegistry │ │ AgentToolExecutor │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ listAvailable(ctx) │ │ execute(name, input, ctx) │ │
|
||||
│ │ → ToolDef[] │ │ 1. assertPermission │ │
|
||||
│ │ │ │ 2. tool.validate(input) │ │
|
||||
│ │ Filtered by exact- │ │ 3. tool.execute(…) │ │
|
||||
│ │ code permission │ │ 4. audit (best-effort) │ │
|
||||
│ └──────────────────────┘ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Built-in tools: │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ search_students (student:view) │ │
|
||||
│ │ get_student_basic (student:view) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 安全保证
|
||||
|
||||
1. **动态暴露(listAvailable)**:只暴露 principal 拥有 exact permission 的 Tool。
|
||||
2. **执行时二次授权(execute)**:不依赖 `listAvailable`,`execute` 再次调用 `assertPermission`。
|
||||
3. **数据库 WHERE 数据范围**:`StudentAccessScope` 使用 TypeORM QueryBuilder + EXISTS 子查询,在 SQL 层面限制数据范围:
|
||||
- `manageAll`:超管或全量学生范围 → 无限制
|
||||
- `teacher`:仅 `ClassTeacher.userId` 分配班级的 active `ClassStudent`
|
||||
4. **输出白名单**:所有 Tool 输出仅限 `id, name, studentNo, gender, status, organizationId, organizationName, classIds`。`phone`, `idNumber`, `emergencyContact`, `emergencyPhone` 不进入查询 SELECT。
|
||||
5. **审计**:模块 `AI Agent Tool`,记录 tool 名、状态(success/denied/failed)、userId/username(来自 context principal)。审计写入失败不影响 Tool 调用结果。
|
||||
6. **审计脱敏**:审计 detail 绝不包含 raw input、phone、idNumber 等敏感值。
|
||||
|
||||
### SDK 适配伪代码(provider-neutral)
|
||||
|
||||
任何 LLM SDK(Vercel AI、LangChain、OpenAI function calling 等)都可以适配:
|
||||
|
||||
```typescript
|
||||
// 1. 获取 NestJS 容器中的 Registry 和 Executor
|
||||
const registry = app.get(AgentToolRegistry);
|
||||
const executor = app.get(AgentToolExecutor);
|
||||
const authz = app.get(AuthorizationService);
|
||||
|
||||
// 2. 构建可信 AgentToolContext(userId/permissions 来自服务端认证)
|
||||
const ability = abilityFactory.createForUser(authenticatedUser);
|
||||
const ctx: AgentToolContext = {
|
||||
userId: authenticatedUser.id,
|
||||
username: authenticatedUser.username,
|
||||
permissions: authenticatedUser.permissions,
|
||||
isSuperAdmin: authenticatedUser.isSuperAdmin,
|
||||
ability,
|
||||
};
|
||||
|
||||
// 3. 动态暴露工具列表(给 LLM SDK 的 tools/functions 定义)
|
||||
const availableTools = registry.listAvailable(ctx);
|
||||
const sdkTools = availableTools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
// … 根据 tool 自定义参数 schema
|
||||
}));
|
||||
|
||||
// 4. 执行 Tool 调用(带输入校验 + 二次授权 + 审计)
|
||||
const result = await executor.execute("search_students", rawInput, ctx);
|
||||
// result.status: 'success' | 'denied' | 'failed'
|
||||
// result.result: 白名单后的数据(仅 success 时)
|
||||
// result.error: 错误信息(denied/failed 时)
|
||||
```
|
||||
|
||||
### 新增文件清单
|
||||
|
||||
|文件|说明|
|
||||
|---|---|
|
||||
|`src/agent-tools/agent-tool.types.ts`|AgentToolContext, ToolDef, ToolExecutionResult 类型定义|
|
||||
|`src/agent-tools/agent-tool.registry.ts`|Tool 注册 + 按权限过滤暴露|
|
||||
|`src/agent-tools/agent-tool.executor.ts`|执行时二次授权 + 输入校验 + 审计|
|
||||
|`src/agent-tools/tools/search-students.tool.ts`|search_students Tool|
|
||||
|`src/agent-tools/tools/get-student-basic.tool.ts`|get_student_basic Tool|
|
||||
|`src/agent-tools/agent-tools.module.ts`|NestJS 模块(不暴露 HTTP endpoint)|
|
||||
|`src/agent-tools/index.ts`|桶导出|
|
||||
|`src/students/student-access-scope.ts`|StudentAccessScope 数据范围类型|
|
||||
|`src/agent-tools/agent-tool.executor.spec.ts`|执行器测试(19 用例)|
|
||||
|`src/agent-tools/tools/search-students.tool.spec.ts`|search_students 测试(12 用例)|
|
||||
|`src/agent-tools/tools/get-student-basic.tool.spec.ts`|get_student_basic 测试(10 用例)|
|
||||
|`src/students/students.agent-api.spec.ts`|agent-safe API 测试(17 用例)|
|
||||
|
||||
### 修改文件
|
||||
|
||||
|文件|变更|
|
||||
|---|---|
|
||||
|`src/authorization/authorization.service.ts`|新增 `canPermission` / `assertPermission` 方法|
|
||||
|`src/authorization/authorization.service.spec.ts`|新增 8 个 exact-code 权限检查测试|
|
||||
|`src/students/students.service.ts`|新增 `agentSearchStudents` / `agentGetStudentBasic` + `applyStudentScope`|
|
||||
|`src/app.module.ts`|导入 `AgentToolsModule`|
|
||||
|`docs/superpowers/plans/casl-migration.md`|本文档新增 Agent Tool 章节|
|
||||
|
||||
### 测试结果
|
||||
|
||||
```bash
|
||||
npx jest --no-coverage --forceExit
|
||||
# 结果: 31 suites, 223 passed, 3 skipped
|
||||
|
||||
npx tsc -p tsconfig.build.json --noEmit
|
||||
# 结果: clean
|
||||
|
||||
npx nest build
|
||||
# 结果: clean
|
||||
|
||||
npx eslint --no-fix src/agent-tools/**/*.ts src/students/student-access-scope.ts src/authorization/authorization.service.ts
|
||||
# 结果: clean
|
||||
```
|
||||
|
||||
### 剩余风险
|
||||
|
||||
1. **classIds 聚合为第二查询**:对大量结果,批量聚合 classIds 的第二条查询使用 `IN (:...ids)`,在 MySQL 中 IN 子句过大时有性能上限(当前 limit 50 安全)。
|
||||
2. **`manageAll` 判定**:当前 `manageAll` = `isSuperAdmin`。若未来有非超管的全量学生范围角色,需扩展 `StudentAccessScope` 的 `manageAll` 判定逻辑。
|
||||
3. **Tool 扩展**:当前仅 `student:view` 的两个 Tool。新增 Tool 只需实现 `ToolDef` 并注册到 `AgentToolsModule`,无需修改框架代码。
|
||||
@@ -1,26 +0,0 @@
|
||||
# Verification Report: add-tsbuildinfo-to-gitignore
|
||||
|
||||
**Date**: 2026-07-02
|
||||
**Verify Mode**: light
|
||||
**Change**: Add `*.tsbuildinfo` to `.gitignore`
|
||||
|
||||
## Checks
|
||||
|
||||
| # | Check | Result | Detail |
|
||||
|---|-------|--------|--------|
|
||||
| 1 | Tasks complete | ✅ PASS | 3/3 tasks `[x]` |
|
||||
| 2 | Diff matches tasks | ✅ PASS | Only `.gitignore` modified |
|
||||
| 3 | Build passes | ✅ PASS | `npm run build` — 2 tasks, 2 cached |
|
||||
| 4 | Tests pass | ✅ PASS | 1 suite, 1 test, 0 failures |
|
||||
| 5 | No security issues | ✅ PASS | No secrets, no unsafe ops |
|
||||
| 6 | Code review | ⏭️ SKIP | `review_mode: off` |
|
||||
|
||||
## Branch Handling
|
||||
|
||||
- **Status**: handled
|
||||
- **Action**: Committed directly to `main` (local-only repo, no remote configured)
|
||||
- **Commit**: `a671980` — `tweak: add *.tsbuildinfo to .gitignore to prevent stale incremental cache`
|
||||
|
||||
## Summary
|
||||
|
||||
All light verification checks passed. The change is minimal (1 line in `.gitignore`) and safe.
|
||||
@@ -1,81 +0,0 @@
|
||||
# Verification Report: migrate-to-turborepo
|
||||
|
||||
- Date: 2026-07-02
|
||||
- Verify Mode: full
|
||||
|
||||
## Summary Scorecard
|
||||
|
||||
| Dimension | Status |
|
||||
|-----------|--------|
|
||||
| Completeness | 27/27 tasks, 3 specs, 0 remaining |
|
||||
| Correctness | 11/11 requirements covered |
|
||||
| Coherence | Followed — design decisions reflected in implementation |
|
||||
|
||||
## Fresh Verification Evidence
|
||||
|
||||
| Check | Command | Result |
|
||||
|-------|---------|--------|
|
||||
| Build | `npm run build` | 2 successful, FULL TURBO |
|
||||
| Test | `npm run test --workspace=apps/server` | 1 passed, 1 total |
|
||||
| Typecheck | `npm run typecheck` | 2 successful |
|
||||
|
||||
## Completeness
|
||||
|
||||
### Tasks: 27/27 ✅
|
||||
|
||||
All tasks checked off in tasks.md and plan. Every task has a corresponding commit.
|
||||
|
||||
### Spec Coverage: 3/3 specs ✅
|
||||
|
||||
**monorepo-structure** (4 requirements):
|
||||
- Monorepo directory structure ✅ — `apps/server/`, `apps/admin/`, `packages/typescript-config/` exist
|
||||
- npm workspaces configuration ✅ — root `package.json` has `workspaces: ["apps/*", "packages/*"]`
|
||||
- Shared TypeScript configuration ✅ — `packages/typescript-config/` with base/nestjs/react-vite presets
|
||||
- Docker Compose path compatibility ✅ — `docker-compose.yml` build contexts updated
|
||||
|
||||
**turborepo-pipeline** (3 requirements):
|
||||
- Turbo pipeline configuration ✅ — `turbo.json` with build/dev/lint/test/format/typecheck
|
||||
- Unified root scripts ✅ — root `package.json` delegates to `turbo run`
|
||||
- Independent workspace scripts ✅ — workspace package.json files retain own scripts
|
||||
|
||||
**oxlint-oxfmt-toolchain** (4 requirements):
|
||||
- oxfmt replaces Prettier ✅ — `.oxfmtrc.json` at root, Prettier configs removed
|
||||
- Admin oxlint replaces ESLint ✅ — `apps/admin/package.json` uses oxlint, ESLint removed
|
||||
- Server retains ESLint ✅ — ESLint config preserved, Prettier integration removed
|
||||
- Pre-existing Prettier/ESLint cleanup ✅ — no Prettier remnants in config files
|
||||
|
||||
## Correctness
|
||||
|
||||
All 11 spec requirements have verifiable implementation evidence in the repository. No requirement-to-implementation divergence detected. The implementation is a structural migration — all changes are in config files (package.json, tsconfig, eslint config, turbo.json, docker-compose.yml, oxlint config, oxfmt config), with zero business logic modifications.
|
||||
|
||||
## Coherence
|
||||
|
||||
### Design Adherence
|
||||
|
||||
| Design Decision | Implementation Evidence |
|
||||
|----------------|------------------------|
|
||||
| git mv for history preservation | 137 file renames at 100% similarity |
|
||||
| npm workspaces (apps/*, packages/*) | Root package.json workspaces field |
|
||||
| Turborepo pipeline | turbo.json with 6 tasks |
|
||||
| oxfmt unified formatting | .oxfmtrc.json at root |
|
||||
| Admin oxlint + Server ESLint | Respective lint scripts and configs |
|
||||
| @gongxue/typescript-config shared | packages/typescript-config/ with exports |
|
||||
| TS ~6.0.2 unified | Both server and admin devDependencies |
|
||||
| Docker container names unchanged | docker-compose.yml confirms |
|
||||
|
||||
### Delta Spec vs Design Doc
|
||||
|
||||
No conflicts detected. Design doc decisions align with delta spec requirements. No implementation divergence to document.
|
||||
|
||||
## Issues
|
||||
|
||||
**CRITICAL**: None
|
||||
|
||||
**WARNING**: None
|
||||
|
||||
**SUGGESTION**:
|
||||
- Docker daemon was unavailable during verification; build contexts are correct but runtime verification deferred
|
||||
|
||||
## Final Assessment
|
||||
|
||||
**All checks passed. Ready for archive.**
|
||||
@@ -1,85 +0,0 @@
|
||||
# RBAC 鉴权重构 — 验证报告
|
||||
|
||||
- **Change**: rbac-refactor
|
||||
- **Date**: 2026-07-02
|
||||
- **Verify Mode**: full
|
||||
- **Commits**: 22 (78676a1 → d159615)
|
||||
- **Files Changed**: ~50+ (核心变更)
|
||||
|
||||
## 1. 构建验证
|
||||
|
||||
| 检查项 | 结果 | 证据 |
|
||||
|--------|------|------|
|
||||
| Backend TypeScript 编译 | PASS | `npx tsc --noEmit` exit 0, 零错误 |
|
||||
| Frontend TypeScript 编译 | PASS | `npx tsc -b --noEmit` exit 0, 零错误 |
|
||||
| Frontend Vite 生产构建 | PASS | `npx vite build` exit 0, 构建成功 |
|
||||
|
||||
## 2. 任务完成度
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| tasks.md 全部勾选 | PASS (0 unchecked) |
|
||||
| Plan 全部勾选 | PASS (0 unchecked) |
|
||||
|
||||
## 3. 设计决策对照
|
||||
|
||||
| 决策 | 实现状态 |
|
||||
|------|---------|
|
||||
| RBAC 数据模型 (User↔Role↔Permission) | ✅ Permission, Role 实体 + ManyToMany 关联 |
|
||||
| 权限码 module:action 格式 | ✅ 51 个权限码按 13 个 group 组织 |
|
||||
| @RequirePermission OR 语义 | ✅ PermissionGuard.getAllAndMerge 扁平匹配 |
|
||||
| PermissionGuard 全局 + @Public 豁免 | ✅ APP_GUARD 注册,@Public 跳过检查 |
|
||||
| JWT payload {sub, username, permissions} | ✅ login() 调用 getUserPermissions 打入 |
|
||||
| 独立 RbacModule | ✅ forwardRef 解决 AuthModule 循环依赖 |
|
||||
| 种子数据幂等 | ✅ orIgnore() INSERT,onModuleInit 触发 |
|
||||
| PermissionButton 隐藏(非禁用) | ✅ return null 实现 |
|
||||
| TypeORM synchronize 保留 | ✅ 保留 synchronize: true(dev mode) |
|
||||
|
||||
## 4. Proposal 目标达成
|
||||
|
||||
| 目标 | 状态 |
|
||||
|------|------|
|
||||
| RBAC 实体层:Role/Permission 四表 | ✅ |
|
||||
| 权限守卫:@RequirePermission + PermissionGuard | ✅ |
|
||||
| User 实体迁移:移除 role/allowedMenus | ✅ |
|
||||
| 种子数据:4 预置角色 + 权限点 | ✅ |
|
||||
| 前端权限管理界面 | ✅ Roles + Permissions 页 |
|
||||
| 前端权限适配:路由/按钮/菜单 | ✅ |
|
||||
| 权限点定义:覆盖所有模块 | ✅ 13 groups, 51 codes |
|
||||
|
||||
## 5. Capabilities 实现
|
||||
|
||||
| Capability | 状态 |
|
||||
|------------|------|
|
||||
| db-migration | ⚠️ 跳过(保留 synchronize 模式) |
|
||||
| rbac-core | ✅ 实体 + 服务层 + 种子数据 |
|
||||
| permission-guard | ✅ PermissionGuard + @Public + @RequirePermission |
|
||||
| permission-admin-ui | ✅ Roles CRUD + Permissions 只读展示 |
|
||||
|
||||
## 6. 安全性检查
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| 无硬编码密钥 | PASS |
|
||||
| 所有 API 受权限保护 | PASS(全局 PermissionGuard + @RequirePermission) |
|
||||
| @Public 不可绕过 | PASS(handler + class 层双重检查) |
|
||||
| 系统角色不可删除 | PASS(isSystem 检查) |
|
||||
| admin 用户不可删除 | PASS(username 检查) |
|
||||
|
||||
## 7. 代码审查
|
||||
|
||||
- 最终审查模式:standard
|
||||
- 审查模型:Fable
|
||||
- 发现问题:3 IMPORTANT
|
||||
- 修复状态:全部修复 + 复查 APPROVED
|
||||
|
||||
## 8. 最终判定
|
||||
|
||||
**VERDICT: PASS**
|
||||
|
||||
所有检查通过。建议进入归档阶段。
|
||||
|
||||
## 9. 已知偏差
|
||||
|
||||
- TypeORM Migration 脚本未创建(保留 synchronize: true,种子数据通过 onModuleInit 幂等插入)
|
||||
- e2e 测试未实现(项目原无 e2e 测试基础设施)
|
||||
@@ -1,76 +0,0 @@
|
||||
# Verification Report: admin-responsive-adaptation
|
||||
|
||||
**Date:** 2026-07-03
|
||||
**Verify Mode:** full
|
||||
**Review Mode:** off (纯 UI/CSS props 改动,无业务逻辑变更)
|
||||
|
||||
## Summary
|
||||
|
||||
| Dimension | Status |
|
||||
|-----------|--------|
|
||||
| Completeness | 56/56 tasks ✅ |
|
||||
| Correctness | 8/8 requirements covered ✅ |
|
||||
| Coherence | Design decisions followed ✅ |
|
||||
| Build | Pass ✅ (`npm run build` exit 0) |
|
||||
| Code Review | Skipped — review_mode:off |
|
||||
|
||||
## Completeness
|
||||
|
||||
All 56 tasks completed and checked off in tasks.md.
|
||||
|
||||
**Implementation commits (15):**
|
||||
```
|
||||
dff74fc feat: 建立三断点 CSS 体系,替换单断点移动端样式
|
||||
3a1c406 feat: MainLayout 三端布局重构,使用 antd Grid.useBreakpoint()
|
||||
456261e feat: Dashboard 响应式网格,统计卡片/图表/工具栏适配三端
|
||||
339ff47 feat: 学生管理字段拆分(学号/身份证分列),表格和工具栏响应式适配
|
||||
49f8044 feat: 宿舍管理表格添加 scroll 横向滚动
|
||||
db4fcf1 feat: 入住管理表格添加 scroll 横向滚动
|
||||
b5a97ab feat: 费用录入两个表格添加 scroll 横向滚动
|
||||
a5c4570 feat: 押金管理表格添加 scroll 横向滚动
|
||||
4e6a3f4 feat: 账单管理表格添加 scroll,详情内嵌表格也添加 scroll
|
||||
8c555a4 feat: 教室管理表格添加 scroll 横向滚动
|
||||
39b3df0 feat: 租赁方表格添加 scroll 横向滚动
|
||||
a6165fd feat: 角色管理工具栏添加 flexWrap 响应式适配
|
||||
ea42b72 feat: 权限一览工具栏添加 flexWrap 响应式适配
|
||||
2137c7a feat: 账号管理工具栏添加 flexWrap 响应式适配
|
||||
4993d2b feat: 登录卡片改为 maxWidth + calc 响应式宽度
|
||||
```
|
||||
|
||||
**Changed files:** 17 (15 source + 2 documentation)
|
||||
|
||||
## Correctness — Requirement Implementation
|
||||
|
||||
| Requirement | Status | Evidence |
|
||||
|-------------|--------|----------|
|
||||
| 1. Three-breakpoint responsive system | ✅ | `index.css` @media rules + `MainLayout.tsx` useBreakpoint() |
|
||||
| 2. Table horizontal scroll on narrow screens | ✅ | All 12 table pages have `scroll={{ x }}` |
|
||||
| 3. Login page responsiveness | ✅ | `Login/index.tsx` maxWidth + calc |
|
||||
| 4. Dashboard responsive grid | ✅ | `Dashboard/index.tsx` Col xs/sm/md |
|
||||
| 5. Modal responsiveness | ✅ | `index.css` max-width: calc(100vw - 24px) |
|
||||
| 6. Page toolbar responsive wrapping | ✅ | All pages have flexWrap + gap |
|
||||
| 7. Student fields separation | ✅ | 学号/身份证 split into two columns |
|
||||
| 8. Classroom schedule table scrolling | ✅ | Existing overflowX:auto + sticky column |
|
||||
|
||||
## Coherence — Design Adherence
|
||||
|
||||
| Design Decision | Status |
|
||||
|----------------|--------|
|
||||
| antd Grid.useBreakpoint() | ✅ Followed |
|
||||
| CSS: antd Props first, @media fallback | ✅ Followed |
|
||||
| Table scroll={{ x }} on all tables | ✅ Followed |
|
||||
| Modal CSS max-width global constraint | ✅ Followed |
|
||||
| Dashboard Col responsive breakpoints | ✅ Followed |
|
||||
| ECharts width: 100% | ✅ Followed |
|
||||
| Login card maxWidth + calc | ✅ Followed |
|
||||
| Student field split with ellipsis | ✅ Followed |
|
||||
|
||||
## Issues
|
||||
|
||||
**CRITICAL:** 0
|
||||
**WARNING:** 0
|
||||
**SUGGESTION:** 0
|
||||
|
||||
## Final Assessment
|
||||
|
||||
All checks passed. No critical issues, no warnings. Ready for archive.
|
||||
@@ -1,104 +0,0 @@
|
||||
# 验证报告:rbac-refactor
|
||||
|
||||
- **日期**: 2026-07-03
|
||||
- **变更**: rbac-refactor
|
||||
- **验证模式**: 完整验证 (full)
|
||||
- **验证结果**: ✅ PASS — 所有检查通过
|
||||
|
||||
## 新鲜验证证据(2026-07-03 09:19 UTC+8)
|
||||
|
||||
| 验证项 | 命令 | 结果 |
|
||||
|--------|------|------|
|
||||
| 类型检查 | `npm run typecheck` (turbo) | ✅ 2/2 成功, 0 errors |
|
||||
| 构建 | `npm run build` (turbo) | ✅ 2/2 成功 (nest build + vite build) |
|
||||
| 测试 | `npm run test` (turbo) | ✅ 1 passed, 0 failures |
|
||||
|
||||
## 摘要评分卡
|
||||
|
||||
| 维度 | 状态 |
|
||||
|------|------|
|
||||
| 完整性 (Completeness) | ✅ 49/49 任务全部完成 |
|
||||
| 正确性 (Correctness) | ✅ 构建/类型检查/测试全部通过(新鲜验证) |
|
||||
| 一致性 (Coherence) | ✅ 设计偏差已记录至 Design Doc §12 |
|
||||
|
||||
## 验证检查项
|
||||
|
||||
| # | 检查项 | 结果 |
|
||||
|---|--------|------|
|
||||
| 1 | tasks.md 全部任务已完成 | ✅ PASS (49/49) |
|
||||
| 2 | 构建通过 | ✅ PASS (turbo build, 2/2) |
|
||||
| 3 | 类型检查通过 | ✅ PASS (turbo typecheck, 0 errors) |
|
||||
| 4 | 测试通过 | ✅ PASS (1 passed, 0 failures) |
|
||||
| 5 | 无明显安全问题 | ✅ PASS |
|
||||
| 6 | 实现符合 design.md 高层设计决策 | ✅ PASS (偏差已记录) |
|
||||
| 7 | 实现符合 Design Doc 技术设计 | ✅ PASS (偏差已记录至 §12) |
|
||||
| 8 | proposal.md 目标已满足 | ✅ PASS |
|
||||
| 9 | Design Doc 可定位 | ✅ PASS (`docs/superpowers/specs/2026-07-02-rbac-refactor-design.md`) |
|
||||
|
||||
## 实现验证详情
|
||||
|
||||
### 后端 RBAC 核心
|
||||
|
||||
| 组件 | 文件 | 状态 |
|
||||
|------|------|------|
|
||||
| Permission 实体 | `apps/server/src/entities/permission.entity.ts` | ✅ |
|
||||
| Role 实体 | `apps/server/src/entities/role.entity.ts` | ✅ |
|
||||
| User 实体(迁移) | `apps/server/src/entities/user.entity.ts` | ✅ (role + allowedMenus 已移除) |
|
||||
| RbacService | `apps/server/src/rbac/rbac.service.ts` | ✅ (52 权限点, 4 角色, 种子数据幂等) |
|
||||
| RbacController | `apps/server/src/rbac/rbac.controller.ts` | ✅ (角色 CRUD + 权限树 + 用户管理) |
|
||||
| PermissionGuard | `apps/server/src/auth/guards/permission.guard.ts` | ✅ (全局 APP_GUARD) |
|
||||
| @RequirePermission 装饰器 | `apps/server/src/auth/decorators/permission.decorator.ts` | ✅ |
|
||||
| @Public 装饰器 | `apps/server/src/auth/decorators/public.decorator.ts` | ✅ |
|
||||
| JwtStrategy(权限扩展) | `apps/server/src/auth/strategies/jwt.strategy.ts` | ✅ (payload.permissions) |
|
||||
| AuthService(登录流程) | `apps/server/src/auth/auth.service.ts` | ✅ (RbacService.getUserPermissions → JWT) |
|
||||
| forwardRef 循环依赖 | `apps/server/src/auth/auth.module.ts` ↔ `apps/server/src/rbac/rbac.module.ts` | ✅ |
|
||||
|
||||
### 控制器权限覆盖率
|
||||
|
||||
所有 12 个业务控制器均已添加 `@RequirePermission`:
|
||||
|
||||
| Controller | 文件 | 权限方法数 |
|
||||
|------------|------|-----------|
|
||||
| OccupanciesController | `apps/server/src/occupancies/occupancies.controller.ts` | 11 个方法 |
|
||||
| ExpensesController | `apps/server/src/expenses/expenses.controller.ts` | 17 个方法 |
|
||||
| RbacController | `apps/server/src/rbac/rbac.controller.ts` | 12 个方法 |
|
||||
| ClassroomRentalsController | `apps/server/src/classroom-rentals/classroom-rentals.controller.ts` | 9 个方法 |
|
||||
| TenantsController | `apps/server/src/tenants/tenants.controller.ts` | 5 个方法 |
|
||||
| DepositsController | `apps/server/src/deposits/deposits.controller.ts` | ✅ |
|
||||
| BillsController | `apps/server/src/bills/bills.controller.ts` | ✅ |
|
||||
| StudentsController | `apps/server/src/students/students.controller.ts` | ✅ |
|
||||
| RoomsController | `apps/server/src/rooms/rooms.controller.ts` | ✅ |
|
||||
| ClassroomsController | `apps/server/src/classrooms/classrooms.controller.ts` | ✅ |
|
||||
| DashboardController | `apps/server/src/dashboard/dashboard.controller.ts` | ✅ |
|
||||
| OperationLogsController | `apps/server/src/operation-logs/operation-logs.controller.ts` | ✅ |
|
||||
|
||||
### 前端实现
|
||||
|
||||
| 组件 | 文件 | 状态 |
|
||||
|------|------|------|
|
||||
| usePermission Hook | `apps/admin/src/hooks/usePermission.ts` | ✅ |
|
||||
| PermissionButton | `apps/admin/src/components/PermissionButton.tsx` | ✅ |
|
||||
| PermissionRoute | `apps/admin/src/components/PermissionRoute.tsx` | ✅ |
|
||||
| 路由权限守卫 | `apps/admin/src/App.tsx` | ✅ (全部路由包装) |
|
||||
| 菜单权限过滤 | `apps/admin/src/layouts/MainLayout.tsx` | ✅ (递归过滤, 含子菜单) |
|
||||
| 角色管理页面 | `apps/admin/src/pages/Roles/index.tsx` | ✅ |
|
||||
| 权限一览页面 | `apps/admin/src/pages/Permissions/index.tsx` | ✅ |
|
||||
| 用户管理重构 | `apps/admin/src/pages/Users/index.tsx` | ✅ (多角色选择) |
|
||||
|
||||
## 设计偏差处理
|
||||
|
||||
### ✅ 已记录:PermissionGuard AND/OR 语义简化
|
||||
|
||||
**Design Doc §12.1** 已记录:实现简化为扁平 OR 匹配(vs. 设计中的嵌套 AND/OR),当前无接口需要 AND 语义,装饰器注释已标注限制。
|
||||
|
||||
### ✅ 已记录:权限点数量
|
||||
|
||||
**Design Doc §12.2** 已记录:实际实现 52 个权限点(vs. 设计文档声称的 42 个),所有权限点均符合 `module:action` 命名规范。
|
||||
|
||||
## 最终评估
|
||||
|
||||
- **CRITICAL 问题**: 0
|
||||
- **WARNING 问题**: 0(偏差已通过 Design Doc §12 Implementation Divergence 记录)
|
||||
- **SUGGESTION 问题**: 0(权限点计数偏差已记录)
|
||||
|
||||
**结论**: 构建、类型检查、测试全部通过(新鲜验证)。49/49 任务完成。无安全风险。设计偏差已通过 Design Doc "Implementation Divergence" 节完整记录。所有 proposal 目标已达成。
|
||||
@@ -1,118 +0,0 @@
|
||||
---
|
||||
comet_change: migrate-to-turborepo
|
||||
role: technical-design
|
||||
canonical_spec: openspec
|
||||
archived-with: 2026-07-02-migrate-to-turborepo
|
||||
status: final
|
||||
---
|
||||
|
||||
# Migrate to Turborepo Monorepo — Technical Design
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
gongxue-base/
|
||||
├── apps/
|
||||
│ ├── server/ # ← git mv backend → apps/server (NestJS API)
|
||||
│ ├── admin/ # ← git mv frontend → apps/admin (React + Vite)
|
||||
│ └── student/ # 未来:学生端
|
||||
├── packages/
|
||||
│ └── typescript-config/ # 共享 TS 配置:base / nestjs / react-vite
|
||||
├── package.json # npm workspaces + turbo 根脚本
|
||||
├── turbo.json # Turborepo 流水线
|
||||
├── .oxfmtrc.json # oxfmt 全局配置
|
||||
├── oxlint.config.ts # oxlint 全局配置(admin 前端)
|
||||
└── docker-compose.yml # 调整 build.context 路径
|
||||
```
|
||||
|
||||
## Key Decisions
|
||||
|
||||
### 1. File Migration: `git mv`
|
||||
|
||||
`node_modules/` 从未被 Git 追踪,因此 `git mv backend apps/server` 和 `git mv frontend apps/admin` 可以干净执行,完整保留文件历史。
|
||||
|
||||
### 2. Package Manager: npm workspaces
|
||||
|
||||
用户指定沿用 npm。根 `package.json` 声明 `workspaces: ["apps/*", "packages/*"]`,所有根脚本委托 `turbo run` 执行。
|
||||
|
||||
### 3. Build Orchestration: Turborepo
|
||||
|
||||
`turbo.json` 定义六条流水线:
|
||||
|
||||
| Task | 配置 |
|
||||
|------|------|
|
||||
| `build` | `dependsOn: ["^build"]`, outputs: `dist/**` |
|
||||
| `dev` | `cache: false`, `persistent: true` |
|
||||
| `lint` | 无特殊配置,各 workspace 自行定义工具 |
|
||||
| `test` | 无特殊配置 |
|
||||
| `format` | `cache: false` (oxfmt) |
|
||||
| `typecheck` | `dependsOn: ["^build"]` |
|
||||
|
||||
### 4. Toolchain: oxfmt + oxlint (mixed)
|
||||
|
||||
- **oxfmt**:根目录 `.oxfmtrc.json`,映射 Prettier 配置 `{ singleQuote: true, trailingComma: "all" }`
|
||||
- **oxlint**:仅用于 `apps/admin`,覆盖 TypeScript + React 规则。`exhaustive-deps` 和 `react-refresh` 由 TypeScript compiler + code review 兜底
|
||||
- **ESLint**:`apps/server` 保留,移除 Prettier 集成。保障 NestJS 装饰器类型检查
|
||||
|
||||
所有 workspace 统一使用 `lint` 脚本名,Turborepo 在 `turbo run lint` 时并行调度。
|
||||
|
||||
### 5. Shared TypeScript Config
|
||||
|
||||
`@gongxue/typescript-config` 包提供三个预设:
|
||||
|
||||
| 预设 | 继承 | 用途 |
|
||||
|------|------|------|
|
||||
| `base.json` | — | 通用选项:ES2023、strictNullChecks、skipLibCheck |
|
||||
| `nestjs.json` | base | NestJS:nodenext module、experimentalDecorators、declaration |
|
||||
| `react-vite.json` | base | Vite + React:bundler resolution、jsx: react-jsx、noEmit |
|
||||
|
||||
TypeScript 版本统一为 `~6.0.2`(从 server 5.7→6.0 和 admin 6.0 对齐)。
|
||||
|
||||
### 6. Docker Compose
|
||||
|
||||
容器名 `dorm_billing_backend` / `dorm_billing_frontend` 保持不变,仅调整 `build.context`:
|
||||
- `backend: build: ./apps/server`
|
||||
- `frontend: build: ./apps/admin`
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
npm run dev (根)
|
||||
│
|
||||
▼
|
||||
turbo run dev
|
||||
│
|
||||
├──▶ apps/server (NestJS, :3003) ← ESLint, @gongxue/typescript-config/nestjs
|
||||
└──▶ apps/admin (Vite, :3002) ← oxlint, @gongxue/typescript-config/react-vite
|
||||
│
|
||||
▼ /api proxy → localhost:3003
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Core Verification Chain
|
||||
|
||||
1. `npm install` — 所有 workspace 依赖正确安装,hoisting 无冲突
|
||||
2. `npm run build` — server + admin 构建通过
|
||||
3. `npm run lint` — server ESLint + admin oxlint 通过
|
||||
4. `npm run format -- --check` — oxfmt 格式化检查通过
|
||||
5. `npm run test --workspace=apps/server` — NestJS Jest 测试通过
|
||||
|
||||
### Runtime Verification
|
||||
|
||||
- `npm run dev` → server :3003 + admin :3002 并行启动
|
||||
- admin Vite 代理 `/api` → `localhost:3003` 正常工作
|
||||
|
||||
### Docker Verification
|
||||
|
||||
- `docker compose build` — 所有镜像构建成功
|
||||
- `docker compose up` — 所有服务启动并响应
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| `git mv` 后路径引用失效 | docker-compose、tsconfig extends、scripts | 逐文件验证,每步 commit |
|
||||
| oxlint 规则覆盖不全 | `exhaustive-deps`、`react-refresh` 缺失 | TS compiler + code review 兜底,明确文档化 |
|
||||
| TS 6.x + NestJS 生态兼容 | ts-jest、ts-node 可能报错 | 迁移后立即验证 build + test |
|
||||
| npm hoisting 行为变化 | 子项目可能拿到不兼容版本 | 重新 install 后逐 workspace 验证 |
|
||||
@@ -1,737 +0,0 @@
|
||||
---
|
||||
comet_change: rbac-refactor
|
||||
role: technical-design
|
||||
canonical_spec: openspec
|
||||
archived-with: 2026-07-03-rbac-refactor
|
||||
status: final
|
||||
---
|
||||
|
||||
# RBAC 鉴权重构技术设计
|
||||
|
||||
- 日期:2026-07-02
|
||||
- 变更:rbac-refactor
|
||||
- 阶段:Design → Build
|
||||
|
||||
## 1. 架构总览
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ Request │
|
||||
└───────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ JwtAuthGuard │
|
||||
│ (Token 有效性校验) │
|
||||
└───────────┬─────────────┘
|
||||
│
|
||||
┌───────────▼─────────────┐
|
||||
│ PermissionGuard │ ← 全局 APP_GUARD
|
||||
│ (从 req.user 读 │
|
||||
│ permissions 数组) │
|
||||
└───────────┬─────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
@Public() @RequirePermission 无装饰器
|
||||
放行 ('student:create') → 403
|
||||
│
|
||||
▼
|
||||
┌───────────────┐
|
||||
│ Controller │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
**核心原则**:
|
||||
|
||||
- **认证(Authentication)** 与 **授权(Authorization)** 分离:`AuthModule` 只负责登录/JWT/profile;新建 `RbacModule` 负责角色/权限/用户-角色关联。
|
||||
- **默认拒绝**:全局 `PermissionGuard` 要求所有接口显式声明权限;公开接口(login)通过 `@Public()` 豁免。
|
||||
- **JWT 携带权限**:登录时将 User→Role→Permission 链展开为 `permissions: string[]` 打入 JWT payload,避免每次请求查库。
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### 2.1 ER 图
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
|
||||
│ User │ │ Role │ │ Permission │
|
||||
├──────────────┤ ├──────────────────┤ ├──────────────┤
|
||||
│ id (PK) │ │ id (PK) │ │ id (PK) │
|
||||
│ username(UK) │ │ name (UK) │ │ code (UK) │
|
||||
│ passwordHash │ │ description │ │ name │
|
||||
│ name │ │ isSystem │ │ group │
|
||||
│ isActive │ │ status │ │ description │
|
||||
│ lastLoginAt │ │ createdAt │ └──────┬───────┘
|
||||
│ createdAt │ │ updatedAt │ │
|
||||
│ updatedAt │ └────────┬─────────┘ │
|
||||
└──────┬───────┘ │ │
|
||||
│ │ │
|
||||
│ N:M │ M:N │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────────┐
|
||||
│ UserRole │ │ RolePermission │
|
||||
├──────────────┤ ├──────────────────┤
|
||||
│ userId (FK) │ │ roleId (FK) │
|
||||
│ roleId (FK) │ │ permissionId(FK) │
|
||||
└──────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 实体定义
|
||||
|
||||
**Permission**(`permissions` 表)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| code | VARCHAR(50) UNIQUE | 权限码,格式 `module:action` |
|
||||
| name | VARCHAR(50) | 中文名称 |
|
||||
| group | VARCHAR(30) | 分组(对应模块) |
|
||||
| description | VARCHAR(200) | 可选说明 |
|
||||
|
||||
**Role**(`roles` 表)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| name | VARCHAR(30) UNIQUE | 角色名称 |
|
||||
| description | VARCHAR(200) | 角色描述 |
|
||||
| isSystem | BOOLEAN DEFAULT FALSE | 系统预置角色,不可删除/改名 |
|
||||
| status | TINYINT DEFAULT 1 | 1=启用, 0=禁用 |
|
||||
| createdAt | DATETIME | |
|
||||
| updatedAt | DATETIME | |
|
||||
|
||||
**User 实体变更**
|
||||
|
||||
| 操作 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| 移除 | `role` VARCHAR(20) | 原 admin/operator 硬编码 |
|
||||
| 移除 | `allowedMenus` TEXT | 原 JSON 数组,前端菜单可见性 |
|
||||
| 新增 | `roles` @ManyToMany → Role | 通过 user_roles 关联表 |
|
||||
|
||||
### 2.3 权限点清单(42 个)
|
||||
|
||||
| Group | 权限码 | 名称 |
|
||||
|-------|--------|------|
|
||||
| dashboard | `dashboard:view` | 查看数据面板 |
|
||||
| student | `student:view` | 查看学生 |
|
||||
| student | `student:create` | 新增学生 |
|
||||
| student | `student:edit` | 编辑学生 |
|
||||
| student | `student:delete` | 删除学生 |
|
||||
| student | `student:import` | 导入学生 |
|
||||
| student | `student:export` | 导出学生 |
|
||||
| room | `room:view` | 查看宿舍 |
|
||||
| room | `room:create` | 新增宿舍 |
|
||||
| room | `room:edit` | 编辑宿舍 |
|
||||
| room | `room:delete` | 删除宿舍 |
|
||||
| occupancy | `occupancy:view` | 查看入住 |
|
||||
| occupancy | `occupancy:checkin` | 办理入住 |
|
||||
| occupancy | `occupancy:checkout` | 办理退宿 |
|
||||
| occupancy | `occupancy:transfer` | 调换宿舍 |
|
||||
| expense | `expense:view` | 查看费用 |
|
||||
| expense | `expense:create` | 录入费用 |
|
||||
| expense | `expense:edit` | 编辑费用 |
|
||||
| expense | `expense:delete` | 删除费用 |
|
||||
| bill | `bill:view` | 查看账单 |
|
||||
| bill | `bill:generate` | 生成账单 |
|
||||
| bill | `bill:confirm` | 确认账单 |
|
||||
| bill | `bill:delete` | 删除账单 |
|
||||
| bill | `bill:export-excel` | 导出 Excel |
|
||||
| bill | `bill:export-pdf` | 导出 PDF |
|
||||
| deposit | `deposit:view` | 查看押金 |
|
||||
| deposit | `deposit:create` | 新增押金 |
|
||||
| deposit | `deposit:edit` | 编辑押金 |
|
||||
| deposit | `deposit:delete` | 删除押金 |
|
||||
| classroom | `classroom:view` | 查看教室 |
|
||||
| classroom | `classroom:create` | 新增教室 |
|
||||
| classroom | `classroom:edit` | 编辑教室 |
|
||||
| classroom | `classroom:delete` | 删除教室 |
|
||||
| tenant | `tenant:view` | 查看租赁方 |
|
||||
| tenant | `tenant:create` | 新增租赁方 |
|
||||
| tenant | `tenant:edit` | 编辑租赁方 |
|
||||
| tenant | `tenant:delete` | 删除租赁方 |
|
||||
| rental | `rental:view` | 查看租赁订单 |
|
||||
| rental | `rental:create` | 新增租赁订单 |
|
||||
| rental | `rental:edit` | 编辑租赁订单 |
|
||||
| rental | `rental:delete` | 删除租赁订单 |
|
||||
| log | `log:view` | 查看操作日志 |
|
||||
| user | `user:view` | 查看用户 |
|
||||
| user | `user:create` | 创建用户 |
|
||||
| user | `user:edit` | 编辑用户 |
|
||||
| user | `user:delete` | 删除用户 |
|
||||
| user | `user:reset-password` | 重置密码 |
|
||||
| role | `role:view` | 查看角色 |
|
||||
| role | `role:create` | 创建角色 |
|
||||
| role | `role:edit` | 编辑角色 |
|
||||
| role | `role:delete` | 删除角色 |
|
||||
|
||||
### 2.4 预置角色与权限映射
|
||||
|
||||
| 角色 | code | isSystem | 权限范围 |
|
||||
|------|------|----------|---------|
|
||||
| 超管 | `super_admin` | true | **全部 42 个权限点** |
|
||||
| 机构负责人 | `institution_head` | true | 预留角色,暂分配教室/租赁相关 view 权限 |
|
||||
| 老师 | `teacher` | true | 预留角色,暂分配学生 view 权限 |
|
||||
| 宿管老师 | `dormitory_supervisor` | true | 学生/宿舍/入住/费用/账单/押金/日志的全部权限 + dashboard:view(替代原 operator) |
|
||||
|
||||
> **注意**:机构负责人和老师为占位角色。其实际业务权限点(课表、考勤等)在当前系统中尚不存在,后续 change 再补充。
|
||||
|
||||
### 2.5 设计决策:为什么不用 JSON 字段?
|
||||
|
||||
- JSON 字段无法做数据库级外键约束和联表查询
|
||||
- "某权限被哪些角色拥有" → 关联表索引查询 O(log n),JSON 需全表扫描 O(n)
|
||||
- 后续数据级权限(机构→班级→学生)扩展时,关联表可直接扩展 Role + 数据策略组合
|
||||
|
||||
## 3. 后端模块设计
|
||||
|
||||
### 3.1 模块拆分
|
||||
|
||||
```
|
||||
AuthModule(瘦身) RbacModule(新建)
|
||||
├── AuthController ├── RbacController
|
||||
│ ├── POST /auth/login │ ├── GET /rbac/roles
|
||||
│ └── GET /auth/profile │ ├── GET /rbac/roles/:id
|
||||
│ │ ├── POST /rbac/roles
|
||||
├── AuthService │ ├── PUT /rbac/roles/:id
|
||||
│ ├── login() ← 改:查询权限 │ ├── DELETE /rbac/roles/:id
|
||||
│ ├── validateUser() │ ├── GET /rbac/permissions
|
||||
│ └── initAdmin() ← 转移 │ ├── GET /rbac/users
|
||||
│ │ ├── POST /rbac/users
|
||||
├── JwtStrategy │ ├── PUT /rbac/users/:id
|
||||
│ └── validate() ← 改:返回权限 │ ├── DELETE /rbac/users/:id
|
||||
│ │ └── PUT /rbac/users/:id/password
|
||||
└── JwtAuthGuard(不变) │
|
||||
├── RbacService
|
||||
│ ├── getRoles / CRUD
|
||||
│ ├── getPermissions / getPermissionTree
|
||||
│ ├── getUserPermissions(userId): string[]
|
||||
│ ├── createUser / updateUser / deleteUser
|
||||
│ └── seedData() ← 幂等种子数据
|
||||
│
|
||||
└── 导入 TypeOrmModule.forFeature([
|
||||
User, Role, Permission
|
||||
])
|
||||
```
|
||||
|
||||
**关键改动**:
|
||||
|
||||
1. `AuthService.login()` → 登录成功后调用 `RbacService.getUserPermissions(userId)`,将结果打入 JWT payload
|
||||
2. `AuthService.initAdmin()` → 移到 `RbacService.seedData()` 中,因为超管角色和权限种子数据是 RBAC 层的职责
|
||||
3. `AuthModule` 需要 `imports: [RbacModule]` 或使用 `forwardRef` 避免循环依赖
|
||||
4. 原 `/auth/register`、`/auth/users` CRUD 全部迁移到 `RbacController`,变为 `/rbac/users`
|
||||
|
||||
### 3.2 循环依赖处理
|
||||
|
||||
`AuthModule` 和 `RbacModule` 存在双向依赖:
|
||||
- `AuthService.login()` 需要 `RbacService.getUserPermissions()`
|
||||
- `RbacController` 的用户 CRUD 接口需要 `JwtAuthGuard`
|
||||
|
||||
```typescript
|
||||
// auth.module.ts
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User]),
|
||||
forwardRef(() => RbacModule), // 延迟解析
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({...}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
|
||||
// rbac.module.ts
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, Role, Permission]),
|
||||
forwardRef(() => AuthModule), // 延迟解析(获取 JwtAuthGuard)
|
||||
],
|
||||
controllers: [RbacController],
|
||||
providers: [RbacService],
|
||||
exports: [RbacService],
|
||||
})
|
||||
```
|
||||
|
||||
## 4. 权限守卫设计
|
||||
|
||||
### 4.1 装饰器
|
||||
|
||||
```typescript
|
||||
// backend/src/auth/decorators/public.decorator.ts
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
// backend/src/auth/decorators/permission.decorator.ts
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
export const PERMISSION_KEY = 'permissions';
|
||||
export const RequirePermission = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSION_KEY, permissions);
|
||||
```
|
||||
|
||||
**语义约定**:
|
||||
|
||||
| 用法 | 语义 |
|
||||
|------|------|
|
||||
| `@RequirePermission('student:create')` | 需要此权限 |
|
||||
| `@RequirePermission('bill:export-excel', 'bill:export-pdf')` | 满足**任一**即可(OR) |
|
||||
| `@RequirePermission('bill:view')` + `@RequirePermission('bill:delete')` | 两个都需满足(AND) |
|
||||
|
||||
装饰器多次 SetMetadata 时 NestJS 自动合并为数组,`Reflector.get('permissions')` 返回 `[['bill:view'], ['bill:delete']]`,以此区分 AND 还是 OR 调用。
|
||||
|
||||
### 4.2 PermissionGuard
|
||||
|
||||
```typescript
|
||||
// backend/src/auth/guards/permission.guard.ts
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
// 1. @Public() 豁免
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(), context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
// 2. 获取所需权限
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<string[][]>(
|
||||
PERMISSION_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
// 无装饰器 = 默认拒绝
|
||||
if (!requiredPermissions) return false;
|
||||
|
||||
// 3. 从 JWT payload 获取用户权限
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!user?.permissions) return false;
|
||||
|
||||
// 4. 匹配逻辑:外层 AND,内层 OR
|
||||
return requiredPermissions.every(group =>
|
||||
group.some(p => user.permissions.includes(p))
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**匹配算法**:
|
||||
|
||||
```
|
||||
装饰器层级: @ReqPerm(A) @ReqPerm(B) @ReqPerm(C, D)
|
||||
↓
|
||||
Reflector 返回: [ [A], [B], [C, D] ]
|
||||
↓
|
||||
PermissionGuard: [A] ∧ [B] ∧ [C, D]
|
||||
A 满足 ∧ B 满足 ∧ (C 或 D 满足)
|
||||
```
|
||||
|
||||
超管拥有全部权限 → `user.permissions.includes(p)` 始终 true → 全部放行。
|
||||
|
||||
### 4.3 全局注册
|
||||
|
||||
```typescript
|
||||
// app.module.ts — providers 数组新增
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
```
|
||||
|
||||
注意:`APP_GUARD` 的顺序即为执行顺序。ThrottlerGuard 在前、JwtAuthGuard 在各模块注册、PermissionGuard 全局。
|
||||
|
||||
## 5. JWT 变更
|
||||
|
||||
### 5.1 Payload 结构
|
||||
|
||||
```
|
||||
变更前: { sub: number, username: string, role: string }
|
||||
变更后: { sub: number, username: string, permissions: string[] }
|
||||
```
|
||||
|
||||
- 移除 `role` 字段(用户不再有单一角色)
|
||||
- 新增 `permissions` 数组(登录时计算,有效期跟随 JWT 过期,默认 4h)
|
||||
|
||||
### 5.2 登录流程
|
||||
|
||||
```
|
||||
POST /auth/login { username, password }
|
||||
→ AuthService.login()
|
||||
→ 验证凭证
|
||||
→ RbacService.getUserPermissions(userId)
|
||||
→ SELECT DISTINCT p.code
|
||||
FROM permission p
|
||||
JOIN role_permission rp ON p.id = rp.permissionId
|
||||
JOIN user_role ur ON rp.roleId = ur.roleId
|
||||
WHERE ur.userId = ?
|
||||
→ JWT sign({ sub, username, permissions })
|
||||
→ 返回 { access_token, user: { id, username, name, roles, permissions } }
|
||||
```
|
||||
|
||||
### 5.3 JwtStrategy.validate()
|
||||
|
||||
```typescript
|
||||
async validate(payload: any) {
|
||||
return {
|
||||
id: payload.sub,
|
||||
username: payload.username,
|
||||
permissions: payload.permissions || [],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
req.user 后续被 PermissionGuard 消费,`permissions` 数组供匹配。
|
||||
|
||||
### 5.4 设计决策:为什么权限不入库实时查询?
|
||||
|
||||
- **性能**:避免每次请求都做 4 表 JOIN
|
||||
- **JWT 无状态**:不依赖数据库连接,适合水平扩展
|
||||
- **折中**:权限变更在下次登录生效。对极高安全要求的操作(账单确认/删除),可在 Controller 内额外做一次实时校验
|
||||
- **体积**:42 个短字符串约增加 500 bytes,在可接受范围
|
||||
|
||||
## 6. 前端架构
|
||||
|
||||
### 6.1 权限基础设施
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── hooks/
|
||||
│ └── usePermission.ts ← 新建
|
||||
├── components/
|
||||
│ ├── PermissionButton.tsx ← 新建
|
||||
│ └── PermissionRoute.tsx ← 新建
|
||||
└── pages/
|
||||
├── Roles/ ← 新建(角色管理)
|
||||
├── Permissions/ ← 新建(权限一览)
|
||||
└── Users/ ← 重构
|
||||
```
|
||||
|
||||
### 6.2 usePermission Hook
|
||||
|
||||
```typescript
|
||||
// hooks/usePermission.ts
|
||||
export function usePermission() {
|
||||
const permissions: string[] = JSON.parse(
|
||||
localStorage.getItem('permissions') || '[]'
|
||||
);
|
||||
|
||||
const hasPermission = (code: string) => permissions.includes(code);
|
||||
const hasAnyPermission = (...codes: string[]) =>
|
||||
codes.some(c => permissions.includes(c));
|
||||
const hasAllPermissions = (...codes: string[]) =>
|
||||
codes.every(c => permissions.includes(c));
|
||||
|
||||
return { permissions, hasPermission, hasAnyPermission, hasAllPermissions };
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 PermissionButton
|
||||
|
||||
```typescript
|
||||
// components/PermissionButton.tsx
|
||||
// 无权限时 return null(隐藏),不占界面空间
|
||||
const PermissionButton: React.FC<{
|
||||
permission: string;
|
||||
children: React.ReactNode;
|
||||
} & ButtonProps> = ({ permission, children, ...btnProps }) => {
|
||||
const { hasPermission } = usePermission();
|
||||
if (!hasPermission(permission)) return null;
|
||||
return <Button {...btnProps}>{children}</Button>;
|
||||
};
|
||||
```
|
||||
|
||||
### 6.4 PermissionRoute
|
||||
|
||||
```typescript
|
||||
// components/PermissionRoute.tsx
|
||||
// 无权限渲染 403 页面
|
||||
const PermissionRoute: React.FC<{
|
||||
permission: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ permission, children }) => {
|
||||
const { hasPermission } = usePermission();
|
||||
if (!hasPermission(permission)) {
|
||||
return <Result status="403" title="无权访问" subTitle="您没有访问此页面的权限" />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
```
|
||||
|
||||
### 6.5 Login 页面变更
|
||||
|
||||
```typescript
|
||||
// 登录成功后
|
||||
localStorage.setItem('token', res.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(res.user));
|
||||
localStorage.setItem('permissions', JSON.stringify(res.user.permissions));
|
||||
```
|
||||
|
||||
### 6.6 404/403 处理
|
||||
|
||||
```typescript
|
||||
// api/index.ts — axios 拦截器
|
||||
// 401 → 跳登录(不变)
|
||||
// 403 → 不跳转,显示 "权限不足" 提示
|
||||
if (error.response?.status === 403) {
|
||||
message.error('权限不足');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
```
|
||||
|
||||
### 6.7 菜单过滤
|
||||
|
||||
MainLayout 每项菜单新增 `permission` 字段:
|
||||
|
||||
```typescript
|
||||
const menuItems = [
|
||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据面板', permission: 'dashboard:view' },
|
||||
{ key: '/students', icon: <TeamOutlined />, label: '学生管理', permission: 'student:view' },
|
||||
// ...
|
||||
];
|
||||
|
||||
// 按权限过滤
|
||||
const { hasPermission } = usePermission();
|
||||
const visibleItems = menuItems.filter(item => !item.permission || hasPermission(item.permission));
|
||||
```
|
||||
|
||||
### 6.8 角色管理页面设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 角色管理 [+ 新增角色] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ID │ 名称 │ 描述 │ 权限标签 │ 系统 │ 操作 │
|
||||
├─────┼──────────┼──────────────┼─────────────┼──────┼─────────┤
|
||||
│ 1 │ 超管 │ 全部权限 │ 42个权限 │ ✓ │ - │
|
||||
│ 2 │ 宿管老师 │ 宿舍相关管理 │ 查看/管理 │ ✓ │ 编辑 │
|
||||
│ 3 │ 老师 │ 学生查看 │ 学生:view │ ✓ │ 编辑 │
|
||||
│ 4 │ 机构负责人│ 教室管理 │ 教室:view │ ✓ │ 编辑 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
点击编辑 → Modal 弹窗:
|
||||
- 基本信息:名称(系统角色禁用编辑)、描述
|
||||
- 权限勾选区域:按 group 分组,每组一个 Card,内含 Checkbox.Group
|
||||
- 每组支持全选/取消全选
|
||||
|
||||
### 6.9 用户管理页面变更
|
||||
|
||||
| 变更项 | 原设计 | 新设计 |
|
||||
|--------|--------|--------|
|
||||
| 角色列 | 单一 Tag(admin/operator) | 多角色 Tag 列表 |
|
||||
| 编辑弹窗-角色 | Select 单选(admin/operator) | Select mode="multiple"(所有活跃角色) |
|
||||
| 编辑弹窗-菜单 | Checkbox.Group(14 项菜单) | 移除 |
|
||||
| 新增弹窗 | 默认 operator | 必选角色列表 |
|
||||
| API 端点 | `/auth/register`、`/auth/users` | `/rbac/users` |
|
||||
|
||||
## 7. 迁移策略
|
||||
|
||||
### 7.1 TypeORM Migration 配置
|
||||
|
||||
```json
|
||||
// backend/package.json 新增
|
||||
{
|
||||
"scripts": {
|
||||
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
|
||||
"migration:generate": "npm run typeorm -- migration:generate -d src/data-source.ts",
|
||||
"migration:run": "npm run typeorm -- migration:run -d src/data-source.ts",
|
||||
"migration:revert": "npm run typeorm -- migration:revert -d src/data-source.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 迁移脚本步骤
|
||||
|
||||
```sql
|
||||
-- 在同一事务中执行
|
||||
BEGIN;
|
||||
|
||||
-- Step 1: 创建新表
|
||||
CREATE TABLE permissions (...);
|
||||
CREATE TABLE roles (...);
|
||||
CREATE TABLE role_permissions (...);
|
||||
CREATE TABLE user_roles (...);
|
||||
|
||||
-- Step 2: 插入种子数据
|
||||
-- 42 个权限点
|
||||
INSERT INTO permissions (code, name, `group`) VALUES
|
||||
('dashboard:view', '查看数据面板', 'dashboard'),
|
||||
...
|
||||
|
||||
-- 4 个预置角色
|
||||
INSERT INTO roles (name, description, isSystem) VALUES
|
||||
('超管', '系统超级管理员,拥有全部权限', 1),
|
||||
('宿管老师', '管理宿舍相关业务', 1),
|
||||
('老师', '查看和管理本班学生', 1),
|
||||
('机构负责人', '管理机构教室和课程', 1);
|
||||
|
||||
-- 角色-权限关联
|
||||
INSERT INTO role_permissions (roleId, permissionId)
|
||||
SELECT r.id, p.id FROM roles r, permissions p WHERE r.name = '超管';
|
||||
|
||||
-- 宿管老师:学生/宿舍/入住/费用/账单/押金/日志/dashboard
|
||||
INSERT INTO role_permissions (roleId, permissionId)
|
||||
SELECT r.id, p.id FROM roles r, permissions p
|
||||
WHERE r.name = '宿管老师' AND p.`group` IN ('student', 'room', 'occupancy', 'expense', 'bill', 'deposit', 'log', 'dashboard');
|
||||
|
||||
-- Step 3: 迁移现有用户
|
||||
-- admin → 超管
|
||||
INSERT INTO user_roles (userId, roleId)
|
||||
SELECT u.id, r.id FROM users u, roles r
|
||||
WHERE u.role = 'admin' AND r.name = '超管';
|
||||
|
||||
-- operator → 宿管老师
|
||||
INSERT INTO user_roles (userId, roleId)
|
||||
SELECT u.id, r.id FROM users u, roles r
|
||||
WHERE u.role = 'operator' AND r.name = '宿管老师';
|
||||
|
||||
-- Step 4: 删除旧字段
|
||||
ALTER TABLE users DROP COLUMN role;
|
||||
ALTER TABLE users DROP COLUMN allowed_menus;
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### 7.3 回滚策略
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
-- 1. 恢复 users 表旧字段(从备份临时表)
|
||||
ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'operator';
|
||||
ALTER TABLE users ADD COLUMN allowed_menus TEXT;
|
||||
|
||||
UPDATE users SET role = COALESCE(
|
||||
(SELECT CASE WHEN r.name = '超管' THEN 'admin' ELSE 'operator' END
|
||||
FROM user_roles ur JOIN roles r ON ur.roleId = r.id
|
||||
WHERE ur.userId = users.id LIMIT 1),
|
||||
'operator'
|
||||
);
|
||||
|
||||
-- 2. 删除新表
|
||||
DROP TABLE IF EXISTS user_roles;
|
||||
DROP TABLE IF EXISTS role_permissions;
|
||||
DROP TABLE IF EXISTS roles;
|
||||
DROP TABLE IF EXISTS permissions;
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
### 7.4 synchronize → migration 切换
|
||||
|
||||
AppModule 中 TypeORM 配置将 `synchronize: true` 改为 `synchronize: false`(仅对迁移后的环境生效,首次迁移前可保留),`migrations: [...]` 指向迁移目录。
|
||||
|
||||
## 8. 种子数据初始化
|
||||
|
||||
`RbacModule.onModuleInit()` 中调用 `RbacService.seedData()`:
|
||||
|
||||
```typescript
|
||||
async seedData() {
|
||||
// 1. 权限点:INSERT ... WHERE NOT EXISTS (幂等)
|
||||
for (const perm of PRESET_PERMISSIONS) {
|
||||
await this.permRepo
|
||||
.createQueryBuilder()
|
||||
.insert().into(Permission)
|
||||
.values(perm)
|
||||
.orIgnore() // SQLite: OR IGNORE; MySQL: ON DUPLICATE KEY
|
||||
.execute();
|
||||
}
|
||||
|
||||
// 2. 角色:同上
|
||||
// 3. 角色-权限关联:先查角色和权限,批量 INSERT IGNORE
|
||||
// 4. 初始 admin 用户(复用原 initAdmin 逻辑)
|
||||
}
|
||||
```
|
||||
|
||||
**幂等性保证**:
|
||||
- 权限点:`code` UNIQUE 约束 + `orIgnore()`
|
||||
- 角色:`name` UNIQUE 约束 + `orIgnore()`
|
||||
- 角色-权限关联:`(roleId, permissionId)` 联合唯一约束
|
||||
- 重复启动不会重复插入
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
### 9.1 单元测试
|
||||
|
||||
**PermissionGuard(5 用例)**:
|
||||
|
||||
| # | 场景 | 预期 |
|
||||
|---|------|------|
|
||||
| 1 | @Public() 装饰的接口 | 放行 |
|
||||
| 2 | 无装饰器 | 403 |
|
||||
| 3 | @RequirePermission('a'),用户有 'a' | 放行 |
|
||||
| 4 | @RequirePermission('a'),用户无 'a' | 403 |
|
||||
| 5 | @ReqPerm('a') + @ReqPerm('b'),用户有 'a' 无 'b' | 403(AND 不满足) |
|
||||
|
||||
**RbacService.getUserPermissions(3 用例)**:
|
||||
|
||||
| # | 场景 | 预期 |
|
||||
|---|------|------|
|
||||
| 1 | 单角色用户 | 返回该角色全部权限 |
|
||||
| 2 | 多角色用户 | 返回并集去重 |
|
||||
| 3 | 无角色用户 | 返回空数组 |
|
||||
|
||||
### 9.2 e2e 测试
|
||||
|
||||
| # | 场景 | 步骤 | 预期 |
|
||||
|---|------|------|------|
|
||||
| 1 | 超管全通链路 | 登录 → GET /students → POST /students | 全部 200 |
|
||||
| 2 | 宿管老师受限链路 | 登录 → GET /students → 200;POST /rbac/roles → 403 | 学生可查,角色管理拒绝 |
|
||||
|
||||
### 9.3 迁移验证
|
||||
|
||||
- SQLite 环境:`npm run migration:run` → 表结构正确 → 种子数据完整
|
||||
- MySQL 环境:同上
|
||||
- 现有 admin 用户:迁移后登录 → 拥有 `super_admin` 角色 → 全部权限可访问
|
||||
|
||||
## 10. 风险与缓解
|
||||
|
||||
| 风险 | 等级 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| users 表结构变更破坏现有业务 | 中 | 迁移在事务中执行,失败自动回滚;部署前 staging 验证 |
|
||||
| JWT payload 增大 | 低 | ~42 个短字符串约 500 bytes,可接受;后续若权限点过多可改用压缩编码 |
|
||||
| 权限变更在下次登录才生效 | 低 | 关键操作可额外做实时校验;后续可用 Redis 黑名单强制下线 |
|
||||
| 前端改造面大 | 中 | 分步迁移:先建组件 → 逐页替换 → 每步可编译可运行 |
|
||||
| synchronize → migration 切换有学习成本 | 低 | 保留 `synchronize: true` 作为本地开发模式,migration 仅用于生产部署 |
|
||||
|
||||
## 11. 后续扩展预留
|
||||
|
||||
本次 RBAC 基础设施为以下扩展预留架构空间:
|
||||
|
||||
```
|
||||
当前阶段(本 change) 后续阶段
|
||||
══════════════════════════ ══════════════════════════
|
||||
User ↔ Role ↔ Permission User ↔ Organization(数据级)
|
||||
操作级:student:create 数据级:student:view:org-1
|
||||
→ RBAC + 数据策略组合
|
||||
```
|
||||
|
||||
- `Permission.code` 保持纯粹的操作定义(`module:action`),不混入数据范围
|
||||
- 后续数据级权限通过新增 `DataPolicy` 实体 + `@DataScope` 装饰器实现
|
||||
- `Role` 实体预留扩展空间,可关联数据策略
|
||||
|
||||
## 12. 实现偏差记录 (Implementation Divergence)
|
||||
|
||||
> 本节记录 build 阶段验证时发现的、与原始设计不一致但经确认可接受的实现偏差。
|
||||
|
||||
### 12.1 PermissionGuard AND/OR 语义简化
|
||||
|
||||
**设计 (Design Doc §4.2)**:
|
||||
```typescript
|
||||
// 嵌套 AND/OR: getAllAndOverride<string[][]> → 返回 [['A'], ['B','C']]
|
||||
// 外层 AND (every),内层 OR (some)
|
||||
requiredPermissions.every(group => group.some(p => user.permissions.includes(p)))
|
||||
```
|
||||
|
||||
**实现 (PermissionGuard)**:
|
||||
```typescript
|
||||
// 扁平 OR: getAllAndMerge<string[]> → 返回 ['A', 'B', 'C']
|
||||
// 仅 OR 匹配
|
||||
requiredPermissions.some(p => user.permissions.includes(p))
|
||||
```
|
||||
|
||||
**偏差原因**: 当前所有接口均为单一 `@RequirePermission(...)` 调用,不涉及多次装饰器叠加的 AND 场景。扁平 OR 语义足以覆盖"一个接口需要多个权限中的任一即可访问"的需求,同时简化了匹配逻辑和调试成本。装饰器注释已标注"不支持 AND 语义:多次调用装饰器会被全局 PermissionGuard 合并为扁平数组"。
|
||||
|
||||
**影响**: 无。当前无接口需要 AND 权限组合。若未来业务需要"同时满足多个权限才可访问",届时再升级匹配算法(将 `getAllAndMerge` 改为 `getAllAndOverride` + 双层循环),向后兼容。
|
||||
|
||||
### 12.2 权限点数量
|
||||
|
||||
**设计文档声称** 42 个权限点,**实际实现** 52 个。差异来自:
|
||||
- 设计文档手动列举时遗漏 `occupancy:delete` 等权限点
|
||||
- 设计文档实际列举 51 个而非 42 个(计数偏差)
|
||||
|
||||
所有 52 个权限点均符合 `module:action` 命名规范且与模块目录结构一致,实现正确。
|
||||
@@ -1,163 +0,0 @@
|
||||
---
|
||||
comet_change: admin-responsive-adaptation
|
||||
role: technical-design
|
||||
canonical_spec: openspec
|
||||
archived-with: 2026-07-03-admin-responsive-adaptation
|
||||
status: final
|
||||
---
|
||||
|
||||
# 管理后台三端响应式适配 — 技术设计
|
||||
|
||||
## 1. 架构概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Admin Frontend │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ App.tsx │
|
||||
│ └─ ConfigProvider (antd theme + locale) │
|
||||
│ └─ BrowserRouter │
|
||||
│ ├─ LoginPage │
|
||||
│ └─ MainLayout │
|
||||
│ ├─ Sider/Drawer (断点决定) │
|
||||
│ ├─ Header (用户区域断点隐藏文字) │
|
||||
│ └─ Content → <Outlet> │
|
||||
│ ├─ Dashboard (统计卡片 + ECharts) │
|
||||
│ ├─ RoomVisual (房态网格卡片) │
|
||||
│ ├─ [14 个表格型页面] (Table + 搜索/操作工具栏) │
|
||||
│ └─ ClassroomSchedule (HTML 排期大表) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2. 断点体系
|
||||
|
||||
使用 antd v6 内置 `Grid.useBreakpoint()` hook,映射三类设备行为:
|
||||
|
||||
| 端侧 | antd 断点 | 视口宽度 | 布局行为 |
|
||||
|------|-----------|---------|---------|
|
||||
| 手机 | `xs` | < 576px | Drawer 抽屉菜单,padding 12px |
|
||||
| 平板 | `sm`, `md` | 576-991px | 侧栏默认折叠,padding 16px |
|
||||
| 桌面 | `lg`, `xl`, `xxl` | ≥ 992px | 侧栏可折叠(默认展开),padding 24px |
|
||||
|
||||
**选择理由:** 使用 antd 内置断点与 `Row/Col` 响应式 props 天然一致,避免维护两套断点逻辑。992px 与最初规划的 1024px 相差 32px,实际设备无感知。
|
||||
|
||||
## 3. 核心决策
|
||||
|
||||
### 3.1 MainLayout 响应式检测
|
||||
|
||||
**当前:** `useState(window.innerWidth < 768)` + `resize` 事件 → `isMobile` 布尔值
|
||||
|
||||
**目标:** `Grid.useBreakpoint()` → `{ xs, sm, md, lg, xl, xxl }` 布尔值
|
||||
|
||||
```typescript
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.sm; // < 576px (仅 xs)
|
||||
const isTablet = (screens.sm || screens.md) && !screens.lg; // 576-991px
|
||||
const isDesktop = !!screens.lg; // ≥ 992px
|
||||
```
|
||||
|
||||
影响范围:仅 `MainLayout.tsx` 一个文件,`useEffect` + `resize` 事件监听可移除。
|
||||
|
||||
### 3.2 CSS 策略:antd Props 优先
|
||||
|
||||
| 场景 | 优先方案 | 兜底方案 |
|
||||
|------|---------|---------|
|
||||
| 表格列过多 | `scroll={{ x }}` | 无(antd 自带滚动条) |
|
||||
| 卡片网格 | `Col` 响应式断点 props | `index.css` @media |
|
||||
| 文字截断 | `ellipsis: true` | `text-overflow: ellipsis` CSS |
|
||||
| 弹窗宽度 | antd `width` prop | `index.css` `max-width` 约束 |
|
||||
| 工具栏换行 | `flexWrap: 'wrap'` + `gap` inline style | — |
|
||||
|
||||
**`index.css` 追加的 @media 规则(总计约 40 行):**
|
||||
- `(max-width: 575px)`: 表格字体 13px、弹窗 max-width 约束、Modal body max-height
|
||||
- `(min-width: 576px) and (max-width: 991px)`: 平板特有的间距微调
|
||||
- 通用: `.ant-table-wrapper { overflow-x: auto }` 确保所有表格容器可滚动
|
||||
|
||||
### 3.3 表格横向滚动
|
||||
|
||||
所有 `<Table>` 统一添加 `scroll={{ x }}`。具体值:
|
||||
- 列少(≤6 列):`scroll={{ x: 'max-content' }}` 或省略(antd 自动处理)
|
||||
- 列中(7-10 列):`scroll={{ x: 800 }}`
|
||||
- 列多(>10 列或含长文本列):`scroll={{ x: 1000 }}` 或更大
|
||||
|
||||
操作列(最后一列)使用 `width` 固定宽度,必要时添加 `fixed: 'right'` 在宽表场景下提升体验。
|
||||
|
||||
### 3.4 Dashboard 响应式网格
|
||||
|
||||
```
|
||||
统计卡片 (4 张):
|
||||
<Col xs={12} sm={12} md={6}> // 手机2列 平板2列 桌面4列
|
||||
|
||||
图表卡片 (2 张):
|
||||
<Col xs={24} sm={12}> // 手机堆叠 平板及以上并排
|
||||
```
|
||||
|
||||
顶部工具栏(标题 + DatePicker)在小屏下从 `flex` 横向排列改为 `flexDirection: 'column'` 堆叠。
|
||||
|
||||
### 3.5 ECharts 图表
|
||||
|
||||
通过 `echarts-for-react` 的内置 `ResizeObserver` 自动适配:
|
||||
```tsx
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ width: '100%', height: isMobile ? 250 : 300 }}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
/>
|
||||
```
|
||||
容器宽度由 antd `Col` 响应式断点控制,图表自动跟随。甘特图动态高度逻辑不变(`Math.max(300, data.length * 40)`)。
|
||||
|
||||
### 3.6 弹窗适配
|
||||
|
||||
**全局 CSS(index.css):**
|
||||
```css
|
||||
@media (max-width: 575px) {
|
||||
.ant-modal { max-width: calc(100vw - 24px) !important; }
|
||||
.ant-modal-body { max-height: 60vh; overflow-y: auto; }
|
||||
}
|
||||
```
|
||||
|
||||
**组件级别:** 各弹窗 `width` 在桌面端固定值(400-600px),移动端由全局 CSS 覆盖为 `max-width` 约束。
|
||||
|
||||
### 3.7 教室排期表
|
||||
|
||||
最复杂的适配场景 — HTML `<table>` 含 31+ 日期列 + sticky 首列:
|
||||
- 外层 `div` 保持 `overflowX: 'auto'`
|
||||
- 首列(教室名)`position: sticky; left: 0` 保持已有
|
||||
- 日期列 `minWidth: 26` 不压窄
|
||||
- `overflowX` 容器在平板/手机下自动出现横向滚动条
|
||||
|
||||
### 3.8 学生字段拆分
|
||||
|
||||
学生管理页将 `{ title: '学号/身份证', dataIndex: 'idNumber' }` 一列拆为两列:
|
||||
```typescript
|
||||
{ title: '学号', dataIndex: 'studentNumber', width: 120, ellipsis: true },
|
||||
{ title: '身份证', dataIndex: 'idNumber', width: 180, ellipsis: true },
|
||||
```
|
||||
向后兼容:若后端暂未返回 `studentNumber` 字段,该列显示 `-`,不报错。
|
||||
|
||||
## 4. 实现顺序
|
||||
|
||||
```
|
||||
Phase 1: 基础
|
||||
1. index.css 三断点体系
|
||||
2. MainLayout useBreakpoint 重构
|
||||
|
||||
Phase 2: 高优先级页面
|
||||
3. Dashboard (4 张统计卡 + 3 张图)
|
||||
4. 学生管理 (表格 + 字段拆分)
|
||||
5. 入住管理 (表格 + 弹窗多)
|
||||
|
||||
Phase 3: 批量页面
|
||||
6-17. 剩余 12 个表格型页面(模式统一,效率高)
|
||||
|
||||
Phase 4: 收尾
|
||||
18. 教室排期表(特殊 HTML table)
|
||||
19. 登录页(单卡片)
|
||||
20. 全局验证
|
||||
```
|
||||
|
||||
## 5. 验证计划
|
||||
|
||||
- **视觉检查:** Chrome DevTools 响应式模式 → 375 / 768 / 992 / 1440 四个宽度
|
||||
- **每个页面检查:** 表格有无横向滚动 → 按钮是否错位/溢出 → 弹窗是否出屏 → 文字是否截断
|
||||
- **编译检查:** `npm run build` 确保 TypeScript 无报错
|
||||
@@ -1,302 +0,0 @@
|
||||
# 恭学教育学生管理系统 P0 批次 — 设计规格
|
||||
|
||||
> 版本:v1.0
|
||||
> 日期:2026-07-05
|
||||
> 基于 PRD:`../../Downloads/PRD-恭学教育学生管理系统.md`(commit `59f75bb`)
|
||||
|
||||
## 目标
|
||||
|
||||
按 PRD 优先级分批实施,本批次覆盖 P0(阻塞上线)的全部 5 项 + 部分 P1(上线前完成),打通班级→排课→宿舍增强→操作日志→RBAC→考勤前端→数据面板的完整链路。
|
||||
|
||||
## 架构
|
||||
|
||||
Monorepo (Turborepo),后端 NestJS 11 + TypeORM 0.3 + SQLite/MySQL,前端 React 19 + Vite + Ant Design 6 + ECharts。新增 `classes`、`schedules` 两个独立 NestJS 模块,在现有 `rooms`/`occupancies`/`bills` 模块上增量增强,考勤前端新建页面。遵循现有模块结构:每个业务模块独立目录,含 `entity`/`dto`/`service`/`controller`/`module`。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 架构 | Monorepo (Turborepo) |
|
||||
| 前端 | React 19 + Vite + Ant Design 6 + ECharts |
|
||||
| 后端 | NestJS 11 + TypeORM 0.3 + JWT + Passport |
|
||||
| 数据库 | 开发 SQLite / 生产 MySQL 8 |
|
||||
| 测试 | Jest (后端) + Playwright (前端 E2E) |
|
||||
|
||||
## 全局约束
|
||||
|
||||
- 所有涉及大量数据的列表/表单页面必须有详细的筛选方案
|
||||
- 敏感信息(手机号、身份证)需脱敏展示,查看时二次确认+记录操作日志
|
||||
- 遵循现有 NestJS 模块结构,每个业务模块独立目录
|
||||
- 前端页面放在 `apps/admin/src/pages/` 下,每个模块独立目录
|
||||
- 表名使用复数形式(与现有 `students`、`rooms`、`classrooms`、`bills` 一致)
|
||||
- 冲突检测使用数据库唯一约束 + 应用层双重保障
|
||||
|
||||
---
|
||||
|
||||
## 模块 1:班级管理(Class)
|
||||
|
||||
### 实体
|
||||
|
||||
**`classes`**:班级主表,含 name/code/class_type/status/日期/教师/人数上限,通过 `department_id` 关联校区。
|
||||
|
||||
**`class_student`**:班级-学员关联,`UNIQUE(class_id, student_id)`,含 enroll_id/join_date/leave_date/status。
|
||||
|
||||
**`class_teacher`**:班级-教师关联,`UNIQUE(class_id, user_id, role_type)`,含 role_type(任课老师/班主任/生活老师/学服老师)+ subject。
|
||||
|
||||
Class 自身的 `head_teacher_id`/`life_teacher_id`/`academic_teacher_id` 与 `class_teacher` 表保持同步——创建/编辑班级时,若传了 head_teacher_id 等字段,同时写入 class_teacher 表对应记录。
|
||||
|
||||
### API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/classes` | 班级列表(筛选:department_id/status/class_type/keyword) |
|
||||
| POST | `/classes` | 创建班级(含初始教师/学员分配) |
|
||||
| GET | `/classes/:id` | 班级详情(教师列表+学员统计) |
|
||||
| PUT | `/classes/:id` | 编辑班级 |
|
||||
| DELETE | `/classes/:id` | 删除班级(CASCADE 删除关联) |
|
||||
| GET | `/classes/:id/students` | 班级学员列表 |
|
||||
| POST | `/classes/:id/students` | 批量添加学员 `{ studentIds: number[] }` |
|
||||
| DELETE | `/classes/:id/students/:studentId` | 移除学员 |
|
||||
| GET | `/classes/:id/teachers` | 班级教师列表 |
|
||||
| POST | `/classes/:id/teachers` | 添加教师 `{ userId, roleType, subject? }` |
|
||||
| DELETE | `/classes/:id/teachers/:userId` | 移除教师 |
|
||||
|
||||
### 前端
|
||||
|
||||
**列表页 `Classes/index.tsx`**:筛选栏(校区下拉/班型下拉/状态下拉/搜索输入)+ 表格(名称/编码/校区/班型/日期/学员数/班主任/状态Tag/操作)+ 分页。
|
||||
|
||||
**详情页 `Classes/Detail.tsx`**:3 Tab — 基本信息(可编辑表单)、花名册(学员表格+批量添加/移除)、教师(教师表格+添加/移除)。
|
||||
|
||||
---
|
||||
|
||||
## 模块 2:排课管理(ClassSchedule)
|
||||
|
||||
### 实体
|
||||
|
||||
**`class_schedule`**:排课记录,含 class_id/classroom_id/week_day/start_time/end_time/start_date/end_date/subject/teacher_id/schedule_type(INTERNAL/RENTAL)/rental_id/status。
|
||||
|
||||
唯一约束:同教室 + 同 week_day + 时间段重叠 + status=active 拒绝写入(应用层检测 + 数据库层面依赖应用层保证,SQLite 不支持排他约束)。
|
||||
|
||||
### 核心规则
|
||||
|
||||
1. INTERNAL 排课:按班级维度,展示科目+教师,关联 class_id
|
||||
2. RENTAL 排课:仅标记教室占用,关联 rental_id(classroom_rentals)
|
||||
3. 冲突检测:新增/编辑时检查同教室同星期同时段是否已有 active 排课
|
||||
4. 时段定义:预设 PRD 作息表(早自习 7:30-8:40、上午 9:00-12:00、下午 14:00-17:00、晚自习 18:30-21:00),管理员可在系统设置中调整
|
||||
|
||||
### API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/class-schedules` | 排课列表(筛选:classroom_id/class_id/week_day/date_range) |
|
||||
| POST | `/class-schedules` | 创建排课(含冲突检测) |
|
||||
| PUT | `/class-schedules/:id` | 编辑排课(含冲突检测) |
|
||||
| DELETE | `/class-schedules/:id` | 删除排课 |
|
||||
| GET | `/class-schedules/weekly` | 周视图数据(按教室+week_day聚合) |
|
||||
| GET | `/class-schedules/classroom/:id/occupancy` | 教室占用时间线(合并内部+外部) |
|
||||
|
||||
### 前端
|
||||
|
||||
**周视图 `Schedules/index.tsx`**:顶部周切换 + 教室/班级筛选,主体 7列×N行矩阵(列=周一~周日,行=教室),每格显示科目/教师/时间。点击格子弹出排课表单 Modal(班级/科目/教师/教室/星期/时段/日期范围)。冲突时 Modal 内红色提示。
|
||||
|
||||
---
|
||||
|
||||
## 模块 3:宿舍/入住/账单增强
|
||||
|
||||
### 数据变更
|
||||
|
||||
**rooms**:新增 `rental_category`(VARCHAR(10),long/short)、`monthly_rate`(DECIMAL(10,2))。
|
||||
|
||||
**occupancies**:新增 `rental_type`(VARCHAR(10),long/short)、`tenant_id`(FK → tenants.id)。
|
||||
|
||||
**students**:新增 `tenant_id`(FK → tenants.id),替代原 `organization` 文本字段。迁移时根据 `organization` 匹配 `tenants.name`。
|
||||
|
||||
### 账单逻辑
|
||||
|
||||
`POST /bills/generate` 内部:遍历宿舍的 occupancy 记录,`rental_type === 'long'` 的长租学生走固定月费独立生成(不参与分摊),`short` 学生走原人天数加权分摊。
|
||||
|
||||
### 前端
|
||||
|
||||
Rooms 表单增加 `rental_category` Select + `monthly_rate` InputNumber。Occupancies 表单增加 `rental_type` Select + `tenant_id` Select。
|
||||
|
||||
---
|
||||
|
||||
## 模块 4:操作日志全量接入
|
||||
|
||||
### 覆盖范围
|
||||
|
||||
在以下 controller 的每个写操作中统一调用 `OperationLogsService.log()`:
|
||||
|
||||
| 模块 | 审计操作 |
|
||||
|------|---------|
|
||||
| Classes | 创建/编辑/删除班级、添加/移除学员、添加/移除教师 |
|
||||
| ClassSchedules | 创建/编辑/删除排课 |
|
||||
| Students | 新增/编辑/删除学生、导入/导出、查看敏感信息 |
|
||||
| Occupancies | 新增/编辑/退住 |
|
||||
| Expenses | 新增/编辑/删除费用 |
|
||||
| Bills | 生成账单、确认账单、标记已付、导出 |
|
||||
| Deposits | 收取/退还押金 |
|
||||
| Attendance | 补录/编辑考勤、匹配钉钉数据 |
|
||||
| RBAC | 角色变更、权限变更 |
|
||||
|
||||
### 调用模式
|
||||
|
||||
```typescript
|
||||
this.operationLogsService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: 'CLASS',
|
||||
action: 'CREATE',
|
||||
targetId: result.id,
|
||||
targetType: 'class',
|
||||
detail: { name: result.name },
|
||||
ipAddress: req.ip,
|
||||
status: 'success',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模块 5:RBAC 权限扩展
|
||||
|
||||
`permission.json` 新增以下节点:
|
||||
|
||||
| 权限节点 | super_admin | staff | class_teacher | student |
|
||||
|----------|:---:|:---:|:---:|:---:|
|
||||
| CLASS:READ | ✅ | ✅ | ✅ | - |
|
||||
| CLASS:ADD | ✅ | ✅ | - | - |
|
||||
| CLASS:UPDATE | ✅ | ✅ | - | - |
|
||||
| CLASS:DELETE | ✅ | - | - | - |
|
||||
| SCHEDULE:READ | ✅ | ✅ | ✅ | - |
|
||||
| SCHEDULE:ADD | ✅ | ✅ | - | - |
|
||||
| SCHEDULE:UPDATE | ✅ | ✅ | - | - |
|
||||
| SCHEDULE:DELETE | ✅ | - | - | - |
|
||||
| ATTENDANCE:READ | ✅ | ✅ | ✅ | ✅ |
|
||||
| ATTENDANCE:ADD | ✅ | ✅ | - | - |
|
||||
| ATTENDANCE:UPDATE | ✅ | ✅ | - | - |
|
||||
|
||||
---
|
||||
|
||||
## 模块 6:考勤管理前端
|
||||
|
||||
### 页面
|
||||
|
||||
**列表页 `Attendance/index.tsx`**:筛选栏(班级/日期范围/时段/状态/来源)+ 表格(姓名/班级/日期/时段/状态Tag/来源/打卡时间/备注/操作)+ 批量补录按钮 + 切换到日历视图按钮。
|
||||
|
||||
**日历视图**:切换模式,行=学生、列=日期+时段,格=状态色块(绿=出勤、黄=迟到、红=缺勤、蓝=请假)。
|
||||
|
||||
### API
|
||||
|
||||
增强现有 `/attendance-records` 筛选参数,新增:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/attendance-records/batch` | 批量补录 |
|
||||
| GET | `/attendance-records/summary` | 考勤汇总统计(按班级/日期范围) |
|
||||
| GET | `/attendance-records/calendar` | 日历视图数据 |
|
||||
| GET | `/ding-attendance-raw` | 钉钉原始数据列表(筛选match_status) |
|
||||
| POST | `/ding-attendance-raw/:id/match` | 手动匹配钉钉数据到学生 |
|
||||
|
||||
---
|
||||
|
||||
## 模块 7:数据面板增强
|
||||
|
||||
在现有 Dashboard 的基础上:
|
||||
|
||||
- **第二行指标卡**:教室总数/占用率、今日出勤率、本月收入总额、教室占用率
|
||||
- **新增图表**:考勤趋势折线图(近30天)、近6月收入趋势图
|
||||
- **权限控制**:不同角色看到不同指标——班主任只看本班考勤,超管看全局
|
||||
|
||||
### API
|
||||
|
||||
`GET /dashboard` 返回数据增加字段:`classroomCount`/`classroomOccupancyRate`/`todayAttendanceRate`/`monthlyIncome`/`attendanceTrend`/`incomeTrend`。
|
||||
|
||||
---
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
### 新增表
|
||||
|
||||
`classes`、`class_student`、`class_teacher`、`class_schedule` — TypeORM `synchronize: true` 自动建表。
|
||||
|
||||
### 现有表变更
|
||||
|
||||
```sql
|
||||
ALTER TABLE rooms ADD COLUMN rental_category VARCHAR(10) DEFAULT 'short';
|
||||
ALTER TABLE rooms ADD COLUMN monthly_rate DECIMAL(10,2) DEFAULT 0;
|
||||
ALTER TABLE occupancies ADD COLUMN rental_type VARCHAR(10) DEFAULT 'short';
|
||||
ALTER TABLE occupancies ADD COLUMN tenant_id INTEGER REFERENCES tenants(id);
|
||||
ALTER TABLE students ADD COLUMN tenant_id INTEGER REFERENCES tenants(id);
|
||||
```
|
||||
|
||||
### 数据迁移
|
||||
|
||||
`students.organization` → `students.tenant_id`:遍历 students 表,根据 `organization` 文本匹配 `tenants.name`,匹配到的写入 tenant_id,未匹配的置 NULL。原 `organization` 列保留不删,待后续迭代清理。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
apps/server/src/
|
||||
├── entities/
|
||||
│ ├── class.entity.ts 🆕
|
||||
│ ├── class-student.entity.ts 🆕
|
||||
│ ├── class-teacher.entity.ts 🆕
|
||||
│ ├── class-schedule.entity.ts 🆕
|
||||
│ ├── room.entity.ts ✏️
|
||||
│ ├── occupancy.entity.ts ✏️
|
||||
│ ├── student.entity.ts ✏️
|
||||
│ └── index.ts ✏️
|
||||
├── classes/ 🆕
|
||||
│ ├── classes.module.ts
|
||||
│ ├── classes.controller.ts
|
||||
│ ├── classes.service.ts
|
||||
│ └── dto/
|
||||
├── schedules/ 🆕
|
||||
│ ├── schedules.module.ts
|
||||
│ ├── schedules.controller.ts
|
||||
│ ├── schedules.service.ts
|
||||
│ └── dto/
|
||||
├── rooms/ ✏️ dto
|
||||
├── occupancies/ ✏️ dto + service
|
||||
├── bills/ ✏️ service
|
||||
├── operation-logs/ ✏️ 注入各模块
|
||||
├── rbac/ ✏️ permission.json
|
||||
├── dashboard/ ✏️ service + controller
|
||||
└── app.module.ts ✏️ 注册新模块
|
||||
|
||||
apps/admin/src/pages/
|
||||
├── Classes/ 🆕
|
||||
│ ├── index.tsx
|
||||
│ └── Detail.tsx
|
||||
├── Schedules/ 🆕
|
||||
│ └── index.tsx
|
||||
├── Attendance/ 🆕
|
||||
│ └── index.tsx
|
||||
├── Dashboard/ ✏️ index.tsx
|
||||
├── Rooms/ ✏️ index.tsx
|
||||
└── Occupancies/ ✏️ index.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发顺序
|
||||
|
||||
```
|
||||
Phase 1 班级管理(无上游依赖)
|
||||
│
|
||||
Phase 2 排课管理(依赖班级+教室)
|
||||
│
|
||||
Phase 3 宿舍/入住/账单增强(独立,可与1/2并行)
|
||||
│
|
||||
Phase 4 操作日志全量接入(依赖1/2/3的controller就绪)
|
||||
│
|
||||
Phase 5 RBAC权限扩展(依赖1/2模块存在)
|
||||
│
|
||||
Phase 6 考勤管理前端(后端API已有,独立开发)
|
||||
│
|
||||
Phase 7 数据面板增强(依赖各方面数据)
|
||||
```
|
||||
|
||||
Phase 1-3 可部分并行,Phase 4-5 需等 1-3 完成,Phase 6-7 可在 1-5 完成后并行。
|
||||
@@ -1,340 +0,0 @@
|
||||
# 多校区切换/隔离 — 设计规格
|
||||
|
||||
> 版本:v1.0
|
||||
> 日期:2026-07-05
|
||||
> 基于 PRD:`PRD-恭学教育学生管理系统.md` §23.7(多校区隔离确认需要,Department.type=campus 预留)+
|
||||
> §1.2(角色数据范围:超管全部 / 教职工指定部门+子部门 / 班主任本班 / 学生本人)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为系统引入多校区(Campus)概念,实现:
|
||||
- 校区树形组织结构(校区 → 子部门 → 班级)
|
||||
- 用户-部门绑定 + 默认校区
|
||||
- 全局数据查询按校区自动隔离
|
||||
- 前端校区切换器(支持单校区 / 全部校区视图)
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### 2.1 部门表 `departments`
|
||||
|
||||
```sql
|
||||
departments
|
||||
├── id INTEGER PK AUTOINCREMENT
|
||||
├── name VARCHAR(100) NOT NULL -- 部门名称,如 "鼓楼校区"
|
||||
├── parent_id INTEGER NULLABLE FK → self -- 上级部门,NULL = 顶层校区
|
||||
├── type VARCHAR(20) DEFAULT 'department'
|
||||
-- 'campus' = 校区(顶层,parent_id=NULL)
|
||||
-- 'department' = 子部门(教学部、后勤部等)
|
||||
├── sort_order INTEGER DEFAULT 0
|
||||
├── status VARCHAR(20) DEFAULT 'active' -- active / archived
|
||||
├── created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
├── updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
```
|
||||
|
||||
### 2.2 用户-部门关联 `user_departments`
|
||||
|
||||
```sql
|
||||
user_departments
|
||||
├── id INTEGER PK AUTOINCREMENT
|
||||
├── user_id INTEGER NOT NULL FK → users.id
|
||||
├── department_id INTEGER NOT NULL FK → departments.id
|
||||
├── is_default BOOLEAN DEFAULT false
|
||||
├── created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
UNIQUE(user_id, department_id)
|
||||
```
|
||||
|
||||
超管不在此表有记录 → 默认全部数据可见。学生不发此关联,通过 `students.department_id` 表所属部门。
|
||||
|
||||
### 2.3 现有实体追加 `department_id`
|
||||
|
||||
采用**冗余存储**策略(写入时填入,避免查询时多表 JOIN):
|
||||
|
||||
| 实体 | 操作 | 说明 |
|
||||
|------|:---:|------|
|
||||
| `students` | 新增 | 学生所属部门 |
|
||||
| `classes` | 保留 | 已有 `department_id` 字段 |
|
||||
| `rooms` | 新增 | 宿舍归属校区 |
|
||||
| `classrooms` | 新增 | 教室归属校区 |
|
||||
| `class_schedules` | 新增 | 冗余加速(也可从 class→department 间接获取) |
|
||||
| `attendance_records` | 新增 | 冗余加速(也可从 student→department 间接获取) |
|
||||
| `room_expenses` | 新增 | 冗余加速(也可从 room→department 间接获取) |
|
||||
| `personal_expenses` | 新增 | 冗余加速(也可从 student→department 间接获取) |
|
||||
| `occupancies` | 新增 | 冗余加速(也可从 room→department 间接获取) |
|
||||
| `bills` | 新增 | 冗余加速(也可从 student→department 间接获取) |
|
||||
| `deposits` | 新增 | 冗余加速(也可从 student→department 间接获取) |
|
||||
| `deposit_installments` | 新增 | 冗余加速 |
|
||||
| `classroom_rentals` | 新增 | 冗余加速(也可从 classroom→department 间接获取) |
|
||||
|
||||
**全局共享不隔离**:`users`、`roles`、`permissions`、`tenants`、`operation_logs`、`notifications`、`sync_logs`、`sync_states`、`ding_attendance_raw`、`expense_types`。
|
||||
|
||||
## 3. 后端隔离机制
|
||||
|
||||
### 3.1 JWT Payload 扩展
|
||||
|
||||
```typescript
|
||||
// 登录时注入
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
permissions,
|
||||
isSuperAdmin: user.roles?.some(r => r.name === 'super_admin'),
|
||||
// 不再注入 departmentIds,改为请求级 CampusScope 实时查询
|
||||
};
|
||||
```
|
||||
|
||||
不将 `departmentIds` 写入 JWT,避免校区分配变更后需重新登录。
|
||||
|
||||
### 3.2 CampusScope(请求级 Provider)
|
||||
|
||||
```typescript
|
||||
// apps/server/src/common/campus-scope.ts
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class CampusScope {
|
||||
private _departmentIds: number[] | null = null;
|
||||
currentDepartmentId: number | null;
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST) private req: any,
|
||||
private departmentsService: DepartmentsService,
|
||||
) {
|
||||
this.currentDepartmentId = parseInt(
|
||||
req.headers['x-campus-id'] || '0'
|
||||
) || null;
|
||||
}
|
||||
|
||||
get isSuperAdmin(): boolean {
|
||||
return this.req.user?.isSuperAdmin ?? false;
|
||||
}
|
||||
|
||||
/** 获取当前用户可访问的所有部门 ID(含子部门) */
|
||||
async getDepartmentIds(): Promise<number[]> {
|
||||
if (this._departmentIds) return this._departmentIds;
|
||||
const userDepts = await this.departmentsService.getUserDepartments(
|
||||
this.req.user.id
|
||||
);
|
||||
this._departmentIds = userDepts;
|
||||
return this._departmentIds;
|
||||
}
|
||||
|
||||
/** 对 TypeORM find 条件追加校区过滤 */
|
||||
async filter<T extends Record<string, any>>(where: T): Promise<T> {
|
||||
if (this.isSuperAdmin && !this.currentDepartmentId) return where;
|
||||
const ids = await this.getEffectiveScopeIds();
|
||||
return { ...where, departmentId: In(ids) } as any;
|
||||
}
|
||||
|
||||
private async getEffectiveScopeIds(): Promise<number[]> {
|
||||
// 选择了具体校区 → 该校区 + 所有子部门
|
||||
// 未选 → 用户所有可访问部门 + 子部门
|
||||
const baseId = this.currentDepartmentId;
|
||||
if (baseId) {
|
||||
return this.departmentsService.getDescendantIds(baseId);
|
||||
}
|
||||
const allIds = await this.getDepartmentIds();
|
||||
const expanded = await Promise.all(
|
||||
allIds.map(id => this.departmentsService.getDescendantIds(id))
|
||||
);
|
||||
return [...new Set(expanded.flat())];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Controller 使用模式
|
||||
|
||||
```typescript
|
||||
@Get()
|
||||
async findAll(@Req() req: Request) {
|
||||
const scope = req.campusScope; // 由 middleware 注入
|
||||
const where = await scope.filter({ status: 'active' });
|
||||
return this.repo.find({ where, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
```
|
||||
|
||||
超管不传 `X-Campus-Id` → `filter()` 原样返回 → 不做隔离。
|
||||
|
||||
### 3.4 校区选择 Header
|
||||
|
||||
前端 axios interceptor 注入:
|
||||
|
||||
```typescript
|
||||
api.interceptors.request.use((config) => {
|
||||
const campusId = localStorage.getItem('currentCampusId');
|
||||
if (campusId) {
|
||||
config.headers['X-Campus-Id'] = campusId;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
### 3.5 部门管理 CRUD
|
||||
|
||||
| 方法 | 路径 | 认证 | 说明 |
|
||||
|------|------|:---:|------|
|
||||
| GET | `/departments` | JWT | 部门列表(树形结构,按 sort_order + name) |
|
||||
| GET | `/departments/:id` | JWT | 部门详情 |
|
||||
| POST | `/departments` | JWT | 创建部门(指定 parent_id + type) |
|
||||
| PUT | `/departments/:id` | JWT | 编辑部门 |
|
||||
| DELETE | `/departments/:id` | JWT | 删除部门(检查无子部门+无关联用户) |
|
||||
| GET | `/departments/:id/users` | JWT | 部门下关联的用户列表 |
|
||||
| POST | `/departments/:id/users` | JWT | 为用户分配部门 `{ userId, isDefault? }` |
|
||||
| DELETE | `/departments/:id/users/:userId` | JWT | 移除用户-部门关联 |
|
||||
| GET | `/departments/tree` | JWT | 树形数据(前端级联选择器用) |
|
||||
|
||||
### 3.6 写操作时 department_id 填充
|
||||
|
||||
新建宿舍时:
|
||||
```typescript
|
||||
async create(dto: CreateRoomDto) {
|
||||
// department_id 由前端传入(校区选择器当前选中值)
|
||||
return this.repo.save({ ...dto, departmentId: dto.departmentId });
|
||||
}
|
||||
```
|
||||
|
||||
新建学生时关联班级的 department:
|
||||
```typescript
|
||||
async create(dto: CreateStudentDto) {
|
||||
const cls = await this.classesRepo.findOne({ where: { id: dto.classId } });
|
||||
return this.studentRepo.save({
|
||||
...dto,
|
||||
departmentId: cls?.departmentId,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 前端
|
||||
|
||||
### 4.1 校区选择器 `CampusSwitcher`
|
||||
|
||||
Header 左侧,Logo 旁边:
|
||||
|
||||
```
|
||||
[ 恭学教育 ] [ 鼓楼校区 ▾ ]
|
||||
```
|
||||
|
||||
- `Select` 组件,列出当前用户可访问的校区(从 JWT payload 或 `/departments` API 获取)
|
||||
- 切换时:`localStorage.setItem('currentCampusId', id)` + 刷新所有页面数据
|
||||
- 多校区权限用户底部显示「全部校区」选项(value 为空字符串)
|
||||
- 仅一个校区 → 纯文本展示,不可切换
|
||||
- 默认选中 `localStorage.getItem('currentCampusId')` 或用户默认校区
|
||||
|
||||
### 4.2 `useCampus` Hook
|
||||
|
||||
```typescript
|
||||
function useCampus() {
|
||||
const [campuses, setCampuses] = useState<Department[]>([]);
|
||||
const [currentId, setCurrentId] = useState<string>(
|
||||
() => localStorage.getItem('currentCampusId') || ''
|
||||
);
|
||||
|
||||
const switchCampus = (id: string) => {
|
||||
setCurrentId(id);
|
||||
localStorage.setItem('currentCampusId', id);
|
||||
// 触发全局数据刷新(通过 event 或 context)
|
||||
window.dispatchEvent(new CustomEvent('campus-changed', { detail: id }));
|
||||
};
|
||||
|
||||
return { campuses, currentId, switchCampus };
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 部门管理页面 `Departments/index.tsx`
|
||||
|
||||
- 左侧 `Tree` 组件展示部门树
|
||||
- 点击节点 → 右侧表单编辑部门信息
|
||||
- 右键/操作按钮:添加子部门、编辑、删除
|
||||
- 「部门成员」Tab:用户列表 + 分配/移除
|
||||
|
||||
### 4.4 数据面板适配
|
||||
|
||||
选中「全部校区」时:
|
||||
- 统计卡片数值为各校区汇总
|
||||
- 图表按校区分组(图例标注校区名)
|
||||
- 不选全部校区 → 仅显示当前校区数据
|
||||
|
||||
## 5. 文件结构
|
||||
|
||||
```
|
||||
apps/server/src/
|
||||
├── entities/
|
||||
│ ├── department.entity.ts 🆕
|
||||
│ ├── user-department.entity.ts 🆕
|
||||
│ ├── student.entity.ts ✏️ +department_id
|
||||
│ ├── room.entity.ts ✏️ +department_id
|
||||
│ ├── classroom.entity.ts ✏️ +department_id
|
||||
│ ├── class-schedule.entity.ts ✏️ +department_id
|
||||
│ ├── attendance-record.entity.ts ✏️ +department_id
|
||||
│ ├── room-expense.entity.ts ✏️ +department_id
|
||||
│ ├── personal-expense.entity.ts ✏️ +department_id
|
||||
│ ├── occupancy.entity.ts ✏️ +department_id
|
||||
│ ├── bill.entity.ts ✏️ +department_id
|
||||
│ ├── deposit.entity.ts ✏️ +department_id
|
||||
│ ├── deposit-installment.entity.ts ✏️ +department_id
|
||||
│ ├── classroom-rental.entity.ts ✏️ +department_id
|
||||
│ └── index.ts ✏️
|
||||
├── departments/ 🆕
|
||||
│ ├── departments.module.ts
|
||||
│ ├── departments.controller.ts
|
||||
│ ├── departments.service.ts
|
||||
│ └── dto/
|
||||
│ └── department.dto.ts
|
||||
├── common/
|
||||
│ └── campus-scope.ts 🆕
|
||||
├── auth/
|
||||
│ ├── auth.service.ts ✏️ 登录注入 isSuperAdmin
|
||||
│ └── strategies/jwt.strategy.ts ✏️ payload 扩展
|
||||
├── students/
|
||||
│ └── students.service.ts ✏️ create 时填充 department_id
|
||||
├── rooms/
|
||||
│ └── rooms.service.ts ✏️ create 时填充 department_id
|
||||
├── ...(各 service 使用 scope.filter())
|
||||
└── app.module.ts ✏️ 注册 DepartmentsModule + CampusScope
|
||||
|
||||
apps/admin/src/
|
||||
├── pages/
|
||||
│ └── Departments/
|
||||
│ └── index.tsx 🆕 部门管理页
|
||||
├── components/
|
||||
│ └── CampusSwitcher.tsx 🆕
|
||||
├── api/index.ts ✏️ interceptor 加 X-Campus-Id
|
||||
├── hooks/
|
||||
│ └── useCampus.ts 🆕
|
||||
├── layouts/
|
||||
│ └── MainLayout.tsx ✏️ 挂载 CampusSwitcher
|
||||
└── App.tsx ✏️ 注册 /departments 路由
|
||||
```
|
||||
|
||||
## 6. 数据库迁移
|
||||
|
||||
### 新增表
|
||||
|
||||
`departments`、`user_departments` — TypeORM `synchronize: true` 自动建表。
|
||||
|
||||
### 现有表 ALTER
|
||||
|
||||
```sql
|
||||
ALTER TABLE students ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE rooms ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE classrooms ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE class_schedules ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE attendance_records ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE room_expenses ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE personal_expenses ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE occupancies ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE bills ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE deposits ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE deposit_installments ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
ALTER TABLE classroom_rentals ADD COLUMN department_id INTEGER REFERENCES departments(id);
|
||||
```
|
||||
|
||||
### 数据回填
|
||||
|
||||
1. 创建默认校区 "主校区"(`departments` type=campus)
|
||||
2. 所有现有数据的 `department_id` 回填为默认校区 ID
|
||||
3. 现有用户全部关联到默认校区(`user_departments`)
|
||||
|
||||
## 7. 钉钉同步集成
|
||||
|
||||
钉钉组织架构拉取已有能力(PRD §19.1),同步时将钉钉部门树映射到 `departments` 表:
|
||||
- 根部门 → `type = 'campus'`
|
||||
- 子部门 → `type = 'department'`
|
||||
- 同步时维护 `parent_id` 树结构
|
||||
@@ -1,233 +0,0 @@
|
||||
# 站内信通知中心 — 设计规格
|
||||
|
||||
> 版本:v1.0
|
||||
> 日期:2026-07-05
|
||||
> 基于 PRD:`PRD-恭学教育学生管理系统.md` §23.7(通知中心确认需要) + §12.3(账单通知推送 P2)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为系统全部角色(超管、教职工、班主任、学生)提供统一的站内信通知中心,支撑以下业务场景的实时通知,同时预留钉钉/企微外发扩展点。
|
||||
|
||||
## 2. 通知场景
|
||||
|
||||
| 场景 | 触发方 | 通知类型 | 接收方 |
|
||||
|------|--------|----------|--------|
|
||||
| 账单生成 | 系统/管理员 | `bill_generated` | 学生 + 财务 |
|
||||
| 账单确认/已付 | 管理员 | `bill_paid` | 学生 + 宿管 |
|
||||
| 入住登记 | 宿管 | `check_in` | 宿管 + 学生 |
|
||||
| 退宿 | 宿管 | `check_out` | 宿管 + 学生 |
|
||||
| 押金催缴 | 财务 | `deposit_due` | 学生 + 财务 |
|
||||
| 押金退还 | 财务 | `deposit_refunded` | 学生 + 财务 |
|
||||
| 班级学员增减 | 教务 | `class_change` | 班主任 |
|
||||
| 班级教师调整 | 教务 | `class_change` | 相关教师 |
|
||||
| 排课冲突 | 系统检测 | `schedule_conflict` | 教务 |
|
||||
| 系统公告 | 管理员手动 | `announcement` | 全员/指定角色 |
|
||||
|
||||
## 3. 技术方案
|
||||
|
||||
**SSE (Server-Sent Events) 推送 + 轮询兜底。**
|
||||
|
||||
- NestJS 原生 `@Sse()` + RxJS `Observable`
|
||||
- 前端 `EventSource` 建立长连接,断开时自动重连
|
||||
- 重连间隙兜底轮询 `GET /notifications/unread-count`(60s 间隔)
|
||||
- 钉钉/企微外发通过 EventEmitter2 异步解耦
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
```sql
|
||||
notifications
|
||||
├── id INTEGER PK AUTOINCREMENT
|
||||
├── recipient_id INTEGER NOT NULL -- FK → users.id
|
||||
├── type VARCHAR(30) NOT NULL -- bill_generated | bill_paid | check_in | check_out |
|
||||
-- deposit_due | deposit_refunded | class_change |
|
||||
-- schedule_conflict | announcement
|
||||
├── title VARCHAR(200) NOT NULL -- 通知标题
|
||||
├── content TEXT -- 通知正文(支持模板变量)
|
||||
├── link VARCHAR(500) NULLABLE -- 点击跳转路径,如 /bills/123
|
||||
├── is_read BOOLEAN DEFAULT false
|
||||
├── read_at DATETIME NULLABLE
|
||||
├── created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
```
|
||||
|
||||
设计决策:
|
||||
- **一通知一接收方** — 同一事件对 N 个用户各建一条记录,避免 `is_read` 共享状态。
|
||||
- **无软删除** — 通知不可删除(保留审计痕迹),支持"全部已读"。
|
||||
- **cursor-based 分页** — `?after=<id>&limit=20`,适合实时追加场景。
|
||||
|
||||
## 5. 后端模块
|
||||
|
||||
### 5.1 文件结构
|
||||
|
||||
```
|
||||
apps/server/src/
|
||||
├── entities/
|
||||
│ └── notification.entity.ts 🆕
|
||||
├── notifications/ 🆕
|
||||
│ ├── notifications.module.ts
|
||||
│ ├── notifications.controller.ts
|
||||
│ ├── notifications.service.ts
|
||||
│ └── dto/
|
||||
│ └── notification.dto.ts
|
||||
└── app.module.ts ✏️ 注册 NotificationsModule
|
||||
```
|
||||
|
||||
### 5.2 API
|
||||
|
||||
| 方法 | 路径 | 认证 | 说明 |
|
||||
|------|------|:---:|------|
|
||||
| GET | `/notifications` | JWT | 当前用户通知列表(cursor 分页,`?after=&limit=20`) |
|
||||
| GET | `/notifications/unread-count` | JWT | `{ count: number }` |
|
||||
| GET | `/notifications/stream` | JWT | SSE 端点,`text/event-stream` |
|
||||
| PUT | `/notifications/:id/read` | JWT | 标记单条已读 |
|
||||
| PUT | `/notifications/read-all` | JWT | 当前用户全部已读 |
|
||||
|
||||
### 5.3 Service 接口
|
||||
|
||||
```typescript
|
||||
class NotificationsService {
|
||||
create(dto: CreateNotificationDto): Promise<Notification>;
|
||||
findByUser(userId: number, after?: number, limit?: number): Promise<Notification[]>;
|
||||
getUnreadCount(userId: number): Promise<number>;
|
||||
markRead(id: number, userId: number): Promise<void>;
|
||||
markAllRead(userId: number): Promise<void>;
|
||||
subscribe(userId: number): Observable<Notification>; // SSE
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 SSE 实现要点
|
||||
|
||||
- Controller 使用 `@Sse('stream')` + `@Req()` 获取 `req.user.id`
|
||||
- Service 内部维护 `Map<userId, Subject<Notification>>`
|
||||
- `create()` 方法写入 DB 后 → `subject.next(notification)` 推送给订阅者
|
||||
- 用户断开连接时清理 Subject
|
||||
|
||||
### 5.5 业务模块集成模式
|
||||
|
||||
各业务 Controller 写操作完成后调用:
|
||||
|
||||
```typescript
|
||||
this.notificationsService.create({
|
||||
recipientIds: [studentUserId, financeUserIds],
|
||||
type: 'bill_generated',
|
||||
title: '账单已生成',
|
||||
content: `您的 ${periodLabel} 账单已生成,总额 ¥${totalAmount}`,
|
||||
link: `/bills/${billId}`,
|
||||
});
|
||||
```
|
||||
|
||||
钉钉/企微外发通过 `EventEmitter2` 解耦:
|
||||
|
||||
```typescript
|
||||
this.eventEmitter.emit('notification.created', notification);
|
||||
```
|
||||
|
||||
## 6. 前端
|
||||
|
||||
### 6.1 文件结构
|
||||
|
||||
```
|
||||
apps/admin/src/
|
||||
├── pages/
|
||||
│ └── Notifications/
|
||||
│ └── index.tsx 🆕 通知全屏页
|
||||
├── components/
|
||||
│ └── NotificationBell.tsx 🆕 Header 铃铛组件
|
||||
├── hooks/
|
||||
│ └── useNotifications.ts 🆕 SSE 连接 + 未读计数
|
||||
└── layouts/
|
||||
└── MainLayout.tsx ✏️ 挂载 NotificationBell + SSE hook
|
||||
```
|
||||
|
||||
### 6.2 Header 铃铛
|
||||
|
||||
- `Badge` 组件显示未读数(count > 99 显示 "99+")
|
||||
- 点击展开 `Popover`(宽 380px,高 480px)
|
||||
- Popover 内容:
|
||||
- 头部:"通知中心" + "全部已读" `Button`
|
||||
- 列表:虚拟滚动,未读条目左侧蓝点
|
||||
- 点击条目 → `api.put(/notifications/${id}/read)` + `navigate(link)`
|
||||
- 底部 "查看全部 →" → `/notifications`
|
||||
- 空状态:"暂无通知" 插画
|
||||
|
||||
### 6.3 全屏通知页 `/notifications`
|
||||
|
||||
- 左侧类型筛选 `Menu`(全部/账单/入住/班级/系统)
|
||||
- 右侧通知列表 + `InfiniteScroll`
|
||||
- 列表项:类型图标 + 标题 + 内容摘要 + 时间(相对时间 "3分钟前")
|
||||
- 点击条目 → 标已读 + 跳转 `link`
|
||||
|
||||
### 6.4 SSE Hook (`useNotifications`)
|
||||
|
||||
```typescript
|
||||
function useNotifications() {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const es = new EventSource(`/api/notifications/stream?token=${token}`);
|
||||
|
||||
es.onmessage = (event) => {
|
||||
const notification = JSON.parse(event.data);
|
||||
setUnreadCount((c) => c + 1);
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
// SSE 断开,切换到轮询兜底
|
||||
const interval = setInterval(async () => {
|
||||
const { count } = await api.get('/notifications/unread-count');
|
||||
setUnreadCount(count);
|
||||
}, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
};
|
||||
|
||||
return () => es.close();
|
||||
}, []);
|
||||
|
||||
return { unreadCount };
|
||||
}
|
||||
```
|
||||
|
||||
SSE 认证:URL query 传 JWT token(EventSource 不支持自定义 header)。
|
||||
|
||||
## 7. SSE 认证与 Nginx 配置
|
||||
|
||||
### 7.1 后端 Guard 适配
|
||||
|
||||
`JwtAuthGuard` 需支持从 query string 提取 token(当前仅从 `Authorization` header):
|
||||
|
||||
```typescript
|
||||
// 在 canActivate 中增加 fallback
|
||||
const token = extractFromHeader(request) || request.query?.token;
|
||||
```
|
||||
|
||||
### 7.2 Nginx 配置
|
||||
|
||||
SSE 长连接需关闭对该路径的 proxy buffering:
|
||||
|
||||
```nginx
|
||||
location /api/notifications/stream {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 数据库迁移
|
||||
|
||||
TypeORM `synchronize: true` 自动建表。Entity 注册在 `apps/server/src/entities/index.ts`,`AppModule` 中 `TypeOrmModule.forFeature([Notification])`。
|
||||
|
||||
## 9. 钉钉/企微外发(预留)
|
||||
|
||||
- `NotificationsService.create()` 后 emit `notification.created` 事件
|
||||
- 钉钉模块(`apps/server/src/sync/` 下已有工作通知能力)监听该事件
|
||||
- 根据 `notification.type` 判断是否外发(如 `bill_generated` 发钉钉,`announcement` 仅站内信)
|
||||
- 外发失败不影响站内信记录,日志告警即可
|
||||
|
||||
## 10. 扩展点(学生端未来接入)
|
||||
|
||||
- 学生端前端独立部署时,复用同一套 API(JWT 认证统一)
|
||||
- `link` 字段路径由前端根据当前角色拼接 base path
|
||||
- 通知类型枚举预留 `student_*` 前缀扩展
|
||||
@@ -1,79 +0,0 @@
|
||||
# 学生档案报告:后端渲染 → 前端预览
|
||||
|
||||
> 将档案报告从后端 Puppeteer 生成 PDF 改为后端生成 HTML、前端新窗口预览、浏览器打印出 PDF。
|
||||
|
||||
## 目标
|
||||
|
||||
- 去掉 Puppeteer 依赖(减小编译/运行时镜像体积)
|
||||
- 报告预览即时可用,不再等待 PDF 生成
|
||||
- PDF 导出由用户通过浏览器 Ctrl+P → 另存为 PDF 完成
|
||||
- 改动最小化:复用现有 `buildHtml()` 等 HTML 构造方法
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
前端: fetch /archive/:studentId/report-html
|
||||
→ window.open → document.write(html)
|
||||
→ 用户浏览/打印
|
||||
|
||||
后端: ArchiveReportService.generateReportHtml(studentId)
|
||||
→ 查数据库组装 ReportData
|
||||
→ 调用现有 buildHtml(data)
|
||||
→ 返回 HTML 字符串
|
||||
```
|
||||
|
||||
## 后端改动
|
||||
|
||||
### `apps/server/src/archive/archive-report.service.ts`
|
||||
|
||||
| 操作 | 详情 |
|
||||
|------|------|
|
||||
| 新增 | `generateReportHtml(studentId: number): Promise<string>` — 复用现有数据查询和 `buildHtml()`,返回纯 HTML 字符串 |
|
||||
| 删除 | `generateReport(studentId: number, res: Response): Promise<void>` — Puppeteer PDF 流式输出 |
|
||||
| 删除 | `import puppeteer from 'puppeteer'` |
|
||||
| 删除 | `import { Response } from 'express'` |
|
||||
|
||||
其余 `buildHtml`、`buildCover`、`buildBasicInfo`、`buildExamOverview`、`buildAttendance`、`buildExamDetail`、`buildLearningAndResult`、`renderScoreTable`、`renderScoreTrendChart`、`renderAttendanceBar`、`renderAttendanceMatrix`、`css`、`pageFrame`、`pageHeader`、`pageFooter`、`esc` 等私有方法全部保留不变。
|
||||
|
||||
### `apps/server/src/archive/archive.controller.ts`
|
||||
|
||||
| 操作 | 详情 |
|
||||
|------|------|
|
||||
| 新增 | `GET /archive/:studentId/report-html` — 调用 `reportService.generateReportHtml(+studentId)`,返回 `{ html: string }`,记录操作日志 |
|
||||
| 删除 | `GET /archive/:studentId/report` — 原 Puppeteer PDF 下载端点 |
|
||||
|
||||
### 依赖清理
|
||||
|
||||
| 文件 | 操作 |
|
||||
|------|------|
|
||||
| `apps/server/package.json` | 移除 `puppeteer` |
|
||||
| `apps/server/Dockerfile` | 移除 Chromium 相关依赖安装步骤 |
|
||||
|
||||
## 前端改动
|
||||
|
||||
### `apps/admin/src/pages/StudentProfile/index.tsx`
|
||||
|
||||
| 操作 | 详情 |
|
||||
|------|------|
|
||||
| 修改 | "生成档案报表"按钮文本 → "预览报告" |
|
||||
| 修改 | `handleDownloadReport` → `handlePreviewReport`:`fetch(/api/archive/:id/report-html)` → 解析 JSON → `window.open` → `document.write(html)` |
|
||||
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 层级 | 文件 | 改动量 |
|
||||
|------|------|--------|
|
||||
| 后端 service | `archive-report.service.ts` | +15 行, -30 行 |
|
||||
| 后端 controller | `archive.controller.ts` | +12 行, -10 行 |
|
||||
| 后端依赖 | `package.json`, `Dockerfile` | 小改动 |
|
||||
| 前端 | `StudentProfile/index.tsx` | ~10 行 |
|
||||
|
||||
无数据库变更,无 API 兼容性破坏(`/report` 端点被替换为 `/report-html`)。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 点击「预览报告」按钮,新窗口打开完整报告(封面→学情记录共 6 页)
|
||||
- [ ] 报告样式与现有 PDF 版视觉一致
|
||||
- [ ] 新窗口内 Ctrl+P 可正常打印,打印预览显示分页正确
|
||||
- [ ] Puppeteer 已从依赖中移除,Docker 构建不再安装 Chromium
|
||||
- [ ] 现有学生档案 CRUD 功能不受影响
|
||||
@@ -1,149 +0,0 @@
|
||||
# PRD 差距收尾 — 设计文档
|
||||
|
||||
> 日期:2026-07-07
|
||||
> 范围:PRD v1.0 审计后剩余的全部 ⚠️ 部分实现 / ❌ 未实现项 + 1 个确认 bug,共 12 项,拆为 8 个工作包(WP)。
|
||||
> 执行方式:实现工作全部派发给 `deepseek/deepseek-v4-pro` 子代理,每个 WP 独立验收,最后统一构建验证。
|
||||
|
||||
## 背景
|
||||
|
||||
基于 6 路并行代码审计(2026-07-07),PRD 的 P0/P1 主体功能已完成。剩余缺口:
|
||||
|
||||
| 类别 | 项 |
|
||||
|---|---|
|
||||
| Bug | 今日出勤率恒为 0 |
|
||||
| ⚠️ 部分实现 | 钉钉兜底匹配、档案脱敏、多班型封面、PDF 专业课对比、面板角色差异化、organization 残留、增量同步、账单外部推送 |
|
||||
| ❌ 未实现 | 入住 enrollment 预填、入住时间线、排课单双周 |
|
||||
|
||||
## WP1 — Bug:今日出勤率恒为 0
|
||||
|
||||
**问题**:`apps/server/src/dashboard/dashboard.service.ts` `getStats()` 在 ~L92 计算了 `todayAttendanceRate`,但 L153-173 的 return 对象漏掉该字段;前端 `apps/admin/src/pages/Dashboard/index.tsx:386` 读 `stats?.todayAttendanceRate` 恒为 undefined,指标卡显示 0。
|
||||
|
||||
**修复**:return 对象补 `todayAttendanceRate`。无其他改动。
|
||||
|
||||
**验收**:GET /dashboard/stats 响应含 `todayAttendanceRate` 字段(有当日考勤数据时为非零字符串)。
|
||||
|
||||
## WP2 — 钉钉考勤兜底匹配
|
||||
|
||||
**现状**:`apps/server/src/attendance/attendance.service.ts` `autoMatchDingRecords()` 仅按姓名匹配;PRD 23.3 要求手机号、身份证号兜底。
|
||||
|
||||
**设计**:
|
||||
|
||||
1. 匹配链(顺序执行,命中即停):
|
||||
- `students.resource_user_id = raw.ding_user_id`
|
||||
- 经 users 同步表由 dingUserId 取 phone → `students.phone` 精确匹配
|
||||
- 同上取到的身份信息 → `students.id_card` 精确匹配(若钉钉侧有)
|
||||
- 姓名精确匹配(现有逻辑保留,作为最后一级)
|
||||
2. 任一级命中**多个**候选学生 → 不自动绑定,保持 `match_status=待匹配`(人工处理页兜底)。
|
||||
3. `ding_attendance_raw` 实体新增可空 `phone` 列;打卡事件落库时若能从已同步 User 拿到手机号则冗余写入,便于人工匹配页展示与排查。SQLite 开发库用 `synchronize` 自动加列;不需要手写迁移(生产 MySQL 上线时统一走 schema 同步流程)。
|
||||
4. 人工匹配 API(POST /ding-attendance-raw/:id/match)不变。
|
||||
|
||||
**验收**:构造 4 条 raw 记录(分别只能由 resourceUserId / phone / idCard / name 命中),autoMatch 后均绑定正确 student;构造重名两学生 → 该记录保持待匹配。
|
||||
|
||||
## WP3 — 学生档案三项
|
||||
|
||||
### 3a 敏感信息脱敏(PRD 1.3)
|
||||
|
||||
**现状**:`apps/admin/src/pages/Students/index.tsx` 已实现脱敏+二次确认+后端日志;`apps/admin/src/components/StudentProfileContent/index.tsx` 直接明文展示 phone/idNumber。
|
||||
|
||||
**设计**:复用 Students 页的既有模式(同一确认弹窗文案、同一后端敏感查看日志端点),StudentProfileContent 中 phone/idNumber 默认脱敏(`138****1234` / `110***********1234`),点击"查看"→ Modal.confirm → 调日志端点 → 显示明文。**不新造第二套脱敏组件**:若 Students 页逻辑是内联的,抽为共享工具/组件后两处复用。
|
||||
|
||||
### 3b 多班型封面(PRD 2.1)
|
||||
|
||||
**现状**:档案封面 enrollment 卡片只展示前 2 个。
|
||||
|
||||
**设计**:改为全部展示,卡片区 flex-wrap 布局;3+ 班型时自动换行。
|
||||
|
||||
### 3c PDF 报表专业课对比(PRD 2.2)
|
||||
|
||||
**现状**:`apps/server/src/archive/archive-report.service.ts` `buildExamDetail` 仅渲染文化课考试。
|
||||
|
||||
**设计**:镜像文化课渲染逻辑,增加专业课成绩独立表格 + 趋势区块;按 exam 记录的课程类别(文化课/专业课)分组,专业课组为空时该区块不渲染(不出空表)。
|
||||
|
||||
**验收**:3a 档案页 phone/idNumber 默认脱敏、确认后显示且 operation_logs 有记录;3b 造 3 个 enrollment 的学生封面全部可见;3c 有专业课成绩的学生报表 HTML 含专业课对比表,无专业课成绩的学生报表不含空区块。
|
||||
|
||||
## WP4 — 数据面板角色差异化(PRD 23.6)
|
||||
|
||||
**现状**:Dashboard 仅有 CampusScope 部门过滤,无角色差异化。
|
||||
|
||||
**设计**:
|
||||
|
||||
1. 后端 `getStats()`(及 class-attendance-ranking 等端点)注入当前用户:
|
||||
- 用户为班主任类角色(存在 ClassTeacher 关联且非 super_admin/staff 系管理角色)→ 考勤指标、班级出勤排行仅统计其所带班级(ClassTeacher → classId 过滤)。
|
||||
- 用户无 `bill`/`deposit` 相关权限 → return 对象**不含** `monthlyIncome`、`pendingDeposits`、`incomeTrend`、`billStats` 字段。
|
||||
2. 前端指标卡/图表按字段有无条件渲染:字段 undefined 即不渲染该卡片/图。
|
||||
3. super_admin 与 staff 系角色行为完全不变。
|
||||
4. 判定依据用现有 RBAC 权限节点(请求上下文里已有用户权限),不新增权限节点。
|
||||
|
||||
**验收**:以班主任账号请求 /dashboard/stats → 无收入/押金字段、考勤数只含本班;超管请求 → 字段齐全,与改动前一致。
|
||||
|
||||
## WP5 — organization → tenant_id 收尾(PRD 1.1)
|
||||
|
||||
**现状**:`students.tenant_id` 外键与筛选已生效,旧 `organization` 文本列共存。
|
||||
|
||||
**设计**(干净切换):
|
||||
|
||||
1. 回填逻辑放在 StudentsService(或专用 backfill service)的 `onModuleInit` 中幂等执行(项目约定:`synchronize: true` + 服务内幂等 seed,参照 `expense-types.service.ts` 的 `seedDefaults`):对 `organization` 非空且 `tenant_id` 为空的学生,按名称 find-or-create Tenant(新建的给默认颜色),回填 `tenantId`。注意:`organization` 列从 entity 删除后 TypeORM synchronize 不会自动删物理列(SQLite/MySQL 均保留孤列),回填需在删除 entity 字段前用 raw query 读取该列,保证幂等重跑安全。
|
||||
2. 迁移完成后删除 `organization`:entity 字段、DTO、导入导出模板列、前端表单/表格列全部清除。
|
||||
3. Excel 导入模板中原"机构"列改为按 Tenant 名称解析(find-or-create 同上),保持导入体验不回退。
|
||||
|
||||
**验收**:迁移后无 `organization` 引用(grep 为零,实体除外的历史注释可留);导入含机构名的 Excel 仍能正确挂 tenant;学生列表机构筛选正常。
|
||||
|
||||
## WP6 — 第三方集成两项
|
||||
|
||||
### 6a 增量同步(INT.3)
|
||||
|
||||
**约束**:钉钉/企微组织架构 API 不支持"按变更时间查询",真增量不可行。
|
||||
|
||||
**设计**:全量拉取 + 本地 diff 写入:
|
||||
- `syncAll()` 拉取后与库内记录逐条比对,仅对有实际字段变化的记录执行 update,新记录 insert,无变化 skip。
|
||||
- 同步日志(sync_log)记录 `created/updated/skipped` 三个数量;`lastSyncAt` 保留用于展示。
|
||||
- 移除 `_lastSyncAt` 假形参(要么真用于日志,要么删除),不留误导性签名。
|
||||
|
||||
### 6b 账单外部推送(10.2)
|
||||
|
||||
**设计**:
|
||||
- 账单**确认**(confirmed)时触发(draft 不推,避免打扰):对账单学生查 `resource_user_id`,钉钉侧发工作通知、企微侧发应用消息(走 integration 模块现有 token 机制,新增发消息方法)。
|
||||
- 凭据未配置或学生无 resourceUserId → 静默跳过,仅站内 SSE 通知(现状保留),不抛错不阻塞账单流程。
|
||||
- 推送结果写操作日志(module=bills, action=notify)。
|
||||
|
||||
**验收**:6a 连续两次同步,第二次日志 skipped≈全部、updated=0;6b 无凭据环境下确认账单不报错且站内通知正常,推送代码路径有单测覆盖(mock integration service 验证调用参数)。
|
||||
|
||||
## WP7 — 入住管理两项
|
||||
|
||||
### 7a enrollment 预填(8.1)
|
||||
|
||||
入住弹窗选中学生后,调现有档案/enrollment 查询接口拉取该生当前在读 enrollment,在表单内只读展示班级/班型/班主任提示信息。不改数据模型、不落库。
|
||||
|
||||
### 7b 入住历史时间线(8.3)
|
||||
|
||||
入住列表行加"历史"按钮 → Drawer 内 AntD `Timeline` 按时间倒序展示该学生全部 occupancy 记录(入住/换房/退住节点,含房间号、日期、退住原因)。数据用现有 occupancies 按 studentId 查询的 API(如无该筛选参数则补上)。
|
||||
|
||||
**验收**:7a 选择有 enrollment 的学生显示班级提示、无 enrollment 学生不报错;7b 多次入住的学生时间线节点完整有序。
|
||||
|
||||
## WP8 — 排课单双周
|
||||
|
||||
**设计**:
|
||||
|
||||
1. `class_schedule` 新增 `week_parity` 列:`'all' | 'odd' | 'even'`,默认 `'all'`;奇偶按 **ISO 8601 周数**判定。
|
||||
2. 冲突检测(`apps/server/src/schedules/schedules.service.ts`):同教室同 weekDay 时间重叠时,`odd` vs `even` 不冲突;`all` 与任何值冲突。RENTAL 类型视为 `all`。
|
||||
3. 前端排课表单加"周次"选择(默认每周);周视图格子对 odd/even 排课显示"单/双"角标;月视图按具体日期的 ISO 周奇偶过滤显示。
|
||||
4. 排课→考勤自动生成(`attendance.service.ts` generateFromSchedules):按目标日期 ISO 周奇偶跳过不匹配的排课。
|
||||
5. 班级课表(/classes/:id/schedule)响应带 weekParity 字段,前端同样标注。
|
||||
|
||||
**验收**:同教室同时段创建 odd+even 两条排课成功、再建 all 报冲突;单周排课在双周日期不生成考勤记录;周视图角标正确。
|
||||
|
||||
## 横切约束
|
||||
|
||||
- 所有新增写操作接入 OperationLogsService(沿现有各 controller 模式)。
|
||||
- 不引入新依赖;ISO 周数计算用 dayjs 现有插件或自实现纯函数。
|
||||
- 各 WP 不跑项目级 lint/test/build——由主控最后统一验证。
|
||||
- 前端遵循现有页面结构与 AntD 6 组件用法;后端遵循 entity/dto/service/controller 模块结构。
|
||||
|
||||
## 工作包依赖
|
||||
|
||||
全部 WP 相互独立、可并行。WP8 内部排课字段与考勤生成改动同包完成,无跨包依赖。
|
||||
|
||||
## 测试策略
|
||||
|
||||
- WP2、WP6b、WP8 冲突检测:Jest 单测(新增/修改的 service 方法)。
|
||||
- 其余 WP:以 API 响应/页面行为验收,主控统一 `turbo build` + 相关单测。
|
||||
@@ -1,256 +0,0 @@
|
||||
# 床位管理 & 柜子管理 — 设计规约
|
||||
|
||||
> 日期:2026-07-09
|
||||
> 状态:待评审
|
||||
> 关联 PRD:模块 7 宿舍管理
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
在现有宿舍管理(Rooms)基础上,新增**床位管理**和**柜子管理**两个子资源。床位与入住流程强绑定(入住必选床位),柜子可选分配。两者均有独立生命周期(可单独标记维修),不随学生入住/退宿自动创建或删除。
|
||||
|
||||
### 1.1 核心设计决策
|
||||
|
||||
| 决策 | 选择 |
|
||||
|------|------|
|
||||
| 床位/柜子与房间的关系 | Room 1:N Bed / Room 1:N Locker,子资源挂在房间下 |
|
||||
| 床位与入住的关系 | 入住必选床位,occupancies 新增 bed_id / locker_id |
|
||||
| 前端交互方式 | 房间详情 Drawer 内嵌子 Tab(床位/柜子),不改动侧边栏 |
|
||||
| 入住流程改造 | 选房间 → 自动加载可用床位 → 必选床位 + 可选柜子 |
|
||||
| 权限 | 沿用 room:view / room:edit,不新增独立权限 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
### 2.1 新增表
|
||||
|
||||
```sql
|
||||
CREATE TABLE beds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id),
|
||||
bed_number VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'available', -- available / occupied / maintenance
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(room_id, bed_number)
|
||||
);
|
||||
|
||||
CREATE TABLE lockers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id),
|
||||
locker_number VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'available', -- available / occupied / maintenance
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(room_id, locker_number)
|
||||
);
|
||||
```
|
||||
|
||||
### 2.2 现有表变更
|
||||
|
||||
```sql
|
||||
ALTER TABLE occupancies ADD COLUMN bed_id INTEGER REFERENCES beds(id);
|
||||
ALTER TABLE occupancies ADD COLUMN locker_id INTEGER REFERENCES lockers(id);
|
||||
```
|
||||
|
||||
- 两个字段均为 **nullable**,历史数据留空
|
||||
- 新建入住时 bed_id 必填,locker_id 可选
|
||||
|
||||
### 2.3 状态机
|
||||
|
||||
```
|
||||
available ──入住──▶ occupied ──退宿──▶ available
|
||||
│ │
|
||||
└──手动维修──▶ maintenance ──手动恢复──▶ available
|
||||
```
|
||||
|
||||
- `occupied` → `maintenance`:**禁止**,必须先退宿
|
||||
- `maintenance` 的床位不出现在入住选择列表中
|
||||
|
||||
---
|
||||
|
||||
## 3. 后端 API
|
||||
|
||||
### 3.1 床位
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/rooms/:roomId/beds` | 房间下所有床位列表 |
|
||||
| GET | `/rooms/:roomId/beds/available` | 仅返回 available 状态床位(入住下拉用) |
|
||||
| POST | `/rooms/:roomId/beds` | 新增床位 |
|
||||
| PUT | `/rooms/:roomId/beds/:id` | 编辑床位(编号/状态/备注) |
|
||||
| DELETE | `/rooms/:roomId/beds/:id` | 删除床位(仅当未被占用时) |
|
||||
| POST | `/rooms/:roomId/beds/batch` | 批量创建床位(如"一键生成4张床") |
|
||||
|
||||
### 3.2 柜子
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/rooms/:roomId/lockers` | 房间下所有柜子列表 |
|
||||
| GET | `/rooms/:roomId/lockers/available` | 仅返回 available 状态柜子 |
|
||||
| POST | `/rooms/:roomId/lockers` | 新增柜子 |
|
||||
| PUT | `/rooms/:roomId/lockers/:id` | 编辑柜子 |
|
||||
| DELETE | `/rooms/:roomId/lockers/:id` | 删除柜子(仅当未被占用时) |
|
||||
| POST | `/rooms/:roomId/lockers/batch` | 批量创建柜子 |
|
||||
|
||||
### 3.3 入住 API 变更
|
||||
|
||||
- `POST /occupancies` 请求体新增 `bedId`(必填)、`lockerId`(可选)
|
||||
- 创建时校验:`bedId` 对应的床位 status 必须为 `available` 且属于指定 roomId
|
||||
- 成功后后端事务:创建 occupancy + 更新 bed.status = 'occupied'(+ locker 同理)
|
||||
- `PUT /occupancies/:id`(换房/换床场景)同理校验
|
||||
- 退宿时(check-out):恢复 bed/locker 状态为 `available`
|
||||
|
||||
### 3.4 房间删除/归档
|
||||
|
||||
- 归档房间时,不自动归档床位/柜子(保持数据完整性)
|
||||
- 删除床位/柜子的 DELETE 为硬删除,仅在未被占用时允许
|
||||
- 若房间已被归档,其床位/柜子的新增/编辑操作被拒绝
|
||||
|
||||
---
|
||||
|
||||
## 4. 前端 UI
|
||||
|
||||
### 4.1 房间详情 Drawer(核心变更)
|
||||
|
||||
**位置**:`apps/admin/src/pages/Rooms/index.tsx`
|
||||
|
||||
- 将现有的简单 Modal 替换为 Ant Design `<Drawer>`
|
||||
- Drawer 内含 `<Tabs>`,三个面板:
|
||||
|
||||
| Tab | 内容 |
|
||||
|-----|------|
|
||||
| 基本信息 | 现有房间字段展示 + 编辑表单(房号、楼栋、楼层、容量、类型、性别、租赁类别、月租金)|
|
||||
| 床位管理 | `<Table>`:编号、状态 Tag、备注、操作(编辑/删除);顶部工具栏:新增 + 批量生成 |
|
||||
| 柜子管理 | `<Table>`:编号、状态 Tag、备注、操作(编辑/删除);顶部工具栏:新增 + 批量生成 |
|
||||
|
||||
- 床位/柜子操作均为就地弹窗(Modal),不跳页
|
||||
- 已归档房间:隐藏床位/柜子的新增按钮,操作列仅显示查看
|
||||
|
||||
### 4.2 入住登记改造
|
||||
|
||||
**位置**:`apps/admin/src/pages/Occupancies/index.tsx`
|
||||
|
||||
现有"入住登记"弹窗改为**分步表单**或**分组表单**:
|
||||
|
||||
```
|
||||
┌─ 基本信息 ────────────────────────┐
|
||||
│ 学生选择 [Select 搜索] │
|
||||
│ 房间选择 [Select 搜索] │
|
||||
│ 入住日期 [DatePicker] │
|
||||
│ 计费起始 [DatePicker] │
|
||||
│ 租赁类型 [长租/短租] │
|
||||
│ 租赁方 [Select] │
|
||||
└──────────────────────────────────┘
|
||||
┌─ 床位/柜子分配 ───────────────────┐
|
||||
│ 床位 * [Select] 空闲 3/4 │
|
||||
│ 柜子 [Select] 空闲 3/4 (可选)│
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 选择房间后,自动请求 `/rooms/:id/beds/available` 填充床位下拉
|
||||
- 床位下拉旁显示"空闲 X / 总共 Y"提示
|
||||
- 柜子下拉从 `/rooms/:id/lockers/available` 加载
|
||||
- 入住列表表格新增"床位号""柜子号"两列
|
||||
- 换房时也允许重新选择床位/柜子
|
||||
|
||||
### 4.3 宿舍总览(RoomVisual)
|
||||
|
||||
**位置**:`apps/admin/src/pages/RoomVisual/index.tsx`
|
||||
|
||||
- 房间卡片底部增加床位占用信息:「🛏 2/4 床已占用」
|
||||
- 统计栏:总床位数、可用床位数(替代或补充现有"可用床位"统计,当前是基于 capacity 计算的)
|
||||
- 点击卡片进入房间详情时,直接定位到"床位管理" Tab
|
||||
|
||||
### 4.4 侧边栏 & 路由
|
||||
|
||||
**不变。** 不新增菜单项,不新增路由。所有操作在现有页面内完成。
|
||||
|
||||
---
|
||||
|
||||
## 5. 迁移策略
|
||||
|
||||
### 5.1 数据库迁移
|
||||
|
||||
1. 创建 `beds` 和 `lockers` 表
|
||||
2. ALTER TABLE `occupancies` 添加 `bed_id`、`locker_id` 列(nullable)
|
||||
3. 根据现有 `rooms.capacity`,为每个房间自动生成 capacity 张默认床位(编号 "1号床"~"N号床",status = 'available')
|
||||
4. 不自动生成柜子(柜子数量无法从现有数据推断)
|
||||
|
||||
### 5.2 数据兼容
|
||||
|
||||
- 历史入住记录 bed_id/locker_id 为 NULL,前端展示为 "-"
|
||||
- 现有入住功能不受影响(入住弹窗中床位默认可选第一张可用床,或留空允许不选 — 取决于业务要求)
|
||||
|
||||
---
|
||||
|
||||
## 6. 边界情况 & 约束
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 床位被占用时标记为维修 | 禁止,提示"请先退宿" |
|
||||
| 删除已被占用的床位 | 禁止,提示"该床位有人入住" |
|
||||
| 房间已满,但还有空床位(数据不一致)| 允许入住,以床位为准;rooms.capacity 降级为展示用途 |
|
||||
| 入住时选择房间后无可选床位 | 提示"该房间暂无可用床位",阻止提交 |
|
||||
| 批量导入入住名单 | 模板新增床号列;导入时自动查找或创建床位 |
|
||||
| 导出 | 床位/柜子数据包含在房间导出中,作为子 sheet |
|
||||
|
||||
---
|
||||
|
||||
## 7. 实现范围
|
||||
|
||||
### 包含
|
||||
|
||||
- beds + lockers 实体、迁移、CRUD API
|
||||
- occupancies 表新增 bed_id/locker_id
|
||||
- 入住/退宿/换房时床柜状态联动
|
||||
- 房间详情 Drawer + 床/柜子 Tab
|
||||
- 入住登记改造(床位必选)
|
||||
- 宿舍总览卡片的床位统计
|
||||
|
||||
### 不包含
|
||||
|
||||
- 柜子钥匙管理(后续独立需求)
|
||||
- 床位/柜子独立的全局列表页(预留 API,不做前端)
|
||||
- 床位与学生的独立关联表(通过 occupancy 关联即可)
|
||||
- 床位/柜子的操作日志(后续统一做时纳入)
|
||||
|
||||
---
|
||||
|
||||
## 8. 文件清单
|
||||
|
||||
### 后端(NestJS)
|
||||
|
||||
| 文件 | 操作 |
|
||||
|------|------|
|
||||
| `apps/server/src/entities/bed.entity.ts` | 新增 |
|
||||
| `apps/server/src/entities/locker.entity.ts` | 新增 |
|
||||
| `apps/server/src/entities/occupancy.entity.ts` | 修改(加字段)|
|
||||
| `apps/server/src/rooms/rooms.module.ts` | 修改(注册 Bed/Locker)|
|
||||
| `apps/server/src/rooms/rooms.controller.ts` | 修改(加床位/柜子路由)|
|
||||
| `apps/server/src/rooms/rooms.service.ts` | 修改(加床柜 CRUD)|
|
||||
| `apps/server/src/occupancies/occupancies.service.ts` | 修改(入住/退宿联动床柜状态)|
|
||||
| `apps/server/src/occupancies/occupancies.controller.ts` | 修改(接收 bedId/lockerId)|
|
||||
| Migration 脚本 | 新增 |
|
||||
|
||||
### 前端(React)
|
||||
|
||||
| 文件 | 操作 |
|
||||
|------|------|
|
||||
| `apps/admin/src/pages/Rooms/index.tsx` | 重写 Detail Modal → Drawer + Tabs |
|
||||
| `apps/admin/src/pages/Occupancies/index.tsx` | 改造入住登记 + 新增列 |
|
||||
| `apps/admin/src/pages/RoomVisual/index.tsx` | 卡片底部加床位统计 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 自我审查
|
||||
|
||||
- [x] 无 TBD / 占位符
|
||||
- [x] 数据模型与 API 一致
|
||||
- [x] 边界情况已覆盖(维修冲突、空床位、历史数据兼容)
|
||||
- [x] 范围明确(包含/不包含)
|
||||
- [x] 权限策略清晰(沿用 room:*)
|
||||
@@ -1,194 +0,0 @@
|
||||
# 钉钉组织导入 — 标记班级功能
|
||||
|
||||
**日期**: 2026-07-09
|
||||
**状态**: 设计完成
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
在钉钉组织架构导入抽屉(`IntegrationConfig` 页面)中,支持将钉钉部门标记为「班级」。导入时自动创建班级实体,部门下的教师加入班级教师关联,学生加入班级学生关联,勾选「班主任」角色的用户自动成为该班级主班主任。
|
||||
|
||||
---
|
||||
|
||||
## 前端改造
|
||||
|
||||
### 1. 树节点增强
|
||||
|
||||
**部门节点**右侧新增操作入口:
|
||||
|
||||
- 未标记班级的部门 → 显示 `🏫 标为班级` 按钮
|
||||
- 已标记班级的部门 → 显示 `🏫 班级: XXXXX [已标记]`,颜色区分,点击可修改
|
||||
|
||||
用户节点无变化。
|
||||
|
||||
### 2. 标记班级 Modal
|
||||
|
||||
点击「标为班级」/「已标记」→ 弹出 Modal 表单:
|
||||
|
||||
|字段|组件|说明|
|
||||
|---|---|---|
|
||||
|班级名称|Input|预填部门名称,可修改|
|
||||
|班级编码|Input|必填,手动输入|
|
||||
|班型|Select|culture / professional / bootcamp / sprint|
|
||||
|开班日期|DatePicker|选填|
|
||||
|结束日期|DatePicker|选填|
|
||||
|最大人数|InputNumber|默认 0(不限制)|
|
||||
|备注|TextArea|选填|
|
||||
|
||||
状态管理:`classMarks: Record<number, ClassMarkForm>`,key 为钉钉部门 ID。
|
||||
|
||||
### 3. 导入 payload 调整
|
||||
|
||||
点击「导入」时构建的 payload:
|
||||
|
||||
```ts
|
||||
{
|
||||
classes: [
|
||||
{ deptId, name, code, classType, startDate?, endDate?, maxStudents?, notes? }
|
||||
],
|
||||
users: [
|
||||
{
|
||||
dingUserId: u.userid,
|
||||
name: u.name,
|
||||
mobile: u.mobile,
|
||||
roleId: teacherChecks[u.userid]
|
||||
? (teacherRoles[u.userid] || defaultTeacherRoleId)
|
||||
: null,
|
||||
dingDeptIds: u.deptIds
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 状态清理
|
||||
|
||||
关闭抽屉时重置 `classMarks`。
|
||||
|
||||
---
|
||||
|
||||
## 后端改造
|
||||
|
||||
### 1. DTO 扩展
|
||||
|
||||
`apps/server/src/sync/dto/import-users.dto.ts`:
|
||||
|
||||
```ts
|
||||
export class ImportUserItemDto {
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
roleId: number | null;
|
||||
dingDeptIds: number[]; // 新增
|
||||
}
|
||||
|
||||
export class ImportClassItemDto {
|
||||
deptId: number;
|
||||
name: string;
|
||||
code: string;
|
||||
classType: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
maxStudents?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class ImportUsersDto {
|
||||
classes?: ImportClassItemDto[]; // 新增,可选
|
||||
users: ImportUserItemDto[];
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 服务层逻辑
|
||||
|
||||
`apps/server/src/sync/sync.service.ts` — `importDingTalkUsers` 方法改造,全量事务:
|
||||
|
||||
```
|
||||
1. 解析 classes[],创建 Class 实体,以 deptId → classId 建 Map
|
||||
2. 遍历 users[]:
|
||||
a. 检查 UserDingMapping 是否已存在 → 跳过
|
||||
b. 创建 User + 角色分配 / Student(现有逻辑不变)
|
||||
c. 记录 userId → dingDeptIds 映射
|
||||
3. 遍历每个用户:
|
||||
a. user.dingDeptIds 匹配 classes[].deptId → 找到归属班级
|
||||
b. roleId = 班主任 → ClassTeacher(roleType=head_teacher),
|
||||
第一个班主任设 Class.headTeacherId
|
||||
c. roleId != null 且非班主任 → ClassTeacher(roleType 按角色名映射)
|
||||
d. roleId = null → ClassStudent
|
||||
e. 用户在多个部门 → 全部匹配到的班级都建立关联
|
||||
```
|
||||
|
||||
**返回结构**:
|
||||
|
||||
```ts
|
||||
{
|
||||
teacherCount: number;
|
||||
studentCount: number;
|
||||
classCount: number; // 新增
|
||||
skipped: number;
|
||||
warnings: string[]; // 非致命警告
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 教师角色判定
|
||||
|
||||
通过角色表查询:导入时勾选的用户分配指定角色后即为「老师」。所有老师在班级关联中统一写入 `ClassTeacher`(`roleType = 'teacher'`),不区分班主任/任课老师。班级对老师为多对多关系。
|
||||
|
||||
### 4. 错误场景
|
||||
|
||||
|场景|行为|
|
||||
|---|---|
|
||||
|班级编码重复|事务回滚,返回 `"班级编码 XX 已存在"`|
|
||||
|部门标为班级但无人|班级正常创建(空班),warning|
|
||||
|用户在多个被标记的部门|同时加入所有匹配班级|
|
||||
|无 classes 参数|兼容旧调用,仅导入师生,不创建班级|
|
||||
|角色不存在|事务回滚,返回具体错误|
|
||||
|
||||
---
|
||||
|
||||
## dingDeptIds 数据来源
|
||||
|
||||
`DingTalkService.fetchOrgTreeWithUsers` 返回的 `DingOrgTreeNodeWithUsers` 中,每个 user 已包含钉钉 API 返回的 `department` 字段(用户所属部门 ID 列表)。前端 `DingOrgTreeNodeExt` interface 需新增 `deptIds: number[]` 字段。
|
||||
|
||||
---
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 后端单测(`apps/server/src/sync/sync.service.spec.ts`)
|
||||
|
||||
|用例|预期|
|
||||
|---|---|
|
||||
|单部门标班级 + 1班主任 + 1学生|班级创建,headTeacherId 正确,关联正确|
|
||||
|单部门多班主任|第一个设 headTeacherId,其余进 ClassTeacher,warning|
|
||||
|用户在多个被标记部门|同时加入多个班级|
|
||||
|班级编码重复|事务回滚,错误返回|
|
||||
|空部门|班级创建,warning|
|
||||
|纯学生无老师|班级创建,headTeacherId=null,学生关联正确|
|
||||
|无 classes 参数|向下兼容,行为不变|
|
||||
|
||||
### 前端验证(手动 QA)
|
||||
|
||||
1. 标记一个部门为班级 → 填表单 → 导入 → 验证班级列表、班主任、学生归属
|
||||
2. 标记多个部门 → 批量导入 → 每个班独立创建
|
||||
3. 点击已标记班级 → 修改表单 → 重新导入
|
||||
4. 无标记班级的普通导入 → 行为不受影响
|
||||
|
||||
---
|
||||
|
||||
## 影响范围
|
||||
|
||||
|文件|改动|
|
||||
|---|---|
|
||||
|`apps/admin/src/pages/IntegrationConfig/index.tsx`|树节点、Modal、import payload|
|
||||
|`apps/server/src/sync/dto/import-users.dto.ts`|新增 ImportClassItemDto,扩展 ImportUserItemDto|
|
||||
|`apps/server/src/sync/sync.service.ts`|importDingTalkUsers 改造|
|
||||
|`apps/server/src/sync/sync.service.spec.ts`|新增测试用例|
|
||||
|`apps/server/src/integration/dingtalk.service.ts`|org-tree-with-users 返回增加 deptIds|
|
||||
|
||||
---
|
||||
|
||||
## 实现阶段技能注入
|
||||
|
||||
编码 agent 需加载:
|
||||
- `ui-ux-pro-max` — 前端 UI/UX 设计(Ant Design 6 组件选择、交互细节)
|
||||
- `vercel-react-best-practices` — React 性能优化(memo、useMemo、避免无意义重渲染)
|
||||
@@ -1,90 +0,0 @@
|
||||
# DingTalk 导入链路修复 — 设计文档
|
||||
|
||||
> 日期:2026-07-09
|
||||
> 状态:已确认
|
||||
> 关联:PRD 钉钉集成批次 1/2
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
DingTalk 用户导入 → 班级标记 → 排课 → 考勤 的链路已基本搭建完成,但存在两个阻塞问题:
|
||||
|
||||
1. **前端:** "标为班级"按钮出现在所有部门节点上,包括有子部门的父节点。应该在叶子节点(无子部门、有用户)才显示。
|
||||
2. **后端:** `CampusScope` 按 `departmentId` 做数据隔离,但钉钉同步的老师没有 `UserDepartment` 关联,导致非超管老师看不到学生和考勤数据。
|
||||
|
||||
本设计的核心原则:**教学域(班级、学生、考勤、排课)不走部门过滤,权限边界是"老师教的班",不是"老师归哪个部门"。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 改动清单
|
||||
|
||||
### 2.1 前端:叶子节点判断
|
||||
|
||||
**文件:** `apps/admin/src/pages/IntegrationConfig/index.tsx`
|
||||
|
||||
**位置:** `buildTreeData` 回调函数,第 371–431 行
|
||||
|
||||
**改动:** 部门节点的标题渲染逻辑改为:
|
||||
|
||||
```
|
||||
if (node.children.length === 0 && node.users.length > 0) {
|
||||
// 叶子节点:显示"标为班级"按钮 或 "班级: xxx [已标记]"
|
||||
} else {
|
||||
// 非叶子节点:只显示部门名称
|
||||
}
|
||||
```
|
||||
|
||||
非叶子节点(有子部门)不显示任何班级标记入口。
|
||||
|
||||
### 2.2 后端:移除教学域 CampusScope
|
||||
|
||||
**原则:** CampusScope 保留给管理域(宿舍 `OccupanciesService`、房间 `RoomsService`、账单 `BillsService`、费用 `ExpensesService`)——这些按校区/部门隔离数据是合理的。教学域不走部门过滤。
|
||||
|
||||
#### 2.2.1 StudentsService
|
||||
|
||||
**文件:** `apps/server/src/students/students.service.ts`
|
||||
|
||||
**改动:**
|
||||
- 移除 `CampusScope` 注入(构造函数参数 `private readonly scope: CampusScope`)
|
||||
- 移除 `findAll` 方法中的 `await this.scope.filter(where)` 调用
|
||||
- `findAll` 直接使用原始 `where` 条件查询
|
||||
|
||||
**影响:** 有 `student:view` 权限的用户可以看到所有学生,不再按部门隔离。
|
||||
|
||||
#### 2.2.2 AttendanceService
|
||||
|
||||
**文件:** `apps/server/src/attendance/attendance.service.ts`
|
||||
|
||||
**改动:**
|
||||
- 移除 `CampusScope` 注入
|
||||
- 移除所有 `scope.filter()` / `scope.getScopeDepartmentIds()` 调用
|
||||
- 考勤记录查询不再按部门过滤
|
||||
|
||||
**影响:** 有 `attendance:view` 权限的用户可以看到所有考勤数据。
|
||||
|
||||
---
|
||||
|
||||
## 3. 不改的
|
||||
|
||||
| 模块 | 原因 |
|
||||
|---|---|
|
||||
| `ClassesService` | 已不使用 CampusScope |
|
||||
| `SchedulesService` | 已不使用 CampusScope |
|
||||
| `OccupanciesService` | 宿舍管理需要部门隔离,保留 |
|
||||
| `RoomsService` | 房间管理需要部门隔离,保留 |
|
||||
| `BillsService` | 财务管理需要部门隔离,保留 |
|
||||
| `ExpensesService` | 费用管理需要部门隔离,保留 |
|
||||
| `DingTalkService.syncAll` | 同步逻辑不变 |
|
||||
| `SyncService.importDingTalkUsers` | 导入逻辑不变 |
|
||||
| RBAC 权限码 | 不动 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 验收标准
|
||||
|
||||
1. 钉钉组织架构树中,父部门节点不显示"标为班级"按钮,叶子节点正常显示
|
||||
2. 非超管老师登录后,学生列表正常显示所有学生
|
||||
3. 非超管老师登录后,考勤页面正常显示所有考勤记录
|
||||
4. 宿舍、财务等管理域功能不受影响(部门过滤仍然生效)
|
||||
5. `tsc --noEmit` 编译通过
|
||||
@@ -1,201 +0,0 @@
|
||||
# 钉钉同步 — 用户角色选择设计
|
||||
|
||||
> 2026-07-09 | 状态:待实现
|
||||
|
||||
## 问题
|
||||
|
||||
当前 `DingTalkService.syncOneUser()` 将每个从钉钉同步过来的新用户**无条件创建为 Student**。钉钉组织里教职工和学生混在一起,导致每次同步后管理员需要手动去用户管理页面逐人修正角色(「标记为教职工」「标记为学员」按钮)。
|
||||
|
||||
## 方案
|
||||
|
||||
将同步入口从 Users 页面移到 IntegrationConfig 页面,新增「同步用户」Tab。同步时前端拉取钉钉组织树(含用户),在 Drawer 内勾选「谁是老师」,导入时后端据此分别创建:
|
||||
|
||||
- **老师**(勾选):User + 指定角色,不建 Student
|
||||
- **学生**(未勾选):User + Student,不分配角色
|
||||
|
||||
---
|
||||
|
||||
## 后端
|
||||
|
||||
### 1. 新接口:`GET /api/sync/dingtalk/org-tree-with-users`
|
||||
|
||||
权限:`sync:read`
|
||||
|
||||
Query: `rootDeptId` (可选,默认 1)
|
||||
|
||||
返回:部门树,每个节点含 `users` 数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "恭学教育",
|
||||
"parentId": 0,
|
||||
"children": [
|
||||
{
|
||||
"id": 10,
|
||||
"name": "教务处",
|
||||
"parentId": 1,
|
||||
"children": [],
|
||||
"users": [
|
||||
{ "userid": "abc123", "name": "张老师", "mobile": "138..." }
|
||||
]
|
||||
}
|
||||
],
|
||||
"users": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
实现层级:
|
||||
- `DingTalkService` 新增 `fetchOrgTreeWithUsers(rootDeptId)` — BFS 拉部门 + 按部门拉用户,组装树
|
||||
- `SyncService` 新增 `getDingTalkOrgTreeWithUsers(rootDeptId)` — 透传
|
||||
- `SyncController` 新增 `@Get('dingtalk/org-tree-with-users')`
|
||||
|
||||
### 2. 新接口:`POST /api/sync/dingtalk/import-users`
|
||||
|
||||
权限:`sync:trigger`
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{ "dingUserId": "abc123", "name": "张老师", "mobile": "138...", "roleId": 2 },
|
||||
{ "dingUserId": "def456", "name": "李同学", "mobile": "139...", "roleId": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `roleId: number` → 老师,创建 User + 分配角色,不创建 Student
|
||||
- `roleId: null` → 学生,创建 User + Student(status=active),不分配角色
|
||||
> `roleId: null`(前端传 null, 不是 undefined)表示学生。两端统一用 `null` 作为"不是老师"的标记值。
|
||||
- users 数组为空时 → 返回 `{ teacherCount: 0, studentCount: 0, skipped: 0 }`,不报错
|
||||
|
||||
- 已存在 UserDingMapping → 跳过,计入 skipped
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{ "teacherCount": 3, "studentCount": 25, "skipped": 2 }
|
||||
```
|
||||
|
||||
实现层级:
|
||||
- `SyncService` 新增 `importDingTalkUsers(users)` — 遍历、去重、创建 User/Student/UserDingMapping
|
||||
- `SyncController` 新增 `@Post('dingtalk/import-users')`
|
||||
|
||||
### 3. 旧逻辑不删
|
||||
|
||||
`syncAll()` / `syncOneUser()` 保持不变,保持向后兼容。只是前端入口不再走这个路径。
|
||||
|
||||
---
|
||||
|
||||
## 前端
|
||||
|
||||
### 文件:`apps/admin/src/pages/IntegrationConfig/index.tsx`
|
||||
|
||||
页面加 Tabs,拆两个标签:
|
||||
|
||||
| Tab | key | 内容 |
|
||||
|-----|-----|------|
|
||||
| 钉钉配置 | `config` | 现有表单(不变) |
|
||||
| 同步用户 | `sync-users` | 新增(见下) |
|
||||
|
||||
**钉钉未配置时**:「同步用户」Tab 不显示(`config === null`)。
|
||||
|
||||
**「同步用户」Tab 结构:**
|
||||
|
||||
```
|
||||
Card
|
||||
├─ TreeSelect (选起始部门,默认全部)
|
||||
├─ Button 「获取组织架构」
|
||||
├─ Drawer (open=hasTree)
|
||||
│ ├─ Tree 组件
|
||||
│ │ ├─ 部门节点 (可展开)
|
||||
│ │ └─ 用户节点 (叶子,不可展开)
|
||||
│ │ ├─ 姓名 + 手机号
|
||||
│ │ ├─ Checkbox 「老师」(默认不勾)
|
||||
│ │ └─ 勾选后:Select 角色 (默认「班主任」)
|
||||
│ └─ Footer
|
||||
│ ├─ Button 「取消」
|
||||
│ └─ Button 「导入」(type=primary, loading)
|
||||
└─ result message (导入后显示)
|
||||
```
|
||||
|
||||
**状态管理:**
|
||||
|
||||
```typescript
|
||||
// 新增 state
|
||||
const [orgTree, setOrgTree] = useState<TreeNode[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [fetchingTree, setFetchingTree] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [teacherChecks, setTeacherChecks] = useState<Record<string, boolean>>({});
|
||||
const [teacherRoles, setTeacherRoles] = useState<Record<string, number>>({});
|
||||
```
|
||||
|
||||
|
||||
**默认角色解析:**
|
||||
|
||||
> `defaultTeacherRoleId` 在组件挂载时从 `/rbac/roles` 加载所有角色后,查找 `name === '班主任'` 的 id 作为默认值。若角色列表里没有「班主任」,取第一个非系统角色的 id。
|
||||
|
||||
### 文件:`apps/admin/src/pages/Users/index.tsx`
|
||||
|
||||
删除以下内容:
|
||||
- 「同步钉钉用户」按钮 (PermissionButton, permission `sync:trigger`)
|
||||
- 同步部门 TreeSelect
|
||||
- 「标记为教职工」「标记为学员」操作按钮
|
||||
- 相关 state:`syncing`、`syncDeptId`、`orgTree`
|
||||
- 相关函数:`loadOrgTree`、`handleSyncDingTalk`、`handleMarkStaff`
|
||||
- 未使用的 import:`CloudDownloadOutlined`、`TreeSelect`
|
||||
|
||||
---
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
[同步用户 Tab]
|
||||
│
|
||||
├─ 选部门 → 点「获取组织架构」
|
||||
├─ GET /sync/dingtalk/org-tree-with-users?rootDeptId=X
|
||||
│ DingTalkService.fetchOrgTreeWithUsers()
|
||||
│ 按部门拉用户 → 去重(dingUserId) → 组装树
|
||||
│
|
||||
├─ Drawer 展示树,勾选老师+选角色
|
||||
├─ 点「导入」
|
||||
├─ POST /sync/dingtalk/import-users
|
||||
│ { users: [{ dingUserId, name, mobile, roleId|null }] }
|
||||
│ │
|
||||
│ ├─ 查 UserDingMapping → 已存在则跳过
|
||||
│ ├─ roleId != null: User + role assignment
|
||||
│ └─ roleId == null: User + Student(status=active)
|
||||
│ └─ UserDingMapping(dingUserId, userId, dingName, dingMobile)
|
||||
│
|
||||
└─ 结果: { teacherCount, studentCount, skipped }
|
||||
```
|
||||
|
||||
## 边界情况
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 钉钉未配置 | 不显示「同步用户」Tab |
|
||||
| 已存在 mapping | 跳过,计入 skipped |
|
||||
| 手机号为空 | username = `dd_<dingUserId>` |
|
||||
| 同用户属多部门 | 去重,仅在首个部门展示 |
|
||||
| 角色下拉选项 | 从 `/rbac/roles` 获取所有非禁用角色 |
|
||||
| 导入单个失败 | 独立 try/catch,log 错误但不阻断其余 |
|
||||
|
||||
## 涉及文件
|
||||
|
||||
### 后端 (4 个文件修改)
|
||||
- `apps/server/src/integration/dingtalk.service.ts` — 新增 `fetchOrgTreeWithUsers`
|
||||
- `apps/server/src/sync/sync.service.ts` — 新增 `getDingTalkOrgTreeWithUsers`、`importDingTalkUsers`
|
||||
- `apps/server/src/sync/sync.controller.ts` — 新增 2 个 endpoint
|
||||
- `apps/server/src/sync/sync.module.ts` — 注入 User/Student/Role Repository(如需)
|
||||
|
||||
### 前端 (2 个文件修改)
|
||||
- `apps/admin/src/pages/IntegrationConfig/index.tsx` — 加 Tabs + Drawer
|
||||
- `apps/admin/src/pages/Users/index.tsx` — 删同步/标记按钮及相关代码
|
||||
@@ -1,62 +0,0 @@
|
||||
# 组织同步 Drawer 只展示最深两层
|
||||
|
||||
**日期**:2026-07-09
|
||||
**范围**:后端 `DingTalkService.fetchOrgTreeWithUsers` + 前端 `IntegrationConfig` Drawer
|
||||
|
||||
## 需求
|
||||
|
||||
钉钉组织架构树可能很深(5+ 层),「同步用户」Drawer 中只展示整棵树的最深两层,减少噪音和 API 调用。
|
||||
|
||||
- 深度以 rootDeptId=1 的全局树为准
|
||||
- 最深两层 = depth ∈ [maxDepth-1, maxDepth]
|
||||
- `fetchOrgTree`(dept picker)不变
|
||||
- `syncAll` 同步逻辑不变
|
||||
|
||||
## 方案
|
||||
|
||||
### 后端:`DingTalkService`
|
||||
|
||||
#### 新增 `getDeptDepthMap(token)`
|
||||
|
||||
```typescript
|
||||
// BFS 从 rootDeptId=1 出发,返回 Map<deptId, depth(1-based)>
|
||||
private async getDeptDepthMap(token: string): Promise<Map<number, number>>
|
||||
```
|
||||
|
||||
不调 `getDeptDetail`,仅用 `listsubid` API 遍历树。
|
||||
|
||||
#### 修改 `fetchOrgTreeWithUsers(rootDeptId)`
|
||||
|
||||
```
|
||||
depthMap = getDeptDepthMap(token) // 全局深度
|
||||
maxDepth = max(depthMap.values())
|
||||
subtree = getAllDeptIds(token, rootDeptId) // 子树范围
|
||||
filtered = subtree.filter(d => depthMap.get(d) ∈ [maxDepth-1, maxDepth])
|
||||
|
||||
for each filtered: getDeptDetail + getDeptUsers
|
||||
assemble tree(父节点不在 filtered 中的提升为根)
|
||||
```
|
||||
|
||||
#### 边界
|
||||
|
||||
|情况|行为|
|
||||
|---|---|
|
||||
|树只有 1 层|取 depth=1,展示根部门|
|
||||
|树 2 层|取 depth=1,2,展示全部(和现在一致)|
|
||||
|子树最深层 < maxDepth-1|返回空数组|
|
||||
|中间层有用户|不展示(被裁剪)|
|
||||
|
||||
### 前端
|
||||
|
||||
零改动。返回数据仍是 `DingOrgTreeNodeWithUsers[]`,只是节点变少。空树时现有 `<Spin />` 兜底。
|
||||
|
||||
## 不受影响
|
||||
|
||||
- `fetchOrgTree` — dept picker 仍显示完整树
|
||||
- `syncAll` — 同步全部部门
|
||||
- `getAllDeptIds` / `getDeptDetail` / `getDeptUsers` — 不变
|
||||
|
||||
## 改动量
|
||||
|
||||
- `dingtalk.service.ts`:约 30 行(新方法 + 改旧方法)
|
||||
- 前端:0 行
|
||||
@@ -1,56 +0,0 @@
|
||||
# 组织同步重构:Student 独立实体 + Drawer 批量操作
|
||||
|
||||
**日期**:2026-07-09
|
||||
**范围**:数据模型、同步、导入、前端 Drawer
|
||||
|
||||
## 背景
|
||||
|
||||
Student 是独立业务实体,不参与 RBAC,不与 User 挂钩。当前同步链路 `dingUserId → User → Student.userId` 应改为 `dingUserId → Student`。
|
||||
|
||||
## 需求
|
||||
|
||||
### 数据模型
|
||||
|
||||
- **新增** `StudentDingMapping`:`ding_user_id`(UNIQUE) + `student_id`(UNIQUE) + `created_at`
|
||||
- **废弃** `UserDingMapping`:删 entity、删所有引用、删表
|
||||
|
||||
### 同步 (`syncAll`)
|
||||
|
||||
不再创建 User,直接创建 Student + StudentDingMapping。同步进来的全是学生。
|
||||
|
||||
### 组织树 (`fetchOrgTreeWithUsers`)
|
||||
|
||||
恢复全量返回,去掉最深两层过滤,去掉 `getDeptDepthMap`。
|
||||
|
||||
### 批量导入 (`POST /classes/:id/students/import`)
|
||||
|
||||
接收 `{ dingUserIds: string[] }`,逐个:无则创建 Student + StudentDingMapping,有则跳过 → 创建 ClassStudent。
|
||||
|
||||
### 班级创建
|
||||
|
||||
`POST /classes` 扩展可选 `dingUserIds`,创建班级后自动导入。
|
||||
|
||||
### 前端 Drawer(IntegrationConfig 同步用户 tab)
|
||||
|
||||
左右分栏:
|
||||
- **左栏**:Ant Design `<Tree checkable>`,完整钉钉组织树,部门+用户均可勾选,级联
|
||||
- **右栏**:已有班级列表(名称+班型),选中高亮 + "创建班级"按钮
|
||||
- **底部**:"加入选中的班级" / "创建班级"
|
||||
|
||||
两个操作:
|
||||
| 操作 | 前提 | 动作 |
|
||||
|---|---|---|
|
||||
| 加入班级 | 勾选用户 + 选中班级 | POST /classes/:id/students/import |
|
||||
| 创建班级 | 勾选用户 + 勾选部门 | 弹出班级表单 → POST /classes + 关联学生 |
|
||||
|
||||
### 删除
|
||||
|
||||
- 逐用户 teacher/student toggle
|
||||
- "标记为班级" modal
|
||||
- 最深两层过滤
|
||||
|
||||
## 不受影响
|
||||
|
||||
- `fetchOrgTree`(dept picker)
|
||||
- 考勤导入(`DingAttendanceRaw.dingUserId` → `StudentDingMapping`)
|
||||
- 排班同步(教师逻辑后续处理,本轮不碰)
|
||||
@@ -1,92 +0,0 @@
|
||||
# Student 角色分离设计
|
||||
|
||||
> 日期:2026-07-09 | 状态:待审批
|
||||
|
||||
## 背景
|
||||
|
||||
钉钉同步 (`syncOneUser`) 目前对每个用户都自动创建 `Student` 实体。实际上钉钉用户分为:
|
||||
|
||||
- **学员** — 需要 Student 实体(考勤、班级、费用)
|
||||
- **教职工** — 只需要 User(登录+角色),不需要 Student
|
||||
|
||||
钉钉标准 API 不提供用户身份标记。采用 **批量建 + 手动摘** 策略。
|
||||
|
||||
## 设计
|
||||
|
||||
### 1. Student 状态新增 `'staff'`
|
||||
|
||||
`Student.status` 现有值:`active`、`graduated`、`withdrawn`、`archived`
|
||||
|
||||
新增:`'staff'` — 标记为教职工,不作为学员管理。
|
||||
|
||||
| status | 含义 | 学员列表显示 | 同步时覆盖? |
|
||||
|--------|------|:---:|:---:|
|
||||
| active | 在读学员 | ✅ | ✅ |
|
||||
| graduated | 已毕业 | ❌(筛选可见) | ❌ |
|
||||
| withdrawn | 已退训 | ❌(筛选可见) | ❌ |
|
||||
| archived | 已归档 | ❌(归档开关) | ❌ |
|
||||
| **staff** | **教职工** | **❌ 默认隐藏** | **❌ 不覆盖** |
|
||||
|
||||
### 2. syncOneUser 防护
|
||||
|
||||
```typescript
|
||||
// 已有 Student 且 status 为非 active → 跳过更新
|
||||
const existingStudent = await this.studentRepo.findOne({ where: { userId: user.id } });
|
||||
if (existingStudent && existingStudent.status !== 'active') {
|
||||
// 用户已被标记为教职工/毕业/退训,不覆盖
|
||||
return; // 跳过 Student 操作
|
||||
}
|
||||
```
|
||||
|
||||
### 3. API 端点
|
||||
|
||||
| 端点 | 权限 | 效果 |
|
||||
|------|------|------|
|
||||
| `PUT /rbac/users/:id/mark-staff` | `user:edit` | Student.status → `'staff'` |
|
||||
| `PUT /rbac/users/:id/mark-student` | `user:edit` | Student.status → `'active'` |
|
||||
|
||||
### 4. 前端改动
|
||||
|
||||
**账号管理页面**:操作列新增按钮
|
||||
|
||||
```
|
||||
用户有 Student 且 status='active' → 显示 [标记为教职工]
|
||||
用户有 Student 且 status='staff' → 显示 [恢复为学员]
|
||||
用户无 Student → 不显示
|
||||
```
|
||||
|
||||
**学员管理页面**:默认筛选 `status != 'staff'`,可通过状态筛选查看。
|
||||
|
||||
### 5. 用户操作流程
|
||||
|
||||
```
|
||||
同步后 → 所有人在学员列表可见
|
||||
↓
|
||||
管理员到「账号管理」→ 找到李老师 → 点「标记为教职工」
|
||||
↓
|
||||
Student.status → 'staff'
|
||||
↓
|
||||
「学员管理」→ 李老师不再显示
|
||||
「考勤管理」→ 李老师的考勤数据不参与学生统计
|
||||
下次同步 → 不会恢复李老师为学员
|
||||
```
|
||||
|
||||
## 边际情况
|
||||
|
||||
### 变更文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `entities/student.entity.ts` | status 注释更新(staff 已是有效值,无需改 schema) |
|
||||
| `integration/dingtalk.service.ts` | syncOneUser: 已有 Student 且 status!='active' 时跳过 |
|
||||
| `rbac/rbac.service.ts` | +markAsStaff、+markAsStudent |
|
||||
| `rbac/rbac.controller.ts` | +两个端点 |
|
||||
| `students/students.service.ts` | findAll 默认排除 status='staff' |
|
||||
| `pages/Users/index.tsx` | +标记/恢复按钮 |
|
||||
| `pages/Students/index.tsx` | +staff 状态筛选 |
|
||||
|
||||
### 风险点
|
||||
|
||||
- Student 表 `status` 是 varchar,不需要迁移(`'staff'` 是新增有效值)
|
||||
- 同步不覆盖的原则:只保护 `status != 'active'` 的 Student,`active` 的依然正常更新
|
||||
- 如果用户之前没有 Student(纯手工创建的教职工),标记操作报友好错误
|
||||
@@ -1,112 +0,0 @@
|
||||
# 修复学生导入:姓名与账号字段 — 设计文档
|
||||
|
||||
> 日期:2026-07-10
|
||||
> 状态:已确认
|
||||
> 关联:PRD 钉钉集成批次 2
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
`batchImportStudents` 创建 Student 时:
|
||||
- `name` 填的是占位符 `dd_${dingUserId}`
|
||||
- `phone` 未设置
|
||||
|
||||
正确行为:
|
||||
- `name` = 钉钉用户真实姓名
|
||||
- `phone` = 钉钉用户手机号;无手机号时填 `dt_${dingUserId}`
|
||||
|
||||
## 2. 方案:前端传用户信息
|
||||
|
||||
前端组织架构树已有 `{ userid, name, mobile }` 数据(来自 `getDeptUsers`),导入时直接传给后端,零额外钉钉 API 调用。
|
||||
|
||||
## 3. 改动清单
|
||||
|
||||
### 3.1 后端 DTO
|
||||
|
||||
**文件:** `apps/server/src/classes/dto/class.dto.ts`
|
||||
|
||||
`BatchImportStudentsDto`:
|
||||
|
||||
```typescript
|
||||
// 旧
|
||||
export class BatchImportStudentsDto {
|
||||
dingUserIds: string[];
|
||||
}
|
||||
|
||||
// 新
|
||||
export class BatchImportStudentsDto {
|
||||
@IsArray() @ArrayNotEmpty()
|
||||
users: Array<{ dingUserId: string; name: string; mobile?: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
`CreateClassDto.dingUserIds` 同改为 `users` 字段。
|
||||
|
||||
### 3.2 后端 Service
|
||||
|
||||
**文件:** `apps/server/src/classes/classes.service.ts`
|
||||
|
||||
`batchImportStudents` 签名改为:
|
||||
|
||||
```typescript
|
||||
async batchImportStudents(classId: number, users: Array<{
|
||||
dingUserId: string; name: string; mobile?: string;
|
||||
}>): Promise<{ imported: number; skipped: number }>
|
||||
```
|
||||
|
||||
创建 Student 时:
|
||||
|
||||
```typescript
|
||||
this.studentRepo.create({
|
||||
name: u.name,
|
||||
phone: u.mobile || `dt_${u.dingUserId}`,
|
||||
status: 'active',
|
||||
})
|
||||
```
|
||||
|
||||
`create()` 方法中 `dingUserIds` → `users` 透传。
|
||||
|
||||
### 3.3 后端 Controller
|
||||
|
||||
**文件:** `apps/server/src/classes/classes.controller.ts`
|
||||
|
||||
`batchImportStudents` 端点传参改为:
|
||||
|
||||
```typescript
|
||||
return this.service.batchImportStudents(+id, dto.users);
|
||||
```
|
||||
|
||||
### 3.4 前端
|
||||
|
||||
**文件:** `apps/admin/src/pages/IntegrationConfig/index.tsx`
|
||||
|
||||
`handleJoinClass` 和 `handleCreateClass`:从 `checkedKeys` 反查 `orgTree` 提取完整用户信息,不再只传 ID。
|
||||
|
||||
```typescript
|
||||
// 旧
|
||||
const userIds = checkedKeys.filter(...).map(k => k.replace('user-', ''));
|
||||
api.post('/classes/.../import', { dingUserIds: userIds });
|
||||
|
||||
// 新
|
||||
const checkedUsers = extractCheckedUsers(checkedKeys, orgTree);
|
||||
api.post('/classes/.../import', { users: checkedUsers });
|
||||
```
|
||||
|
||||
`extractCheckedUsers` 工具函数遍历 `orgTree`,匹配 `checkedKeys` 中的 user 节点,返回 `{ dingUserId, name, mobile }[]`。
|
||||
|
||||
## 4. 不改的
|
||||
|
||||
- `DingTalkService.syncOneUser` / `syncAll` — 不受影响
|
||||
- `Student` entity — 字段不变
|
||||
- 数据库 schema — 不变
|
||||
- `StudentDingMapping` — 不受影响
|
||||
|
||||
## 5. 验收标准
|
||||
|
||||
1. 导入新用户后,Student.name 为真实姓名,非 `dd_xxx`
|
||||
2. 有手机号的用户,Student.phone = 手机号
|
||||
3. 无手机号的用户,Student.phone = `dt_<dingUserId>`
|
||||
4. 已存在 mapping 的用户跳过,不重复创建
|
||||
5. 班级创建时传入 `users` 同样生效
|
||||
6. `tsc --noEmit` 编译通过
|
||||
@@ -1,63 +0,0 @@
|
||||
# 课程二态考勤与截止自动结算设计
|
||||
|
||||
## 目标
|
||||
|
||||
课程考勤仅向教师展示“已打卡 / 未打卡”。迟到属于已打卡。每节课程到达截止时间后,系统自动从钉钉做最后一次拉取并将最终结果写入课程考勤记录;截止仍无实际打卡的学生记为缺勤。
|
||||
|
||||
## 范围
|
||||
|
||||
- 仅调整排课关联的课程考勤。
|
||||
- 不迁移历史记录。
|
||||
- 不改变后台其他考勤来源、迟到统计或钉钉原始数据。
|
||||
- 不引入队列或新依赖,复用 NestJS Schedule 与现有导入、匹配、课程考勤服务。
|
||||
|
||||
## 状态规则
|
||||
|
||||
### 教师当前课程页面
|
||||
|
||||
- 存在课程时间窗口内的实际打卡时间:显示“已打卡”。
|
||||
- 不存在实际打卡时间:显示“未打卡”。
|
||||
- `Late`、`SeriousLate` 和 `Normal` 均显示为“已打卡”。
|
||||
- 页面汇总仅显示已打卡数、未打卡数和总人数。
|
||||
|
||||
### 最终记录
|
||||
|
||||
- 截止时存在实际打卡时间:`present`。
|
||||
- 截止时不存在实际打卡时间:`absent`。
|
||||
- 钉钉原始记录继续保留 `timeResult`,因此不会丢失迟到信息。
|
||||
- 自动结算完成后,课程考勤场次状态改为 `completed`,不再被后续拉取覆盖。
|
||||
|
||||
## 自动结算
|
||||
|
||||
后台任务每分钟扫描:
|
||||
|
||||
1. 当天有效的内部课程;
|
||||
2. 当前时间已经达到课程 `endTime`;
|
||||
3. 对应日期的课程考勤场次尚未完成或尚未创建。
|
||||
|
||||
对每节符合条件的课程:
|
||||
|
||||
1. 获取该班在读学生的钉钉用户 ID;
|
||||
2. 拉取当天最终钉钉考勤并自动匹配;
|
||||
3. 创建或刷新课程考勤记录;
|
||||
4. 将有实际打卡的记录归为 `present`,其余归为 `absent`;
|
||||
5. 将场次标记为 `completed`。
|
||||
|
||||
任务按课程独立处理。单节课拉取失败只记录错误,其他课程继续;下一分钟继续补偿失败课程。现有 `(scheduleId, lessonDate)` 唯一约束和完成状态保证重复扫描幂等。
|
||||
|
||||
跨午夜课程以结束时间不晚于开始时间判断为次日截止;扫描同时覆盖昨日跨午夜课程。
|
||||
|
||||
## 手动查看
|
||||
|
||||
课程开始后,教师点击“查看当前考勤”仍会拉取最新数据。课程截止前结果是临时二态视图;课程截止后读取自动结算的最终记录。若自动任务尚未成功,手动查看可继续拉取,但只有自动结算或明确完成操作会冻结最终结果。
|
||||
|
||||
## 测试
|
||||
|
||||
- 迟到且存在实际打卡时间时,当前课程视图为“已打卡”。
|
||||
- 无实际打卡时间时,当前课程视图为“未打卡”。
|
||||
- 截止结算把迟到和正常打卡写为 `present`。
|
||||
- 截止结算把无打卡写为 `absent` 并完成场次。
|
||||
- 未截止课程不结算。
|
||||
- 重复扫描已完成课程不重复拉取或写入。
|
||||
- 单节课程失败不阻断其他课程,后续扫描可补偿。
|
||||
- 跨午夜课程在次日截止后结算。
|
||||
@@ -1,31 +0,0 @@
|
||||
# 教室字段精简设计
|
||||
|
||||
## 目标
|
||||
|
||||
删除教室中没有业务联动的“课程类型”和“负责人/班主任”,避免与班级班型、真实教师关系重复维护。
|
||||
|
||||
## 范围
|
||||
|
||||
- 教室创建和编辑表单删除 `courseType`、`supervisor`。
|
||||
- 教室列表删除“课程类型”“负责人”两列。
|
||||
- 教室创建、更新 DTO 删除对应字段。
|
||||
- 教室实体删除对应列,并通过数据库迁移删除已有列。
|
||||
- Excel 导入、导入模板和示例删除对应字段。
|
||||
- 排课保持现状:`subject` 表示科目,班型来自所选班级的 `classType`,教室不重复记录班型或课程类型。
|
||||
|
||||
## 数据流
|
||||
|
||||
教室只维护名称、楼栋、楼层、容量、规格、状态和备注。创建排课时选择班级、科目、教师、教室及时间;系统继续按教室和时间检测排课及租赁冲突,不增加课程类型匹配规则。
|
||||
|
||||
## 兼容策略
|
||||
|
||||
采用干净切换:所有调用方同时迁移,不保留 DTO 字段、实体列、别名或兼容逻辑。迁移删除历史 `course_type`、`supervisor` 数据。
|
||||
|
||||
## 验证
|
||||
|
||||
- 管理端构建通过。
|
||||
- 服务端相关测试和构建通过。
|
||||
- 创建、编辑教室时不再显示或提交两个字段。
|
||||
- 教室列表不再显示两列。
|
||||
- Excel 模板及导入不再包含两个字段。
|
||||
- 创建排课仍可正常选择教室并保存。
|
||||
Reference in New Issue
Block a user