commit 8430ba0452a5158b93fba8d8520fba05a79b06ab Author: yanpeng200212 <32453@DESKTOP-HTJ2T0B> Date: Thu Oct 8 10:43:59 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f4d799 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# 依赖 +node_modules/ + +# 构建产物 +dist/ +bin/ +obj/ +publish/ + +# 运行时临时文件 / 日志 +*.log +_runlog/ +_tmp_hubtest/ +_rename_backup/ +*.db-shm +*.db-wal + +# 证书私钥 +cert.pem +key.pem + +# IDE / 系统 +.vs/ +.idea/ +.vscode/ +*.user +Thumbs.db +Desktop.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..f377ebf --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# f9-web-ai-test + +企业级 RBAC 权限管理系统 + +一个基于 .NET 8 分层架构 + Vue 3 + Element Plus 的企业内部管理系统,采用经典的面包屑后台布局,实现完整的 RBAC(基于角色的访问控制)权限体系。 + +## 技术栈 + +- **后端**:.NET 8(分层架构)+ NSwag(OpenAPI 文档) +- **前端**:Vue 3 + Vite + Element Plus + Pinia + Vue Router(绿色主题) +- **数据库**:SQLite(EF Core) +- **认证**:JWT 双令牌(access token + refresh token)+ BCrypt 密码加密 +- **API 客户端**:NSwag 自动生成 TypeScript 客户端(`src/api/client.ts`) + +## 后端分层架构 + +``` +backend/ +├── F9WebAiTest.Domain/ # 实体层 +│ ├── Common/ # 基类 + 仓储接口 IRepository +│ └── Entities/ # 实体(部门/职工/角色/菜单/关联/日志) +├── F9WebAiTest.Infrastructure/ # 仓储实现层 +│ ├── Data/ # DbContext + 种子数据 +│ └── Repositories/ # Repository 通用实现 +├── F9WebAiTest.Application/ # 服务层 +│ ├── Common/ # ApiResult / ServiceException +│ ├── Dtos/ # 数据传输对象 +│ └── Services/ # 业务逻辑(认证/部门/职工/角色/菜单) +└── F9WebAiTest.Api/ # 表示层(Web API 控制器) +``` + +依赖方向:`Api → Application → Domain ← Infrastructure` + +## RBAC 核心功能 + +| 模块 | 说明 | +|------|------| +| 认证登录 | JWT 登录、动态菜单、权限标识下发 | +| 部门管理 | 树形部门结构、负责人、子部门、排序 | +| 职工管理 | 职工增删改查、分页搜索、分配角色、重置密码 | +| 角色管理 | 角色增删改查、菜单权限分配(树形勾选) | +| 菜单管理 | 目录/菜单/按钮三级,动态路由、权限标识 | +| 权限控制 | 动态路由 + 按钮级权限指令 v-permission | +| 考勤打卡 | 上班签到/下班签退,迟到/早退自动判定 | +| 我的考勤 | 个人考勤记录 + 月度统计(出勤/迟到/早退/缺勤/请假) | +| 请假管理 | 请假申请、审批(批准/驳回)、多类型(事假/病假/年假等) | +| 考勤记录 | 管理员按日期/部门/姓名查询全员考勤 | +| 工艺庄口 | 庄口基础数据(年度/本地外地/基地外购/产地等) | +| 生丝规格 | 规格指标(中心范围/野纤/内控/劳动竞赛等上下限) | +| 生丝定级指标 | 等级指标(厂标/国标/个人,偏差/大差/清洁/洁净等) | +| 机组车台 | 车间机组与车台号配置 | + +## 数据模型关系 + +``` +Department (部门) 1 ── N Employee (职工) +Employee (职工) N ── N Role (角色) via EmployeeRole +Role (角色) N ── N Menu (菜单) via RoleMenu +Menu (菜单) 自引用树形(目录/菜单/按钮) +``` + +## 启动方式 + +### 1. 启动后端 + +```bash +cd backend/F9WebAiTest.Api +dotnet run +``` + +后端运行在 `http://localhost:3000`,Swagger 文档 `http://localhost:3000/swagger`。 + +### 2. 启动前端 + +```bash +cd web +npm install +npm run dev +``` + +前端运行在 `http://localhost:5173`,已配置代理 `/api` 转发到后端。 + +### 3. 生成 API 客户端(NSwag) + +后端运行时,用 NSwag 从 OpenAPI 文档自动生成 TypeScript 客户端: + +```bash +# 先安装 NSwag CLI 工具(只需一次) +dotnet tool install --global NSwag.ConsoleCore + +# 生成 TypeScript 客户端到 src/api/client.ts +cd web +npm run gen:api +``` + +生成的 `src/api/client.ts` 包含所有 DTO 类型和 API 方法,类型安全地调用后端接口。 + +### 3. 访问系统 + +打开 `http://localhost:5173` + +**默认账号**:`admin` / `123456`(超级管理员,拥有全部权限) + +## 前端目录结构 + +``` +web/src/ +├── layout/index.vue # 主布局(侧边栏 + 面包屑 + 内容区) +├── router/index.js # 路由 + 动态路由构建 +├── store/user.js # 用户状态(Pinia) +├── directives/permission.js # 权限指令 v-permission +├── utils/request.js # axios 封装(拦截器) +└── views/ + ├── login/ # 登录页 + ├── dashboard/ # 首页 + └── system/ + ├── department/ # 部门管理 + ├── employee/ # 职工管理 + ├── role/ # 角色管理(含菜单分配) + ├── menu/ # 菜单管理 + └── log/ # 操作日志 +``` + +## 权限使用示例 + +```vue + +新增 + + +操作 +``` + +## API 接口 + +- `POST /api/auth/login` - 登录(返回 access token + refresh token) +- `POST /api/auth/refresh` - 刷新令牌(用 refresh token 换新 access token) +- `POST /api/auth/logout` - 退出(撤销 refresh token) +- `GET /api/auth/me` - 当前用户信息 +- `GET /api/auth/menus` - 用户菜单树 +- `GET /api/auth/permissions` - 用户权限标识 +- `PUT /api/auth/password` - 修改密码 +- `PUT /api/auth/profile` - 更新资料 +- `GET /api/department/tree` - 部门树 +- `POST /api/department` - 新增部门 +- `PUT /api/department/:id` - 更新部门 +- `DELETE /api/department/:id` - 删除部门 +- `GET /api/employee` - 职工分页 +- `POST /api/employee` - 新增职工 +- `PUT /api/employee/:id` - 更新职工 +- `DELETE /api/employee/:id` - 删除职工 +- `PUT /api/employee/:id/password` - 重置密码 +- `GET /api/role` - 角色列表 +- `GET /api/role/:id/menus` - 角色菜单 +- `POST /api/role` - 新增角色 +- `PUT /api/role/:id` - 更新角色 +- `DELETE /api/role/:id` - 删除角色 +- `GET /api/menu/tree` - 菜单树 +- `POST /api/menu` - 新增菜单 +- `PUT /api/menu/:id` - 更新菜单 +- `DELETE /api/menu/:id` - 删除菜单 +- `POST /api/attendance/punch` - 打卡(签到/签退) +- `GET /api/attendance/my` - 我的考勤记录 +- `GET /api/attendance/my/summary` - 我的考勤统计 +- `GET /api/attendance` - 全员考勤记录(管理员) +- `POST /api/attendance/leave` - 申请请假 +- `GET /api/attendance/leave/my` - 我的请假 +- `GET /api/attendance/leave` - 全部请假(管理员) +- `PUT /api/attendance/leave/:id/approve` - 审批请假 diff --git a/TempleRun/GameForm.cs b/TempleRun/GameForm.cs new file mode 100644 index 0000000..c8a30b1 --- /dev/null +++ b/TempleRun/GameForm.cs @@ -0,0 +1,132 @@ +using System.Diagnostics; +using System.Drawing.Drawing2D; + +namespace TempleRun; + +/// +/// 主窗体:负责游戏循环(定时器 + 高精度计时)、键盘输入、双缓冲绘制调度。 +/// +public class GameForm : Form +{ + private readonly GameWorld _world = new(); + private readonly System.Windows.Forms.Timer _timer; + private readonly Stopwatch _watch = Stopwatch.StartNew(); + + private double _lastTime; + private double _fpsAccum; + private int _frameCount; + private bool _showFps; + + public GameForm() + { + Text = "神庙逃亡 · Temple Run (WinForms)"; + ClientSize = new Size(1024, 720); + MinimumSize = new Size(760, 540); + StartPosition = FormStartPosition.CenterScreen; + BackColor = Color.FromArgb(12, 12, 20); + DoubleBuffered = true; + ResizeRedraw = true; + AutoScaleMode = AutoScaleMode.None; + SetStyle(ControlStyles.AllPaintingInWmPaint + | ControlStyles.UserPaint + | ControlStyles.OptimizedDoubleBuffer, true); + + try { Icon = BuildIcon(); } catch { /* 图标失败不影响运行 */ } + + _lastTime = _watch.Elapsed.TotalSeconds; + + _timer = new System.Windows.Forms.Timer { Interval = 15 }; // ≈66Hz + _timer.Tick += OnTick; + _timer.Start(); + } + + // ------------------------------------------------------------------ 主循环 + private void OnTick(object sender, EventArgs e) + { + double now = _watch.Elapsed.TotalSeconds; + float dt = (float)(now - _lastTime); + _lastTime = now; + + if (dt <= 0f) return; + if (dt > 0.06f) dt = 0.06f; // 卡顿/拖动窗口后不产生穿模 + + // FPS 统计 + _frameCount++; + _fpsAccum += dt; + if (_fpsAccum >= 0.5) + { + _world.Fps = (float)(_frameCount / _fpsAccum); + _frameCount = 0; + _fpsAccum = 0; + } + + _world.Update(dt); + Invalidate(); + } + + protected override void OnPaint(PaintEventArgs e) + { + GameRenderer.Draw(e.Graphics, ClientSize.Width, ClientSize.Height, _world, _showFps); + } + + // ------------------------------------------------------------------ 输入 + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + Keys key = keyData & Keys.KeyCode; + + if (key == Keys.F1) { _showFps = !_showFps; Invalidate(); return true; } + if (key == Keys.F11) + { + WindowState = WindowState == FormWindowState.Maximized + ? FormWindowState.Normal : FormWindowState.Maximized; + return true; + } + if (key == Keys.F12) { _world.Muted = !_world.Muted; return true; } + + if (_world.HandleKey(key)) return true; + + return base.ProcessCmdKey(ref msg, keyData); + } + + protected override void OnMouseClick(MouseEventArgs e) + { + base.OnMouseClick(e); + // 鼠标左键也能触发"开始/重开",方便触屏设备 + if (e.Button == MouseButtons.Left) + { + if (_world.State == RunState.Ready) _world.Start(); + else if (_world.State == RunState.GameOver && _world.CanRestart) + { + _world.Start(); + } + } + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + _timer.Stop(); + _timer.Dispose(); + GameRenderer.ResetCache(); + base.OnFormClosed(e); + } + + // ------------------------------------------------------------------ 图标 + private static Icon BuildIcon() + { + using var bmp = new Bitmap(32, 32); + using (var g = Graphics.FromImage(bmp)) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + g.Clear(Color.Transparent); + using (var bg = new SolidBrush(Color.FromArgb(46, 34, 28))) + g.FillEllipse(bg, 0, 0, 31, 31); + using (var gold = new SolidBrush(Cfg.Gold)) + g.FillEllipse(gold, 7, 7, 18, 18); + using (var dark = new Pen(Cfg.GoldDark, 2.4f)) + g.DrawEllipse(dark, 7, 7, 18, 18); + using (var hi = new SolidBrush(Color.FromArgb(230, 255, 250, 220))) + g.FillEllipse(hi, 11, 10, 5, 4); + } + return Icon.FromHandle(bmp.GetHicon()); + } +} diff --git a/TempleRun/GameModel.cs b/TempleRun/GameModel.cs new file mode 100644 index 0000000..015b011 --- /dev/null +++ b/TempleRun/GameModel.cs @@ -0,0 +1,202 @@ +using System.Drawing.Drawing2D; + +namespace TempleRun; + +/// +/// 全局常量与可调参数。想改手感直接改这里。 +/// 世界坐标约定:X 向右为正,Y 离地向上为正,Z 玩家前方为正。 +/// 玩家永远停在 Z = 0,世界向 Z 负方向滚动。 +/// +public static class Cfg +{ + // ---- 相机(透视投影参数)---- + public const float Focal = 520f; // 焦距:越大视野越窄、远处物体越大 + public const float CamZ = 235f; // 相机位于玩家后方多远 + public const float CamHeight = 142f; // 相机离地高度 + public const float FarZ = 3000f; // 渲染最远距离 + public const float MinDepth = 45f; // 相机空间最小深度,防止除零 + + // ---- 跑道 ---- + public const int LaneCount = 3; + public const float LaneWidth = 112f; // 单条车道宽 + public const float RoadHalfWidth = LaneWidth * 1.5f; + + // ---- 玩家 ---- + public const float PlayerHeight = 62f; // 站立高度 + public const float SlideHeight = 30f; // 滑铲高度 + public const float PlayerHalfDepth = 22f; + public const float JumpSpeed = 430f; + public const float Gravity = 950f; + public const float LaneLerp = 13f; // 换道插值速度 + public const float SlideDuration = 0.55f; + + // ---- 速度与难度 ---- + public const float StartSpeed = 620f; + public const float MaxSpeed = 1900f; + public const float SpeedAccel = 3.4f; // 每秒自然加速 + public const float SpeedPerCoin = 5f; // 每金币额外加速 + public const float SpawnIntervalMin = 0.62f; // 相邻障碍组最小时间间隔(秒) + public const float SpawnIntervalMax = 1.15f; + + // ---- 颜色(丛林日落 + 古神庙)---- + public static readonly Color SkyTop = Color.FromArgb(30, 34, 72); + public static readonly Color SkyMid = Color.FromArgb(122, 78, 96); + public static readonly Color SkyHorizon = Color.FromArgb(240, 168, 96); + public static readonly Color Fog = Color.FromArgb(200, 152, 118); + public static readonly Color GroundNear = Color.FromArgb(44, 36, 28); + public static readonly Color GroundFar = Color.FromArgb(122, 100, 78); + public static readonly Color RoadA = Color.FromArgb(178, 150, 110); + public static readonly Color RoadB = Color.FromArgb(156, 130, 94); + public static readonly Color Stone = Color.FromArgb(120, 112, 104); + public static readonly Color Wood = Color.FromArgb(122, 82, 48); + public static readonly Color Vine = Color.FromArgb(74, 116, 62); + public static readonly Color Gold = Color.FromArgb(255, 202, 62); + public static readonly Color GoldDark = Color.FromArgb(196, 140, 24); + public static readonly Color Player = Color.FromArgb(226, 92, 62); + + public static float LaneX(int lane) => (lane - 1) * LaneWidth; + + public static Color Lerp(Color a, Color b, float t) + { + t = Math.Clamp(t, 0f, 1f); + return Color.FromArgb( + (int)(a.R + (b.R - a.R) * t), + (int)(a.G + (b.G - a.G) * t), + (int)(a.B + (b.B - a.B) * t)); + } +} + +public enum RunState { Ready, Playing, Paused, GameOver } + +public enum ObstacleKind +{ + /// 矮障碍:必须跳跃 + Low, + /// 悬空横梁:必须滑铲 + High, + /// 整道石墙:必须换道 + Full +} + +/// 透视相机:把世界坐标投影到屏幕坐标。 +public static class Cam +{ + public static float CenterX; + public static float HorizonY; + public static int Width; + public static int Height; + + public static void Setup(int w, int h) + { + Width = w; + Height = h; + CenterX = w * 0.5f; + HorizonY = h * 0.30f; + } + + /// + /// 投影一个世界点。 + /// scale 为"每世界单位对应多少像素",用于缩放实体自身尺寸。 + /// + public static void Project(float x, float y, float z, out float sx, out float sy, out float scale) + { + float depth = z + Cfg.CamZ; + if (depth < Cfg.MinDepth) depth = Cfg.MinDepth; + scale = Cfg.Focal / depth; + sx = CenterX + x * scale; + sy = HorizonY + (Cfg.CamHeight - y) * scale; + } + + /// 只投影地面上的点(y = 0)。 + public static void ProjectGround(float x, float z, out float sx, out float sy, out float scale) + => Project(x, 0f, z, out sx, out sy, out scale); + + /// 距离雾化系数:0 = 完全清晰,1 = 完全融入雾色。 + public static float FogAmount(float z) + { + float t = Math.Clamp(z / Cfg.FarZ, 0f, 1f); + return t * t; // 平方衰减,近处保持通透 + } +} + +public class Obstacle +{ + public float Z; + public int Lane; + public ObstacleKind Kind; + public bool Scored; // 是否已计入"躲避成功" + + public float Width => Kind == ObstacleKind.Full ? 96f : 90f; + public float Depth => Kind == ObstacleKind.Full ? 54f : 56f; + public float BottomY => Kind == ObstacleKind.High ? 40f : 0f; + public float TopY => Kind switch + { + ObstacleKind.Low => 46f, // 低于跳跃高度,跳过去 + ObstacleKind.High => 98f, // 悬空横梁,滑铲钻过去 + _ => 108f // 整道石墙,只能换道 + }; + public float CenterX => Cfg.LaneX(Lane); +} + +public class Coin +{ + public float Z; + public int Lane; + public float Y = 36f; + public bool Taken; + public float Phase; + public float CenterX => Cfg.LaneX(Lane); +} + +public class Particle +{ + public float X, Y, Z; + public float Vx, Vy, Vz; + public float Life; + public float MaxLife; + public float Size; + public float GravityScale = 1f; + public Color Color; +} + +/// 路边装饰物:石柱、火把、树、雕像碎块。 +public class SideProp +{ + public float Z; + public int Side; // -1 左,+1 右 + public int Type; // 0=石柱 1=火把 2=枯树 3=碎石 + public float Height; + public float Phase; + + public float CenterX => Side * (Cfg.RoadHalfWidth + 205f + (Type == 3 ? 40f : 0f)); +} + +public class Player +{ + public float X; // 当前横向世界坐标(插值中) + public int TargetLane = 1; // 目标车道 0/1/2 + public float Y; // 离地高度 + public float Vy; + public bool OnGround = true; + public bool Sliding; + public float SlideTimer; + public float RunPhase; // 跑动动画相位 + public bool Alive = true; + public float Tilt; // 换道时的侧倾(视觉) + + public float Height => Sliding ? Cfg.SlideHeight : Cfg.PlayerHeight; + + public void Reset() + { + TargetLane = 1; + X = Cfg.LaneX(1); + Y = 0; + Vy = 0; + OnGround = true; + Sliding = false; + SlideTimer = 0; + RunPhase = 0; + Alive = true; + Tilt = 0; + } +} diff --git a/TempleRun/GameRenderer.cs b/TempleRun/GameRenderer.cs new file mode 100644 index 0000000..0e9997b --- /dev/null +++ b/TempleRun/GameRenderer.cs @@ -0,0 +1,902 @@ +using System.Drawing.Drawing2D; +using System.Drawing.Text; + +namespace TempleRun; + +/// +/// 全部绘制逻辑。使用 GDI+ 手工做透视投影,不依赖任何 3D 库。 +/// +public static class GameRenderer +{ + // ---------------------------------------------------------------- 缓存对象 + private static Font _fHuge, _fTitle, _fBig, _fMid, _fSmall, _fTiny; + private static readonly List<(float Z, int Type, object Obj)> _drawList = new(512); + private const int TypeObstacle = 0; + private const int TypeCoin = 1; + + private static void EnsureFonts() + { + if (_fBig != null) return; + const string face = "Microsoft YaHei UI"; + _fHuge = new Font(face, 46f, FontStyle.Bold, GraphicsUnit.Pixel); + _fTitle = new Font(face, 30f, FontStyle.Bold, GraphicsUnit.Pixel); + _fBig = new Font(face, 26f, FontStyle.Bold, GraphicsUnit.Pixel); + _fMid = new Font(face, 19f, FontStyle.Regular, GraphicsUnit.Pixel); + _fSmall = new Font(face, 15f, FontStyle.Regular, GraphicsUnit.Pixel); + _fTiny = new Font(face, 12f, FontStyle.Regular, GraphicsUnit.Pixel); + } + + public static void ResetCache() { _fBig = null; } + + // ---------------------------------------------------------------- 入口 + public static void Draw(Graphics g, int w, int h, GameWorld world, bool showFps = false) + { + if (w <= 0 || h <= 0) return; + EnsureFonts(); + + Cam.Setup(w, h); + g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; + + g.Clear(Color.FromArgb(12, 12, 20)); + + // 屏幕震动 + float shakeX = 0f, shakeY = 0f; + if (world.Shake > 0.15f) + { + float s = world.Shake; + shakeX = (float)(Random.Shared.NextDouble() * 2 - 1) * s; + shakeY = (float)(Random.Shared.NextDouble() * 2 - 1) * s * 0.7f; + } + + GraphicsState st = g.Save(); + g.TranslateTransform(shakeX, shakeY); + + g.SmoothingMode = SmoothingMode.AntiAlias; + DrawSky(g, w, h); + DrawDistantScenery(g, w, h); + DrawGround(g, w, h, world); + DrawProps(g, world); + DrawEntities(g, world); + DrawParticles(g, world); + DrawPlayer(g, world); + + g.Restore(st); + + // 撞击红闪 + if (world.HitFlash > 0.01f) + { + using var fb = new SolidBrush(Color.FromArgb((int)(130 * world.HitFlash), 220, 40, 30)); + g.FillRectangle(fb, 0, 0, w, h); + } + + DrawHud(g, w, h, world, showFps); + } + + // ---------------------------------------------------------------- 投影辅助 + private static PointF P(float x, float y, float z) + { + Cam.Project(x, y, z, out float sx, out float sy, out _); + return new PointF(sx, sy); + } + + private static float PX(float x, float y, float z) + { + Cam.Project(x, y, z, out float sx, out _, out _); + return sx; + } + + private static float PY(float x, float y, float z) + { + Cam.Project(x, y, z, out _, out float sy, out _); + return sy; + } + + private static float PS(float x, float y, float z) + { + Cam.Project(x, y, z, out _, out _, out float s); + return s; + } + + // ---------------------------------------------------------------- 天空 + private static void DrawSky(Graphics g, int w, int h) + { + int skyBottom = (int)Cam.HorizonY + 30; + var rect = new Rectangle(0, -30, w, skyBottom + 30); + if (rect.Height < 2) return; + + using var br = new LinearGradientBrush(rect, Color.Black, Color.White, 90f) + { + InterpolationColors = new ColorBlend(3) + { + Colors = new[] { Cfg.SkyTop, Cfg.SkyMid, Cfg.SkyHorizon }, + Positions = new[] { 0f, 0.60f, 1f } + } + }; + g.FillRectangle(br, rect); + + // 太阳 + float sunX = w * 0.74f; + float sunY = Cam.HorizonY - h * 0.115f; + float sunR = Math.Max(16f, h * 0.072f); + + using (var path = new GraphicsPath()) + { + path.AddEllipse(sunX - sunR * 3.4f, sunY - sunR * 3.4f, sunR * 6.8f, sunR * 6.8f); + using var glow = new PathGradientBrush(path) + { + CenterColor = Color.FromArgb(140, 255, 220, 150), + SurroundColors = new[] { Color.FromArgb(0, 255, 200, 120) } + }; + g.FillPath(glow, path); + } + using (var sb = new SolidBrush(Color.FromArgb(255, 255, 240, 200))) + g.FillEllipse(sb, sunX - sunR, sunY - sunR, sunR * 2, sunR * 2); + + // 云条 + using (var cb = new SolidBrush(Color.FromArgb(46, 255, 226, 190))) + { + g.FillEllipse(cb, w * 0.10f, Cam.HorizonY - h * 0.20f, w * 0.16f, h * 0.022f); + g.FillEllipse(cb, w * 0.30f, Cam.HorizonY - h * 0.255f, w * 0.11f, h * 0.017f); + g.FillEllipse(cb, w * 0.62f, Cam.HorizonY - h * 0.16f, w * 0.20f, h * 0.020f); + } + } + + // ---------------------------------------------------------------- 远景(山脊 + 神庙) + private static void DrawDistantScenery(Graphics g, int w, int h) + { + float hy = Cam.HorizonY; + + // 远山脊 + using (var far = new SolidBrush(Cfg.Lerp(Cfg.Fog, Color.FromArgb(72, 62, 104), 0.42f))) + { + using var path = new GraphicsPath(); + path.AddPolygon(new[] + { + new PointF(-w * 0.05f, hy + 2), + new PointF(w * 0.08f, hy - h * 0.075f), + new PointF(w * 0.20f, hy - h * 0.012f), + new PointF(w * 0.33f, hy - h * 0.105f), + new PointF(w * 0.47f, hy - h * 0.020f), + new PointF(w * 0.60f, hy - h * 0.088f), + new PointF(w * 0.74f, hy - h * 0.028f), + new PointF(w * 0.88f, hy - h * 0.070f), + new PointF(w * 1.05f, hy + 2) + }); + g.FillPath(far, path); + } + + // 神庙剪影(左) + DrawTemple(g, w * 0.19f, hy + 4, w * 0.115f, h * 0.155f, + Cfg.Lerp(Cfg.Fog, Color.FromArgb(58, 48, 78), 0.55f), 5); + + // 神庙剪影(右,稍小) + DrawTemple(g, w * 0.83f, hy + 4, w * 0.085f, h * 0.115f, + Cfg.Lerp(Cfg.Fog, Color.FromArgb(66, 56, 86), 0.46f), 4); + + // 地平线雾带 + using var haze = new LinearGradientBrush( + new Rectangle(0, (int)hy - 26, w, 58), + Color.FromArgb(0, Cfg.Fog), Color.FromArgb(190, Cfg.Fog), 90f); + g.FillRectangle(haze, 0, hy - 26, w, 58); + } + + private static void DrawTemple(Graphics g, float cx, float baseY, float w, float h, Color c, int steps) + { + using var br = new SolidBrush(c); + float sh = h / steps; + for (int i = 0; i < steps; i++) + { + float frac = 1f - (float)i / steps * 0.70f; + float ww = w * frac; + float y = baseY - sh * (i + 1); + g.FillRectangle(br, cx - ww * 0.5f, y, ww, sh + 1.2f); + } + // 塔尖 + float topW = w * 0.30f; + float topY = baseY - h; + g.FillPolygon(br, new[] + { + new PointF(cx - topW * 0.5f, topY + 1), + new PointF(cx, topY - sh * 0.85f), + new PointF(cx + topW * 0.5f, topY + 1) + }); + } + + // ---------------------------------------------------------------- 地面与跑道 + private static void DrawGround(Graphics g, int w, int h, GameWorld world) + { + // 整体地面渐变 + int gy = (int)Cam.HorizonY; + var rect = new Rectangle(0, gy - 2, w, h - gy + 4); + if (rect.Height > 1) + { + using var gb = new LinearGradientBrush(rect, Color.White, Color.White, 90f) + { + InterpolationColors = new ColorBlend(4) + { + Colors = new[] { Cfg.GroundFar, Cfg.Lerp(Cfg.GroundFar, Cfg.GroundNear, 0.35f), Cfg.GroundNear, Color.FromArgb(52, 40, 30) }, + Positions = new[] { 0f, 0.18f, 0.62f, 1f } + } + }; + g.FillRectangle(gb, rect); + } + + SmoothingMode old = g.SmoothingMode; + g.SmoothingMode = SmoothingMode.None; // 条纹用无抗锯齿更快 + + const float period = 190f; + const float drawNear = -230f; + + // 跑道两侧的暗色沟渠 + DrawSideBand(g, w, -1); + DrawSideBand(g, w, +1); + + // 跑道条纹(世界坐标固定,随世界滚动) + float offset = world.Distance % (period * 2f); + for (int i = -8; i < 40; i++) + { + float z0 = i * period * 2f - offset; + float z1 = z0 + period; + if (z1 < drawNear) continue; + if (z0 > Cfg.FarZ) break; + + z0 = Math.Max(z0, drawNear); + z1 = Math.Min(z1, Cfg.FarZ); + if (z1 <= z0) continue; + + float fog = Cam.FogAmount(z0); + Color baseColor = ((i % 2) + 2) % 2 == 0 ? Cfg.RoadA : Cfg.RoadB; + Color c = Cfg.Lerp(baseColor, Cfg.Fog, fog * 0.92f); + + float lx0 = PX(-Cfg.RoadHalfWidth, 0, z0); + float rx0 = PX(+Cfg.RoadHalfWidth, 0, z0); + float ly0 = PY(0, 0, z0); + float ly1 = PY(0, 0, z1); + float lx1 = PX(-Cfg.RoadHalfWidth, 0, z1); + float rx1 = PX(+Cfg.RoadHalfWidth, 0, z1); + + using var br = new SolidBrush(c); + g.FillPolygon(br, new[] + { + new PointF(lx0, ly0), new PointF(rx0, ly0), + new PointF(rx1, ly1), new PointF(lx1, ly1) + }); + } + + // 跑道边缘描边 + DrawRoadEdge(g, -1); + DrawRoadEdge(g, +1); + + // 车道分隔虚线 + DrawLaneMark(g, -Cfg.LaneWidth * 0.5f, world, period); + DrawLaneMark(g, +Cfg.LaneWidth * 0.5f, world, period); + + g.SmoothingMode = old; + } + + private static void DrawRoadEdge(Graphics g, int side) + { + float x = side * Cfg.RoadHalfWidth; + var pts = new List(); + for (float z = Cfg.FarZ; z >= -230f; z -= 120f) + pts.Add(new PointF(PX(x, 0, z), PY(0, 0, z))); + + if (pts.Count < 2) return; + using var pen = new Pen(Color.FromArgb(120, 58, 44, 32), 3.2f); + g.DrawLines(pen, pts.ToArray()); + } + + private static void DrawSideBand(Graphics g, int w, int side) + { + float xi = side * Cfg.RoadHalfWidth; + float xo = side * (Cfg.RoadHalfWidth + 170f); + + float yFar = PY(0, 0, Cfg.FarZ); + float yNear = PY(0, 0, -230f); + var poly = new[] + { + new PointF(PX(xi, 0, Cfg.FarZ), yFar), new PointF(PX(xo, 0, Cfg.FarZ), yFar), + new PointF(PX(xo, 0, -230f), yNear), new PointF(PX(xi, 0, -230f), yNear) + }; + using var br = new SolidBrush(Color.FromArgb(96, 34, 26, 20)); + g.FillPolygon(br, poly); + } + + private static void DrawLaneMark(Graphics g, float x, GameWorld world, float period) + { + float offset = world.Distance % (period * 2f); + for (int i = -8; i < 40; i++) + { + float z0 = i * period * 2f - offset; + float z1 = z0 + period * 0.45f; + if (z1 < -230f) continue; + if (z0 > Cfg.FarZ) break; + z0 = Math.Max(z0, -230f); + z1 = Math.Min(z1, Cfg.FarZ); + if (z1 <= z0) continue; + + float fog = Cam.FogAmount(z0); + var c = Cfg.Lerp(Color.FromArgb(168, 146, 108), Cfg.Fog, fog * 0.9f); + using var br = new SolidBrush(Color.FromArgb((byte)(120 * (1 - fog) + 30), c)); + g.FillPolygon(br, new[] + { + new PointF(PX(x - 4, 0, z0), PY(0, 0, z0)), new PointF(PX(x + 4, 0, z0), PY(0, 0, z0)), + new PointF(PX(x + 4, 0, z1), PY(0, 0, z1)), new PointF(PX(x - 4, 0, z1), PY(0, 0, z1)) + }); + } + } + + // ---------------------------------------------------------------- 路边装饰 + private static void DrawProps(Graphics g, GameWorld world) + { + var props = world.Props; + for (int i = 0; i < props.Count; i++) + { + var p = props[i]; + if (p.Z < 70f) continue; // 太近会盖住跑道,直接跳过 + + float fog = Cam.FogAmount(p.Z); + if (fog > 0.96f) continue; + + float wx = p.CenterX; // 世界坐标 + float sx = PX(wx, 0f, p.Z); // ★ 屏幕 x(原来误用了世界坐标) + float top = PY(wx, p.Height, p.Z); + float bottom = PY(wx, 0f, p.Z); + float s = PS(wx, 0f, p.Z); + float wpx = Math.Min(40f * s, 220f); // 限制近处被夸张放大的尺寸 + if (bottom < Cam.HorizonY - 60) continue; + + switch (p.Type) + { + case 0: // 石柱 + { + var body = Cfg.Lerp(Color.FromArgb(96, 86, 76), Cfg.Fog, fog); + using var br = new SolidBrush(body); + g.FillRectangle(br, sx - wpx * 0.5f, top, wpx, bottom - top); + using var cap = new SolidBrush(Cfg.Lerp(Color.FromArgb(124, 110, 96), Cfg.Fog, fog)); + g.FillRectangle(cap, sx - wpx * 0.62f, top - wpx * 0.22f, wpx * 1.24f, wpx * 0.26f); + using var pen = new Pen(Color.FromArgb(80, 34, 28, 22), Math.Max(1f, s * 1.4f)); + g.DrawLine(pen, sx - wpx * 0.18f, top, sx - wpx * 0.18f, bottom); + break; + } + case 1: // 火把 + { + using var br = new SolidBrush(Cfg.Lerp(Color.FromArgb(88, 60, 38), Cfg.Fog, fog)); + g.FillRectangle(br, sx - wpx * 0.16f, top, wpx * 0.32f, bottom - top); + float fr = wpx * 0.52f * (1f + 0.14f * MathF.Sin(world.Clock * 6f + p.Phase)); + using var glow = new SolidBrush(Color.FromArgb((int)(130 * (1 - fog)), 255, 170, 62)); + g.FillEllipse(glow, sx - fr, top - fr * 1.1f, fr * 2, fr * 2); + using var core = new SolidBrush(Color.FromArgb((int)(235 * (1 - fog)), 255, 234, 158)); + g.FillEllipse(core, sx - fr * 0.42f, top - fr * 0.95f, fr * 0.84f, fr * 0.95f); + break; + } + case 2: // 枯树 + { + var wood = Cfg.Lerp(Color.FromArgb(64, 50, 38), Cfg.Fog, fog); + using var br = new SolidBrush(wood); + float trunkW = Math.Max(2f, wpx * 0.20f); + g.FillRectangle(br, sx - trunkW * 0.5f, top, trunkW, bottom - top); + using var pen = new Pen(wood, Math.Max(1.6f, wpx * 0.11f)) + { StartCap = LineCap.Round, EndCap = LineCap.Round }; + float midY = top + (bottom - top) * 0.42f; + g.DrawLine(pen, sx, midY, sx - wpx * 0.85f, midY - (bottom - top) * 0.30f); + g.DrawLine(pen, sx, midY + (bottom - top) * 0.12f, sx + wpx * 0.90f, midY - (bottom - top) * 0.20f); + break; + } + default: // 碎石 + { + using var br = new SolidBrush(Cfg.Lerp(Color.FromArgb(84, 76, 68), Cfg.Fog, fog)); + float hgt = (bottom - top) * 1.1f; + g.FillEllipse(br, sx - wpx * 0.7f, bottom - hgt, wpx * 1.4f, hgt); + break; + } + } + } + } + + // ---------------------------------------------------------------- 实体(障碍 + 金币) + private static void DrawEntities(Graphics g, GameWorld world) + { + _drawList.Clear(); + for (int i = 0; i < world.Obstacles.Count; i++) + { + var o = world.Obstacles[i]; + if (o.Z > Cfg.FarZ + 200f || o.Z < -260f) continue; + _drawList.Add((o.Z, TypeObstacle, o)); + } + for (int i = 0; i < world.Coins.Count; i++) + { + var c = world.Coins[i]; + if (c.Taken || c.Z > Cfg.FarZ + 200f || c.Z < -260f) continue; + _drawList.Add((c.Z, TypeCoin, c)); + } + + _drawList.Sort(static (a, b) => b.Z.CompareTo(a.Z)); // 远 → 近 + + for (int i = 0; i < _drawList.Count; i++) + { + var it = _drawList[i]; + if (it.Type == TypeObstacle) DrawObstacle(g, (Obstacle)it.Obj); + else DrawCoin(g, (Coin)it.Obj); + } + } + + private static void DrawObstacle(Graphics g, Obstacle o) + { + float fog = Cam.FogAmount(o.Z); + if (fog > 0.97f) return; + + float cx = o.CenterX; + float hw = o.Width * 0.5f; + float zn = o.Z - o.Depth * 0.5f; + float zf = o.Z + o.Depth * 0.5f; + float yb = o.BottomY, yt = o.TopY; + + // 8 个顶点 + PointF nlb = P(cx - hw, yb, zn), nrb = P(cx + hw, yb, zn); + PointF nlt = P(cx - hw, yt, zn), nrt = P(cx + hw, yt, zn); + PointF flb = P(cx - hw, yb, zf), frb = P(cx + hw, yb, zf); + PointF flt = P(cx - hw, yt, zf), frt = P(cx + hw, yt, zf); + + // 落地阴影(让障碍"坐"在跑道上) + if (o.BottomY <= 1f) + { + Cam.Project(cx, 0.6f, o.Z, out float shx, out float shy, out float shs); + using var shb = new SolidBrush(Color.FromArgb(78, 0, 0, 0)); + g.FillEllipse(shb, shx - o.Width * 0.62f * shs, shy - 8f * shs, + o.Width * 1.24f * shs, 16f * shs); + } + + Color cFront, cTop, cSide; + switch (o.Kind) + { + case ObstacleKind.Low: + cFront = Color.FromArgb(78, 70, 62); + cTop = Color.FromArgb(120, 108, 94); + cSide = Color.FromArgb(56, 50, 44); + break; + case ObstacleKind.High: + cFront = Color.FromArgb(112, 72, 40); + cTop = Color.FromArgb(150, 106, 58); + cSide = Color.FromArgb(80, 52, 28); + break; + default: + cFront = Color.FromArgb(64, 60, 58); + cTop = Color.FromArgb(104, 96, 90); + cSide = Color.FromArgb(46, 44, 42); + break; + } + + cFront = Cfg.Lerp(cFront, Cfg.Fog, fog); + cTop = Cfg.Lerp(cTop, Cfg.Fog, fog); + cSide = Cfg.Lerp(cSide, Cfg.Fog, fog); + + // 顶面 + using (var br = new SolidBrush(cTop)) + g.FillPolygon(br, new[] { nlt, nrt, frt, flt }); + + // 侧面(朝向画面中心的那一面) + bool showLeft = cx > 0f; + using (var br = new SolidBrush(cSide)) + { + var quad = showLeft + ? new[] { nlb, nlt, flt, flb } + : new[] { nrb, nrt, frt, frb }; + g.FillPolygon(br, quad); + } + + // 近面 + using (var br = new SolidBrush(cFront)) + g.FillPolygon(br, new[] { nlb, nrb, nrt, nlt }); + + // 近面描边 + 细节 + float lw = Math.Max(1f, PS(cx, 0, o.Z) * 1.6f); + using (var pen = new Pen(Color.FromArgb(150, 34, 26, 20), lw)) + g.DrawPolygon(pen, new[] { nlb, nrb, nrt, nlt }); + + float s = PS(cx, 0, o.Z); + if (s > 0.35f) + { + if (o.Kind == ObstacleKind.High) + { + // 横梁上的藤蔓 + using var vine = new Pen(Cfg.Lerp(Cfg.Vine, Cfg.Fog, fog), Math.Max(1.5f, s * 2.4f)) + { StartCap = LineCap.Round, EndCap = LineCap.Round }; + float topY = (nlt.Y + nrt.Y) * 0.5f; + float botY = (nlb.Y + nrb.Y) * 0.5f; + for (int k = 0; k < 3; k++) + { + float fx = nlb.X + (nrb.X - nlb.X) * (0.22f + k * 0.28f); + g.DrawLine(vine, fx, topY + (botY - topY) * 0.30f, fx + s * 6f, botY + s * 10f); + } + } + else if (o.Kind == ObstacleKind.Full) + { + // 石墙刻纹 + using var pen = new Pen(Color.FromArgb(90, 40, 34, 30), Math.Max(1f, s * 1.8f)); + for (int k = 1; k <= 2; k++) + { + float t = k / 3f; + float y = nlb.Y + (nlt.Y - nlb.Y) * t; + g.DrawLine(pen, nlb.X + s * 4f, y, nrb.X - s * 4f, y); + } + } + else + { + // 矮石堆的碎石纹理 + using var br2 = new SolidBrush(Color.FromArgb(70, 46, 38, 30)); + g.FillEllipse(br2, nlb.X + (nrb.X - nlb.X) * 0.16f, nlb.Y - (nlb.Y - nlt.Y) * 0.55f, + (nrb.X - nlb.X) * 0.26f, (nlb.Y - nlt.Y) * 0.30f); + } + } + } + + private static void DrawCoin(Graphics g, Coin c) + { + float fog = Cam.FogAmount(c.Z); + if (fog > 0.96f) return; + + Cam.Project(c.CenterX, c.Y, c.Z, out float sx, out float sy, out float s); + float r = Math.Min(10f * s, 46f); + if (r < 0.6f) return; + + float wobble = MathF.Abs(MathF.Cos(c.Phase)); + float rx = r * (0.30f + 0.70f * wobble); + float ry = r; + + int fade = (int)(255 * (1f - fog * 0.9f)); + Color gold = Cfg.Lerp(Cfg.Gold, Cfg.Fog, fog * 0.7f); + Color goldDark = Cfg.Lerp(Cfg.GoldDark, Cfg.Fog, fog * 0.7f); + + // 光晕 + using (var glow = new SolidBrush(Color.FromArgb(Math.Max(0, fade / 6), gold))) + g.FillEllipse(glow, sx - ry * 1.4f, sy - ry * 1.4f, ry * 2.8f, ry * 2.8f); + + using (var br = new SolidBrush(Color.FromArgb(fade, gold))) + g.FillEllipse(br, sx - rx, sy - ry, rx * 2, ry * 2); + + using (var pen = new Pen(Color.FromArgb(fade, goldDark), Math.Max(1f, r * 0.16f))) + g.DrawEllipse(pen, sx - rx, sy - ry, rx * 2, ry * 2); + + // 高光 + if (r > 3f) + { + using var hl = new SolidBrush(Color.FromArgb((int)(fade * 0.75f), 255, 250, 220)); + g.FillEllipse(hl, sx - rx * 0.42f, sy - ry * 0.52f, rx * 0.46f, ry * 0.40f); + } + } + + // ---------------------------------------------------------------- 粒子 + private static void DrawParticles(Graphics g, GameWorld world) + { + var list = world.Particles; + for (int i = 0; i < list.Count; i++) + { + var p = list[i]; + float t = p.Life / p.MaxLife; + int alpha = (int)(255 * (1f - t * t)); + if (alpha <= 4) continue; + + Cam.Project(p.X, p.Y, p.Z, out float sx, out float sy, out float s); + float r = Math.Max(0.8f, p.Size * s * (1f - t * 0.45f)); + + using var br = new SolidBrush(Color.FromArgb(alpha, p.Color)); + g.FillEllipse(br, sx - r, sy - r, r * 2, r * 2); + } + } + + // ---------------------------------------------------------------- 玩家 + private static void DrawPlayer(Graphics g, GameWorld world) + { + var h = world.Hero; + + float footX = PX(h.X, h.Y, 0f); + float footY = PY(h.X, h.Y, 0f); + float headY = PY(h.X, h.Y + h.Height, 0f); + float s = PS(h.X, 0f, 0f); + + float bodyH = Math.Max(3f, footY - headY); + + // ---- 地面阴影 ---- + float shX = PX(h.X, 0f, 0f); + float shY = PY(h.X, 0f, 0f); + float lift = Math.Clamp(h.Y / 220f, 0f, 1f); + float shW = 58f * s * (1f - lift * 0.55f); + float shH = 17f * s * (1f - lift * 0.50f); + if (shW > 1f) + { + using var sb = new SolidBrush(Color.FromArgb((int)(120 * (1f - lift)), 0, 0, 0)); + g.FillEllipse(sb, shX - shW * 0.5f, shY - shH * 0.5f, shW, shH); + } + + if (world.State == RunState.GameOver && !h.Alive) + { + // 倒地:简单压扁 + 变灰 + DrawHero(g, footX, footY, bodyH, s, h, flattened: true); + return; + } + + DrawHero(g, footX, footY, bodyH, s, h, flattened: false); + } + + private static void DrawHero(Graphics g, float footX, float footY, float bodyH, float s, + Player h, bool flattened) + { + // 影子偏移(换道侧倾) + float lean = flattened ? 0f : h.Tilt; + float cx = footX + lean * bodyH * 0.45f; + + Color cloth = flattened ? Color.FromArgb(150, 110, 100, 96) : Cfg.Player; + Color clothDark = flattened ? Color.FromArgb(150, 84, 74, 70) : Color.FromArgb(176, 62, 40); + Color skin = flattened ? Color.FromArgb(150, 170, 150, 132) : Color.FromArgb(242, 196, 152); + Color hair = Color.FromArgb(48, 36, 30); + + float unit = bodyH / 62f; // 1 = 站立时的相对比例 + + using var penBody = new Pen(cloth, Math.Max(2.2f, bodyH * 0.155f)) + { StartCap = LineCap.Round, EndCap = LineCap.Round }; + using var penLimb = new Pen(clothDark, Math.Max(2f, bodyH * 0.115f)) + { StartCap = LineCap.Round, EndCap = LineCap.Round }; + + float hipY = footY - bodyH * 0.42f; + float shoulderY = footY - bodyH * 0.76f; + + if (flattened) + { + // 趴倒 + float len = bodyH * 1.05f; + using var bodyPen = new Pen(cloth, Math.Max(2.4f, bodyH * 0.17f)) + { StartCap = LineCap.Round, EndCap = LineCap.Round }; + g.DrawLine(bodyPen, cx - len * 0.32f, footY - bodyH * 0.16f, cx + len * 0.38f, footY - bodyH * 0.10f); + using var headBr = new SolidBrush(skin); + float hr = bodyH * 0.135f; + g.FillEllipse(headBr, cx + len * 0.34f - hr, footY - bodyH * 0.26f - hr, hr * 2, hr * 2); + return; + } + + // ---- 腿 ---- + float swing = MathF.Sin(h.RunPhase); + bool airborne = !h.OnGround; + float legLen = bodyH * 0.42f; + + void DrawLeg(float sign) + { + float lift = airborne ? 0.42f : MathF.Max(0f, sign * swing) * 0.75f; + float fx = cx + sign * 9f * unit + (airborne ? 0f : sign * swing * 12f * unit); + float fy = footY - legLen * (airborne ? 0.45f : lift); + float kneeX = cx + sign * 6f * unit + (fx - cx) * 0.4f; + float kneeY = (hipY + fy) * 0.5f; + g.DrawLine(penLimb, cx + sign * 5f * unit, hipY, kneeX, kneeY); + g.DrawLine(penLimb, kneeX, kneeY, fx, fy); + } + DrawLeg(-1f); + DrawLeg(+1f); + + // ---- 躯干 ---- + float torsoW = bodyH * 0.30f; + using (var torso = new SolidBrush(cloth)) + { + var rc = new RectangleF(cx - torsoW * 0.5f, shoulderY, torsoW, hipY - shoulderY); + using var rp = RoundRect(rc, torsoW * 0.34f); + g.FillPath(torso, rp); + } + + // ---- 手臂(与腿反向摆动) ---- + void DrawArm(float sign) + { + float a = -sign * swing; + float ex = cx + sign * (bodyH * 0.20f) + a * bodyH * 0.15f; + float ey = shoulderY + bodyH * 0.26f - MathF.Abs(a) * bodyH * 0.10f; + float midX = cx + sign * (bodyH * 0.19f); + float midY = shoulderY + bodyH * 0.13f; + g.DrawLine(penLimb, cx + sign * bodyH * 0.13f, shoulderY + bodyH * 0.03f, midX, midY); + g.DrawLine(penLimb, midX, midY, ex, ey); + } + DrawArm(-1f); + DrawArm(+1f); + + // ---- 头 ---- + float headR = bodyH * 0.145f; + float headCY = shoulderY - headR * 0.95f; + using (var hb = new SolidBrush(skin)) + g.FillEllipse(hb, cx - headR, headCY - headR, headR * 2, headR * 2); + + // 头发(上半圆) + using (var hb = new SolidBrush(hair)) + { + var rc = new RectangleF(cx - headR * 1.02f, headCY - headR * 1.10f, headR * 2.04f, headR * 1.35f); + using var rp = RoundRect(rc, headR * 0.75f); + g.FillPath(hb, rp); + } + + // 滑铲时的速度线 + if (h.Sliding) + { + using var sl = new Pen(Color.FromArgb(130, 255, 240, 210), Math.Max(1.4f, bodyH * 0.05f)) + { StartCap = LineCap.Round, EndCap = LineCap.Round }; + for (int i = 0; i < 3; i++) + { + float yy = footY - bodyH * (0.10f + i * 0.14f); + g.DrawLine(sl, cx - bodyH * 0.42f, yy, cx - bodyH * 0.18f, yy); + } + } + } + + // ---------------------------------------------------------------- HUD + private static void DrawHud(Graphics g, int w, int h, GameWorld world, bool showFps) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + + // ---- 左上:分数 / 金币 ---- + DrawChip(g, 18, 14, 236, 92); + + string score = world.Score.ToString(); + using (var sh = new SolidBrush(Color.FromArgb(130, 0, 0, 0))) + g.DrawString(score, _fHuge, sh, 32 + 2, 16 + 2); + using (var sb = new SolidBrush(Color.White)) + g.DrawString(score, _fHuge, sb, 32, 16); + + // 金币行 + float coinY = 78; + float pop = 1f + world.CoinPop * 0.35f; + float cr = 9f * pop; + using (var gb = new SolidBrush(Cfg.Gold)) + g.FillEllipse(gb, 34 - cr, coinY - cr, cr * 2, cr * 2); + using (var gp = new Pen(Cfg.GoldDark, 2f)) + g.DrawEllipse(gp, 34 - cr, coinY - cr, cr * 2, cr * 2); + using (var sb = new SolidBrush(Color.FromArgb(255, 245, 210))) + g.DrawString("× " + world.CoinCount, _fMid, sb, 52, coinY - 13); + + // ---- 右上:速度 / 最佳 ---- + string speedTxt = "速度 ×" + (world.Speed / Cfg.StartSpeed).ToString("0.00"); + SizeF st = g.MeasureString(speedTxt, _fMid); + DrawChip(g, w - st.Width - 44, 16, st.Width + 26, 34); + using (var sb = new SolidBrush(Color.FromArgb(255, 226, 170))) + g.DrawString(speedTxt, _fMid, sb, w - st.Width - 31, 24); + + string bestTxt = "最佳 " + world.Best; + SizeF bt = g.MeasureString(bestTxt, _fSmall); + using (var sb = new SolidBrush(Color.FromArgb(210, 236, 226, 214))) + g.DrawString(bestTxt, _fSmall, sb, w - bt.Width - 22, 58); + + if (world.Muted) + { + using var sb = new SolidBrush(Color.FromArgb(150, 236, 226, 214)); + g.DrawString("已静音 (M)", _fTiny, sb, w - 96, 82); + } + + // ---- 中间状态层 ---- + switch (world.State) + { + case RunState.Ready: DrawReady(g, w, h, world); break; + case RunState.Paused: DrawPaused(g, w, h); break; + case RunState.GameOver: DrawGameOver(g, w, h, world); break; + } + + // ---- 右下角操作提示 ---- + using (var sb = new SolidBrush(Color.FromArgb(120, 240, 234, 226))) + g.DrawString("← → 换道 ↑ 跳跃 ↓ 滑铲 P 暂停 M 静音 F1 调试 F11 全屏", + _fTiny, sb, 18, h - 26); + + if (showFps) + { + using var db = new SolidBrush(Color.FromArgb(185, 168, 255, 196)); + g.DrawString( + $"FPS {world.Fps:0} 障碍 {world.Obstacles.Count} 金币 {world.Coins.Count}" + + $" 装饰 {world.Props.Count} 粒子 {world.Particles.Count} 速度 {world.Speed:0}", + _fTiny, db, 18, h - 46); + } + } + + private static void DrawReady(Graphics g, int w, int h, GameWorld world) + { + float cy = h * 0.055f; // 贴顶排布,避免压住画面中央的跑者 + + DrawCentered(g, "神庙逃亡", _fHuge, w, cy + 3, Color.FromArgb(130, 0, 0, 0)); + DrawCentered(g, "神庙逃亡", _fHuge, w, cy, Color.FromArgb(255, 232, 176)); + + DrawCentered(g, "TEMPLE RUN", _fTitle, w, cy + 58, Color.FromArgb(214, 176, 120)); + + string[] lines = + { + "← → 左右换道", + "↑ 或 空格 跳跃越过矮石堆", + "↓ 滑铲穿过悬空横梁", + "石墙无法越过,只能换道躲避", + "收集金币可提高速度倍率" + }; + for (int i = 0; i < lines.Length; i++) + DrawCentered(g, lines[i], _fMid, w, cy + 118 + i * 30, Color.FromArgb(226, 216, 202)); + + // 闪烁提示 + float blink = (MathF.Sin(world.Clock * 4.2f) + 1f) * 0.5f; + var c = Cfg.Lerp(Color.FromArgb(120, 255, 236, 190), Color.FromArgb(255, 255, 244, 214), blink); + DrawCentered(g, "按 空格 开始奔跑", _fBig, w, cy + 118 + lines.Length * 30 + 26, c); + } + + private static void DrawPaused(Graphics g, int w, int h) + { + using (var ov = new SolidBrush(Color.FromArgb(140, 10, 10, 18))) + g.FillRectangle(ov, 0, 0, w, h); + DrawCentered(g, "已 暂 停", _fHuge, w, h * 0.38f, Color.White); + DrawCentered(g, "按 P / 空格 继续 按 R 重新开始", _fMid, w, h * 0.38f + 70, + Color.FromArgb(210, 226, 218, 206)); + } + + private static void DrawGameOver(Graphics g, int w, int h, GameWorld world) + { + bool newRecord = world.Score >= world.Best && world.Score > 0; + + float pw = Math.Min(520f, w - 80f); + float ph = 330f; + float px = (w - pw) * 0.5f; + float py = h * 0.5f - ph * 0.5f; + + using (var ov = new SolidBrush(Color.FromArgb(150, 8, 8, 14))) + g.FillRectangle(ov, 0, 0, w, h); + + var panel = new RectangleF(px, py, pw, ph); + using (var pp = RoundRect(panel, 18f)) + { + using var pb = new SolidBrush(Color.FromArgb(226, 26, 24, 34)); + g.FillPath(pb, pp); + using var pen = new Pen(Color.FromArgb(200, 214, 168, 96), 2.4f); + g.DrawPath(pen, pp); + } + + DrawCentered(g, "游 戏 结 束", _fTitle, w, py + 24, Color.FromArgb(255, 138, 106)); + + float y = py + 92; + DrawStatRow(g, px, pw, y, "本局得分", world.Score.ToString(), + newRecord ? Cfg.Gold : Color.White, _fBig); + DrawStatRow(g, px, pw, y + 46, "金币数量", world.CoinCount.ToString(), Color.FromArgb(255, 214, 120), _fBig); + DrawStatRow(g, px, pw, y + 92, "奔跑距离", ((int)(world.Distance / 10f)) + " m", Color.FromArgb(200, 226, 216, 200), _fBig); + DrawStatRow(g, px, pw, y + 138, "历史最佳", world.Best.ToString(), Color.FromArgb(150, 226, 214, 196), _fBig); + + if (newRecord) + DrawCentered(g, "★ 新 纪 录 ★", _fMid, w, py + ph - 88, Cfg.Gold); + + DrawCentered(g, "按 R / 空格 再来一局", _fMid, w, py + ph - 46, + Color.FromArgb(226, 236, 226, 210)); + } + + private static void DrawStatRow(Graphics g, float px, float pw, float y, string label, string value, + Color valueColor, Font vf) + { + using (var lb = new SolidBrush(Color.FromArgb(190, 208, 198, 184))) + g.DrawString(label, _fMid, lb, px + 36, y + 6); + + SizeF vs = g.MeasureString(value, vf); + using (var vb = new SolidBrush(valueColor)) + g.DrawString(value, vf, vb, px + pw - 36 - vs.Width, y); + } + + private static void DrawCentered(Graphics g, string text, Font f, int w, float y, Color color) + { + using var br = new SolidBrush(color); + SizeF sz = g.MeasureString(text, f); + g.DrawString(text, f, br, (w - sz.Width) * 0.5f, y); + } + + private static void DrawChip(Graphics g, float x, float y, float w, float h) + { + var r = new RectangleF(x, y, w, h); + using var p = RoundRect(r, 10f); + using var br = new SolidBrush(Color.FromArgb(118, 12, 14, 22)); + g.FillPath(br, p); + using var pen = new Pen(Color.FromArgb(86, 226, 198, 140), 1.4f); + g.DrawPath(pen, p); + } + + private static GraphicsPath RoundRect(RectangleF r, float radius) + { + var p = new GraphicsPath(); + float d = Math.Min(radius * 2f, Math.Min(r.Width, r.Height)); + p.AddArc(r.X, r.Y, d, d, 180, 90); + p.AddArc(r.Right - d, r.Y, d, d, 270, 90); + p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); + p.AddArc(r.X, r.Bottom - d, d, d, 90, 90); + p.CloseFigure(); + return p; + } +} diff --git a/TempleRun/GameWorld.cs b/TempleRun/GameWorld.cs new file mode 100644 index 0000000..0806aff --- /dev/null +++ b/TempleRun/GameWorld.cs @@ -0,0 +1,605 @@ +using System.Media; + +namespace TempleRun; + +/// +/// 游戏世界:状态机 + 生成器 + 物理 + 碰撞。 +/// 不含任何绘制代码,纯逻辑,便于单独测试。 +/// +public class GameWorld +{ + public RunState State = RunState.Ready; + + public readonly Player Hero = new(); + public readonly List Obstacles = new(); + public readonly List Coins = new(); + public readonly List Particles = new(); + public readonly List Props = new(); + + // 统计 + public float Speed; + public float Distance; // 累计前进距离(世界单位) + public int CoinCount; + public int Best; + public int Score => (int)(Distance / 10f) + CoinCount * 10; + + // 表现层效果 + public float Shake; // 屏幕震动强度(像素) + public float HitFlash; // 撞击红闪 0..1 + public float CoinPop; // 吃金币时的 HUD 弹跳 0..1 + + public bool Muted; + public float Clock; // 累计运行时间(用于 UI 呼吸/闪烁动画) + public float Fps; // 由窗体写入,仅用于调试显示 + + /// 死亡动画播完才允许重开,避免手快误触 + public bool CanRestart => _deathDelay <= 0f; + + private float _nextSpawn; // 距下一组障碍还有多远 + private float _nextProp; // 距下一组路边装饰还有多远 + private float _deathDelay; // 死亡动画剩余时间 + private float _lastCoinSfx; + private float _sfxClock; + + private readonly Random _rng = new(); + private readonly string _bestPath; + + public GameWorld() + { + try + { + string dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "TempleRun"); + Directory.CreateDirectory(dir); + _bestPath = Path.Combine(dir, "best.txt"); + if (File.Exists(_bestPath) && int.TryParse(File.ReadAllText(_bestPath), out int b)) + Best = b; + } + catch { _bestPath = null; } + + Reset(); + } + + // ------------------------------------------------------------------ + // 生命周期 + // ------------------------------------------------------------------ + + public void Reset() + { + Hero.Reset(); + Obstacles.Clear(); + Coins.Clear(); + Particles.Clear(); + Props.Clear(); + + Speed = Cfg.StartSpeed; + Distance = 0; + CoinCount = 0; + Shake = 0; + HitFlash = 0; + CoinPop = 0; + _deathDelay = 0; + _nextSpawn = 1400f; + _nextProp = 120f; + + // 预铺一条"石柱长廊",避免开局两侧空荡 + for (float z = 400; z < Cfg.FarZ; z += 165f) + SpawnProp(z); + + State = RunState.Ready; + } + + public void Start() + { + Reset(); + State = RunState.Playing; + } + + // ------------------------------------------------------------------ + // 输入 + // ------------------------------------------------------------------ + + public bool HandleKey(Keys key) + { + switch (State) + { + case RunState.Ready: + if (key is Keys.Space or Keys.Enter) { Start(); return true; } + break; + + case RunState.Playing: + if (key is Keys.Left or Keys.A) { ShiftLane(-1); return true; } + if (key is Keys.Right or Keys.D) { ShiftLane(+1); return true; } + if (key is Keys.Up or Keys.W or Keys.Space) { Jump(); return true; } + if (key is Keys.Down or Keys.S) { Slide(); return true; } + if (key is Keys.P or Keys.Escape) { State = RunState.Paused; return true; } + break; + + case RunState.Paused: + if (key is Keys.P or Keys.Escape or Keys.Space) { State = RunState.Playing; return true; } + if (key is Keys.R) { Start(); return true; } + break; + + case RunState.GameOver: + if (!CanRestart) return true; + if (key is Keys.R or Keys.Enter or Keys.Space) { Start(); return true; } + break; + } + + if (key is Keys.M) { Muted = !Muted; return true; } + return false; + } + + private void ShiftLane(int dir) + { + if (!Hero.Alive) return; + int t = Math.Clamp(Hero.TargetLane + dir, 0, Cfg.LaneCount - 1); + if (t != Hero.TargetLane) + Hero.TargetLane = t; + } + + private void Jump() + { + if (!Hero.Alive || !Hero.OnGround) return; + Hero.Vy = Cfg.JumpSpeed; + Hero.OnGround = false; + Hero.Sliding = false; + Hero.SlideTimer = 0; + SpawnDust(Hero.X, Hero.Y, 0f, 6); + } + + private void Slide() + { + if (!Hero.Alive) return; + // 空中按下 → 快速下落(更顺手) + if (!Hero.OnGround) Hero.Vy = Math.Min(Hero.Vy, -420f); + Hero.Sliding = true; + Hero.SlideTimer = Cfg.SlideDuration; + SpawnDust(Hero.X, Hero.Y, 0f, 8); + } + + // ------------------------------------------------------------------ + // 主更新 + // ------------------------------------------------------------------ + + public void Update(float dt) + { + _sfxClock += dt; + Clock += dt; + + if (State == RunState.Paused) return; + + if (State == RunState.Ready) + { + // 待机:背景缓慢滚动 + 原地跑,营造氛围 + RollWorld(300f * dt); + Hero.RunPhase += dt * 7.5f; + UpdateParticles(dt); + DecayEffects(dt); + return; + } + + if (State == RunState.GameOver) + { + UpdateParticles(dt); + DecayEffects(dt); + if (_deathDelay > 0) _deathDelay -= dt; + return; + } + + // ---------------- 游戏中 ---------------- + if (!Hero.Alive) + { + RollWorld(Speed * dt * 0.25f); + UpdateParticles(dt); + DecayEffects(dt); + _deathDelay -= dt; + if (_deathDelay <= 0f) FinishRun(); + return; + } + + // 难度递增 + float target = Cfg.StartSpeed + Distance * 0.00035f + CoinCount * Cfg.SpeedPerCoin; + Speed = Math.Min(Cfg.MaxSpeed, Math.Max(Speed, target)); + Speed += Cfg.SpeedAccel * dt; + Speed = Math.Min(Speed, Cfg.MaxSpeed); + + float move = Speed * dt; + Distance += move; + + UpdatePlayer(dt); + RollWorld(move); + UpdateEntities(dt, move); + Generate(move); + CheckCollisions(); + UpdateParticles(dt); + DecayEffects(dt); + } + + private void DecayEffects(float dt) + { + Shake = Math.Max(0f, Shake - dt * 60f); + HitFlash = Math.Max(0f, HitFlash - dt * 2.6f); + CoinPop = Math.Max(0f, CoinPop - dt * 4f); + } + + // ------------------------------------------------------------------ + // 玩家 + // ------------------------------------------------------------------ + + private void UpdatePlayer(float dt) + { + float targetX = Cfg.LaneX(Hero.TargetLane); + float k = 1f - MathF.Exp(-Cfg.LaneLerp * dt); // 帧率无关的指数插值 + float dx = targetX - Hero.X; + Hero.X += dx * k; + if (Math.Abs(dx) < 0.6f) Hero.X = targetX; + + // 侧倾:换道时身体倾斜 + float wantTilt = Math.Clamp(dx / Cfg.LaneWidth, -1f, 1f) * 0.32f; + Hero.Tilt += (wantTilt - Hero.Tilt) * Math.Min(1f, dt * 9f); + + // 重力 + if (!Hero.OnGround || Hero.Vy > 0f) + { + Hero.Vy -= Cfg.Gravity * dt; + Hero.Y += Hero.Vy * dt; + + if (Hero.Y <= 0f) + { + Hero.Y = 0f; + Hero.Vy = 0f; + if (!Hero.OnGround) + { + Hero.OnGround = true; + SpawnDust(Hero.X, 0f, 0f, 8); + } + } + } + + // 滑铲计时 + if (Hero.Sliding) + { + Hero.SlideTimer -= dt; + if (Hero.SlideTimer <= 0f) Hero.Sliding = false; + } + + // 跑动动画(速度越快腿摆得越快) + Hero.RunPhase += dt * (4.2f + Speed / 165f); + + // 跑动扬尘 + if (Hero.OnGround && !Hero.Sliding && _rng.NextDouble() < dt * 26) + SpawnDust(Hero.X + (float)(_rng.NextDouble() - 0.5) * 30f, 0f, -8f, 1); + } + + /// 世界向玩家方向滚动:所有实体 Z 减少。 + private void RollWorld(float move) + { + Hero.X += 0f; + + for (int i = Obstacles.Count - 1; i >= 0; i--) + Obstacles[i].Z -= move; + for (int i = Coins.Count - 1; i >= 0; i--) + Coins[i].Z -= move; + for (int i = Props.Count - 1; i >= 0; i--) + Props[i].Z -= move; + for (int i = Particles.Count - 1; i >= 0; i--) + Particles[i].Z -= move; + } + + private void UpdateEntities(float dt, float move) + { + // 金币旋转 + 回收 + for (int i = Coins.Count - 1; i >= 0; i--) + { + var c = Coins[i]; + c.Phase += dt * 5.5f; + if (c.Taken || c.Z < -260f) Coins.RemoveAt(i); + } + + // 障碍回收 + for (int i = Obstacles.Count - 1; i >= 0; i--) + if (Obstacles[i].Z < -300f) Obstacles.RemoveAt(i); + + // 装饰回收 + for (int i = Props.Count - 1; i >= 0; i--) + if (Props[i].Z < -300f) Props.RemoveAt(i); + } + + // ------------------------------------------------------------------ + // 生成器 + // ------------------------------------------------------------------ + + private void Generate(float move) + { + _nextSpawn -= move; + if (_nextSpawn <= 0f) + { + SpawnRow(Cfg.FarZ); + float sec = (float)(Cfg.SpawnIntervalMin + + _rng.NextDouble() * (Cfg.SpawnIntervalMax - Cfg.SpawnIntervalMin)); + // 速度越快,按"时间间隔"换算成距离间隔,保证反应时间一致 + _nextSpawn = Math.Max(420f, sec * Speed * 0.62f); + } + + _nextProp -= move; + if (_nextProp <= 0f) + { + SpawnProp(Cfg.FarZ); + _nextProp = 130f + (float)_rng.NextDouble() * 150f; + } + } + + /// 生成一组障碍 + 可能附带的金币。保证至少留一条可通行车道。 + private void SpawnRow(float z) + { + int laneCount = _rng.NextDouble() < 0.78 ? 1 : 2; // 1~2 条道放障碍 + + var lanes = new[] { 0, 1, 2 }; + // 洗牌 + for (int i = lanes.Length - 1; i > 0; i--) + { + int j = _rng.Next(i + 1); + (lanes[i], lanes[j]) = (lanes[j], lanes[i]); + } + + var used = new List(); + for (int i = 0; i < laneCount; i++) + { + int lane = lanes[i]; + used.Add(lane); + + ObstacleKind kind; + double r = _rng.NextDouble(); + // 两条道同时出现时,避免双"整道墙"把路堵死(剩 1 条空道仍可走,允许) + kind = r < 0.40 ? ObstacleKind.Low + : r < 0.72 ? ObstacleKind.High + : ObstacleKind.Full; + + Obstacles.Add(new Obstacle { Z = z, Lane = lane, Kind = kind }); + } + + // 在空道上撒一串金币 + if (_rng.NextDouble() < 0.72) + { + int freeLane = -1; + for (int i = 0; i < Cfg.LaneCount; i++) + if (!used.Contains(i)) { freeLane = i; break; } + + if (freeLane >= 0) + { + int n = 3 + _rng.Next(4); + float dz = 52f; + // 金币以本组障碍为中心分布,稍微错开一点更自然 + float start = z - (n - 1) * dz * 0.5f + (float)(_rng.NextDouble() - 0.5) * 40f; + for (int i = 0; i < n; i++) + { + Coins.Add(new Coin + { + Z = start + i * dz, + Lane = freeLane, + Phase = i * 0.6f + }); + } + } + } + } + + private void SpawnProp(float z) + { + int side = _rng.NextDouble() < 0.5 ? -1 : 1; + int type = _rng.Next(100) switch + { + < 42 => 0, // 石柱 + < 62 => 1, // 火把 + < 90 => 2, // 枯树 + _ => 3 // 碎石 + }; + float h = type switch + { + 0 => 150f + (float)_rng.NextDouble() * 90f, + 1 => 92f, + 2 => 130f + (float)_rng.NextDouble() * 70f, + _ => 26f + (float)_rng.NextDouble() * 22f + }; + Props.Add(new SideProp + { + Z = z, + Side = side, + Type = type, + Height = h, + Phase = (float)_rng.NextDouble() * 6.28f + }); + } + + // ------------------------------------------------------------------ + // 碰撞 + // ------------------------------------------------------------------ + + private void CheckCollisions() + { + float pNear = -Cfg.PlayerHalfDepth; + float pFar = Cfg.PlayerHalfDepth; + float py0 = Hero.Y + 5f; // 判定区间略微收缩,手感宽容 + float py1 = Hero.Y + Hero.Height - 5f; + float px = Hero.X; + const float halfBodyW = 15f; + + // --- 障碍 --- + for (int i = 0; i < Obstacles.Count; i++) + { + var o = Obstacles[i]; + float oNear = o.Z - o.Depth * 0.5f; + float oFar = o.Z + o.Depth * 0.5f; + if (oFar < pNear || oNear > pFar) continue; // Z 不重叠 + + if (Math.Abs(px - o.CenterX) > o.Width * 0.5f + halfBodyW) continue; // 不在同一道 + + // 高度区间是否相交 + bool hit = py0 < o.TopY && py1 > o.BottomY; + //"整道墙"永远相交 + if (o.Kind == ObstacleKind.Full) hit = true; + + if (hit) { Kill(o); return; } + + if (!o.Scored && o.Z < 0f) + { + o.Scored = true; + // 成功躲避的微小正反馈 + Shake = Math.Max(Shake, 1.2f); + } + } + + // --- 金币 --- + float coinNear = -34f, coinFar = 34f; + for (int i = 0; i < Coins.Count; i++) + { + var c = Coins[i]; + if (c.Taken) continue; + if (c.Z < coinNear || c.Z > coinFar) continue; + if (Math.Abs(px - c.CenterX) > Cfg.LaneWidth * 0.45f) continue; + // 金币在 36 高度,跳跃或站立都能吃到,滑铲吃不到 + float cy = c.Y; + if (cy < py0 - 22f || cy > py1 + 22f) continue; + + c.Taken = true; + CoinCount++; + CoinPop = 1f; + Speed = Math.Min(Cfg.MaxSpeed, Speed + Cfg.SpeedPerCoin * 0.35f); + SpawnCoinBurst(c.CenterX, c.Y, c.Z); + PlayCoinSfx(); + } + } + + private void Kill(Obstacle o) + { + if (!Hero.Alive) return; + Hero.Alive = false; + Hero.Sliding = false; + _deathDelay = 0.85f; + Shake = 16f; + HitFlash = 1f; + SpawnDebris(o.CenterX, Hero.Y + 26f, 0f); + PlayHitSfx(); + } + + private void FinishRun() + { + State = RunState.GameOver; + if (Score > Best) + { + Best = Score; + try { if (_bestPath != null) File.WriteAllText(_bestPath, Best.ToString()); } + catch { /* 忽略写盘失败 */ } + } + } + + // ------------------------------------------------------------------ + // 粒子 + // ------------------------------------------------------------------ + + public void AddParticle(Particle p) => Particles.Add(p); + + private void SpawnDust(float x, float y, float z, int count) + { + for (int i = 0; i < count; i++) + { + Particles.Add(new Particle + { + X = x + (float)(_rng.NextDouble() - 0.5) * 26f, + Y = y + (float)_rng.NextDouble() * 8f, + Z = z + (float)(_rng.NextDouble() - 0.5) * 20f, + Vx = (float)(_rng.NextDouble() - 0.5) * 60f, + Vy = 20f + (float)_rng.NextDouble() * 55f, + Vz = -30f - (float)_rng.NextDouble() * 40f, + MaxLife = 0.35f + (float)_rng.NextDouble() * 0.25f, + Life = 0f, + Size = 2.4f + (float)_rng.NextDouble() * 3.2f, + GravityScale = 0.4f, + Color = Color.FromArgb(196, 172, 140) + }); + } + } + + private void SpawnCoinBurst(float x, float y, float z) + { + for (int i = 0; i < 10; i++) + { + float a = (float)(_rng.NextDouble() * Math.PI * 2); + float sp = 40f + (float)_rng.NextDouble() * 90f; + Particles.Add(new Particle + { + X = x, Y = y, Z = z, + Vx = (float)Math.Cos(a) * sp, + Vy = 50f + (float)_rng.NextDouble() * 100f, + Vz = (float)Math.Sin(a) * sp * 0.6f, + MaxLife = 0.45f + (float)_rng.NextDouble() * 0.3f, + Size = 2.8f + (float)_rng.NextDouble() * 3.4f, + GravityScale = 0.8f, + Color = _rng.NextDouble() < 0.5 ? Cfg.Gold : Color.FromArgb(255, 240, 170) + }); + } + } + + private void SpawnDebris(float x, float y, float z) + { + for (int i = 0; i < 26; i++) + { + float a = (float)(_rng.NextDouble() * Math.PI * 2); + float sp = 60f + (float)_rng.NextDouble() * 190f; + Particles.Add(new Particle + { + X = x, Y = y, Z = z, + Vx = (float)Math.Cos(a) * sp, + Vy = 40f + (float)_rng.NextDouble() * 210f, + Vz = (float)Math.Sin(a) * sp, + MaxLife = 0.6f + (float)_rng.NextDouble() * 0.5f, + Size = 3f + (float)_rng.NextDouble() * 5f, + GravityScale = 1f, + Color = _rng.NextDouble() < 0.5 + ? Color.FromArgb(210, 90, 60) + : Color.FromArgb(150, 140, 130) + }); + } + } + + private void UpdateParticles(float dt) + { + for (int i = Particles.Count - 1; i >= 0; i--) + { + var p = Particles[i]; + p.Life += dt; + if (p.Life >= p.MaxLife) { Particles.RemoveAt(i); continue; } + + p.X += p.Vx * dt; + p.Y += p.Vy * dt; + p.Z += p.Vz * dt; + p.Vy -= Cfg.Gravity * p.GravityScale * dt; + + if (p.Y < 0f) + { + p.Y = 0f; + p.Vy *= -0.32f; // 轻微反弹 + p.Vx *= 0.6f; + p.Vz *= 0.6f; + } + } + } + + // ------------------------------------------------------------------ + // 音效(系统提示音,零依赖;M 键静音) + // ------------------------------------------------------------------ + + private void PlayCoinSfx() + { + if (Muted || _sfxClock - _lastCoinSfx < 0.07f) return; + _lastCoinSfx = _sfxClock; + try { SystemSounds.Asterisk.Play(); } catch { } + } + + private void PlayHitSfx() + { + if (Muted) return; + try { SystemSounds.Hand.Play(); } catch { } + } +} diff --git a/TempleRun/Program.cs b/TempleRun/Program.cs new file mode 100644 index 0000000..03cbeb0 --- /dev/null +++ b/TempleRun/Program.cs @@ -0,0 +1,14 @@ +namespace TempleRun; + +internal static class Program +{ + [STAThread] + private static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.SetHighDpiMode(HighDpiMode.SystemAware); + + Application.Run(new GameForm()); + } +} diff --git a/TempleRun/TempleRun.csproj b/TempleRun/TempleRun.csproj new file mode 100644 index 0000000..e210898 --- /dev/null +++ b/TempleRun/TempleRun.csproj @@ -0,0 +1,18 @@ + + + + WinExe + net8.0-windows + true + disable + enable + latest + TempleRun + TempleRun + app.manifest + zh-Hans + + true + + + diff --git a/TempleRun/_shot/01_ready.png b/TempleRun/_shot/01_ready.png new file mode 100644 index 0000000..fadb194 Binary files /dev/null and b/TempleRun/_shot/01_ready.png differ diff --git a/TempleRun/_shot/02_playing.png b/TempleRun/_shot/02_playing.png new file mode 100644 index 0000000..468c07d Binary files /dev/null and b/TempleRun/_shot/02_playing.png differ diff --git a/TempleRun/_shot/03_playing2.png b/TempleRun/_shot/03_playing2.png new file mode 100644 index 0000000..ea63eb5 Binary files /dev/null and b/TempleRun/_shot/03_playing2.png differ diff --git a/TempleRun/_smoke.ps1 b/TempleRun/_smoke.ps1 new file mode 100644 index 0000000..ebb3571 --- /dev/null +++ b/TempleRun/_smoke.ps1 @@ -0,0 +1,53 @@ +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms + +Add-Type @" +using System; +using System.Runtime.InteropServices; +public class Win32 { + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); +} +"@ + +$root = "c:\Users\32453\CodeBuddy\20260825142915\TempleRun" +$exe = Join-Path $root "bin\Release\net8.0-windows\TempleRun.exe" +$out = Join-Path $root "_shot" +New-Item -ItemType Directory -Force -Path $out | Out-Null + +$p = Start-Process -FilePath $exe -PassThru +Start-Sleep -Seconds 3 +$p.Refresh() + +function Grab([string]$name) { + $h = $p.MainWindowHandle + if ($h -eq [IntPtr]::Zero) { Write-Output "no window"; return } + [Win32]::SetForegroundWindow($h) | Out-Null + Start-Sleep -Milliseconds 700 + $r = New-Object 'Win32+RECT' + [Win32]::GetWindowRect($h, [ref]$r) | Out-Null + $w = $r.Right - $r.Left + $ht = $r.Bottom - $r.Top + if ($w -le 0 -or $ht -le 0) { Write-Output "bad rect $w x $ht"; return } + $bmp = New-Object System.Drawing.Bitmap $w, $ht + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.CopyFromScreen($r.Left, $r.Top, 0, 0, $bmp.Size) + $path = Join-Path $out "$name.png" + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + $g.Dispose(); $bmp.Dispose() + Write-Output "saved $name : $w x $ht" +} + +Grab "01_ready" + +[System.Windows.Forms.SendKeys]::SendWait(" ") +Start-Sleep -Seconds 4 +Grab "02_playing" + +Start-Sleep -Seconds 4 +Grab "03_playing2" + +try { $p.Kill() } catch {} +Write-Output "exit=$($p.HasExited)" diff --git a/TempleRun/app.manifest b/TempleRun/app.manifest new file mode 100644 index 0000000..cdd7f40 --- /dev/null +++ b/TempleRun/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/backend/F9WebAiTest.Api/Controllers/Attendance/AttendanceController.cs b/backend/F9WebAiTest.Api/Controllers/Attendance/AttendanceController.cs new file mode 100644 index 0000000..2ed70c5 --- /dev/null +++ b/backend/F9WebAiTest.Api/Controllers/Attendance/AttendanceController.cs @@ -0,0 +1,103 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Attendance; +using F9WebAiTest.Application.Services; +using F9WebAiTest.Application.Services.Attendance; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9WebAiTest.Api.Controllers.Attendance; + +[ApiController] +[Route("api/attendance")] +[Authorize] +public class AttendanceController : ControllerBase +{ + private readonly IAttendanceService _service; + + public AttendanceController(IAttendanceService service) + { + _service = service; + } + + // POST /api/attendance/punch 打卡 + [HttpPost("punch")] + public async Task Punch() + { + var result = await _service.PunchAsync(GetUserId()); + return Ok(ApiResult.Ok(result)); + } + + // GET /api/attendance/my 我的考勤记录 + [HttpGet("my")] + public async Task GetMy([FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 10) + { + var result = await _service.GetMyRecordsAsync(GetUserId(), pageIndex, pageSize); + return Ok(ApiResult>.Ok(result)); + } + + // GET /api/attendance/my/summary 我的考勤统计 + [HttpGet("my/summary")] + public async Task GetMySummary([FromQuery] string month) + { + var result = await _service.GetMySummaryAsync(GetUserId(), month); + return Ok(ApiResult.Ok(result)); + } + + // GET /api/attendance 全部考勤(管理员) + [HttpGet] + public async Task GetAll([FromQuery] AttendanceQueryDto query) + { + var result = await _service.GetAllRecordsAsync(query); + return Ok(ApiResult>.Ok(result)); + } + + // POST /api/attendance/leave 申请请假 + [HttpPost("leave")] + public async Task ApplyLeave([FromBody] CreateLeaveDto dto) + { + try + { + var result = await _service.ApplyLeaveAsync(GetUserId(), dto); + return Ok(ApiResult.Ok(result, "申请已提交")); + } + catch (ServiceException ex) + { + return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); + } + } + + // GET /api/attendance/leave/my 我的请假 + [HttpGet("leave/my")] + public async Task GetMyLeaves() + { + var result = await _service.GetMyLeavesAsync(GetUserId()); + return Ok(ApiResult>.Ok(result)); + } + + // GET /api/attendance/leave 全部请假(管理员) + [HttpGet("leave")] + public async Task GetAllLeaves([FromQuery] string status) + { + var result = await _service.GetAllLeavesAsync(status); + return Ok(ApiResult>.Ok(result)); + } + + // PUT /api/attendance/leave/{id}/approve 审批请假 + [HttpPut("leave/{id}/approve")] + public async Task ApproveLeave(long id, [FromBody] ApproveLeaveDto dto) + { + try + { + var approverName = User.FindFirst("realName")?.Value ?? "管理员"; + var result = await _service.ApproveLeaveAsync(id, GetUserId(), approverName, dto); + return Ok(ApiResult.Ok(result, "审批完成")); + } + catch (ServiceException ex) + { + return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); + } + } + + private long GetUserId() => long.Parse(User.FindFirst("id")!.Value); +} diff --git a/backend/F9WebAiTest.Api/Controllers/Auth/AuthController.cs b/backend/F9WebAiTest.Api/Controllers/Auth/AuthController.cs new file mode 100644 index 0000000..0bf4861 --- /dev/null +++ b/backend/F9WebAiTest.Api/Controllers/Auth/AuthController.cs @@ -0,0 +1,109 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Auth; +using F9WebAiTest.Application.Services; +using F9WebAiTest.Application.Services.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9WebAiTest.Api.Controllers.Auth; + +[ApiController] +[Route("api/auth")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginDto dto) + { + try + { + var result = await _authService.LoginAsync(dto); + return Ok(ApiResult.Ok(result, "登录成功")); + } + catch (ServiceException ex) + { + return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); + } + } + + [HttpPost("refresh")] + public async Task Refresh([FromBody] RefreshTokenDto dto) + { + try + { + var result = await _authService.RefreshTokenAsync(dto.RefreshToken); + return Ok(ApiResult.Ok(result, "刷新成功")); + } + catch (ServiceException ex) + { + return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); + } + } + + [Authorize] + [HttpPost("logout")] + public async Task Logout([FromBody] RefreshTokenDto dto) + { + await _authService.RevokeRefreshTokenAsync(dto.RefreshToken); + return Ok(ApiResult.Ok(null, "已退出登录")); + } + + [Authorize] + [HttpGet("me")] + public async Task GetCurrentUser() + { + var id = GetUserId(); + var user = await _authService.GetCurrentUserAsync(id); + return Ok(ApiResult.Ok(user)); + } + + [Authorize] + [HttpGet("menus")] + public async Task GetMenus() + { + var id = GetUserId(); + var menus = await _authService.GetUserMenusAsync(id); + return Ok(ApiResult>.Ok(menus)); + } + + [Authorize] + [HttpGet("permissions")] + public async Task GetPermissions() + { + var id = GetUserId(); + var perms = await _authService.GetUserPermissionsAsync(id); + return Ok(ApiResult>.Ok(perms)); + } + + [Authorize] + [HttpPut("password")] + public async Task ChangePassword([FromBody] ChangePasswordDto dto) + { + try + { + await _authService.ChangePasswordAsync(GetUserId(), dto); + return Ok(ApiResult.Ok(null, "密码修改成功")); + } + catch (ServiceException ex) + { + return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); + } + } + + [Authorize] + [HttpPut("profile")] + public async Task UpdateProfile([FromBody] UpdateProfileDto dto) + { + await _authService.UpdateProfileAsync(GetUserId(), dto); + return Ok(ApiResult.Ok(null, "资料更新成功")); + } + + private long GetUserId() => long.Parse(User.FindFirst("id")!.Value); +} diff --git a/backend/F9WebAiTest.Api/Controllers/Base/BusinessControllers.cs b/backend/F9WebAiTest.Api/Controllers/Base/BusinessControllers.cs new file mode 100644 index 0000000..4376d7e --- /dev/null +++ b/backend/F9WebAiTest.Api/Controllers/Base/BusinessControllers.cs @@ -0,0 +1,153 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Base; +using F9WebAiTest.Application.Services; +using F9WebAiTest.Application.Services.Base; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9WebAiTest.Api.Controllers.Base; + +// ==================== 工艺庄口 ==================== +[ApiController] +[Route("api/craftzhuangkou")] +[Authorize] +public class CraftZhuangKouController : ControllerBase +{ + private readonly ICraftZhuangKouService _svc; + public CraftZhuangKouController(ICraftZhuangKouService svc) => _svc = svc; + + [HttpGet] + public async Task GetPaged([FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 20, [FromQuery] string keyword = "", [FromQuery] int? year = null) + => Ok(ApiResult>.Ok(await _svc.GetPagedAsync(pageIndex, pageSize, keyword, year))); + + [HttpPost] + public async Task Create([FromBody] CraftZhuangKouCreateDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.CreateAsync(dto), "创建成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpPut("{id}")] + public async Task Update(long id, [FromBody] CraftZhuangKouCreateDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.UpdateAsync(id, dto), "更新成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpDelete("{id}")] + public async Task Delete(long id) + { + try { await _svc.DeleteAsync(id); return Ok(ApiResult.Ok(null, "删除成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } +} + +// ==================== 生丝规格 ==================== +[ApiController] +[Route("api/shengsiguige")] +[Authorize] +public class ShengsiGuigeController : ControllerBase +{ + private readonly IShengsiGuigeService _svc; + public ShengsiGuigeController(IShengsiGuigeService svc) => _svc = svc; + + [HttpGet] + public async Task GetPaged([FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 20, [FromQuery] string keyword = "") + => Ok(ApiResult>.Ok(await _svc.GetPagedAsync(pageIndex, pageSize, keyword))); + + [HttpGet("all")] + public async Task GetAllGuige() + => Ok(ApiResult>.Ok(await _svc.GetAllGuigeAsync())); + + [HttpPost] + public async Task Create([FromBody] ShengsiGuigeDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.CreateAsync(dto), "创建成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpPut("{id}")] + public async Task Update(long id, [FromBody] ShengsiGuigeDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.UpdateAsync(id, dto), "更新成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpDelete("{id}")] + public async Task Delete(long id) + { + try { await _svc.DeleteAsync(id); return Ok(ApiResult.Ok(null, "删除成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } +} + +// ==================== 生丝定级指标 ==================== +[ApiController] +[Route("api/shengsidingjizhibiao")] +[Authorize] +public class ShengsiDingjizhibiaoController : ControllerBase +{ + private readonly IShengsiDingjizhibiaoService _svc; + public ShengsiDingjizhibiaoController(IShengsiDingjizhibiaoService svc) => _svc = svc; + + [HttpGet] + public async Task GetPaged([FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 20, [FromQuery] string keyword = "", [FromQuery] int? standard = null, [FromQuery] string guige = "") + => Ok(ApiResult>.Ok(await _svc.GetPagedAsync(pageIndex, pageSize, keyword, standard, guige))); + + [HttpPost] + public async Task Create([FromBody] ShengsiDingjizhibiaoDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.CreateAsync(dto), "创建成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpPut("{id}")] + public async Task Update(long id, [FromBody] ShengsiDingjizhibiaoDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.UpdateAsync(id, dto), "更新成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpDelete("{id}")] + public async Task Delete(long id) + { + try { await _svc.DeleteAsync(id); return Ok(ApiResult.Ok(null, "删除成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } +} + +// ==================== 机组车台 ==================== +[ApiController] +[Route("api/jizuchetai")] +[Authorize] +public class JizuChetaiController : ControllerBase +{ + private readonly IJizuChetaiService _svc; + public JizuChetaiController(IJizuChetaiService svc) => _svc = svc; + + [HttpGet] + public async Task GetPaged([FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 20, [FromQuery] string keyword = "") + => Ok(ApiResult>.Ok(await _svc.GetPagedAsync(pageIndex, pageSize, keyword))); + + [HttpPost] + public async Task Create([FromBody] JizuChetaiDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.CreateAsync(dto), "创建成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpPut("{id}")] + public async Task Update(long id, [FromBody] JizuChetaiDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.UpdateAsync(id, dto), "更新成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpDelete("{id}")] + public async Task Delete(long id) + { + try { await _svc.DeleteAsync(id); return Ok(ApiResult.Ok(null, "删除成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } +} diff --git a/backend/F9WebAiTest.Api/Controllers/Notice/NotificationControllers.cs b/backend/F9WebAiTest.Api/Controllers/Notice/NotificationControllers.cs new file mode 100644 index 0000000..8c0d80b --- /dev/null +++ b/backend/F9WebAiTest.Api/Controllers/Notice/NotificationControllers.cs @@ -0,0 +1,191 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Notice; +using F9WebAiTest.Application.Services; +using F9WebAiTest.Application.Services.Auth; +using F9WebAiTest.Application.Services.Notice; +using F9WebAiTest.Domain.Entities; +using F9WebAiTest.Domain.Entities.Notice; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9WebAiTest.Api.Controllers.Notice; + +// ==================== 消息类型管理(管理员) ==================== +[ApiController] +[Route("api/notification/type")] +[Authorize] +public class NotificationTypeController : ControllerBase +{ + private readonly INotificationService _svc; + public NotificationTypeController(INotificationService svc) => _svc = svc; + + [HttpGet] + public async Task GetList() + => Ok(ApiResult>.Ok(await _svc.GetTypeListAsync())); + + [HttpPost] + public async Task Create([FromBody] NotificationTypeCreateDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.CreateTypeAsync(dto, GetUserId()), "创建成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpPut("{id}")] + public async Task Update(long id, [FromBody] NotificationTypeCreateDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.UpdateTypeAsync(id, dto), "更新成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpDelete("{id}")] + public async Task Delete(long id) + { + try { await _svc.DeleteTypeAsync(id); return Ok(ApiResult.Ok(null, "删除成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + private long GetUserId() => long.Parse(User.FindFirst("id")!.Value); +} + +// ==================== 用户端:订阅 + 消息 ==================== +[ApiController] +[Route("api/notification")] +[Authorize] +public class NotificationController : ControllerBase +{ + private readonly INotificationService _svc; + private readonly IMediator _mediator; + private readonly IAuthService _authService; + public NotificationController(INotificationService svc, IMediator mediator, IAuthService authService) + { + _svc = svc; + _mediator = mediator; + _authService = authService; + } + + // 可订阅的消息类型列表(有权限的才可见/可订阅) + [HttpGet("subscribe")] + public async Task GetSubscribeList() + => Ok(ApiResult>.Ok(await _svc.GetSubscribeListAsync(GetUserId()))); + + // 订阅 / 取消订阅 + [HttpPost("subscribe")] + public async Task Subscribe([FromBody] SubscribeDto dto) + { + try + { + await _svc.SubscribeAsync(GetUserId(), dto.NotificationTypeId, dto.Subscribe); + return Ok(ApiResult.Ok(null, dto.Subscribe ? "订阅成功" : "已取消订阅")); + } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + // 我的消息列表(readStatus: 0未读 1已读;category: 0通知 1待办 2公告) + [HttpGet("messages")] + public async Task GetMyMessages([FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 20, [FromQuery] int? readStatus = null, [FromQuery] int? category = null) + => Ok(ApiResult>.Ok(await _svc.GetMyMessagesAsync(GetUserId(), pageIndex, pageSize, readStatus, category))); + + // 我的待办列表 + [HttpGet("todos")] + public async Task GetMyTodos([FromQuery] int? status = null) + => Ok(ApiResult>.Ok(await _svc.GetMyTodosAsync(GetUserId(), status))); + + // 完成待办 + [HttpPost("todos/{id}/complete")] + public async Task CompleteTodo(long id, [FromBody] CompleteTodoDto dto) + { + try { await _svc.CompleteTodoAsync(GetUserId(), id, dto.HandleResult); return Ok(ApiResult.Ok(null, "已完成")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + // 标记已读 + [HttpPut("messages/{id}/read")] + public async Task MarkRead(long id) + { + try { await _svc.MarkReadAsync(GetUserId(), id); return Ok(ApiResult.Ok(null)); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + // 全部已读 + [HttpPut("messages/read-all")] + public async Task MarkAllRead() + { + await _svc.MarkAllReadAsync(GetUserId()); + return Ok(ApiResult.Ok(null)); + } + + // 未读数量 + [HttpGet("unread-count")] + public async Task GetUnreadCount() + => Ok(ApiResult.Ok(new UnreadCountDto { Count = await _svc.GetUnreadCountAsync(GetUserId()) })); + + // 触发一条消息(模拟业务系统触发,用于演示/测试) + [HttpPost("trigger")] + public async Task Trigger([FromBody] TriggerDto dto) + { + try + { + var msgId = await _mediator.Send(new CreateNotificationCommand + { + NotificationTypeCode = dto.Code, + BusinessType = dto.BusinessType ?? "Manual", + BusinessId = dto.BusinessId ?? Guid.NewGuid().ToString("N"), + Title = dto.Title ?? "测试消息", + Content = dto.Content ?? "这是一条测试消息", + Priority = dto.Priority ?? NotificationPriority.Normal, + RoutePath = dto.RoutePath ?? "", + ForceReceivers = dto.AssigneeIds ?? new List(), + AssigneeIds = dto.AssigneeIds ?? new List(), + CreatorId = GetUserId(), + CreatorName = User.FindFirst("realName")?.Value ?? "系统" + }); + return Ok(ApiResult.Ok(new { id = msgId }, "消息已创建并即时推送给在线接收人")); + } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + // 发送广播通知(拥有通知广播权限的用户可发送,空接收人 = 订阅广播) + [HttpPost("broadcast")] + public async Task Broadcast([FromBody] BroadcastNotificationDto dto) + { + try + { + var userId = GetUserId(); + var perms = await _authService.GetUserPermissionsAsync(userId); + if (!perms.Contains("notification:broadcast")) + return StatusCode(403, ApiResult.Fail("您没有发送广播通知的权限", 403)); + + var msgId = await _mediator.Send(new CreateNotificationCommand + { + NotificationTypeCode = "Broadcast", + BusinessType = "Broadcast", + BusinessId = Guid.NewGuid().ToString("N"), + Title = dto.Title, + Content = dto.Content, + Priority = dto.Priority ?? NotificationPriority.Normal, + RoutePath = dto.RoutePath ?? "", + ForceReceivers = new List(), + CreatorId = userId, + CreatorName = User.FindFirst("realName")?.Value ?? "系统" + }); + return Ok(ApiResult.Ok(new { id = msgId }, "广播消息已创建")); + } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + private long GetUserId() => long.Parse(User.FindFirst("id")!.Value); +} + +public class TriggerDto +{ + public string Code { get; set; } = "TeyeFound"; + public string Title { get; set; } + public string Content { get; set; } + public string BusinessType { get; set; } + public string BusinessId { get; set; } + public NotificationPriority? Priority { get; set; } + public string RoutePath { get; set; } + public List AssigneeIds { get; set; } +} diff --git a/backend/F9WebAiTest.Api/Controllers/Prod/HbjyController.cs b/backend/F9WebAiTest.Api/Controllers/Prod/HbjyController.cs new file mode 100644 index 0000000..1967017 --- /dev/null +++ b/backend/F9WebAiTest.Api/Controllers/Prod/HbjyController.cs @@ -0,0 +1,41 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Prod; +using F9WebAiTest.Application.Services; +using F9WebAiTest.Application.Services.Prod; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9WebAiTest.Api.Controllers.Prod; + +[ApiController] +[Route("api/hbjy")] +[Authorize] +public class HbjyController : ControllerBase +{ + private readonly IHbjyService _svc; + public HbjyController(IHbjyService svc) => _svc = svc; + + // 黑板检验列表 + [HttpGet] + public async Task GetPaged( + [FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 20, + [FromQuery] DateTime? date = null, [FromQuery] long? deptId = null) + => Ok(ApiResult>.Ok(await _svc.GetPagedAsync(pageIndex, pageSize, date, deptId))); + + // 获取检验编辑数据 + [HttpGet("{lsjlId}")] + public async Task GetEdit(long lsjlId) + { + try { return Ok(ApiResult.Ok(await _svc.GetEditAsync(lsjlId))); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + // 保存黑板检验 + [HttpPost] + public async Task Save([FromBody] HbjySaveDto dto) + { + try { await _svc.SaveAsync(dto); return Ok(ApiResult.Ok(null, "保存成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } +} diff --git a/backend/F9WebAiTest.Api/Controllers/Prod/LsjlController.cs b/backend/F9WebAiTest.Api/Controllers/Prod/LsjlController.cs new file mode 100644 index 0000000..9e5f27c --- /dev/null +++ b/backend/F9WebAiTest.Api/Controllers/Prod/LsjlController.cs @@ -0,0 +1,58 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Base; +using F9WebAiTest.Application.Dtos.Prod; +using F9WebAiTest.Application.Services; +using F9WebAiTest.Application.Services.Base; +using F9WebAiTest.Application.Services.Prod; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9WebAiTest.Api.Controllers.Prod; + +[ApiController] +[Route("api/lsjl")] +[Authorize] +public class LsjlController : ControllerBase +{ + private readonly ILsjlService _svc; + private readonly IJizuChetaiService _jzSvc; + + public LsjlController(ILsjlService svc, IJizuChetaiService jzSvc) + { + _svc = svc; + _jzSvc = jzSvc; + } + + [HttpGet] + public async Task GetPaged( + [FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 20, + [FromQuery] DateTime? date = null, [FromQuery] long? deptId = null, [FromQuery] string keyword = "") + => Ok(ApiResult>.Ok(await _svc.GetPagedAsync(pageIndex, pageSize, date, deptId, keyword))); + + // 根据车间+机组查询车台号范围 + [HttpGet("chetai")] + public async Task GetChetai([FromQuery] long deptId, [FromQuery] int jizu) + => Ok(ApiResult.Ok(await _jzSvc.GetByDepIdAndJizuAsync(deptId, jizu))); + + [HttpPost] + public async Task Create([FromBody] LsjlCreateDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.CreateAsync(dto), "创建成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpPut("{id}")] + public async Task Update(long id, [FromBody] LsjlCreateDto dto) + { + try { return Ok(ApiResult.Ok(await _svc.UpdateAsync(id, dto), "更新成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + [HttpDelete("{id}")] + public async Task Delete(long id) + { + try { await _svc.DeleteAsync(id); return Ok(ApiResult.Ok(null, "删除成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } +} diff --git a/backend/F9WebAiTest.Api/Controllers/Prod/XdjyController.cs b/backend/F9WebAiTest.Api/Controllers/Prod/XdjyController.cs new file mode 100644 index 0000000..0db64bd --- /dev/null +++ b/backend/F9WebAiTest.Api/Controllers/Prod/XdjyController.cs @@ -0,0 +1,41 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Prod; +using F9WebAiTest.Application.Services; +using F9WebAiTest.Application.Services.Prod; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9WebAiTest.Api.Controllers.Prod; + +[ApiController] +[Route("api/xdjy")] +[Authorize] +public class XdjyController : ControllerBase +{ + private readonly IXdjyService _svc; + public XdjyController(IXdjyService svc) => _svc = svc; + + // 纤度检验列表(落丝记录 + 检验汇总) + [HttpGet] + public async Task GetPaged( + [FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 20, + [FromQuery] DateTime? date = null, [FromQuery] long? deptId = null) + => Ok(ApiResult>.Ok(await _svc.GetPagedAsync(pageIndex, pageSize, date, deptId))); + + // 获取检验编辑数据 + [HttpGet("{lsjlId}")] + public async Task GetEdit(long lsjlId) + { + try { return Ok(ApiResult.Ok(await _svc.GetEditAsync(lsjlId))); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } + + // 保存纤度检验 + [HttpPost] + public async Task Save([FromBody] XdjySaveDto dto) + { + try { await _svc.SaveAsync(dto); return Ok(ApiResult.Ok(null, "保存成功")); } + catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult.Fail(ex.Message, ex.Code)); } + } +} diff --git a/backend/F9WebAiTest.Api/Controllers/SysDepartmentController.cs b/backend/F9WebAiTest.Api/Controllers/SysDepartmentController.cs new file mode 100644 index 0000000..a68927b --- /dev/null +++ b/backend/F9WebAiTest.Api/Controllers/SysDepartmentController.cs @@ -0,0 +1,55 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.CQRS.SYS.Departments.Commands; +using F9WebAiTest.Application.CQRS.SYS.Departments.Queries; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9WebAiTest.Api.Controllers; + +/// 系统部门管理(DDD + CQRS) +[ApiController] +[Route("api/sys/department")] +[Authorize] +public class SysDepartmentController : ControllerBase +{ + private readonly IMediator _mediator; + public SysDepartmentController(IMediator mediator) => _mediator = mediator; + + [HttpGet] + public async Task GetAll() + => Ok(ApiResult.Ok(await _mediator.Send(new GetDepartmentAllQuery()))); + + [HttpPost] + public async Task Create([FromBody] Department_CreateCommand command) + { + try { return Ok(ApiResult.Ok(new { id = await _mediator.Send(command) }, "创建成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpPut("{id}")] + public async Task Update(long id, [FromBody] Department_UpdateCommand command) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(command with { Id = id }), "修改成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpDelete("{id}")] + public async Task Delete(long id) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(new Department_DelCommand(id)), "删除成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpPut("{id}/active")] + public async Task ActiveOrDeactivate(long id, [FromBody] Department_ActiveOrDeactivateCommand command) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(command with { Id = id }), "操作成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } +} diff --git a/backend/F9WebAiTest.Api/Controllers/SysMenuController.cs b/backend/F9WebAiTest.Api/Controllers/SysMenuController.cs new file mode 100644 index 0000000..66918fe --- /dev/null +++ b/backend/F9WebAiTest.Api/Controllers/SysMenuController.cs @@ -0,0 +1,68 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.CQRS.SYS.RBAC.Menus.Commands; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Menus; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9WebAiTest.Api.Controllers; + +/// 系统菜单管理(DDD + CQRS) +[ApiController] +[Route("api/sys/menu")] +[Authorize] +public class SysMenuController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly IMenuReadRepository _menuReadRepository; + public SysMenuController(IMediator mediator, IMenuReadRepository menuReadRepository) + { + _mediator = mediator; + _menuReadRepository = menuReadRepository; + } + + [HttpGet("tree")] + public async Task GetTree() + => Ok(ApiResult.Ok(await _menuReadRepository.GetTreeMenuAsync())); + + [HttpGet] + public async Task GetAll([FromQuery] string like = "", [FromQuery] long? parentId = null) + => Ok(ApiResult.Ok(await _menuReadRepository.GetAllMenuAsync(like, parentId))); + + [HttpGet("{id}")] + public async Task GetById(long id) + => Ok(ApiResult.Ok(await _menuReadRepository.GetMenuByIdForEditAsync(id))); + + [HttpPost] + public async Task Create([FromBody] Menu_CreateCommand command) + { + try { return Ok(ApiResult.Ok(new { id = await _mediator.Send(command) }, "创建成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpPut("{id}")] + public async Task Update(long id, [FromBody] Menu_UpdateCommand command) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(command with { Id = id }), "修改成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpDelete("{id}")] + public async Task Delete(long id) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(new Menu_DelCommand(id)), "删除成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpPut("{id}/active")] + public async Task ActiveOrDeactivate(long id, [FromBody] Menu_ActiveOrDeactivateCommand command) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(command with { Id = id }), "操作成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } +} diff --git a/backend/F9WebAiTest.Api/Controllers/SysRoleController.cs b/backend/F9WebAiTest.Api/Controllers/SysRoleController.cs new file mode 100644 index 0000000..6a8b5e3 --- /dev/null +++ b/backend/F9WebAiTest.Api/Controllers/SysRoleController.cs @@ -0,0 +1,80 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.CQRS.SYS.RBAC.Roles.Commands; +using F9WebAiTest.Application.CQRS.SYS.RBAC.Roles.Queries; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9WebAiTest.Api.Controllers; + +/// 系统角色管理(DDD + CQRS) +[ApiController] +[Route("api/sys/role")] +[Authorize] +public class SysRoleController : ControllerBase +{ + private readonly IMediator _mediator; + public SysRoleController(IMediator mediator) => _mediator = mediator; + + [HttpGet] + public async Task GetAll() + => Ok(ApiResult.Ok(await _mediator.Send(new GetRoleAllQuery()))); + + [HttpGet("{id}")] + public async Task GetById(long id) + => Ok(ApiResult.Ok(await _mediator.Send(new GetRoleByIdForEditQuery(id)))); + + [HttpGet("{id}/permissions")] + public async Task GetPermissions(long id, [FromQuery] Resource_Type_Enum resourceType) + => Ok(ApiResult.Ok(await _mediator.Send(new GetRolePermissionsByResourceTypeQuery(id, resourceType)))); + + [HttpPost] + public async Task Create([FromBody] Role_CreateCommand command) + { + try { return Ok(ApiResult.Ok(new { id = await _mediator.Send(command) }, "创建成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpPut("{id}")] + public async Task Update(long id, [FromBody] Role_UpdateCommand command) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(command with { Id = id }), "修改成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpDelete("{id}")] + public async Task Delete(long id) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(new Role_DelCommand(id)), "删除成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpPut("{id}/active")] + public async Task ActiveOrDeactivate(long id, [FromBody] Role_ActiveOrDeactivateCommand command) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(command with { Id = id }), "操作成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpPost("{id}/permissions")] + public async Task AssignPermission(long id, [FromBody] RolePermission_AssignCommand command) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(command with { RoleId = id }), "绑定成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpDelete("{id}/permissions/{rpId}")] + public async Task RemovePermission(long id, long rpId, [FromQuery] Resource_Type_Enum resourceType) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(new RolePermission_RemoveCommand(rpId, resourceType)), "解绑成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } +} diff --git a/backend/F9WebAiTest.Api/Controllers/SysUserController.cs b/backend/F9WebAiTest.Api/Controllers/SysUserController.cs new file mode 100644 index 0000000..82d52e3 --- /dev/null +++ b/backend/F9WebAiTest.Api/Controllers/SysUserController.cs @@ -0,0 +1,87 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Commands; +using F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Queries; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace F9WebAiTest.Api.Controllers; + +/// 系统用户管理(DDD + CQRS) +[ApiController] +[Route("api/sys/user")] +[Authorize] +public class SysUserController : ControllerBase +{ + private readonly IMediator _mediator; + public SysUserController(IMediator mediator) => _mediator = mediator; + + [HttpGet] + public async Task GetPaged([FromQuery] long departmentId = 0, [FromQuery] string like = "", [FromQuery] int page = 1, [FromQuery] int pageSize = 10) + => Ok(ApiResult.Ok(await _mediator.Send(new GetUserPagedQuery(departmentId, like, page, pageSize)))); + + [HttpGet("{id}")] + public async Task GetById(long id) + => Ok(ApiResult.Ok(await _mediator.Send(new GetUserByIdQuery(id)))); + + [HttpGet("{id}/roles")] + public async Task GetUserRoles(long id) + => Ok(ApiResult.Ok(await _mediator.Send(new GetUserRolesByUserIdQuery(id)))); + + [HttpPost] + public async Task Create([FromBody] User_CreateCommand command) + { + try { return Ok(ApiResult.Ok(new { id = await _mediator.Send(command) }, "创建成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpPut("{id}")] + public async Task Update(long id, [FromBody] User_UpdateCommand command) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(command with { Id = id }), "修改成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpDelete("{id}")] + public async Task Delete(long id) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(new User_DelCommand(id)), "删除成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpPut("{id}/active")] + public async Task ActiveOrDeactivate(long id, [FromBody] User_ActiveOrDeactivateCommand command) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(command with { Id = id }), "操作成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpPut("{id}/reset-password")] + public async Task ResetPassword(long id) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(new User_ResetPasswordCommand(id)), "密码已重置")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpPost("{id}/roles")] + public async Task AssignRole(long id, [FromBody] UserRole_AssignCommand command) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(command with { UserId = id }), "绑定成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } + + [HttpDelete("{id}/roles/{roleId}")] + public async Task RemoveRole(long id, long roleId) + { + try { return Ok(ApiResult.Ok(await _mediator.Send(new UserRole_RemoveCommand(id, roleId)), "解绑成功")); } + catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } + catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } + } +} diff --git a/backend/F9WebAiTest.Api/F9WebAiTest.Api.csproj b/backend/F9WebAiTest.Api/F9WebAiTest.Api.csproj new file mode 100644 index 0000000..a623f35 --- /dev/null +++ b/backend/F9WebAiTest.Api/F9WebAiTest.Api.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + disable + F9WebAiTest.Api + + + + + + + + + + + + + + + diff --git a/backend/F9WebAiTest.Api/Hubs/NotificationHub.cs b/backend/F9WebAiTest.Api/Hubs/NotificationHub.cs new file mode 100644 index 0000000..1996003 --- /dev/null +++ b/backend/F9WebAiTest.Api/Hubs/NotificationHub.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace F9WebAiTest.Api.Hubs; + +/// 在线用户登记表(用于判断用户是否在线,离线则走"拉取兜底") +public static class OnlineUserRegistry +{ + private static readonly ConcurrentDictionary _users = new(); + + public static void Add(long userId) => _users[userId] = 0; + + public static void Remove(long userId) => _users.TryRemove(userId, out _); + + public static bool IsOnline(long userId) => _users.ContainsKey(userId); +} + +/// 通知推送 Hub(客户端连接后按 UserId 标识,服务端用 Clients.User(userId) 定向推送) +public class NotificationHub : Hub +{ + public override async Task OnConnectedAsync() + { + var userId = GetUserId(); + if (userId > 0) OnlineUserRegistry.Add(userId); + await base.OnConnectedAsync(); + } + public override async Task OnDisconnectedAsync(Exception exception) + { + var userId = GetUserId(); + if (userId > 0) OnlineUserRegistry.Remove(userId); + await base.OnDisconnectedAsync(exception); + } + + private long GetUserId() + { + var raw = Context.User?.FindFirst("id")?.Value; + return long.TryParse(raw, out var id) ? id : 0; + } +} + +/// 自定义用户标识:使用 JWT 中的 "id" 声明作为 SignalR 的 UserIdentifier +public class NotificationUserIdProvider : IUserIdProvider +{ + public string GetUserId(HubConnectionContext connection) + => connection.User?.FindFirst("id")?.Value ?? connection.ConnectionId; +} diff --git a/backend/F9WebAiTest.Api/JsonConverters/LongToStringJsonConverter.cs b/backend/F9WebAiTest.Api/JsonConverters/LongToStringJsonConverter.cs new file mode 100644 index 0000000..c6865ca --- /dev/null +++ b/backend/F9WebAiTest.Api/JsonConverters/LongToStringJsonConverter.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace F9WebAiTest.Api.JsonConverters; + +/// +/// 将 long 序列化为字符串,避免雪花 ID(超过 JS Number 安全整数 2^53)在前后端传递时丢失精度。 +/// 反序列化时兼容字符串与数字两种形式。 +/// +public class LongToStringJsonConverter : JsonConverter +{ + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType == JsonTokenType.String + ? long.Parse(reader.GetString()!) + : reader.GetInt64(); + + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); +} diff --git a/backend/F9WebAiTest.Api/Program.cs b/backend/F9WebAiTest.Api/Program.cs new file mode 100644 index 0000000..7fb8ee8 --- /dev/null +++ b/backend/F9WebAiTest.Api/Program.cs @@ -0,0 +1,112 @@ +using System.Text; +using FreeSql; +using F9WebAiTest.Api.Hubs; +using F9WebAiTest.Api.Services; +using F9WebAiTest.Application; +using F9WebAiTest.Application.Services; +using F9WebAiTest.Application.Services.Notice; +using F9WebAiTest.Infrastructure; +using F9WebAiTest.Infrastructure.Data; +using MediatR; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.SignalR; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +// 分层注册 +builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddApplication(); + +// JWT 认证 +var jwtKey = builder.Configuration["Jwt:Key"]; +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) + }; + // SignalR 通过 query string 传递 token,需从 access_token 读取 + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs")) + { + context.Token = accessToken; + } + return Task.CompletedTask; + } + }; + }); +builder.Services.AddAuthorization(); + +builder.Services.AddControllers() + .AddJsonOptions(options => + { + // long 序列化为字符串,避免雪花 ID 超出 JS 安全整数范围导致精度丢失 + options.JsonSerializerOptions.Converters.Add(new F9WebAiTest.Api.JsonConverters.LongToStringJsonConverter()); + }); + +// SignalR 实时推送 + 自定义用户标识 + 即时推送器(创建消息时直接推送,无后台投递服务) +builder.Services.AddSignalR(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +// 业务状态变更事件 → SignalR 推送(多端状态同步) +builder.Services.AddScoped, BusinessChangedEventHandler>(); + +// CORS +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); +}); + +// NSwag(OpenAPI 文档 + Swagger UI + 可生成 TS/C# 客户端) +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddOpenApiDocument(settings => +{ + settings.Title = "企业 RBAC 管理系统 API"; + settings.Version = "v1"; + settings.Description = "基于 NSwag 生成的 OpenAPI 文档,可用于生成 TypeScript/C# 客户端"; + settings.AddSecurity("Bearer", new NSwag.OpenApiSecurityScheme + { + Type = NSwag.OpenApiSecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "请输入 access token(格式:Bearer {token})" + }); + settings.OperationProcessors.Add(new NSwag.Generation.Processors.Security.AspNetCoreOperationSecurityScopeProcessor("Bearer")); +}); + +var app = builder.Build(); + +// 初始化数据库(FreeSql CodeFirst 建表 + 种子) +using (var scope = app.Services.CreateScope()) +{ + var fsql = scope.ServiceProvider.GetRequiredService(); + var snowflake = scope.ServiceProvider.GetRequiredService(); + DbSeeder.Seed(fsql, snowflake); +} + +// NSwag 中间件(swagger 文档 + UI) +app.UseOpenApi(); +app.UseSwaggerUi(); + +app.UseCors("AllowAll"); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); +app.MapHub("/hubs/notification"); + +app.Run(); diff --git a/backend/F9WebAiTest.Api/Properties/launchSettings.json b/backend/F9WebAiTest.Api/Properties/launchSettings.json new file mode 100644 index 0000000..348c87c --- /dev/null +++ b/backend/F9WebAiTest.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "F9WebAiTest.Api": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:3000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/F9WebAiTest.Api/Services/BusinessChangedEventHandler.cs b/backend/F9WebAiTest.Api/Services/BusinessChangedEventHandler.cs new file mode 100644 index 0000000..acc7062 --- /dev/null +++ b/backend/F9WebAiTest.Api/Services/BusinessChangedEventHandler.cs @@ -0,0 +1,22 @@ +using F9WebAiTest.Api.Hubs; +using F9WebAiTest.Application.Services; +using MediatR; +using Microsoft.AspNetCore.SignalR; + +namespace F9WebAiTest.Api.Services; + +/// +/// 业务状态变更事件的 SignalR 推送处理器。 +/// 业务服务发布 BusinessChangedEvent 后,推送给所有在线客户端, +/// 各端监听 "BusinessChanged" 后刷新已打开的相关页面,实现多端实时同步。 +/// +public class BusinessChangedEventHandler : INotificationHandler +{ + private readonly IHubContext _hubContext; + + public BusinessChangedEventHandler(IHubContext hubContext) + => _hubContext = hubContext; + + public Task Handle(BusinessChangedEvent e, CancellationToken ct) + => _hubContext.Clients.All.SendAsync("BusinessChanged", e, ct); +} diff --git a/backend/F9WebAiTest.Api/Services/NotificationDeliveryService.cs b/backend/F9WebAiTest.Api/Services/NotificationDeliveryService.cs new file mode 100644 index 0000000..18e2a8b --- /dev/null +++ b/backend/F9WebAiTest.Api/Services/NotificationDeliveryService.cs @@ -0,0 +1,10 @@ +// 已移除:消息投递不再使用后台服务(BackgroundService)与轮询。 +// +// 现在的投递方式(即时推送): +// CreateNotificationCommandHandler(F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs) +// 在消息创建后立即: +// 1. 确定接收人(ForceReceivers 精确送达,否则按消息类型订阅关系广播); +// 2. 写入 NotificationReceiver(MessageId + UserId 幂等); +// 3. 通过 INotificationPusher(实现在 F9WebAiTest.Api/Services/NotificationPusher.cs)经 SignalR 直接推送。 +// +// 本文件已无任何代码,可直接删除。 diff --git a/backend/F9WebAiTest.Api/Services/NotificationPusher.cs b/backend/F9WebAiTest.Api/Services/NotificationPusher.cs new file mode 100644 index 0000000..c7709cd --- /dev/null +++ b/backend/F9WebAiTest.Api/Services/NotificationPusher.cs @@ -0,0 +1,45 @@ +using F9WebAiTest.Api.Hubs; +using F9WebAiTest.Application.Dtos.Notice; +using F9WebAiTest.Application.Services.Notice; +using Microsoft.AspNetCore.SignalR; + +namespace F9WebAiTest.Api.Services; + +/// +/// 消息实时推送器实现:基于 SignalR 的 IHubContext 直接推送。 +/// 无后台队列、无轮询——消息创建时同步完成投递与推送。 +/// +/// 在线判定:OnlineUserRegistry(Hub 连接/断开时登记的内存表)。 +/// 之所以需要显式判定而不能依赖推送异常:SignalR 对"无连接的 Clients.User 调用"是静默成功的, +/// 不判定的话离线用户会被误记为推送成功。 +/// +/// 用户标识:由 NotificationUserIdProvider 统一取 JWT 的 "id" 声明, +/// 因此 Clients.User(userId) 可精准命中该用户的所有在线连接(支持多端同时在线)。 +/// +public class NotificationPusher : INotificationPusher +{ + private readonly IHubContext _hubContext; + + public NotificationPusher(IHubContext hubContext) + { + _hubContext = hubContext; + } + + /// + public bool IsOnline(long userId) => OnlineUserRegistry.IsOnline(userId); + + /// + public async Task PushNewMessageAsync(long userId, NotificationMessageDto message, CancellationToken ct = default) + { + // 离线用户:不推送(消息已持久化,上线后拉取兜底),返回 false 由调用方标记 IsOffline + if (!OnlineUserRegistry.IsOnline(userId)) return false; + + await _hubContext.Clients.User(userId.ToString()).SendAsync("ReceiveNotification", message, ct); + return true; + } + + /// + public Task PushUnreadCountAsync(long userId, int unreadCount, int todoCount, CancellationToken ct = default) + => _hubContext.Clients.User(userId.ToString()) + .SendAsync("UnreadCountChanged", new { unreadCount, todoCount }, ct); +} diff --git a/backend/F9WebAiTest.Api/appsettings.json b/backend/F9WebAiTest.Api/appsettings.json new file mode 100644 index 0000000..57f452d --- /dev/null +++ b/backend/F9WebAiTest.Api/appsettings.json @@ -0,0 +1,26 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://0.0.0.0:3000" + } + } + }, + "Jwt": { + "Key": "f9-web-ai-test-rbac-secret-key-2026-please-change-in-production", + "Issuer": "F9WebAiTest", + "Audience": "F9WebAiTestClient", + "AccessTokenExpireMinutes": 120, + "RefreshTokenExpireDays": 7 + }, + "ConnectionStrings": { + "Default": "Data Source=f9_web_ai_test_rbac.db" + } +} diff --git a/backend/F9WebAiTest.Api/f9_web_ai_test_rbac.db b/backend/F9WebAiTest.Api/f9_web_ai_test_rbac.db new file mode 100644 index 0000000..8b5a56a Binary files /dev/null and b/backend/F9WebAiTest.Api/f9_web_ai_test_rbac.db differ diff --git a/backend/F9WebAiTest.Api/life_circle_rbac.db.bak b/backend/F9WebAiTest.Api/life_circle_rbac.db.bak new file mode 100644 index 0000000..ecf4c62 Binary files /dev/null and b/backend/F9WebAiTest.Api/life_circle_rbac.db.bak differ diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_ActiveOrDeactivateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_ActiveOrDeactivateCommand.cs new file mode 100644 index 0000000..602009d --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_ActiveOrDeactivateCommand.cs @@ -0,0 +1,31 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Domain.Interfaces.SYS.Departments; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.Departments.Commands; + +/// 命令定义 +public record Department_ActiveOrDeactivateCommand(long Id, bool Active) : IRequest; + +/// 命令处理器 +public class ActiveOrDeactivateDepartmentCommandHandler( + IDepartmentWriteRepository departmentWriteRepository, + IPublisher eventBus) : IRequestHandler +{ + public async Task Handle(Department_ActiveOrDeactivateCommand command, CancellationToken cancellationToken) + { + var dep = await departmentWriteRepository.GetByIdAsync(command.Id, CancellationToken.None); + if (dep == null) throw new F9NotFoundException("部门", command.Id); + + if (command.Active) dep.Activate(); + else dep.Deactivate(); + + var result = await departmentWriteRepository.ActiveOrDeactive(command.Id, dep.IsActive); + if (result == false) throw new F9OperationException("启用/禁用", "操作"); + + foreach (var de in dep.DomainEvents) await eventBus.Publish(de, cancellationToken); + dep.ClearDomainEvents(); + + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_CreateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_CreateCommand.cs new file mode 100644 index 0000000..34906a2 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_CreateCommand.cs @@ -0,0 +1,35 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Domain.Entities.SYS.Departments; +using F9WebAiTest.Domain.Enums.SYS.Departments; +using F9WebAiTest.Domain.Interfaces.SYS.Departments; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.Departments.Commands; + +/// 命令定义 +public record Department_CreateCommand(string Code, string Name, long ParentId, Department_ManageType_Enum ManageType, Department_DutyType_Enum DutyType, int SortOrder, string Description) : IRequest; + +/// 命令处理器 +public class CreateDepartmentCommandHandler( + IDepartmentWriteRepository departmentRepository, + ISnowflakeIdGenerator snowflakeIdGenerator, + IPublisher eventBus) : IRequestHandler +{ + public async Task Handle(Department_CreateCommand command, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.Code)) throw new F9OperationException("部门编码不能为空"); + if (string.IsNullOrWhiteSpace(command.Name)) throw new F9OperationException("部门名称不能为空"); + + var dep = new Department(snowflakeIdGenerator.CreateId(), command.Code, command.Name, command.ParentId, + command.ManageType, command.DutyType, command.SortOrder, command.Description); + + var result = await departmentRepository.AddAsync(dep); + if (result == false) throw new F9OperationException("部门", "新增"); + + foreach (var de in dep.DomainEvents) await eventBus.Publish(de, cancellationToken); + dep.ClearDomainEvents(); + + return dep.Id; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_DelCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_DelCommand.cs new file mode 100644 index 0000000..d1819d1 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_DelCommand.cs @@ -0,0 +1,22 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Domain.Interfaces.SYS.Departments; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.Departments.Commands; + +/// 命令定义 +public record Department_DelCommand(long Id) : IRequest; + +/// 命令处理器 +public class DelDepartmentCommandHandler( + IDepartmentWriteRepository departmentWriteRepository) : IRequestHandler +{ + public async Task Handle(Department_DelCommand command, CancellationToken cancellationToken) + { + if (command.Id <= 0) throw new F9OperationException("部门ID不能为空"); + + var result = await departmentWriteRepository.SoftDeleteAsync(command.Id); + if (result == false) throw new F9OperationException("部门", "删除"); + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_UpdateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_UpdateCommand.cs new file mode 100644 index 0000000..e26f6cb --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Commands/Department_UpdateCommand.cs @@ -0,0 +1,29 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Domain.Entities.SYS.Departments; +using F9WebAiTest.Domain.Enums.SYS.Departments; +using F9WebAiTest.Domain.Interfaces.SYS.Departments; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.Departments.Commands; + +/// 命令定义 +public record Department_UpdateCommand(long Id, string Code, string Name, long ParentId, Department_ManageType_Enum ManageType, Department_DutyType_Enum DutyType, int SortOrder, string Description) : IRequest; + +/// 命令处理器 +public class UpdateDepartmentCommandHandler( + IDepartmentWriteRepository departmentRepository) : IRequestHandler +{ + public async Task Handle(Department_UpdateCommand command, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.Code)) throw new F9OperationException("部门编码不能为空"); + if (string.IsNullOrWhiteSpace(command.Name)) throw new F9OperationException("部门名称不能为空"); + + var dep = new Department(command.Id, command.Code, command.Name, command.ParentId, + command.ManageType, command.DutyType, command.SortOrder, command.Description); + + var result = await departmentRepository.UpdateAsync(dep); + if (result == false) throw new F9OperationException("部门", "修改"); + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Queries/GetDepartmentAllQuery.cs b/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Queries/GetDepartmentAllQuery.cs new file mode 100644 index 0000000..b7859fc --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/Departments/Queries/GetDepartmentAllQuery.cs @@ -0,0 +1,13 @@ +using F9WebAiTest.Application.Dtos.SYS.Departments; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.Departments; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.Departments.Queries; + +public record GetDepartmentAllQuery() : IRequest>; + +public class GetDepartmentAllQueryHandler(IDepartmentReadRepository departmentReadRepository) : IRequestHandler> +{ + public async Task> Handle(GetDepartmentAllQuery query, CancellationToken ct) + => await departmentReadRepository.GetAllAsync(); +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_ActiveOrDeactivateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_ActiveOrDeactivateCommand.cs new file mode 100644 index 0000000..7781726 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_ActiveOrDeactivateCommand.cs @@ -0,0 +1,31 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Menus; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Menus.Commands; + +/// 命令定义 +public record Menu_ActiveOrDeactivateCommand(long Id, bool Active) : IRequest; + +/// 命令处理器 +public class ActiveOrDeactivateMenuCommandHandler( + IMenuWriteRepository menuWriteRepository, + IPublisher eventBus) : IRequestHandler +{ + public async Task Handle(Menu_ActiveOrDeactivateCommand command, CancellationToken cancellationToken) + { + var menu = await menuWriteRepository.GetByIdAsync(command.Id); + if (menu == null) throw new F9NotFoundException("菜单", command.Id); + + if (command.Active) menu.Activate(); + else menu.Deactivate(); + + var result = await menuWriteRepository.ActiveOrDeactive(command.Id, menu.IsActive); + if (result == false) throw new F9OperationException("启用/禁用", "操作"); + + foreach (var de in menu.DomainEvents) await eventBus.Publish(de, cancellationToken); + menu.ClearDomainEvents(); + + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_CreateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_CreateCommand.cs new file mode 100644 index 0000000..928afe4 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_CreateCommand.cs @@ -0,0 +1,34 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Menus; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Menus.Commands; + +/// 命令定义 +public record Menu_CreateCommand(string Name, string IconSvg, MenuType_Enum MenuType, string AssemblyName, string TypeFullName, long ParentId, int SortOrder) : IRequest; + +/// 命令处理器 +public class CreateMenuCommandHandler( + IMenuWriteRepository menuRepository, + ISnowflakeIdGenerator snowflakeIdGenerator, + IPublisher eventBus) : IRequestHandler +{ + public async Task Handle(Menu_CreateCommand command, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.Name)) throw new F9OperationException("菜单名称不能为空"); + + var menu = new Menu(snowflakeIdGenerator.CreateId(), command.Name, command.IconSvg, + command.MenuType, command.AssemblyName, command.TypeFullName, command.ParentId, command.SortOrder); + + var result = await menuRepository.AddAsync(menu); + if (result == false) throw new F9OperationException("菜单", "新增"); + + foreach (var de in menu.DomainEvents) await eventBus.Publish(de, cancellationToken); + menu.ClearDomainEvents(); + + return menu.Id; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_DelCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_DelCommand.cs new file mode 100644 index 0000000..0f0c1d6 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_DelCommand.cs @@ -0,0 +1,22 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Menus; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Menus.Commands; + +/// 命令定义 +public record Menu_DelCommand(long Id) : IRequest; + +/// 命令处理器 +public class DelMenuCommandHandler( + IMenuWriteRepository menuWriteRepository) : IRequestHandler +{ + public async Task Handle(Menu_DelCommand command, CancellationToken cancellationToken) + { + if (command.Id <= 0) throw new F9OperationException("菜单ID不能为空"); + + var result = await menuWriteRepository.SoftDeleteAsync(command.Id); + if (result == false) throw new F9OperationException("菜单", "删除"); + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_UpdateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_UpdateCommand.cs new file mode 100644 index 0000000..8e7ddbf --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Menus/Commands/Menu_UpdateCommand.cs @@ -0,0 +1,28 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Menus; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Menus.Commands; + +/// 命令定义 +public record Menu_UpdateCommand(long Id, string Name, string IconSvg, MenuType_Enum MenuType, string AssemblyName, string TypeFullName, long ParentId, int SortOrder) : IRequest; + +/// 命令处理器 +public class UpdateMenuCommandHandler( + IMenuWriteRepository menuRepository) : IRequestHandler +{ + public async Task Handle(Menu_UpdateCommand command, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.Name)) throw new F9OperationException("菜单名称不能为空"); + + var menu = new Menu(command.Id, command.Name, command.IconSvg, + command.MenuType, command.AssemblyName, command.TypeFullName, command.ParentId, command.SortOrder); + + var result = await menuRepository.UpdateAsync(menu); + if (result == false) throw new F9OperationException("菜单", "修改"); + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/RolePermission_AssignCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/RolePermission_AssignCommand.cs new file mode 100644 index 0000000..653dc9b --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/RolePermission_AssignCommand.cs @@ -0,0 +1,80 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Application.Interfaces.UOW; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using NotificationTypeEntity = F9WebAiTest.Domain.Entities.Notice.NotificationType; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Permissions; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Roles; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Roles.Commands; + +/// 命令定义 +public record RolePermission_AssignCommand(long RoleId, Permission_Type_Enum PermissionType, Resource_Type_Enum ResourceType, string ResourceIdentity, long ResourceId) : IRequest; + +/// 命令处理器 +public class AssignRolePermissionCommandHandler( + IRoleWriteRepository roleRepository, + IUnitOfWork_F9 unitOfWork, + IRolePermissionWriteRepository rolePermissionRep, + IPermissionWriteRepository permissionWriteRepository, + IRepository notificationTypeRepository, + ISnowflakeIdGenerator snowflakeIdGenerator, + IPublisher eventBus) : IRequestHandler +{ + public async Task Handle(RolePermission_AssignCommand command, CancellationToken cancellationToken) + { + if (command.RoleId <= 0) throw new F9OperationException("角色ID不能为空"); + if (command.ResourceId <= 0) throw new F9OperationException("资源ID不能为空"); + if (string.IsNullOrWhiteSpace(command.ResourceIdentity)) throw new F9OperationException("资源标识不能为空"); + + Role role = null; + long rolePermissionId = 0; + + var ok = await unitOfWork.ExecuteWithUow(async () => + { + // 查或创建权限 + var dbPermission = await permissionWriteRepository.GetByIdAsync(command.PermissionType, command.ResourceType, command.ResourceId); + if (dbPermission == null) + { + dbPermission = new Permission( + snowflakeIdGenerator.CreateId(), + command.PermissionType, + command.ResourceType, + command.ResourceIdentity, + command.ResourceId); + + // 消息订阅权限:Code 对齐消息类型的 RequiredPermission(业务权限码),保证订阅校验一致 + if (command.ResourceType == Resource_Type_Enum.消息订阅) + { + var type = await notificationTypeRepository.GetByIdAsync(command.ResourceId); + if (type != null && !string.IsNullOrEmpty(type.RequiredPermission)) + dbPermission.SetCode(type.RequiredPermission); + } + + await permissionWriteRepository.AddRangeAsync(new[] { dbPermission }); + } + + // 绑定角色权限 + var rolePermission = new RolePermission(snowflakeIdGenerator.CreateId(), command.RoleId, dbPermission.Id); + var result = await rolePermissionRep.AssignAsync(rolePermission); + + role = await roleRepository.GetByIdAsync(command.RoleId); + role.AddPermission(dbPermission.Id, command.ResourceType); + + rolePermissionId = rolePermission.Id; + return result; + }); + + if (!ok) throw new F9OperationException("权限", "绑定"); + + // 发布领域事件 + foreach (var domainEvent in role.DomainEvents) + await eventBus.Publish(domainEvent, cancellationToken); + role.ClearDomainEvents(); + + return rolePermissionId; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/RolePermission_RemoveCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/RolePermission_RemoveCommand.cs new file mode 100644 index 0000000..f915a6c --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/RolePermission_RemoveCommand.cs @@ -0,0 +1,37 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Roles; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Roles.Commands; + +/// 命令定义 +public record RolePermission_RemoveCommand(long Id, Resource_Type_Enum ResourceType) : IRequest; + +/// 命令处理器 +public class RemoveRolePermissionCommandHandler( + IRoleWriteRepository roleRepository, + IRolePermissionWriteRepository rolePermissionWriteRepository, + IPublisher eventBus) : IRequestHandler +{ + public async Task Handle(RolePermission_RemoveCommand command, CancellationToken cancellationToken) + { + if (command.Id <= 0) throw new F9OperationException("角色权限ID不能为空"); + + var rolePermission = await rolePermissionWriteRepository.GetByIdAsync(command.Id); + if (rolePermission == null) throw new F9NotFoundException("角色权限", command.Id); + + var role = await roleRepository.GetByIdAsync(rolePermission.RoleId); + role.RemovePermission(rolePermission.PermissionId, command.ResourceType); + + var result = await rolePermissionWriteRepository.RemoveAsync(command.Id); + if (result == false) throw new F9OperationException("权限", "解绑"); + + // 发布领域事件 + foreach (var domainEvent in role.DomainEvents) + await eventBus.Publish(domainEvent, cancellationToken); + role.ClearDomainEvents(); + + return true; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_ActiveOrDeactivateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_ActiveOrDeactivateCommand.cs new file mode 100644 index 0000000..531581d --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_ActiveOrDeactivateCommand.cs @@ -0,0 +1,26 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Roles; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Roles.Commands; + +/// 命令定义 +public record Role_ActiveOrDeactivateCommand(long Id, bool Active) : IRequest; + +/// 命令处理器 +public class ActiveOrDeactivateRoleCommandHandler( + IRoleWriteRepository roleWriteRepository) : IRequestHandler +{ + public async Task Handle(Role_ActiveOrDeactivateCommand command, CancellationToken cancellationToken) + { + var role = await roleWriteRepository.GetByIdAsync(command.Id); + if (role == null) throw new F9NotFoundException("角色", command.Id); + + if (command.Active) role.Activate(); + else role.Deactivate(); + + var result = await roleWriteRepository.ActiveOrDeactive(command.Id, role.IsActive); + if (result == false) throw new F9OperationException("启用/禁用", "操作"); + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_CreateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_CreateCommand.cs new file mode 100644 index 0000000..f38a501 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_CreateCommand.cs @@ -0,0 +1,27 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Roles; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Roles.Commands; + +/// 命令定义 +public record Role_CreateCommand(string Name, string Code, int SortOrder, string Description) : IRequest; + +/// 命令处理器 +public class CreateRoleCommandHandler( + IRoleWriteRepository roleRepository, + ISnowflakeIdGenerator snowflakeIdGenerator) : IRequestHandler +{ + public async Task Handle(Role_CreateCommand command, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.Name)) throw new F9OperationException("角色名称不能为空"); + if (string.IsNullOrWhiteSpace(command.Code)) throw new F9OperationException("角色编码不能为空"); + + var newEntity = new Role(snowflakeIdGenerator.CreateId(), command.Name, command.Code, command.SortOrder, command.Description); + var result = await roleRepository.AddAsync(newEntity); + if (result == false) throw new F9OperationException("角色", "新增"); + return newEntity.Id; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_DelCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_DelCommand.cs new file mode 100644 index 0000000..575255b --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_DelCommand.cs @@ -0,0 +1,22 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Roles; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Roles.Commands; + +/// 命令定义 +public record Role_DelCommand(long Id) : IRequest; + +/// 命令处理器 +public class DelRoleCommandHandler( + IRoleWriteRepository roleWriteRepository) : IRequestHandler +{ + public async Task Handle(Role_DelCommand command, CancellationToken cancellationToken) + { + if (command.Id <= 0) throw new F9OperationException("角色ID不能为空"); + + var result = await roleWriteRepository.SoftDeleteAsync(command.Id); + if (result == false) throw new F9OperationException("角色", "删除"); + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_UpdateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_UpdateCommand.cs new file mode 100644 index 0000000..5fb17f1 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Commands/Role_UpdateCommand.cs @@ -0,0 +1,25 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Roles; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Roles.Commands; + +/// 命令定义 +public record Role_UpdateCommand(long Id, string Name, string Code, int SortOrder, string Description) : IRequest; + +/// 命令处理器 +public class UpdateRoleCommandHandler( + IRoleWriteRepository roleRepository) : IRequestHandler +{ + public async Task Handle(Role_UpdateCommand command, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.Name)) throw new F9OperationException("角色名称不能为空"); + if (string.IsNullOrWhiteSpace(command.Code)) throw new F9OperationException("角色编码不能为空"); + + var newEntity = new Role(command.Id, command.Name, command.Code, command.SortOrder, command.Description); + var result = await roleRepository.UpdateAsync(newEntity); + if (result == false) throw new F9OperationException("角色", "修改"); + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Queries/GetRoleAllQuery.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Queries/GetRoleAllQuery.cs new file mode 100644 index 0000000..d8564a5 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Queries/GetRoleAllQuery.cs @@ -0,0 +1,13 @@ +using F9WebAiTest.Application.Dtos.SYS.RBAC.Roles; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Roles; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Roles.Queries; + +public record GetRoleAllQuery() : IRequest>; + +public class GetRoleAllQueryHandler(IRoleReadRepository roleReadRepository) : IRequestHandler> +{ + public async Task> Handle(GetRoleAllQuery query, CancellationToken ct) + => await roleReadRepository.GetAllAsync(); +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Queries/GetRoleByIdForEditQuery.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Queries/GetRoleByIdForEditQuery.cs new file mode 100644 index 0000000..cccafbe --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Queries/GetRoleByIdForEditQuery.cs @@ -0,0 +1,13 @@ +using F9WebAiTest.Application.Dtos.SYS.RBAC.Roles; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Roles; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Roles.Queries; + +public record GetRoleByIdForEditQuery(long Id) : IRequest; + +public class GetRoleByIdForEditQueryHandler(IRoleReadRepository roleReadRepository) : IRequestHandler +{ + public async Task Handle(GetRoleByIdForEditQuery query, CancellationToken ct) + => await roleReadRepository.GetByIdAsync(query.Id); +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Queries/GetRolePermissionsByResourceTypeQuery.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Queries/GetRolePermissionsByResourceTypeQuery.cs new file mode 100644 index 0000000..088c289 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Roles/Queries/GetRolePermissionsByResourceTypeQuery.cs @@ -0,0 +1,14 @@ +using F9WebAiTest.Application.Dtos.SYS.RBAC.Roles; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Roles; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Roles.Queries; + +public record GetRolePermissionsByResourceTypeQuery(long RoleId, Resource_Type_Enum ResourceType) : IRequest>; + +public class GetRolePermissionsByResourceTypeQueryHandler(IRoleReadRepository roleReadRepository) : IRequestHandler> +{ + public async Task> Handle(GetRolePermissionsByResourceTypeQuery query, CancellationToken ct) + => await roleReadRepository.GetRolePermissionsByResourceType(query.RoleId, query.ResourceType); +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/UserRole_AssignCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/UserRole_AssignCommand.cs new file mode 100644 index 0000000..6d988b6 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/UserRole_AssignCommand.cs @@ -0,0 +1,40 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Commands; + +/// 命令定义 +public record UserRole_AssignCommand(long UserId, long RoleId) : IRequest; + +/// 命令处理器 +public class UserRole_AssignCommandHandler( + IUserRoleWriteRepository userRoleWriteRepository, + IUserWriteRepository userWriteRepository, + ISnowflakeIdGenerator snowflakeIdGenerator, + IPublisher eventBus) : IRequestHandler +{ + public async Task Handle(UserRole_AssignCommand command, CancellationToken cancellationToken) + { + if (command.UserId <= 0) throw new F9OperationException("用户ID不能为空"); + if (command.RoleId <= 0) throw new F9OperationException("角色ID不能为空"); + + var newEntity = new UserRole(snowflakeIdGenerator.CreateId(), command.UserId, command.RoleId); + + var user = await userWriteRepository.GetByIdAsync(command.UserId); + if (user == null) throw new F9NotFoundException("用户", command.UserId); + user.AddRole(newEntity.Id); + + var result = await userRoleWriteRepository.AssignAsync(newEntity); + if (result == false) throw new F9OperationException("权限", "绑定"); + + // 发布领域事件 + foreach (var domainEvent in user.DomainEvents) + await eventBus.Publish(domainEvent, cancellationToken); + user.ClearDomainEvents(); + + return newEntity.Id; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/UserRole_RemoveCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/UserRole_RemoveCommand.cs new file mode 100644 index 0000000..b85e0d6 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/UserRole_RemoveCommand.cs @@ -0,0 +1,35 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Commands; + +/// 命令定义 +public record UserRole_RemoveCommand(long UserId, long RoleId) : IRequest; + +/// 命令处理器 +public class UserRole_RemoveCommandHandler( + IUserWriteRepository userWriteRepository, + IUserRoleWriteRepository userRoleWriteRepository, + IPublisher eventBus) : IRequestHandler +{ + public async Task Handle(UserRole_RemoveCommand command, CancellationToken cancellationToken) + { + if (command.UserId <= 0) throw new F9OperationException("用户ID不能为空"); + if (command.RoleId <= 0) throw new F9OperationException("角色ID不能为空"); + + var user = await userWriteRepository.GetByIdAsync(command.UserId); + if (user == null) throw new F9NotFoundException("用户", command.UserId); + user.RemoveRole(command.RoleId); + + var result = await userRoleWriteRepository.RemoveAsync(command.UserId, command.RoleId); + if (result == false) throw new F9OperationException("权限", "解绑"); + + // 发布领域事件 + foreach (var domainEvent in user.DomainEvents) + await eventBus.Publish(domainEvent, cancellationToken); + user.ClearDomainEvents(); + + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_ActiveOrDeactivateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_ActiveOrDeactivateCommand.cs new file mode 100644 index 0000000..c9ba6c8 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_ActiveOrDeactivateCommand.cs @@ -0,0 +1,26 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Commands; + +/// 命令定义 +public record User_ActiveOrDeactivateCommand(long Id, bool Active) : IRequest; + +/// 命令处理器 +public class ActiveOrDeactivateUserCommandHandler( + IUserWriteRepository userWriteRepository) : IRequestHandler +{ + public async Task Handle(User_ActiveOrDeactivateCommand command, CancellationToken cancellationToken) + { + var user = await userWriteRepository.GetByIdAsync(command.Id); + if (user == null) throw new F9NotFoundException("用户", command.Id); + + if (command.Active) user.Activate(); + else user.Deactivate(); + + var result = await userWriteRepository.ActiveOrDeactive(command.Id, user.IsActive); + if (result == false) throw new F9OperationException("启用/禁用", "操作"); + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_CreateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_CreateCommand.cs new file mode 100644 index 0000000..47bd7e0 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_CreateCommand.cs @@ -0,0 +1,73 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.Interfaces.Security; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Application.Interfaces.UOW; +using F9WebAiTest.Domain.Entities.SYS.Departments; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.Departments; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Commands; + +/// 命令定义 +public record User_CreateCommand(string UserName, string PhoneNumber, string LoginName, long DepartmentId, int SortOrder) : IRequest; + +/// 命令处理器 +public class CreateUserCommandHandler( + IUnitOfWork_F9 unitOfWork, + IUserWriteRepository userRepository, + IDepartmentWriteRepository departmentWriteRepository, + IDepartmentUserRepository departmentUserRepository, + IPasswordHasher passwordHasher, + ISnowflakeIdGenerator snowflakeIdGenerator, + IPublisher eventBus) : IRequestHandler +{ + public async Task Handle(User_CreateCommand command, CancellationToken cancellationToken) + { + // 手写校验 + if (string.IsNullOrWhiteSpace(command.UserName)) throw new F9OperationException("用户名不能为空"); + if (string.IsNullOrWhiteSpace(command.LoginName)) throw new F9OperationException("登录名不能为空"); + if (command.DepartmentId <= 0) throw new F9OperationException("部门不能为空"); + + User createdUser = null; + var ok = await unitOfWork.ExecuteWithUow(async () => + { + // 1. 验证部门是否存在(应用层协调) + var department = await departmentWriteRepository.GetByIdAsync(command.DepartmentId, cancellationToken); + if (department == null) throw new F9NotFoundException("部门不存在"); + + // 2. 创建用户聚合根 + var user = new User( + id: snowflakeIdGenerator.CreateId(), + command.UserName, + command.PhoneNumber, + command.LoginName, + command.DepartmentId, + command.SortOrder); + + // 设置默认密码 + var defaultPasswordMd5 = passwordHasher.GetMd5Hash(user.GetDefaultPasswordText()); + var passwordHash = passwordHasher.HashPassword(defaultPasswordMd5); + user.SetPassword(passwordHash); + + await userRepository.AddAsync(user); + + // 3. 创建部门用户关联 + var depUser = new DepartmentUser(snowflakeIdGenerator.CreateId(), command.DepartmentId, user.Id); + await departmentUserRepository.AssignAsync(depUser); + + createdUser = user; + return true; + }); + + if (!ok) throw new F9OperationException("用户", "新增"); + + // 4. 事务提交后发布领域事件 + foreach (var domainEvent in createdUser.DomainEvents) + await eventBus.Publish(domainEvent, cancellationToken); + createdUser.ClearDomainEvents(); + + return createdUser.Id; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_DelCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_DelCommand.cs new file mode 100644 index 0000000..f63918e --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_DelCommand.cs @@ -0,0 +1,22 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Commands; + +/// 命令定义 +public record User_DelCommand(long Id) : IRequest; + +/// 命令处理器 +public class DelUserCommandHandler( + IUserWriteRepository userWriteRepository) : IRequestHandler +{ + public async Task Handle(User_DelCommand command, CancellationToken cancellationToken) + { + if (command.Id <= 0) throw new F9OperationException("用户ID不能为空"); + + var result = await userWriteRepository.SoftDeleteAsync(command.Id); + if (result == false) throw new F9OperationException("用户", "删除"); + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_ResetPasswordCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_ResetPasswordCommand.cs new file mode 100644 index 0000000..f320b3e --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_ResetPasswordCommand.cs @@ -0,0 +1,32 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.Interfaces.Security; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Commands; + +/// 命令定义 +public record User_ResetPasswordCommand(long Id) : IRequest; + +/// 命令处理器 +public class ResetPasswordUserCommandHandler( + IUserWriteRepository userWriteRepository, + IPasswordHasher passwordHasher) : IRequestHandler +{ + public async Task Handle(User_ResetPasswordCommand command, CancellationToken cancellationToken) + { + if (command.Id <= 0) throw new F9OperationException("用户ID不能为空"); + + var dbUser = await userWriteRepository.GetByIdAsync(command.Id); + if (dbUser == null) throw new F9NotFoundException("用户", command.Id); + + // 设置默认密码 + var defaultPasswordMd5 = passwordHasher.GetMd5Hash(dbUser.GetDefaultPasswordText()); + var passwordHash = passwordHasher.HashPassword(defaultPasswordMd5); + dbUser.SetPassword(passwordHash); + + var result = await userWriteRepository.UpdatePassworkdAsync(command.Id, dbUser.PasswordHash); + if (result == false) throw new F9OperationException("用户", "重置密码"); + return result; + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_UpdateCommand.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_UpdateCommand.cs new file mode 100644 index 0000000..3dc4afc --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Commands/User_UpdateCommand.cs @@ -0,0 +1,49 @@ +using F9WebAiTest.Application.Common.Exceptions; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Application.Interfaces.UOW; +using F9WebAiTest.Domain.Entities.SYS.Departments; +using F9WebAiTest.Domain.Interfaces.SYS.Departments; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Commands; + +/// 命令定义 +public record User_UpdateCommand(long Id, string UserName, string PhoneNumber, string LoginName, long DepartmentId, int SortOrder) : IRequest; + +/// 命令处理器 +public class User_UpdateCommandHandler( + IUnitOfWork_F9 unitOfWork, + ISnowflakeIdGenerator snowflakeIdGenerator, + IUserWriteRepository userWriteRepository, + IDepartmentUserRepository departmentUserRepository) : IRequestHandler +{ + public async Task Handle(User_UpdateCommand command, CancellationToken cancellationToken) + { + if (command.Id <= 0) throw new F9OperationException("用户ID不能为空"); + if (string.IsNullOrWhiteSpace(command.UserName)) throw new F9OperationException("用户名不能为空"); + if (command.DepartmentId <= 0) throw new F9OperationException("部门不能为空"); + + return await unitOfWork.ExecuteWithUow(async () => + { + // 获取数据库用户 + var dbUser = await userWriteRepository.GetByIdAsync(command.Id); + if (dbUser == null) throw new F9NotFoundException("用户", command.Id); + + // 移除原部门关系 + await departmentUserRepository.RemoveAsync(dbUser.DepartmentId, dbUser.Id); + + // 领域更新 + dbUser.Update(command.UserName, command.PhoneNumber, command.LoginName, command.DepartmentId, command.SortOrder); + + // 持久化 + var result = await userWriteRepository.UpdateAsync(dbUser); + + // 添加新部门关系 + await departmentUserRepository.AssignAsync(new DepartmentUser( + snowflakeIdGenerator.CreateId(), dbUser.DepartmentId, dbUser.Id)); + + return result; + }); + } +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserByIdQuery.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserByIdQuery.cs new file mode 100644 index 0000000..54c349a --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserByIdQuery.cs @@ -0,0 +1,13 @@ +using F9WebAiTest.Application.Dtos.SYS.RBAC.Users; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Users; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Queries; + +public record GetUserByIdQuery(long id) : IRequest; + +public class GetUserByIdQueryHandler(IUserReadRepository userReadRepository) : IRequestHandler +{ + public async Task Handle(GetUserByIdQuery query, CancellationToken ct) + => await userReadRepository.GetByIdAsync(query.id); +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserByIdsQuery.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserByIdsQuery.cs new file mode 100644 index 0000000..e17675b --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserByIdsQuery.cs @@ -0,0 +1,13 @@ +using F9WebAiTest.Application.Dtos.SYS.RBAC.Users; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Users; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Queries; + +public record GetUserByIdsQuery(List Ids) : IRequest>; + +public class GetUserByIdsQueryHandler(IUserReadRepository userReadRepository) : IRequestHandler> +{ + public async Task> Handle(GetUserByIdsQuery query, CancellationToken ct) + => await userReadRepository.GetByIdsAsync(query.Ids); +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserPagedQuery.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserPagedQuery.cs new file mode 100644 index 0000000..d426f30 --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserPagedQuery.cs @@ -0,0 +1,14 @@ +using F9WebAiTest.Application.Dtos.Common; +using F9WebAiTest.Application.Dtos.SYS.RBAC.Users; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Users; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Queries; + +public record GetUserPagedQuery(long DepartmentId, string Like, int Page = 1, int PageSize = 10) : IRequest>; + +public class GetUserPagedQueryHandler(IUserReadRepository userReadRepository) : IRequestHandler> +{ + public async Task> Handle(GetUserPagedQuery query, CancellationToken ct) + => await userReadRepository.GetPagedAsync(query.DepartmentId, query.Like, query.Page, query.PageSize); +} diff --git a/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserRolesByUserIdQuery.cs b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserRolesByUserIdQuery.cs new file mode 100644 index 0000000..1262b7b --- /dev/null +++ b/backend/F9WebAiTest.Application/CQRS/SYS/RBAC/Users/Queries/GetUserRolesByUserIdQuery.cs @@ -0,0 +1,13 @@ +using F9WebAiTest.Application.Dtos.SYS.RBAC.Users; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Users; +using MediatR; + +namespace F9WebAiTest.Application.CQRS.SYS.RBAC.Users.Queries; + +public record GetUserRolesByUserIdQuery(long UserId) : IRequest>; + +public class GetUserRolesByUserIdQueryHandler(IUserReadRepository userReadRepository) : IRequestHandler> +{ + public async Task> Handle(GetUserRolesByUserIdQuery query, CancellationToken ct) + => await userReadRepository.GetUserRolesByUserId(query.UserId); +} diff --git a/backend/F9WebAiTest.Application/Common/ApiResult.cs b/backend/F9WebAiTest.Application/Common/ApiResult.cs new file mode 100644 index 0000000..74b02a2 --- /dev/null +++ b/backend/F9WebAiTest.Application/Common/ApiResult.cs @@ -0,0 +1,24 @@ +namespace F9WebAiTest.Application.Common; + +/// 统一 API 响应 +public class ApiResult +{ + public int Code { get; set; } + public string Message { get; set; } + public T Data { get; set; } + + public static ApiResult Ok(T data, string message = "success") + => new() { Code = 200, Message = message, Data = data }; + + public static ApiResult Fail(string message, int code = 400) + => new() { Code = code, Message = message, Data = default }; +} + +/// 分页结果 +public class PageResult +{ + public List Items { get; set; } = new(); + public long Total { get; set; } + public int PageIndex { get; set; } + public int PageSize { get; set; } +} diff --git a/backend/F9WebAiTest.Application/Common/Exceptions/F9NotFoundException.cs b/backend/F9WebAiTest.Application/Common/Exceptions/F9NotFoundException.cs new file mode 100644 index 0000000..8ccb79a --- /dev/null +++ b/backend/F9WebAiTest.Application/Common/Exceptions/F9NotFoundException.cs @@ -0,0 +1,12 @@ +namespace F9WebAiTest.Application.Common.Exceptions; + +/// 资源不存在异常 +public class F9NotFoundException : Exception +{ + public F9NotFoundException(string resourceName, object key) + : base($"{resourceName}不存在'{key}'。") + { + } + + public F9NotFoundException(string message) : base(message) { } +} diff --git a/backend/F9WebAiTest.Application/Common/Exceptions/F9OperationException.cs b/backend/F9WebAiTest.Application/Common/Exceptions/F9OperationException.cs new file mode 100644 index 0000000..f58b11d --- /dev/null +++ b/backend/F9WebAiTest.Application/Common/Exceptions/F9OperationException.cs @@ -0,0 +1,15 @@ +namespace F9WebAiTest.Application.Common.Exceptions; + +/// 操作失败异常 +public class F9OperationException : Exception +{ + /// 操作失败 + /// 资源名称 + /// 操作名称 + public F9OperationException(string resourceName, string oporation) + : base($"{resourceName}{oporation}失败。") + { + } + + public F9OperationException(string message) : base(message) { } +} diff --git a/backend/F9WebAiTest.Application/Common/ServiceException.cs b/backend/F9WebAiTest.Application/Common/ServiceException.cs new file mode 100644 index 0000000..1cf0b7a --- /dev/null +++ b/backend/F9WebAiTest.Application/Common/ServiceException.cs @@ -0,0 +1,12 @@ +namespace F9WebAiTest.Application.Common; + +/// 业务异常 +public class ServiceException : Exception +{ + public int Code { get; set; } = 400; + + public ServiceException(string message, int code = 400) : base(message) + { + Code = code; + } +} diff --git a/backend/F9WebAiTest.Application/Contracts/LoginUserInfo.cs b/backend/F9WebAiTest.Application/Contracts/LoginUserInfo.cs new file mode 100644 index 0000000..fbbc980 --- /dev/null +++ b/backend/F9WebAiTest.Application/Contracts/LoginUserInfo.cs @@ -0,0 +1,9 @@ +namespace F9WebAiTest.Application.Contracts; + +/// 登录用户信息 +/// 识别码 +/// 用户名称 +/// 登录名 +/// 所属部门Id +/// 是否开发者 +public record LoginUserInfo(long Id, string Name, string Login_Account, long Department_Id, bool IsDeveloper); diff --git a/backend/F9WebAiTest.Application/DependencyInjection.cs b/backend/F9WebAiTest.Application/DependencyInjection.cs new file mode 100644 index 0000000..3c822dd --- /dev/null +++ b/backend/F9WebAiTest.Application/DependencyInjection.cs @@ -0,0 +1,33 @@ +using F9WebAiTest.Application.Services; +using F9WebAiTest.Application.Services.Attendance; +using F9WebAiTest.Application.Services.Auth; +using F9WebAiTest.Application.Services.Base; +using F9WebAiTest.Application.Services.Notice; +using F9WebAiTest.Application.Services.Prod; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace F9WebAiTest.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // MediatR:注册命令/查询 Handler + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(NotificationService).Assembly)); + + return services; + } +} diff --git a/backend/F9WebAiTest.Application/Dtos/Attendance/AttendanceDtos.cs b/backend/F9WebAiTest.Application/Dtos/Attendance/AttendanceDtos.cs new file mode 100644 index 0000000..3058471 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/Attendance/AttendanceDtos.cs @@ -0,0 +1,87 @@ +namespace F9WebAiTest.Application.Dtos.Attendance; + +public class AttendanceDto +{ + public long Id { get; set; } + public long EmployeeId { get; set; } + public string EmployeeName { get; set; } + public string DepartmentName { get; set; } + public DateTime WorkDate { get; set; } + public DateTime? SignInTime { get; set; } + public DateTime? SignOutTime { get; set; } + public string SignInStatus { get; set; } + public string SignOutStatus { get; set; } + public string Remark { get; set; } +} + +public class PunchResultDto +{ + public string Type { get; set; } // signIn / signOut + public DateTime Time { get; set; } + public string Status { get; set; } // 正常/迟到/早退 + public string Message { get; set; } +} + +public class AttendanceQueryDto +{ + public string Date { get; set; } = ""; // yyyy-MM-dd + public long? DepartmentId { get; set; } + public string Keyword { get; set; } = ""; + public int PageIndex { get; set; } = 1; + public int PageSize { get; set; } = 10; +} + +public class AttendanceSummaryDto +{ + public int NormalDays { get; set; } + public int LateDays { get; set; } + public int EarlyLeaveDays { get; set; } + public int AbsentDays { get; set; } + public int LeaveDays { get; set; } + public int TotalDays { get; set; } +} + +public class LeaveRequestDto +{ + public long Id { get; set; } + public long EmployeeId { get; set; } + public string EmployeeName { get; set; } + public string DepartmentName { get; set; } + public string Type { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public string Reason { get; set; } + public string Status { get; set; } + public int CurrentOrderNo { get; set; } // 当前审批到第几步 + public string ApproverName { get; set; } + public string ApproveRemark { get; set; } + public DateTime? ApprovedAt { get; set; } + public DateTime CreatedAt { get; set; } + public List Approvers { get; set; } = new(); // 审批流程节点 +} + +public class LeaveApproverDto +{ + public long Id { get; set; } + public long ApproverId { get; set; } + public string ApproverName { get; set; } + public int OrderNo { get; set; } // 序号(审批顺序) + public string Status { get; set; } // 待审批/已批准/已驳回 + public string ApproveRemark { get; set; } + public DateTime? ApprovedAt { get; set; } +} + +public class CreateLeaveDto +{ + public string Type { get; set; } = "事假"; + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public string Reason { get; set; } = ""; + public List ApproverIds { get; set; } = new(); // 审批人ID列表(顺序=审批顺序) +} + +public class ApproveLeaveDto +{ + public bool Approved { get; set; } + public string Remark { get; set; } = ""; +} diff --git a/backend/F9WebAiTest.Application/Dtos/Auth/AuthDtos.cs b/backend/F9WebAiTest.Application/Dtos/Auth/AuthDtos.cs new file mode 100644 index 0000000..911ee94 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/Auth/AuthDtos.cs @@ -0,0 +1,50 @@ +namespace F9WebAiTest.Application.Dtos.Auth; + +public class LoginDto +{ + public string Username { get; set; } + public string Password { get; set; } +} + +public class LoginResultDto +{ + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public DateTime AccessTokenExpiresAt { get; set; } + public EmployeeInfoDto User { get; set; } +} + +public class RefreshTokenDto +{ + public string RefreshToken { get; set; } +} + +public class EmployeeInfoDto +{ + public long Id { get; set; } + public string Username { get; set; } + public string RealName { get; set; } + public string Nickname { get; set; } + public string Avatar { get; set; } + public string Phone { get; set; } + public string Email { get; set; } + public long DepartmentId { get; set; } + public string DepartmentName { get; set; } + public int Status { get; set; } + public List Roles { get; set; } = new(); + public List Permissions { get; set; } = new(); +} + +public class ChangePasswordDto +{ + public string OldPassword { get; set; } + public string NewPassword { get; set; } +} + +public class UpdateProfileDto +{ + public string Nickname { get; set; } + public string Avatar { get; set; } + public string Phone { get; set; } + public string Email { get; set; } +} diff --git a/backend/F9WebAiTest.Application/Dtos/Base/BusinessDtos.cs b/backend/F9WebAiTest.Application/Dtos/Base/BusinessDtos.cs new file mode 100644 index 0000000..d4b0df3 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/Base/BusinessDtos.cs @@ -0,0 +1,86 @@ +namespace F9WebAiTest.Application.Dtos.Base; + +// ---- 工艺庄口 ---- +public class CraftZhuangKouDto +{ + public long Id { get; set; } + public int BelongingYear { get; set; } + public int BendiWaidi { get; set; } + public string BendiWaidiName { get; set; } + public string JidiWaigou { get; set; } + public string ZhuangKouCode { get; set; } + public string ZhuangKouName { get; set; } + public string ProductionDisplayName { get; set; } + public string Chandi { get; set; } + public int Sort { get; set; } + public string Memo { get; set; } + public string IsJiezhuang { get; set; } +} + +public class CraftZhuangKouCreateDto +{ + public int BelongingYear { get; set; } + public int BendiWaidi { get; set; } + public string JidiWaigou { get; set; } = ""; + public string ZhuangKouCode { get; set; } = ""; + public string ZhuangKouName { get; set; } = ""; + public string ProductionDisplayName { get; set; } = ""; + public string Chandi { get; set; } = ""; + public int Sort { get; set; } + public string Memo { get; set; } = ""; + public string IsJiezhuang { get; set; } = "0"; +} + +// ---- 生丝规格 ---- +public class ShengsiGuigeDto +{ + public long Id { get; set; } + public string Guige { get; set; } + public double CenterrangeLolimit { get; set; } + public double CenterrangeUplimit { get; set; } + public double CenterXiandu { get; set; } + public double ValidLolimit { get; set; } + public double ValidUplimit { get; set; } + public double XiaoyeLolimit { get; set; } + public double XiaoyeUplimit { get; set; } + public double DayeLolimit { get; set; } + public double DayeUplimit { get; set; } + public double TeyeLolimit { get; set; } + public double TeyeUplimit { get; set; } + public double YexianNkLolimitLeft { get; set; } + public double YexianNkLolimitRight { get; set; } + public double YexianNkUplimitLeft { get; set; } + public double YexianNkUplimitRight { get; set; } + public double LaodongJingsaiYexianLolimit { get; set; } + public double LaodongJingsaiYexianUplimit { get; set; } + public int Sort { get; set; } + public string Memo { get; set; } +} + +// ---- 生丝定级指标 ---- +public class ShengsiDingjizhibiaoDto +{ + public long Id { get; set; } + public int Standard { get; set; } + public string StandardName { get; set; } + public string Guige { get; set; } + public string Grade { get; set; } + public double GradeScore { get; set; } + public double Piancha { get; set; } + public double Dacha { get; set; } + public double Erdu { get; set; } + public double Sandu { get; set; } + public double Qingjie { get; set; } + public double Jiejing { get; set; } +} + +// ---- 机组车台 ---- +public class JizuChetaiDto +{ + public long Id { get; set; } + public long DepartmentId { get; set; } + public string DepartmentName { get; set; } + public int Jizu { get; set; } + public int ChetaiStart { get; set; } + public int ChetaiEnd { get; set; } +} diff --git a/backend/F9WebAiTest.Application/Dtos/Common/PageRequest.cs b/backend/F9WebAiTest.Application/Dtos/Common/PageRequest.cs new file mode 100644 index 0000000..23e743f --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/Common/PageRequest.cs @@ -0,0 +1,3 @@ +namespace F9WebAiTest.Application.Dtos.Common; + +public record PageRequest(int Page = 1, int PageSize = 10); diff --git a/backend/F9WebAiTest.Application/Dtos/Common/PagedResult.cs b/backend/F9WebAiTest.Application/Dtos/Common/PagedResult.cs new file mode 100644 index 0000000..d2f120f --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/Common/PagedResult.cs @@ -0,0 +1,10 @@ +namespace F9WebAiTest.Application.Dtos.Common; + +public record PagedResult( + List Items, + int Page, + int PageSize, + long TotalCount) +{ + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); +} diff --git a/backend/F9WebAiTest.Application/Dtos/DepartmentDtos.cs b/backend/F9WebAiTest.Application/Dtos/DepartmentDtos.cs new file mode 100644 index 0000000..99c804b --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/DepartmentDtos.cs @@ -0,0 +1,33 @@ +namespace F9WebAiTest.Application.Dtos; + +public class DepartmentDto +{ + public long Id { get; set; } + public string Name { get; set; } + public long? ParentId { get; set; } + public long? LeaderId { get; set; } + public string LeaderName { get; set; } + public string Phone { get; set; } + public int Sort { get; set; } + public int Status { get; set; } + public List Children { get; set; } = new(); +} + +public class CreateDepartmentDto +{ + public string Name { get; set; } + public long? ParentId { get; set; } + public long? LeaderId { get; set; } + public string Phone { get; set; } = ""; + public int Sort { get; set; } +} + +public class UpdateDepartmentDto +{ + public string Name { get; set; } + public long? ParentId { get; set; } + public long? LeaderId { get; set; } + public string Phone { get; set; } = ""; + public int Sort { get; set; } + public int Status { get; set; } +} diff --git a/backend/F9WebAiTest.Application/Dtos/EmployeeDtos.cs b/backend/F9WebAiTest.Application/Dtos/EmployeeDtos.cs new file mode 100644 index 0000000..5982f92 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/EmployeeDtos.cs @@ -0,0 +1,61 @@ +namespace F9WebAiTest.Application.Dtos; + +public class EmployeeDto +{ + public long Id { get; set; } + public string Username { get; set; } + public string RealName { get; set; } + public string Nickname { get; set; } + public string Avatar { get; set; } + public string Gender { get; set; } + public string Phone { get; set; } + public string Email { get; set; } + public long DepartmentId { get; set; } + public string DepartmentName { get; set; } + public int Status { get; set; } + public string Remark { get; set; } + public List RoleIds { get; set; } = new(); + public List RoleNames { get; set; } = new(); + public DateTime CreatedAt { get; set; } +} + +public class CreateEmployeeDto +{ + public string Username { get; set; } + public string Password { get; set; } + public string RealName { get; set; } + public string Nickname { get; set; } = ""; + public string Gender { get; set; } = "男"; + public string Phone { get; set; } = ""; + public string Email { get; set; } = ""; + public long DepartmentId { get; set; } + public string Remark { get; set; } = ""; + public List RoleIds { get; set; } = new(); +} + +public class UpdateEmployeeDto +{ + public string RealName { get; set; } + public string Nickname { get; set; } = ""; + public string Gender { get; set; } = "男"; + public string Phone { get; set; } = ""; + public string Email { get; set; } = ""; + public long DepartmentId { get; set; } + public int Status { get; set; } = 1; + public string Remark { get; set; } = ""; + public List RoleIds { get; set; } = new(); +} + +public class ResetPasswordDto +{ + public string NewPassword { get; set; } +} + +public class EmployeeQueryDto +{ + public string Keyword { get; set; } = ""; + public long? DepartmentId { get; set; } + public int? Status { get; set; } + public int PageIndex { get; set; } = 1; + public int PageSize { get; set; } = 10; +} diff --git a/backend/F9WebAiTest.Application/Dtos/MenuDtos.cs b/backend/F9WebAiTest.Application/Dtos/MenuDtos.cs new file mode 100644 index 0000000..3674d45 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/MenuDtos.cs @@ -0,0 +1,41 @@ +namespace F9WebAiTest.Application.Dtos; + +public class MenuDto +{ + public long Id { get; set; } + public long? ParentId { get; set; } + public string Name { get; set; } + public string Type { get; set; } + public string Path { get; set; } + public string Component { get; set; } + public string Icon { get; set; } + public string Permission { get; set; } + public int Sort { get; set; } + public int Status { get; set; } + public List Children { get; set; } = new(); +} + +public class CreateMenuDto +{ + public long? ParentId { get; set; } + public string Name { get; set; } + public string Type { get; set; } = "menu"; + public string Path { get; set; } = ""; + public string Component { get; set; } = ""; + public string Icon { get; set; } = ""; + public string Permission { get; set; } = ""; + public int Sort { get; set; } +} + +public class UpdateMenuDto +{ + public long? ParentId { get; set; } + public string Name { get; set; } + public string Type { get; set; } = "menu"; + public string Path { get; set; } = ""; + public string Component { get; set; } = ""; + public string Icon { get; set; } = ""; + public string Permission { get; set; } = ""; + public int Sort { get; set; } + public int Status { get; set; } = 1; +} diff --git a/backend/F9WebAiTest.Application/Dtos/Notice/NotificationDtos.cs b/backend/F9WebAiTest.Application/Dtos/Notice/NotificationDtos.cs new file mode 100644 index 0000000..b095e2d --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/Notice/NotificationDtos.cs @@ -0,0 +1,96 @@ +using F9WebAiTest.Domain.Entities.Notice; + +namespace F9WebAiTest.Application.Dtos.Notice; + +// ---- 消息类型 ---- +public class NotificationTypeDto +{ + public long Id { get; set; } + public string Code { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string RequiredPermission { get; set; } + public bool IsActive { get; set; } + public long CreatedBy { get; set; } + public int Sort { get; set; } + public int Category { get; set; } // 消息分类:0通知 1待办 2公告 + public string Icon { get; set; } = ""; // 图标 + public string RouteTemplate { get; set; } = ""; // Web 端跳转路由模板 + public bool Subscribed { get; set; } // 当前用户是否已订阅 + public bool CanSubscribe { get; set; } = true; // 当前用户是否有权限订阅 +} + +public class NotificationTypeCreateDto +{ + public string Code { get; set; } = ""; + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public string RequiredPermission { get; set; } = ""; + public bool IsActive { get; set; } = true; + public int Sort { get; set; } + public int Category { get; set; } // 消息分类:0通知 1待办 2公告 + public string Icon { get; set; } = ""; + public string RouteTemplate { get; set; } = ""; // Web 端跳转路由模板,支持 {businessId} 占位符 +} + +// ---- 订阅 ---- +public class SubscribeDto +{ + public long NotificationTypeId { get; set; } + public bool Subscribe { get; set; } = true; // true=订阅 false=取消 +} + +// ---- 消息 ---- +public class NotificationMessageDto +{ + public long Id { get; set; } + public long NotificationTypeId { get; set; } + public string MessageTypeCode { get; set; } + public string TypeName { get; set; } + public string Title { get; set; } + public string Content { get; set; } + public int Priority { get; set; } + public string BusinessType { get; set; } + public string BusinessId { get; set; } + public int Category { get; set; } // 消息分类:0通知 1待办 2公告 + public string RoutePath { get; set; } = ""; // Web 端跳转路由(点击消息时跳转) + public DateTime CreatedTime { get; set; } + public int ReadStatus { get; set; } // 当前用户阅读状态 0=未读 1=已读 + public DateTime? ReadTime { get; set; } +} + +public class UnreadCountDto +{ + public int Count { get; set; } +} + +// ---- 待办 ---- +public class TodoItemDto +{ + public long Id { get; set; } + public long MessageId { get; set; } + public string Title { get; set; } = ""; + public string Content { get; set; } = ""; + public int Priority { get; set; } + public string BusinessType { get; set; } = ""; + public string BusinessId { get; set; } = ""; + public string RoutePath { get; set; } = ""; + public int TodoStatus { get; set; } // 0待处理 1处理中 2已完成 3已取消 + public DateTime? DueTime { get; set; } + public DateTime? CompletedTime { get; set; } + public DateTime CreatedTime { get; set; } +} + +public class CompleteTodoDto +{ + public string HandleResult { get; set; } = ""; // 处理结果说明 +} + +// ---- 广播通知 ---- +public class BroadcastNotificationDto +{ + public string Title { get; set; } = ""; + public string Content { get; set; } = ""; + public NotificationPriority? Priority { get; set; } + public string RoutePath { get; set; } = ""; +} diff --git a/backend/F9WebAiTest.Application/Dtos/Prod/HbjyDtos.cs b/backend/F9WebAiTest.Application/Dtos/Prod/HbjyDtos.cs new file mode 100644 index 0000000..fef876c --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/Prod/HbjyDtos.cs @@ -0,0 +1,86 @@ +namespace F9WebAiTest.Application.Dtos.Prod; + +// ---- 黑板检验 ---- + +/// 黑板检验列表行(落丝记录 + 检验汇总) +public class HbjyListItemDto +{ + public long LsjlId { get; set; } + public DateTime WorkDate { get; set; } + public long DeptId { get; set; } + public string DeptName { get; set; } + public int Jizu { get; set; } + public int Huishu { get; set; } + public string Guige { get; set; } + public string ZhuangKouName { get; set; } + public string Yanse { get; set; } + public bool Isbanche { get; set; } + public string IsbancheName { get; set; } + public string LuosiTime { get; set; } + public int Chetaishu { get; set; } + public int ChetaiStart { get; set; } + public int ChetaiEnd { get; set; } + public int YingluPianshu { get; set; } // 应录片数 = 车台数 × 20 + public bool IsChecked { get; set; } // 是否已检验 + public int Pianshu { get; set; } // 已录片数 + public double Qingjie { get; set; } // 清洁分 + public double Jiejing { get; set; } // 洁净分 + public string Dengji { get; set; } // 等级 +} + +/// 黑板检验编辑页数据 +public class HbjyEditDto +{ + public long LsjlId { get; set; } + public string DeptName { get; set; } + public int Jizu { get; set; } + public int Huishu { get; set; } + public string ZhuangKouName { get; set; } + public string Yanse { get; set; } + public bool Isbanche { get; set; } + public string IsbancheName { get; set; } + public string Guige { get; set; } + public int ChetaiStart { get; set; } + public int ChetaiEnd { get; set; } + public int Chetaishu { get; set; } + public int YingluPianshu { get; set; } // 应录片数 + public string Dengji { get; set; } // 等级 + public List Records { get; set; } = new(); +} + +/// 黑板检验车台明细 DTO +public class HbjyRecordDto +{ + public int Chetaihao { get; set; } + public int YdYidu { get; set; } + public int YdErdu { get; set; } + public int YdSandu { get; set; } + public int Jj100 { get; set; } + public int Jj95 { get; set; } + public int Jj90 { get; set; } + public int Jj85 { get; set; } + public int Jj80 { get; set; } + public int Jj75 { get; set; } + public int Jj70 { get; set; } + public int Jj65 { get; set; } + public int Jj60 { get; set; } + public int QjZycdTdc { get; set; } + public int QjCycdFs { get; set; } + public int QjCycdDc { get; set; } + public int QjCycdNfc { get; set; } + public int QjCycdDcj { get; set; } + public int QjCycdZlx { get; set; } + public int QjPtcdXc { get; set; } + public int QjPtcdCj { get; set; } + public int QjPtcdLx { get; set; } + public int QjPtcdHjls { get; set; } + public int Pianshu { get; set; } // 本车台片数(计算得出) +} + +/// 黑板检验保存请求 +public class HbjySaveDto +{ + public long LsjlId { get; set; } + public string Dengji { get; set; } = ""; + public List Records { get; set; } = new(); +} diff --git a/backend/F9WebAiTest.Application/Dtos/Prod/LsjlDtos.cs b/backend/F9WebAiTest.Application/Dtos/Prod/LsjlDtos.cs new file mode 100644 index 0000000..76dbc2a --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/Prod/LsjlDtos.cs @@ -0,0 +1,41 @@ +namespace F9WebAiTest.Application.Dtos.Prod; + +public class LsjlDto +{ + public long Id { get; set; } + public DateTime WorkDate { get; set; } + public long DeptId { get; set; } + public string DeptName { get; set; } + public long GyzkId { get; set; } + public string ZhuangKouName { get; set; } + public int Jizu { get; set; } + public string LuosiTime { get; set; } + public string Yanse { get; set; } + public int Huishu { get; set; } + public int ChetaiStart { get; set; } + public int ChetaiEnd { get; set; } + public int Chetaishu { get; set; } + public bool Isbanche { get; set; } + public string IsbancheName { get; set; } + public double Hours { get; set; } + public string Guige { get; set; } + public double Chesu { get; set; } +} + +public class LsjlCreateDto +{ + public DateTime WorkDate { get; set; } + public long DeptId { get; set; } + public long GyzkId { get; set; } + public int Jizu { get; set; } + public string LuosiTime { get; set; } = ""; + public string Yanse { get; set; } = ""; + public int Huishu { get; set; } + public int ChetaiStart { get; set; } + public int ChetaiEnd { get; set; } + public int Chetaishu { get; set; } + public bool Isbanche { get; set; } + public double Hours { get; set; } + public string Guige { get; set; } = ""; + public double Chesu { get; set; } +} diff --git a/backend/F9WebAiTest.Application/Dtos/Prod/XdjyDtos.cs b/backend/F9WebAiTest.Application/Dtos/Prod/XdjyDtos.cs new file mode 100644 index 0000000..f8f0af9 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/Prod/XdjyDtos.cs @@ -0,0 +1,93 @@ +using F9WebAiTest.Application.Dtos.Base; + +namespace F9WebAiTest.Application.Dtos.Prod; + +// ---- 纤度检验 ---- + +/// 纤度检验列表行(落丝记录 + 检验汇总) +public class XdjyListItemDto +{ + public long LsjlId { get; set; } // 落丝记录 Id + public DateTime WorkDate { get; set; } // 日期 + public long DeptId { get; set; } + public string DeptName { get; set; } + public int Jizu { get; set; } // 机组 + public int Huishu { get; set; } // 回数 + public string Guige { get; set; } // 规格 + public string ZhuangKouName { get; set; } // 庄口 + public string Yanse { get; set; } // 颜色 + public bool Isbanche { get; set; } // 半车 + public string IsbancheName { get; set; } + public int Chetaishu { get; set; } // 车台数 + public int ChetaiStart { get; set; } + public int ChetaiEnd { get; set; } + public string LuosiTime { get; set; } // 落丝时间 + public int Yingchengnum { get; set; } // 应秤数 = 车台数 × (半车?2:4) + public bool IsChecked { get; set; } // 是否已检验 + public int Jiaoshu { get; set; } // 已秤数 + public double Avgxiandu { get; set; } // 平均纤度 + public double Junfangcha { get; set; } // 均方差 + public double Zongcha { get; set; } // 总差 + public double FanxianLv { get; set; } // 范纤率 + public int FanweiNum { get; set; } // 范围数 + public int XiaoyeNum { get; set; } // 小野数 + public int DayeNum { get; set; } // 大野数 + public int TeyeNum { get; set; } // 特野数 + public int CenterNum { get; set; } // 中心数 + public string Dengji { get; set; } // 等级 +} + +/// 纤度检验编辑页数据 +public class XdjyEditDto +{ + public long LsjlId { get; set; } + public string DeptName { get; set; } + public int Jizu { get; set; } + public int Huishu { get; set; } + public string ZhuangKouName { get; set; } + public string Yanse { get; set; } + public bool Isbanche { get; set; } + public string IsbancheName { get; set; } + public string Guige { get; set; } + public int ChetaiStart { get; set; } + public int ChetaiEnd { get; set; } + public int Chetaishu { get; set; } + public int Yingchengnum { get; set; } // 应秤数 + public string Dengji { get; set; } // 等级 + public ShengsiGuigeDto GuigeConfig { get; set; } // 生丝规格(用于判定/颜色标识) + public List Xdjy { get; set; } = new(); +} + +/// 纤度检验车台明细 DTO +public class XdjyRecordDto +{ + public int Chetaihao { get; set; } // 车台号 + public double Xd1 { get; set; } + public double Xd2 { get; set; } + public double Xd3 { get; set; } + public double Xd4 { get; set; } + public int XdNum { get; set; } // 已称绞数 + public double XdAvg { get; set; } // 平均纤度 + public int FanweiNum { get; set; } // 范围 + public int XiaoyeNum { get; set; } // 小野 + public int DayeNum { get; set; } // 大野 + public int TeyeNum { get; set; } // 特野 + public int CenterNum { get; set; } // 中心 +} + +/// 纤度检验保存请求 +public class XdjySaveDto +{ + public long LsjlId { get; set; } + public string Dengji { get; set; } = ""; + public List Items { get; set; } = new(); +} + +public class XdjySaveItemDto +{ + public int Chetaihao { get; set; } + public double Xd1 { get; set; } + public double Xd2 { get; set; } + public double Xd3 { get; set; } + public double Xd4 { get; set; } +} diff --git a/backend/F9WebAiTest.Application/Dtos/RoleDtos.cs b/backend/F9WebAiTest.Application/Dtos/RoleDtos.cs new file mode 100644 index 0000000..0a75dc7 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/RoleDtos.cs @@ -0,0 +1,31 @@ +namespace F9WebAiTest.Application.Dtos; + +public class RoleDto +{ + public long Id { get; set; } + public string Name { get; set; } + public string Code { get; set; } + public string Description { get; set; } + public int Sort { get; set; } + public int Status { get; set; } + public int EmployeeCount { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class CreateRoleDto +{ + public string Name { get; set; } + public string Code { get; set; } + public string Description { get; set; } = ""; + public int Sort { get; set; } + public List MenuIds { get; set; } = new(); +} + +public class UpdateRoleDto +{ + public string Name { get; set; } + public string Description { get; set; } = ""; + public int Sort { get; set; } + public int Status { get; set; } = 1; + public List MenuIds { get; set; } = new(); +} diff --git a/backend/F9WebAiTest.Application/Dtos/SYS/Departments/DepartmentReadDto_View.cs b/backend/F9WebAiTest.Application/Dtos/SYS/Departments/DepartmentReadDto_View.cs new file mode 100644 index 0000000..3a2bdb1 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/SYS/Departments/DepartmentReadDto_View.cs @@ -0,0 +1,5 @@ +using F9WebAiTest.Domain.Enums.SYS.Departments; + +namespace F9WebAiTest.Application.Dtos.SYS.Departments; + +public record DepartmentReadDto_View(long Id, string Code, string Name, long ParentId, Department_ManageType_Enum ManageType, Department_DutyType_Enum DutyType, string Description, bool IsActive, int SortOrder); diff --git a/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Menus/MenuReadDto_Menu_Edit.cs b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Menus/MenuReadDto_Menu_Edit.cs new file mode 100644 index 0000000..d985303 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Menus/MenuReadDto_Menu_Edit.cs @@ -0,0 +1,16 @@ +using F9WebAiTest.Domain.Enums.SYS.RBAC; + +namespace F9WebAiTest.Application.Dtos.SYS.RBAC.Menus; + +public record MenuReadDto_Menu_Edit( + long Id, + string Code, + string Name, + string IconSvg, + MenuType_Enum MenuType, + string AssemblyName, + string TypeFullName, + bool IsActive, + long ParentId, + int SortOrder +); diff --git a/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Menus/MenuReadDto_Menu_Table.cs b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Menus/MenuReadDto_Menu_Table.cs new file mode 100644 index 0000000..0fb3700 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Menus/MenuReadDto_Menu_Table.cs @@ -0,0 +1,23 @@ +namespace F9WebAiTest.Application.Dtos.SYS.RBAC.Menus; + +// 读模型 +public record MenuReadDto_Menu_Table( + long Id, + long ParentId, + string Code, + string Name, + string IconSvg, + string MenuType, + string AssemblyName, + string TypeFullName, + string ParentName, + int Level, + string IsActive, + int SortOrder, + + // 用于前端 table select + bool Selected = false, + + // 用于前端 table tree + MenuReadDto_Menu_Table[]? Items = default +); diff --git a/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RolePermissionReadDto_View.cs b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RolePermissionReadDto_View.cs new file mode 100644 index 0000000..204b184 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RolePermissionReadDto_View.cs @@ -0,0 +1,5 @@ +using F9WebAiTest.Domain.Enums.SYS.RBAC; + +namespace F9WebAiTest.Application.Dtos.SYS.RBAC.Roles; + +public record RolePermissionReadDto_View(long Id, long RoleId, long PermissionId, long ResourceId, Permission_Type_Enum PermissionType); diff --git a/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RoleReadDto_Cache.cs b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RoleReadDto_Cache.cs new file mode 100644 index 0000000..d024f57 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RoleReadDto_Cache.cs @@ -0,0 +1,3 @@ +namespace F9WebAiTest.Application.Dtos.SYS.RBAC.Roles; + +public record RoleReadDto_Cache(long Id, string Name, string Code, int SortOrder); diff --git a/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RoleReadDto_Edit.cs b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RoleReadDto_Edit.cs new file mode 100644 index 0000000..1a22655 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RoleReadDto_Edit.cs @@ -0,0 +1,3 @@ +namespace F9WebAiTest.Application.Dtos.SYS.RBAC.Roles; + +public record RoleReadDto_Edit(long Id, string Name, string Code, int SortOrder, string Description); diff --git a/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RoleReadDto_View.cs b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RoleReadDto_View.cs new file mode 100644 index 0000000..1d906e0 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Roles/RoleReadDto_View.cs @@ -0,0 +1,3 @@ +namespace F9WebAiTest.Application.Dtos.SYS.RBAC.Roles; + +public record RoleReadDto_View(long Id, string Name, string Code, int SortOrder, int MenuPermissionCount, int ReportPermissionCount, int DepPermissionCount, bool IsActive); diff --git a/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserReadDto_Edit.cs b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserReadDto_Edit.cs new file mode 100644 index 0000000..b59131b --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserReadDto_Edit.cs @@ -0,0 +1,4 @@ +namespace F9WebAiTest.Application.Dtos.SYS.RBAC.Users; + +// 用户读模型 +public record UserReadDto_Edit(long Id, string UserName, string PhoneNumber, string LoginName, long DepartmentId, int SortOrder); diff --git a/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserReadDto_Login.cs b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserReadDto_Login.cs new file mode 100644 index 0000000..feb351d --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserReadDto_Login.cs @@ -0,0 +1,3 @@ +namespace F9WebAiTest.Application.Dtos.SYS.RBAC.Users; + +public record UserReadDto_Login(long Id, string UserName, string LoginName, string PasswordHash, long DepartmentId); diff --git a/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserReadDto_View.cs b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserReadDto_View.cs new file mode 100644 index 0000000..b5673f0 --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserReadDto_View.cs @@ -0,0 +1,4 @@ +namespace F9WebAiTest.Application.Dtos.SYS.RBAC.Users; + +// 用户读模型(列表视图) +public record UserReadDto_View(long Id, string UserName, string PhoneNumber, string LoginName, string DepartmentName, string RoleName, string LastLoginAt, int SortOrder, bool IsActive); diff --git a/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserRoleReadDto_Cache.cs b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserRoleReadDto_Cache.cs new file mode 100644 index 0000000..80e62ad --- /dev/null +++ b/backend/F9WebAiTest.Application/Dtos/SYS/RBAC/Users/UserRoleReadDto_Cache.cs @@ -0,0 +1,3 @@ +namespace F9WebAiTest.Application.Dtos.SYS.RBAC.Users; + +public record UserRoleReadDto_Cache(long Id, long UserId, long RoleId, string RoleName); diff --git a/backend/F9WebAiTest.Application/F9WebAiTest.Application.csproj b/backend/F9WebAiTest.Application/F9WebAiTest.Application.csproj new file mode 100644 index 0000000..9b0f42c --- /dev/null +++ b/backend/F9WebAiTest.Application/F9WebAiTest.Application.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + disable + F9WebAiTest.Application + + + + + + + + + + + + + + + diff --git a/backend/F9WebAiTest.Application/Interfaces/LoginUsers/ILoginUserService.cs b/backend/F9WebAiTest.Application/Interfaces/LoginUsers/ILoginUserService.cs new file mode 100644 index 0000000..fcbc8e1 --- /dev/null +++ b/backend/F9WebAiTest.Application/Interfaces/LoginUsers/ILoginUserService.cs @@ -0,0 +1,10 @@ +using F9WebAiTest.Application.Contracts; + +namespace F9WebAiTest.Application.Interfaces.LoginUsers; + +/// 登录用户服务(从当前请求上下文获取登录用户信息) +public interface ILoginUserService +{ + /// 登录用户信息 + LoginUserInfo LoginUserInfo { get; } +} diff --git a/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/Departments/IDepartmentReadRepository.cs b/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/Departments/IDepartmentReadRepository.cs new file mode 100644 index 0000000..9d849b8 --- /dev/null +++ b/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/Departments/IDepartmentReadRepository.cs @@ -0,0 +1,10 @@ +using F9WebAiTest.Application.Dtos.SYS.Departments; +using F9WebAiTest.Domain.Interfaces; + +namespace F9WebAiTest.Application.Interfaces.Repositories.SYS.Departments; + +/// 部门读仓储(返回读模型 DTO) +public interface IDepartmentReadRepository : IDInterface +{ + Task> GetAllAsync(); +} diff --git a/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/RBAC/Menus/IMenuReadRepository.cs b/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/RBAC/Menus/IMenuReadRepository.cs new file mode 100644 index 0000000..14ec36d --- /dev/null +++ b/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/RBAC/Menus/IMenuReadRepository.cs @@ -0,0 +1,14 @@ +using F9WebAiTest.Application.Dtos.SYS.RBAC.Menus; +using F9WebAiTest.Domain.Interfaces; + +namespace F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Menus; + +/// 菜单读仓储(返回读模型 DTO) +public interface IMenuReadRepository : IDInterface +{ + Task GetMenuByIdForEditAsync(long id); + Task ExistMenuByTypeFullnameExcludeIdAsync(string typeFullname, long? excludeId); + + Task> GetAllMenuAsync(string like, long? parentId); + Task> GetTreeMenuAsync(); +} diff --git a/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/RBAC/Roles/IRoleReadRepository.cs b/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/RBAC/Roles/IRoleReadRepository.cs new file mode 100644 index 0000000..8249301 --- /dev/null +++ b/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/RBAC/Roles/IRoleReadRepository.cs @@ -0,0 +1,21 @@ +using F9WebAiTest.Application.Dtos.SYS.RBAC.Roles; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces; + +namespace F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Roles; + +/// 角色读仓储(返回读模型 DTO) +public interface IRoleReadRepository : IDInterface +{ + /// 查询单个 + Task GetByIdAsync(long id); + + /// 获取所有对象集合 + Task> GetAllAsync(); + + /// 获取所有对象集合(缓存用) + Task> GetAllForCacheAsync(); + + /// 通过资源类型获取角色权限集合 + Task> GetRolePermissionsByResourceType(long roleId, Resource_Type_Enum resourceType); +} diff --git a/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/RBAC/Users/IUserReadRepository.cs b/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/RBAC/Users/IUserReadRepository.cs new file mode 100644 index 0000000..40cbad2 --- /dev/null +++ b/backend/F9WebAiTest.Application/Interfaces/Repositories/SYS/RBAC/Users/IUserReadRepository.cs @@ -0,0 +1,30 @@ +using F9WebAiTest.Application.Dtos.Common; +using F9WebAiTest.Application.Dtos.SYS.RBAC.Users; +using F9WebAiTest.Domain.Interfaces; + +namespace F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Users; + +/// 用户读仓储(返回读模型 DTO) +public interface IUserReadRepository : IDInterface +{ + // 单个用户查询 + Task GetByIdAsync(long id); + + // 通过 ID 集合查询用户集合 + Task> GetByIdsAsync(List ids); + + // 通过登录名查询 + Task GetByLoginNameAsync(string loginName); + + // 通过电话查询 + Task GetByPhoneAsync(string phone); + + // 条件分页查询 + Task> GetPagedAsync(long departmentId, string like, int page = 1, int pageSize = 10); + + // 获取用户绑定角色集合 + Task> GetUserRolesByUserId(long userId); + + // 通过角色 ID 集合查询用户集合 + Task> GetByRoleIdsAsync(List roleIds); +} diff --git a/backend/F9WebAiTest.Application/Interfaces/Security/IPasswordHasher.cs b/backend/F9WebAiTest.Application/Interfaces/Security/IPasswordHasher.cs new file mode 100644 index 0000000..d08c948 --- /dev/null +++ b/backend/F9WebAiTest.Application/Interfaces/Security/IPasswordHasher.cs @@ -0,0 +1,10 @@ +namespace F9WebAiTest.Application.Interfaces.Security; + +/// 密码哈希服务 +public interface IPasswordHasher +{ + string Hash256(string input); + string GetMd5Hash(string input); + string HashPassword(string password); + bool VerifyPassword(string password, string hashedPassword); +} diff --git a/backend/F9WebAiTest.Application/Interfaces/SnowflakeID/ISnowflakeIdGenerator.cs b/backend/F9WebAiTest.Application/Interfaces/SnowflakeID/ISnowflakeIdGenerator.cs new file mode 100644 index 0000000..93e8543 --- /dev/null +++ b/backend/F9WebAiTest.Application/Interfaces/SnowflakeID/ISnowflakeIdGenerator.cs @@ -0,0 +1,7 @@ +namespace F9WebAiTest.Application.Interfaces.SnowflakeID; + +/// 雪花 ID 生成器(ID 生成属于应用能力,故接口放在应用层) +public interface ISnowflakeIdGenerator +{ + long CreateId(); +} diff --git a/backend/F9WebAiTest.Application/Interfaces/UOW/IUnitOfWork_F9.cs b/backend/F9WebAiTest.Application/Interfaces/UOW/IUnitOfWork_F9.cs new file mode 100644 index 0000000..5b7a3b6 --- /dev/null +++ b/backend/F9WebAiTest.Application/Interfaces/UOW/IUnitOfWork_F9.cs @@ -0,0 +1,8 @@ +namespace F9WebAiTest.Application.Interfaces.UOW; + +/// 工作单元:在事务内执行一组操作(简化版,替代 F9 的 Begin/Commit/Rollback) +public interface IUnitOfWork_F9 +{ + /// 在事务内执行操作,返回是否提交成功(异常自动回滚) + Task ExecuteWithUow(Func> func); +} diff --git a/backend/F9WebAiTest.Application/Services/Attendance/AttendanceService.cs b/backend/F9WebAiTest.Application/Services/Attendance/AttendanceService.cs new file mode 100644 index 0000000..4aa3bb1 --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Attendance/AttendanceService.cs @@ -0,0 +1,573 @@ +using System.Text.Json; +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Attendance; +using F9WebAiTest.Application.Services.Notice; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities; +using F9WebAiTest.Domain.Entities.Attendance; +using F9WebAiTest.Domain.Entities.Notice; +using RBAC = F9WebAiTest.Domain.Entities.SYS.RBAC; +using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments; +using MediatR; + +namespace F9WebAiTest.Application.Services.Attendance; + +public class AttendanceService : IAttendanceService +{ + private readonly IRepository _attRepo; + private readonly IRepository _leaveRepo; + private readonly IRepository _approverRepo; + private readonly IRepository _empRepo; + private readonly IRepository _deptRepo; + private readonly IRepository _roleRepo; + private readonly IRepository _empRoleRepo; + private readonly IRepository _msgRepo; + private readonly IRepository _receiverRepo; + private readonly IRepository _todoRepo; + private readonly IRepository _typeRepo; + private readonly IMediator _mediator; + + // 上下班时间(可配置,这里简化固定) + private static readonly TimeSpan WorkStart = new(9, 0, 0); + private static readonly TimeSpan WorkEnd = new(18, 0, 0); + + public AttendanceService( + IRepository attRepo, + IRepository leaveRepo, + IRepository approverRepo, + IRepository empRepo, + IRepository deptRepo, + IRepository roleRepo, + IRepository empRoleRepo, + IRepository msgRepo, + IRepository receiverRepo, + IRepository todoRepo, + IRepository typeRepo, + IMediator mediator) + { + _attRepo = attRepo; + _leaveRepo = leaveRepo; + _approverRepo = approverRepo; + _empRepo = empRepo; + _deptRepo = deptRepo; + _roleRepo = roleRepo; + _empRoleRepo = empRoleRepo; + _msgRepo = msgRepo; + _receiverRepo = receiverRepo; + _todoRepo = todoRepo; + _typeRepo = typeRepo; + _mediator = mediator; + } + + public async Task PunchAsync(long employeeId) + { + var now = DateTime.Now; + var today = now.Date; + + var record = await _attRepo.FirstOrDefaultAsync(a => a.EmployeeId == employeeId && a.WorkDate == today); + + // 签到 + if (record == null) + { + var status = now.TimeOfDay <= WorkStart.Add(TimeSpan.FromMinutes(10)) ? "正常" : "迟到"; + var newRecord = new AttendanceRecord + { + EmployeeId = employeeId, + WorkDate = today, + SignInTime = now, + SignInStatus = status, + SignOutStatus = "未签退" + }; + await _attRepo.AddAsync(newRecord); + await _attRepo.SaveChangesAsync(); + + return new PunchResultDto + { + Type = "signIn", + Time = now, + Status = status, + Message = status == "正常" ? "签到成功,辛苦了!" : "签到成功(已迟到)" + }; + } + + // 已签到,进行签退 + if (record.SignOutTime == null) + { + var status = now.TimeOfDay >= WorkEnd ? "正常" : "早退"; + record.SignOutTime = now; + record.SignOutStatus = status; + await _attRepo.UpdateAsync(record); + await _attRepo.SaveChangesAsync(); + + return new PunchResultDto + { + Type = "signOut", + Time = now, + Status = status, + Message = status == "正常" ? "签退成功,明天见!" : "签退成功(早退)" + }; + } + + // 已签退 + return new PunchResultDto + { + Type = "done", + Time = now, + Status = "已完成", + Message = "今日已签到和签退,无需重复打卡" + }; + } + + public async Task> GetMyRecordsAsync(long employeeId, int pageIndex, int pageSize) + { + var records = (await _attRepo.FindAsync(a => a.EmployeeId == employeeId)) + .OrderByDescending(a => a.WorkDate) + .ToList(); + var total = records.Count; + var items = records.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList(); + + var emp = await _empRepo.GetByIdAsync(employeeId); + var dept = emp != null ? await _deptRepo.GetByIdAsync(emp.DepartmentId) : null; + + return new PageResult + { + Items = items.Select(a => new AttendanceDto + { + Id = a.Id, + EmployeeId = a.EmployeeId, + EmployeeName = emp?.UserName ?? "", + DepartmentName = dept?.Name ?? "", + WorkDate = a.WorkDate, + SignInTime = a.SignInTime, + SignOutTime = a.SignOutTime, + SignInStatus = a.SignInStatus, + SignOutStatus = a.SignOutStatus, + Remark = a.Remark + }).ToList(), + Total = total, + PageIndex = pageIndex, + PageSize = pageSize + }; + } + + public async Task GetMySummaryAsync(long employeeId, string month) + { + // month 格式 yyyy-MM,默认当前月 + if (string.IsNullOrWhiteSpace(month)) + { + month = DateTime.Now.ToString("yyyy-MM"); + } + var start = DateTime.Parse(month + "-01"); + var end = start.AddMonths(1); + + var records = await _attRepo.FindAsync(a => + a.EmployeeId == employeeId && a.WorkDate >= start && a.WorkDate < end); + + var summary = new AttendanceSummaryDto(); + foreach (var r in records) + { + summary.TotalDays++; + if (r.SignInStatus == "迟到") summary.LateDays++; + if (r.SignOutStatus == "早退") summary.EarlyLeaveDays++; + if (r.SignInTime == null) summary.AbsentDays++; + } + summary.NormalDays = summary.TotalDays - summary.LateDays - summary.EarlyLeaveDays - summary.AbsentDays; + + // 请假天数 + var leaves = await _leaveRepo.FindAsync(l => + l.EmployeeId == employeeId && l.Status == "已批准" && l.StartTime < end && l.EndTime >= start); + summary.LeaveDays = leaves.Sum(l => (l.EndTime - l.StartTime).Days + 1); + + return summary; + } + + public async Task> GetAllRecordsAsync(AttendanceQueryDto query) + { + var records = await _attRepo.GetAllAsync(); + var filtered = records.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query.Date)) + { + var date = DateTime.Parse(query.Date).Date; + filtered = filtered.Where(a => a.WorkDate == date); + } + + var emps = await _empRepo.GetAllAsync(); + var depts = await _deptRepo.GetAllAsync(); + var empDict = emps.ToDictionary(e => e.Id, e => e); + var deptDict = depts.ToDictionary(d => d.Id, d => d.Name); + + if (query.DepartmentId.HasValue) + { + var deptEmpIds = emps.Where(e => e.DepartmentId == query.DepartmentId.Value).Select(e => e.Id).ToHashSet(); + filtered = filtered.Where(a => deptEmpIds.Contains(a.EmployeeId)); + } + if (!string.IsNullOrWhiteSpace(query.Keyword)) + { + var kwEmpIds = emps.Where(e => e.UserName.Contains(query.Keyword) || e.LoginName.Contains(query.Keyword)) + .Select(e => e.Id).ToHashSet(); + filtered = filtered.Where(a => kwEmpIds.Contains(a.EmployeeId)); + } + + var total = filtered.LongCount(); + var items = filtered.OrderByDescending(a => a.WorkDate).ThenByDescending(a => a.SignInTime) + .Skip((query.PageIndex - 1) * query.PageSize).Take(query.PageSize).ToList(); + + return new PageResult + { + Items = items.Select(a => + { + var emp = empDict.GetValueOrDefault(a.EmployeeId); + return new AttendanceDto + { + Id = a.Id, + EmployeeId = a.EmployeeId, + EmployeeName = emp?.UserName ?? "", + DepartmentName = emp != null ? deptDict.GetValueOrDefault(emp.DepartmentId, "") : "", + WorkDate = a.WorkDate, + SignInTime = a.SignInTime, + SignOutTime = a.SignOutTime, + SignInStatus = a.SignInStatus, + SignOutStatus = a.SignOutStatus, + Remark = a.Remark + }; + }).ToList(), + Total = total, + PageIndex = query.PageIndex, + PageSize = query.PageSize + }; + } + + public async Task ApplyLeaveAsync(long employeeId, CreateLeaveDto dto) + { + if (dto.EndTime < dto.StartTime) + throw new ServiceException("结束时间不能早于开始时间"); + + // 确定审批人:优先使用前端选择的审批人,为空则回退到管理员 + var approverIds = (dto.ApproverIds ?? new List()).Where(id => id > 0).Distinct().ToList(); + if (approverIds.Count == 0) + approverIds = await GetApproverIdsAsync(); + if (approverIds.Count == 0) + throw new ServiceException("没有可用的审批人,请联系管理员"); + + var firstApprover = await _empRepo.GetByIdAsync(approverIds[0]); + var leave = new LeaveRequest + { + EmployeeId = employeeId, + Type = dto.Type, + StartTime = dto.StartTime, + EndTime = dto.EndTime, + Reason = dto.Reason ?? "", + CurrentOrderNo = 1, + ApproverId = approverIds[0], + ApproverName = firstApprover?.UserName ?? "" + }; + await _leaveRepo.AddAsync(leave); + await _leaveRepo.SaveChangesAsync(); + + // 创建审批节点(序号 = 审批顺序) + for (var i = 0; i < approverIds.Count; i++) + { + var emp = await _empRepo.GetByIdAsync(approverIds[i]); + await _approverRepo.AddAsync(new LeaveApprover + { + LeaveId = leave.Id, + ApproverId = approverIds[i], + ApproverName = emp?.UserName ?? "", + OrderNo = i + 1 + }); + } + await _approverRepo.SaveChangesAsync(); + + // 只给第一个审批人发待办(顺序审批,后续审批人待前一节点通过后才收到) + await SendLeaveApprovalAsync(leave, approverIds[0], 1, approverIds.Count); + + // 业务单据已创建,推送事件让各端刷新(多端同步) + await _mediator.Publish(new BusinessChangedEvent("LeaveApproval", leave.Id.ToString(), "Created")); + + return await ToLeaveDto(leave); + } + + public async Task> GetMyLeavesAsync(long employeeId) + { + var leaves = await _leaveRepo.FindAsync(l => l.EmployeeId == employeeId); + var result = new List(); + foreach (var l in leaves.OrderByDescending(l => l.CreatedAt)) + { + result.Add(await ToLeaveDto(l)); + } + return result; + } + + public async Task> GetAllLeavesAsync(string status) + { + var leaves = await _leaveRepo.GetAllAsync(); + if (!string.IsNullOrWhiteSpace(status) && status != "全部") + { + leaves = leaves.Where(l => l.Status == status).ToList(); + } + var result = new List(); + foreach (var l in leaves.OrderByDescending(l => l.CreatedAt)) + { + result.Add(await ToLeaveDto(l)); + } + return result; + } + + public async Task ApproveLeaveAsync(long leaveId, long approverId, string approverName, ApproveLeaveDto dto) + { + var leave = await _leaveRepo.GetByIdAsync(leaveId); + if (leave == null) throw new ServiceException("请假申请不存在", 404); + if (leave.Status != "待审批") throw new ServiceException("该申请已处理"); + + var approvers = (await _approverRepo.FindAsync(a => a.LeaveId == leaveId)) + .OrderBy(a => a.OrderNo).ToList(); + + // 校验当前审批节点 + var current = approvers.FirstOrDefault(a => a.OrderNo == leave.CurrentOrderNo); + if (current == null) throw new ServiceException("审批节点不存在", 404); + if (current.ApproverId != approverId) + throw new ServiceException($"当前应由「{current.ApproverName}」审批,您无权操作", 403); + if (current.Status != "待审批") throw new ServiceException("该节点已处理"); + + // 更新当前节点 + var handleResult = dto.Approved ? "已批准" : "已驳回"; + current.Status = handleResult; + current.ApproveRemark = dto.Remark ?? ""; + current.ApprovedAt = DateTime.Now; + await _approverRepo.UpdateAsync(current); + + if (!dto.Approved) + { + // 驳回:整体驳回,结束流程 + leave.Status = "已驳回"; + leave.ApproverId = approverId; + leave.ApproverName = approverName; + leave.ApproveRemark = dto.Remark ?? ""; + leave.ApprovedAt = DateTime.Now; + await _leaveRepo.UpdateAsync(leave); + await _leaveRepo.SaveChangesAsync(); + await _approverRepo.SaveChangesAsync(); + + // 流程终结:完成该单所有待办 + 标记消息已读,避免残留未读红点 + await FinalizeLeaveApprovalAsync(leaveId, handleResult); + await NotifyLeaveResultAsync(leave, false, dto.Remark); + await _mediator.Publish(new BusinessChangedEvent("LeaveApproval", leaveId.ToString(), "StatusChanged")); + return await ToLeaveDto(leave); + } + + // 批准:判断是否还有下一级审批人 + var next = approvers.FirstOrDefault(a => a.OrderNo == leave.CurrentOrderNo + 1); + if (next != null) + { + // 流转到下一级审批人 + leave.CurrentOrderNo = next.OrderNo; + leave.ApproverId = next.ApproverId; + leave.ApproverName = next.ApproverName; + await _leaveRepo.UpdateAsync(leave); + await _leaveRepo.SaveChangesAsync(); + await _approverRepo.SaveChangesAsync(); + + // 完成当前审批人待办并标记其消息已读,再把同一条消息流转给下一审批人 + await CompleteCurrentApproverAsync(leaveId, approverId, handleResult); + await SendLeaveApprovalAsync(leave, next.ApproverId, next.OrderNo, approvers.Count); + } + else + { + // 最后一级审批人批准:整体批准 + leave.Status = "已批准"; + leave.ApproverId = approverId; + leave.ApproverName = approverName; + leave.ApproveRemark = dto.Remark ?? ""; + leave.ApprovedAt = DateTime.Now; + await _leaveRepo.UpdateAsync(leave); + await _leaveRepo.SaveChangesAsync(); + await _approverRepo.SaveChangesAsync(); + + // 流程终结 + await FinalizeLeaveApprovalAsync(leaveId, handleResult); + await NotifyLeaveResultAsync(leave, true, dto.Remark); + } + + // 业务状态已变更,推送事件让各端刷新(多端同步) + await _mediator.Publish(new BusinessChangedEvent("LeaveApproval", leaveId.ToString(), "StatusChanged")); + return await ToLeaveDto(leave); + } + + /// 查询审批人(admin 角色用户)ID 列表(未指定审批人时的兜底) + private async Task> GetApproverIdsAsync() + { + var adminRole = await _roleRepo.FirstOrDefaultAsync(r => r.Code == "admin"); + if (adminRole == null) return new List(); + var relations = await _empRoleRepo.FindAsync(ur => ur.RoleId == adminRole.Id); + return relations.Select(ur => ur.UserId).Distinct().ToList(); + } + + /// + /// 请假审批待办消息:一张请假单仅保留一条,随审批流转复用。 + /// 首次创建,流转时更新内容与当前审批人并重置为待投递(投递服务幂等,只推新审批人)。 + /// + private async Task SendLeaveApprovalAsync(LeaveRequest leave, long targetApproverId, int currentLevel, int totalLevel) + { + var businessType = NotificationTypeEnum.LeaveApproval.ToString(); + var businessId = leave.Id.ToString(); + var content = $"类型:{leave.Type},时间:{leave.StartTime:MM-dd HH:mm} ~ {leave.EndTime:MM-dd HH:mm},事由:{leave.Reason}(第 {currentLevel}/{totalLevel} 级审批)"; + + var msg = await _msgRepo.FirstOrDefaultAsync(m => + m.BusinessType == businessType && m.BusinessId == businessId); + + if (msg == null) + { + var type = await _typeRepo.FirstOrDefaultAsync(t => t.Code == businessType); + var applicant = await _empRepo.GetByIdAsync(leave.EmployeeId); + msg = new NotificationMessage + { + NotificationTypeId = type?.Id ?? 0, + MessageTypeCode = businessType, + BusinessType = businessType, + BusinessId = businessId, + Title = $"{applicant?.UserName ?? "员工"} 提交了请假申请", + Content = content, + Priority = NotificationPriority.Important, + Status = NotificationStatus.Ready, + ForceReceivers = JsonSerializer.Serialize(new List { targetApproverId }), + CreatorId = leave.EmployeeId, + CreatorName = applicant?.UserName ?? "", + Category = NotificationCategory.Todo, + RoutePath = !string.IsNullOrWhiteSpace(type?.RouteTemplate) + ? type.RouteTemplate.Replace("{businessId}", businessId) + : $"/attendance/approve?leaveId={businessId}" + }; + await _msgRepo.AddAsync(msg); + await _msgRepo.SaveChangesAsync(); + } + else + { + // 复用:更新内容与当前审批人,重置为待投递,投递服务幂等只会推送给新审批人 + msg.Content = content; + msg.ForceReceivers = JsonSerializer.Serialize(new List { targetApproverId }); + msg.Status = NotificationStatus.Ready; + await _msgRepo.UpdateAsync(msg); + await _msgRepo.SaveChangesAsync(); + } + + // 当前审批人的待办(幂等:已存在则跳过) + var existTodo = await _todoRepo.FirstOrDefaultAsync(t => t.MessageId == msg.Id && t.AssigneeId == targetApproverId); + if (existTodo == null) + { + await _todoRepo.AddAsync(new TodoItem + { + MessageId = msg.Id, + AssigneeId = targetApproverId, + TodoStatus = TodoItemStatus.Pending + }); + await _todoRepo.SaveChangesAsync(); + } + } + + /// 完成当前审批人的待办,并标记其消息已读(流转时调用,避免已处理人残留未读红点) + private async Task CompleteCurrentApproverAsync(long leaveId, long approverId, string handleResult) + { + var msgs = await _msgRepo.FindAsync(m => + m.BusinessType == NotificationTypeEnum.LeaveApproval.ToString() && m.BusinessId == leaveId.ToString()); + var msgIds = msgs.Select(m => m.Id).ToList(); + if (msgIds.Count == 0) return; + + var todos = await _todoRepo.FindAsync(t => + msgIds.Contains(t.MessageId) && t.AssigneeId == approverId && t.TodoStatus == TodoItemStatus.Pending); + foreach (var t in todos) + { + t.TodoStatus = TodoItemStatus.Completed; + t.CompletedTime = DateTime.Now; + t.HandleResult = handleResult; + await _todoRepo.UpdateAsync(t); + } + + var receivers = await _receiverRepo.FindAsync(r => + msgIds.Contains(r.MessageId) && r.UserId == approverId && r.ReadStatus == NotificationReadStatus.Unread); + foreach (var r in receivers) + { + r.ReadStatus = NotificationReadStatus.Read; + r.ReadTime = DateTime.Now; + await _receiverRepo.UpdateAsync(r); + } + } + + /// 流程终结:完成该单所有待处理待办,并标记所有未读消息为已读 + private async Task FinalizeLeaveApprovalAsync(long leaveId, string handleResult) + { + var msgs = await _msgRepo.FindAsync(m => + m.BusinessType == NotificationTypeEnum.LeaveApproval.ToString() && m.BusinessId == leaveId.ToString()); + var msgIds = msgs.Select(m => m.Id).ToList(); + if (msgIds.Count == 0) return; + + var todos = await _todoRepo.FindAsync(t => msgIds.Contains(t.MessageId) && t.TodoStatus == TodoItemStatus.Pending); + foreach (var t in todos) + { + t.TodoStatus = TodoItemStatus.Completed; + t.CompletedTime = DateTime.Now; + t.HandleResult = handleResult; + await _todoRepo.UpdateAsync(t); + } + + var receivers = await _receiverRepo.FindAsync(r => msgIds.Contains(r.MessageId) && r.ReadStatus == NotificationReadStatus.Unread); + foreach (var r in receivers) + { + r.ReadStatus = NotificationReadStatus.Read; + r.ReadTime = DateTime.Now; + await _receiverRepo.UpdateAsync(r); + } + } + + /// 通知申请人审批结果 + private async Task NotifyLeaveResultAsync(LeaveRequest leave, bool approved, string remark) + { + await _mediator.Send(new SendNotificationCommand( + NotificationTypeEnum.LeaveResult, + BusinessId: leave.Id.ToString(), + Title: approved ? "请假申请已批准" : "请假申请已驳回", + Content: $"您的请假({leave.Type},{leave.StartTime:MM-dd HH:mm} ~ {leave.EndTime:MM-dd HH:mm})已{(approved ? "批准" : "驳回")}。审批意见:{remark}", + ForceReceivers: new List { leave.EmployeeId } + )); + } + + private async Task ToLeaveDto(LeaveRequest l) + { + var emp = await _empRepo.GetByIdAsync(l.EmployeeId); + var dept = emp != null ? await _deptRepo.GetByIdAsync(emp.DepartmentId) : null; + + var approvers = (await _approverRepo.FindAsync(a => a.LeaveId == l.Id)) + .OrderBy(a => a.OrderNo) + .Select(a => new LeaveApproverDto + { + Id = a.Id, + ApproverId = a.ApproverId, + ApproverName = a.ApproverName, + OrderNo = a.OrderNo, + Status = a.Status, + ApproveRemark = a.ApproveRemark, + ApprovedAt = a.ApprovedAt + }) + .ToList(); + + return new LeaveRequestDto + { + Id = l.Id, + EmployeeId = l.EmployeeId, + EmployeeName = emp?.UserName ?? "", + DepartmentName = dept?.Name ?? "", + Type = l.Type, + StartTime = l.StartTime, + EndTime = l.EndTime, + Reason = l.Reason, + Status = l.Status, + CurrentOrderNo = l.CurrentOrderNo, + ApproverName = l.ApproverName, + ApproveRemark = l.ApproveRemark, + ApprovedAt = l.ApprovedAt, + CreatedAt = l.CreatedAt, + Approvers = approvers + }; + } +} diff --git a/backend/F9WebAiTest.Application/Services/Attendance/IAttendanceService.cs b/backend/F9WebAiTest.Application/Services/Attendance/IAttendanceService.cs new file mode 100644 index 0000000..2814976 --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Attendance/IAttendanceService.cs @@ -0,0 +1,26 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Attendance; + +namespace F9WebAiTest.Application.Services.Attendance; + +public interface IAttendanceService +{ + // 打卡 + Task PunchAsync(long employeeId); + + // 我的考勤记录(分页) + Task> GetMyRecordsAsync(long employeeId, int pageIndex, int pageSize); + + // 我的考勤统计 + Task GetMySummaryAsync(long employeeId, string month); + + // 全部考勤记录(管理员) + Task> GetAllRecordsAsync(AttendanceQueryDto query); + + // 请假 + Task ApplyLeaveAsync(long employeeId, CreateLeaveDto dto); + Task> GetMyLeavesAsync(long employeeId); + Task> GetAllLeavesAsync(string status); + Task ApproveLeaveAsync(long leaveId, long approverId, string approverName, ApproveLeaveDto dto); +} diff --git a/backend/F9WebAiTest.Application/Services/Auth/AuthService.cs b/backend/F9WebAiTest.Application/Services/Auth/AuthService.cs new file mode 100644 index 0000000..77e8368 --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Auth/AuthService.cs @@ -0,0 +1,300 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Auth; +using F9WebAiTest.Application.Interfaces.Security; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities.SYS.Departments; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using Microsoft.Extensions.Configuration; +using RefreshToken = F9WebAiTest.Domain.Entities.RefreshToken; + +namespace F9WebAiTest.Application.Services.Auth; + +public class AuthService : IAuthService +{ + private readonly IRepository _userRepo; + private readonly IRepository _userRoleRepo; + private readonly IRepository _rolePermissionRepo; + private readonly IRepository _permissionRepo; + private readonly IRepository _menuRepo; + private readonly IRepository _roleRepo; + private readonly IRepository _deptRepo; + private readonly IRepository _refreshTokenRepo; + private readonly IJwtService _jwt; + private readonly IConfiguration _config; + private readonly IPasswordHasher _passwordHasher; + + public AuthService( + IRepository userRepo, + IRepository userRoleRepo, + IRepository rolePermissionRepo, + IRepository permissionRepo, + IRepository menuRepo, + IRepository roleRepo, + IRepository deptRepo, + IRepository refreshTokenRepo, + IJwtService jwt, + IConfiguration config, + IPasswordHasher passwordHasher) + { + _userRepo = userRepo; + _userRoleRepo = userRoleRepo; + _rolePermissionRepo = rolePermissionRepo; + _permissionRepo = permissionRepo; + _menuRepo = menuRepo; + _roleRepo = roleRepo; + _deptRepo = deptRepo; + _refreshTokenRepo = refreshTokenRepo; + _jwt = jwt; + _config = config; + _passwordHasher = passwordHasher; + } + + public async Task LoginAsync(LoginDto dto) + { + var user = await _userRepo.FirstOrDefaultAsync(u => u.LoginName == dto.Username); + if (user == null || !VerifyPassword(dto.Password, user.PasswordHash)) + throw new ServiceException("用户名或密码错误", 401); + + if (!user.IsActive) + throw new ServiceException("账号已被禁用,请联系管理员", 403); + + return await IssueTokensAsync(user); + } + + public async Task RefreshTokenAsync(string refreshToken) + { + var stored = await _refreshTokenRepo.FirstOrDefaultAsync(rt => rt.Token == refreshToken); + if (stored == null || stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow) + throw new ServiceException("刷新令牌无效或已过期", 401); + + var user = await _userRepo.GetByIdAsync(stored.EmployeeId); + if (user == null || !user.IsActive) + throw new ServiceException("用户不存在或已禁用", 401); + + // 撤销旧 token,签发新 token(旋转) + stored.IsRevoked = true; + await _refreshTokenRepo.UpdateAsync(stored); + + return await IssueTokensAsync(user); + } + + public async Task RevokeRefreshTokenAsync(string refreshToken) + { + var stored = await _refreshTokenRepo.FirstOrDefaultAsync(rt => rt.Token == refreshToken); + if (stored != null) + { + stored.IsRevoked = true; + await _refreshTokenRepo.UpdateAsync(stored); + await _refreshTokenRepo.SaveChangesAsync(); + } + } + + public async Task GetCurrentUserAsync(long userId) + { + var user = await _userRepo.GetByIdAsync(userId); + if (user == null) throw new ServiceException("用户不存在", 404); + return await BuildUserInfoAsync(user); + } + + public async Task> GetUserMenusAsync(long userId) + { + var roleIds = (await _userRoleRepo.FindAsync(ur => ur.UserId == userId)) + .Select(ur => ur.RoleId).ToList(); + + // 超级管理员返回全部菜单 + var isAdmin = await IsAdminAsync(userId); + + List menus; + if (isAdmin) + { + menus = await _menuRepo.FindAsync(m => m.IsActive); + } + else + { + // 通过 Permission 表关联菜单:RolePermission → Permission(ResourceType=菜单) → ResourceId(=Menu.Id) + var permissionIds = (await _rolePermissionRepo.FindAsync(rp => roleIds.Contains(rp.RoleId))) + .Select(rp => rp.PermissionId).Distinct().ToList(); + var permittedMenuIds = (await _permissionRepo.FindAsync(p => + permissionIds.Contains(p.Id) && p.ResourceType == Resource_Type_Enum.菜单)) + .Select(p => p.ResourceId).Distinct().ToHashSet(); + + // 全部活跃菜单 + var allMenus = await _menuRepo.FindAsync(m => m.IsActive); + var menuDict = allMenus.ToDictionary(m => m.Id); + + // 有权限的菜单 + 它们的祖先目录(父/祖目录自动包含,保证菜单树完整,非管理员也能看到一级菜单) + var resultIds = new HashSet(); + foreach (var id in permittedMenuIds) + { + var currentId = id; + while (currentId != 0 && menuDict.ContainsKey(currentId) && resultIds.Add(currentId)) + { + currentId = menuDict[currentId].ParentId; + } + } + + menus = allMenus.Where(m => resultIds.Contains(m.Id)).ToList(); + } + + // 只返回目录和菜单(分组/页面),按排序 + menus = menus.Where(m => m.MenuType != MenuType_Enum.默认) + .OrderBy(m => m.SortOrder).ToList(); + + return BuildMenuTree(menus); + } + + public async Task> GetUserPermissionsAsync(long userId) + { + var isAdmin = await IsAdminAsync(userId); + if (isAdmin) + { + // 超级管理员:返回所有权限单元 Code(含菜单权限 + 资源权限) + return (await _permissionRepo.FindAsync(p => !string.IsNullOrEmpty(p.Code))) + .Select(p => p.Code).Distinct().ToList(); + } + + // 普通用户:角色 → RolePermission → Permission → Code(统一权限模型) + var roleIds = (await _userRoleRepo.FindAsync(ur => ur.UserId == userId)) + .Select(ur => ur.RoleId).ToList(); + var permissionIds = (await _rolePermissionRepo.FindAsync(rp => roleIds.Contains(rp.RoleId))) + .Select(rp => rp.PermissionId).Distinct().ToList(); + return (await _permissionRepo.FindAsync(p => permissionIds.Contains(p.Id) && !string.IsNullOrEmpty(p.Code))) + .Select(p => p.Code).Distinct().ToList(); + } + + public async Task ChangePasswordAsync(long userId, ChangePasswordDto dto) + { + var user = await _userRepo.GetByIdAsync(userId); + if (user == null) throw new ServiceException("用户不存在", 404); + if (!VerifyPassword(dto.OldPassword, user.PasswordHash)) + throw new ServiceException("原密码错误"); + if ((dto.NewPassword ?? "").Length < 6) + throw new ServiceException("新密码至少6位"); + + // 新密码与创建用户一致:BCrypt(MD5(明文)) + user.SetPassword(_passwordHasher.HashPassword(_passwordHasher.GetMd5Hash(dto.NewPassword))); + await _userRepo.UpdateAsync(user); + await _userRepo.SaveChangesAsync(); + } + + public async Task UpdateProfileAsync(long userId, UpdateProfileDto dto) + { + var user = await _userRepo.GetByIdAsync(userId); + if (user == null) throw new ServiceException("用户不存在", 404); + + user.UpdateProfile(dto.Nickname, dto.Avatar, dto.Email, dto.Phone, null); + await _userRepo.UpdateAsync(user); + await _userRepo.SaveChangesAsync(); + } + + // ---- 私有方法 ---- + + /// 校验密码,兼容旧格式 BCrypt(明文) 与新格式 BCrypt(MD5(明文)) + private bool VerifyPassword(string password, string passwordHash) + { + if (string.IsNullOrEmpty(passwordHash)) return false; + if (_passwordHasher.VerifyPassword(password, passwordHash)) return true; + return _passwordHasher.VerifyPassword(_passwordHasher.GetMd5Hash(password), passwordHash); + } + + private async Task IssueTokensAsync(User user) + { + var roles = await GetRoleCodesAsync(user.Id); + var accessToken = _jwt.GenerateAccessToken(user, roles); + var refreshToken = _jwt.GenerateRefreshToken(); + var expireMinutes = int.Parse(_config["Jwt:AccessTokenExpireMinutes"] ?? "120"); + + // 存储 refresh token + var expireDays = int.Parse(_config["Jwt:RefreshTokenExpireDays"] ?? "7"); + await _refreshTokenRepo.AddAsync(new RefreshToken + { + EmployeeId = user.Id, + Token = refreshToken, + ExpiresAt = DateTime.UtcNow.AddDays(expireDays) + }); + await _refreshTokenRepo.SaveChangesAsync(); + + var userInfo = await BuildUserInfoAsync(user); + return new LoginResultDto + { + AccessToken = accessToken, + RefreshToken = refreshToken, + AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(expireMinutes), + User = userInfo + }; + } + + private async Task> GetRoleCodesAsync(long userId) + { + var roleIds = (await _userRoleRepo.FindAsync(ur => ur.UserId == userId)) + .Select(ur => ur.RoleId).ToList(); + var roles = await _roleRepo.FindAsync(r => roleIds.Contains(r.Id)); + return roles.Select(r => r.Code).ToList(); + } + + private async Task IsAdminAsync(long userId) + { + var roleIds = (await _userRoleRepo.FindAsync(ur => ur.UserId == userId)) + .Select(ur => ur.RoleId).ToList(); + var roles = await _roleRepo.FindAsync(r => roleIds.Contains(r.Id)); + return roles.Any(r => r.Code == "admin"); + } + + private async Task BuildUserInfoAsync(User user) + { + var dept = await _deptRepo.GetByIdAsync(user.DepartmentId); + var roleIds = (await _userRoleRepo.FindAsync(ur => ur.UserId == user.Id)) + .Select(ur => ur.RoleId).ToList(); + var roles = await _roleRepo.FindAsync(r => roleIds.Contains(r.Id)); + + return new EmployeeInfoDto + { + Id = user.Id, + Username = user.LoginName, + RealName = user.UserName, + Nickname = user.Nickname, + Avatar = user.Avatar, + Phone = user.PhoneNumber, + Email = user.Email, + DepartmentId = user.DepartmentId, + DepartmentName = dept?.Name ?? "", + Status = user.IsActive ? 1 : 0, + Roles = roles.Select(r => r.Code).ToList(), + Permissions = await GetUserPermissionsAsync(user.Id) + }; + } + + private static List BuildMenuTree(List menus) + { + var dict = menus.ToDictionary(m => m.Id, m => new MenuDto + { + Id = m.Id, + ParentId = m.ParentId == 0 ? null : m.ParentId, + Name = m.Name, + Type = m.MenuType == MenuType_Enum.分组 ? "directory" : "menu", + Path = m.Path, + Component = m.Component, + Icon = m.IconSvg, + Permission = m.Permission, + Sort = m.SortOrder, + Status = m.IsActive ? 1 : 0 + }); + + var roots = new List(); + foreach (var menu in menus) + { + var dto = dict[menu.Id]; + if (menu.ParentId == 0 || !dict.ContainsKey(menu.ParentId)) + { + roots.Add(dto); + } + else + { + dict[menu.ParentId].Children.Add(dto); + } + } + return roots.OrderBy(m => m.Sort).ToList(); + } +} diff --git a/backend/F9WebAiTest.Application/Services/Auth/IAuthService.cs b/backend/F9WebAiTest.Application/Services/Auth/IAuthService.cs new file mode 100644 index 0000000..3a12cb4 --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Auth/IAuthService.cs @@ -0,0 +1,16 @@ +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Auth; + +namespace F9WebAiTest.Application.Services.Auth; + +public interface IAuthService +{ + Task LoginAsync(LoginDto dto); + Task RefreshTokenAsync(string refreshToken); + Task RevokeRefreshTokenAsync(string refreshToken); + Task GetCurrentUserAsync(long employeeId); + Task> GetUserMenusAsync(long employeeId); + Task> GetUserPermissionsAsync(long employeeId); + Task ChangePasswordAsync(long employeeId, ChangePasswordDto dto); + Task UpdateProfileAsync(long employeeId, UpdateProfileDto dto); +} diff --git a/backend/F9WebAiTest.Application/Services/Auth/IJwtService.cs b/backend/F9WebAiTest.Application/Services/Auth/IJwtService.cs new file mode 100644 index 0000000..1f49d7c --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Auth/IJwtService.cs @@ -0,0 +1,11 @@ +using F9WebAiTest.Domain.Entities.SYS.RBAC; + +namespace F9WebAiTest.Application.Services.Auth; + +public interface IJwtService +{ + string GenerateAccessToken(User user, IEnumerable roles); + string GenerateRefreshToken(); + (long id, string username) ValidateAccessToken(string token); + long? ValidateRefreshToken(string token); +} diff --git a/backend/F9WebAiTest.Application/Services/Auth/JwtService.cs b/backend/F9WebAiTest.Application/Services/Auth/JwtService.cs new file mode 100644 index 0000000..745ec89 --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Auth/JwtService.cs @@ -0,0 +1,102 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace F9WebAiTest.Application.Services.Auth; + +public class JwtService : IJwtService +{ + private readonly IConfiguration _config; + + public JwtService(IConfiguration config) + { + _config = config; + } + + // 生成 access token(短期,如 2 小时) + public string GenerateAccessToken(User user, IEnumerable roles) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"])); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new("id", user.Id.ToString()), + new(ClaimTypes.Name, user.LoginName), + new("realName", user.UserName) + }; + foreach (var r in roles) + { + claims.Add(new Claim(ClaimTypes.Role, r)); + } + + var expireMinutes = int.Parse(_config["Jwt:AccessTokenExpireMinutes"] ?? "120"); + var token = new JwtSecurityToken( + issuer: _config["Jwt:Issuer"], + audience: _config["Jwt:Audience"], + claims: claims, + expires: DateTime.UtcNow.AddMinutes(expireMinutes), + signingCredentials: creds + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + // 生成 refresh token(随机字符串,长期,如 7 天) + public string GenerateRefreshToken() + { + var randomNumber = new byte[64]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomNumber); + return Convert.ToBase64String(randomNumber); + } + + public (long id, string username) ValidateAccessToken(string token) + { + var handler = new JwtSecurityTokenHandler(); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"])); + var validationParams = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateIssuer = true, + ValidIssuer = _config["Jwt:Issuer"], + ValidateAudience = true, + ValidAudience = _config["Jwt:Audience"], + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; + var principal = handler.ValidateToken(token, validationParams, out _); + var id = long.Parse(principal.FindFirst("id")!.Value); + var username = principal.Identity!.Name; + return (id, username); + } + + // 校验 refresh token 的签名(refresh token 也是 JWT,但只含 id claim) + public long? ValidateRefreshToken(string token) + { + try + { + var handler = new JwtSecurityTokenHandler(); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"])); + var validationParams = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false + }; + var principal = handler.ValidateToken(token, validationParams, out _); + return long.Parse(principal.FindFirst("id")!.Value); + } + catch + { + return null; + } + } +} diff --git a/backend/F9WebAiTest.Application/Services/Base/BusinessServices.cs b/backend/F9WebAiTest.Application/Services/Base/BusinessServices.cs new file mode 100644 index 0000000..a42415a --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Base/BusinessServices.cs @@ -0,0 +1,328 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Base; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities; +using F9WebAiTest.Domain.Entities.Base; +using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments; + +namespace F9WebAiTest.Application.Services.Base; + +// ==================== 工艺庄口 ==================== +public interface ICraftZhuangKouService +{ + Task> GetPagedAsync(int pageIndex, int pageSize, string keyword, int? year); + Task CreateAsync(CraftZhuangKouCreateDto dto); + Task UpdateAsync(long id, CraftZhuangKouCreateDto dto); + Task DeleteAsync(long id); +} + +public class CraftZhuangKouService : ICraftZhuangKouService +{ + private readonly IRepository _repo; + public CraftZhuangKouService(IRepository repo) => _repo = repo; + + private static string BendiWaidiName(int v) => v switch { 1 => "本地", 2 => "外地", _ => "" }; + + public async Task> GetPagedAsync(int pageIndex, int pageSize, string keyword, int? year) + { + var all = await _repo.GetAllAsync(); + var q = all.AsQueryable(); + if (!string.IsNullOrWhiteSpace(keyword)) + q = q.Where(x => x.ZhuangKouName.Contains(keyword) || x.ZhuangKouCode.Contains(keyword) || x.ProductionDisplayName.Contains(keyword)); + if (year.HasValue) + q = q.Where(x => x.BelongingYear == year.Value); + + var total = q.LongCount(); + var items = q.OrderBy(x => x.Sort).ThenBy(x => x.Id) + .Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList(); + + return new PageResult + { + Items = items.Select(ToDto).ToList(), + Total = total, PageIndex = pageIndex, PageSize = pageSize + }; + } + + public async Task CreateAsync(CraftZhuangKouCreateDto dto) + { + if (string.IsNullOrWhiteSpace(dto.ZhuangKouName)) throw new ServiceException("庄口名称不能为空"); + var e = new CraftZhuangKou + { + BelongingYear = dto.BelongingYear, BendiWaidi = dto.BendiWaidi, JidiWaigou = dto.JidiWaigou, + ZhuangKouCode = dto.ZhuangKouCode, ZhuangKouName = dto.ZhuangKouName, + ProductionDisplayName = dto.ProductionDisplayName, Chandi = dto.Chandi, + Sort = dto.Sort, Memo = dto.Memo, IsJiezhuang = dto.IsJiezhuang + }; + await _repo.AddAsync(e); + await _repo.SaveChangesAsync(); + return ToDto(e); + } + + public async Task UpdateAsync(long id, CraftZhuangKouCreateDto dto) + { + var e = await _repo.GetByIdAsync(id); + if (e == null) throw new ServiceException("记录不存在", 404); + e.BelongingYear = dto.BelongingYear; e.BendiWaidi = dto.BendiWaidi; e.JidiWaigou = dto.JidiWaigou; + e.ZhuangKouCode = dto.ZhuangKouCode; e.ZhuangKouName = dto.ZhuangKouName; + e.ProductionDisplayName = dto.ProductionDisplayName; e.Chandi = dto.Chandi; + e.Sort = dto.Sort; e.Memo = dto.Memo; e.IsJiezhuang = dto.IsJiezhuang; + await _repo.UpdateAsync(e); + await _repo.SaveChangesAsync(); + return ToDto(e); + } + + public async Task DeleteAsync(long id) + { + var e = await _repo.GetByIdAsync(id); + if (e == null) throw new ServiceException("记录不存在", 404); + await _repo.DeleteAsync(e); + await _repo.SaveChangesAsync(); + } + + private static CraftZhuangKouDto ToDto(CraftZhuangKou e) => new() + { + Id = e.Id, BelongingYear = e.BelongingYear, BendiWaidi = e.BendiWaidi, BendiWaidiName = BendiWaidiName(e.BendiWaidi), + JidiWaigou = e.JidiWaigou, ZhuangKouCode = e.ZhuangKouCode, ZhuangKouName = e.ZhuangKouName, + ProductionDisplayName = e.ProductionDisplayName, Chandi = e.Chandi, Sort = e.Sort, Memo = e.Memo, IsJiezhuang = e.IsJiezhuang + }; +} + +// ==================== 生丝规格 ==================== +public interface IShengsiGuigeService +{ + Task> GetPagedAsync(int pageIndex, int pageSize, string keyword); + Task> GetAllGuigeAsync(); + Task CreateAsync(ShengsiGuigeDto dto); + Task UpdateAsync(long id, ShengsiGuigeDto dto); + Task DeleteAsync(long id); +} + +public class ShengsiGuigeService : IShengsiGuigeService +{ + private readonly IRepository _repo; + public ShengsiGuigeService(IRepository repo) => _repo = repo; + + public async Task> GetPagedAsync(int pageIndex, int pageSize, string keyword) + { + var all = await _repo.GetAllAsync(); + var q = all.AsQueryable(); + if (!string.IsNullOrWhiteSpace(keyword)) q = q.Where(x => x.Guige.Contains(keyword)); + var total = q.LongCount(); + var items = q.OrderBy(x => x.Sort).ThenBy(x => x.Id).Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList(); + return new PageResult { Items = items.Select(ToDto).ToList(), Total = total, PageIndex = pageIndex, PageSize = pageSize }; + } + + public async Task> GetAllGuigeAsync() + => (await _repo.GetAllAsync()).OrderBy(x => x.Sort).Select(x => x.Guige).Distinct().ToList(); + + public async Task CreateAsync(ShengsiGuigeDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Guige)) throw new ServiceException("规格不能为空"); + var e = Map(new ShengsiGuige(), dto); + await _repo.AddAsync(e); + await _repo.SaveChangesAsync(); + return ToDto(e); + } + + public async Task UpdateAsync(long id, ShengsiGuigeDto dto) + { + var e = await _repo.GetByIdAsync(id); + if (e == null) throw new ServiceException("记录不存在", 404); + Map(e, dto); + await _repo.UpdateAsync(e); + await _repo.SaveChangesAsync(); + return ToDto(e); + } + + public async Task DeleteAsync(long id) + { + var e = await _repo.GetByIdAsync(id); + if (e == null) throw new ServiceException("记录不存在", 404); + await _repo.DeleteAsync(e); + await _repo.SaveChangesAsync(); + } + + private static ShengsiGuige Map(ShengsiGuige e, ShengsiGuigeDto d) + { + e.Guige = d.Guige; e.CenterrangeLolimit = d.CenterrangeLolimit; e.CenterrangeUplimit = d.CenterrangeUplimit; + e.CenterXiandu = d.CenterXiandu; e.ValidLolimit = d.ValidLolimit; e.ValidUplimit = d.ValidUplimit; + e.XiaoyeLolimit = d.XiaoyeLolimit; e.XiaoyeUplimit = d.XiaoyeUplimit; + e.DayeLolimit = d.DayeLolimit; e.DayeUplimit = d.DayeUplimit; + e.TeyeLolimit = d.TeyeLolimit; e.TeyeUplimit = d.TeyeUplimit; + e.YexianNkLolimitLeft = d.YexianNkLolimitLeft; e.YexianNkLolimitRight = d.YexianNkLolimitRight; + e.YexianNkUplimitLeft = d.YexianNkUplimitLeft; e.YexianNkUplimitRight = d.YexianNkUplimitRight; + e.LaodongJingsaiYexianLolimit = d.LaodongJingsaiYexianLolimit; e.LaodongJingsaiYexianUplimit = d.LaodongJingsaiYexianUplimit; + e.Sort = d.Sort; e.Memo = d.Memo; + return e; + } + + private static ShengsiGuigeDto ToDto(ShengsiGuige e) => new() + { + Id = e.Id, Guige = e.Guige, CenterrangeLolimit = e.CenterrangeLolimit, CenterrangeUplimit = e.CenterrangeUplimit, + CenterXiandu = e.CenterXiandu, ValidLolimit = e.ValidLolimit, ValidUplimit = e.ValidUplimit, + XiaoyeLolimit = e.XiaoyeLolimit, XiaoyeUplimit = e.XiaoyeUplimit, + DayeLolimit = e.DayeLolimit, DayeUplimit = e.DayeUplimit, + TeyeLolimit = e.TeyeLolimit, TeyeUplimit = e.TeyeUplimit, + YexianNkLolimitLeft = e.YexianNkLolimitLeft, YexianNkLolimitRight = e.YexianNkLolimitRight, + YexianNkUplimitLeft = e.YexianNkUplimitLeft, YexianNkUplimitRight = e.YexianNkUplimitRight, + LaodongJingsaiYexianLolimit = e.LaodongJingsaiYexianLolimit, LaodongJingsaiYexianUplimit = e.LaodongJingsaiYexianUplimit, + Sort = e.Sort, Memo = e.Memo + }; +} + +// ==================== 生丝定级指标 ==================== +public interface IShengsiDingjizhibiaoService +{ + Task> GetPagedAsync(int pageIndex, int pageSize, string keyword, int? standard, string guige); + Task CreateAsync(ShengsiDingjizhibiaoDto dto); + Task UpdateAsync(long id, ShengsiDingjizhibiaoDto dto); + Task DeleteAsync(long id); +} + +public class ShengsiDingjizhibiaoService : IShengsiDingjizhibiaoService +{ + private readonly IRepository _repo; + public ShengsiDingjizhibiaoService(IRepository repo) => _repo = repo; + + private static string StandardName(int v) => v switch { 0 => "厂标", 1 => "国标", 2 => "个人", _ => "" }; + + public async Task> GetPagedAsync(int pageIndex, int pageSize, string keyword, int? standard, string guige) + { + var all = await _repo.GetAllAsync(); + var q = all.AsQueryable(); + if (!string.IsNullOrWhiteSpace(keyword)) q = q.Where(x => x.Grade.Contains(keyword)); + if (standard.HasValue) q = q.Where(x => x.Standard == standard.Value); + if (!string.IsNullOrWhiteSpace(guige)) q = q.Where(x => x.Guige == guige); + var total = q.LongCount(); + var items = q.OrderBy(x => x.Standard).ThenBy(x => x.Guige).ThenBy(x => x.GradeScore) + .Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList(); + return new PageResult { Items = items.Select(ToDto).ToList(), Total = total, PageIndex = pageIndex, PageSize = pageSize }; + } + + public async Task CreateAsync(ShengsiDingjizhibiaoDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Grade)) throw new ServiceException("等级不能为空"); + var e = Map(new ShengsiDingjizhibiao(), dto); + await _repo.AddAsync(e); + await _repo.SaveChangesAsync(); + return ToDto(e); + } + + public async Task UpdateAsync(long id, ShengsiDingjizhibiaoDto dto) + { + var e = await _repo.GetByIdAsync(id); + if (e == null) throw new ServiceException("记录不存在", 404); + Map(e, dto); + await _repo.UpdateAsync(e); + await _repo.SaveChangesAsync(); + return ToDto(e); + } + + public async Task DeleteAsync(long id) + { + var e = await _repo.GetByIdAsync(id); + if (e == null) throw new ServiceException("记录不存在", 404); + await _repo.DeleteAsync(e); + await _repo.SaveChangesAsync(); + } + + private static ShengsiDingjizhibiao Map(ShengsiDingjizhibiao e, ShengsiDingjizhibiaoDto d) + { + e.Standard = d.Standard; e.Guige = d.Guige; e.Grade = d.Grade; e.GradeScore = d.GradeScore; + e.Piancha = d.Piancha; e.Dacha = d.Dacha; e.Erdu = d.Erdu; e.Sandu = d.Sandu; + e.Qingjie = d.Qingjie; e.Jiejing = d.Jiejing; + return e; + } + + private static ShengsiDingjizhibiaoDto ToDto(ShengsiDingjizhibiao e) => new() + { + Id = e.Id, Standard = e.Standard, StandardName = StandardName(e.Standard), Guige = e.Guige, Grade = e.Grade, + GradeScore = e.GradeScore, Piancha = e.Piancha, Dacha = e.Dacha, Erdu = e.Erdu, Sandu = e.Sandu, + Qingjie = e.Qingjie, Jiejing = e.Jiejing + }; +} + +// ==================== 机组车台 ==================== +public interface IJizuChetaiService +{ + Task> GetPagedAsync(int pageIndex, int pageSize, string keyword); + Task GetByDepIdAndJizuAsync(long deptId, int jizu); + Task CreateAsync(JizuChetaiDto dto); + Task UpdateAsync(long id, JizuChetaiDto dto); + Task DeleteAsync(long id); +} + +public class JizuChetaiService : IJizuChetaiService +{ + private readonly IRepository _repo; + private readonly IRepository _deptRepo; + public JizuChetaiService(IRepository repo, IRepository deptRepo) + { + _repo = repo; _deptRepo = deptRepo; + } + + public async Task> GetPagedAsync(int pageIndex, int pageSize, string keyword) + { + var all = await _repo.GetAllAsync(); + var q = all.AsQueryable(); + var depts = await _deptRepo.GetAllAsync(); + var deptDict = depts.ToDictionary(d => d.Id, d => d.Name); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var kwDeptIds = depts.Where(d => d.Name.Contains(keyword)).Select(d => d.Id).ToHashSet(); + q = q.Where(x => kwDeptIds.Contains(x.DepartmentId) || x.Jizu.ToString().Contains(keyword)); + } + var total = q.LongCount(); + var items = q.OrderBy(x => x.DepartmentId).ThenBy(x => x.Jizu).Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList(); + return new PageResult + { + Items = items.Select(x => new JizuChetaiDto + { + Id = x.Id, DepartmentId = x.DepartmentId, DepartmentName = deptDict.GetValueOrDefault(x.DepartmentId, ""), + Jizu = x.Jizu, ChetaiStart = x.ChetaiStart, ChetaiEnd = x.ChetaiEnd + }).ToList(), + Total = total, PageIndex = pageIndex, PageSize = pageSize + }; + } + + public async Task GetByDepIdAndJizuAsync(long deptId, int jizu) + { + var e = await _repo.FirstOrDefaultAsync(x => x.DepartmentId == deptId && x.Jizu == jizu); + if (e == null) return null; + return await GetDto(e); + } + + public async Task CreateAsync(JizuChetaiDto dto) + { + var e = new JizuChetai { DepartmentId = dto.DepartmentId, Jizu = dto.Jizu, ChetaiStart = dto.ChetaiStart, ChetaiEnd = dto.ChetaiEnd }; + await _repo.AddAsync(e); + await _repo.SaveChangesAsync(); + return await GetDto(e); + } + + public async Task UpdateAsync(long id, JizuChetaiDto dto) + { + var e = await _repo.GetByIdAsync(id); + if (e == null) throw new ServiceException("记录不存在", 404); + e.DepartmentId = dto.DepartmentId; e.Jizu = dto.Jizu; e.ChetaiStart = dto.ChetaiStart; e.ChetaiEnd = dto.ChetaiEnd; + await _repo.UpdateAsync(e); + await _repo.SaveChangesAsync(); + return await GetDto(e); + } + + public async Task DeleteAsync(long id) + { + var e = await _repo.GetByIdAsync(id); + if (e == null) throw new ServiceException("记录不存在", 404); + await _repo.DeleteAsync(e); + await _repo.SaveChangesAsync(); + } + + private async Task GetDto(JizuChetai e) + { + var dept = await _deptRepo.GetByIdAsync(e.DepartmentId); + return new JizuChetaiDto { Id = e.Id, DepartmentId = e.DepartmentId, DepartmentName = dept?.Name ?? "", Jizu = e.Jizu, ChetaiStart = e.ChetaiStart, ChetaiEnd = e.ChetaiEnd }; + } +} diff --git a/backend/F9WebAiTest.Application/Services/BusinessChangedEvent.cs b/backend/F9WebAiTest.Application/Services/BusinessChangedEvent.cs new file mode 100644 index 0000000..756590f --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/BusinessChangedEvent.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace F9WebAiTest.Application.Services; + +/// +/// 业务状态变更事件(通用)。 +/// 业务单据状态变化时发布,Api 层订阅后通过 SignalR 推送给所有在线客户端, +/// 各端监听后刷新已打开的相关页面,实现多端实时同步。 +/// +public record BusinessChangedEvent( + string BusinessType, // 业务类型,如 "LeaveApproval" + string BusinessId, // 业务单据 ID + string Action // 动作,如 "StatusChanged" / "Created" / "Completed" +) : INotification; diff --git a/backend/F9WebAiTest.Application/Services/Notice/INotificationPusher.cs b/backend/F9WebAiTest.Application/Services/Notice/INotificationPusher.cs new file mode 100644 index 0000000..5229d19 --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Notice/INotificationPusher.cs @@ -0,0 +1,26 @@ +using F9WebAiTest.Application.Dtos.Notice; + +namespace F9WebAiTest.Application.Services.Notice; + +/// +/// 消息实时推送器(抽象)。 +/// 定义在应用层、实现在 Api 层(依赖 SignalR 的 IHubContext 与在线登记表), +/// 避免应用层反向依赖表示层,同时让"创建消息即推送"这一动作可以写在命令处理器里。 +/// +public interface INotificationPusher +{ + /// + /// 判断用户当前是否在线。 + /// 判定依据:该用户在 SignalR Hub 上是否有活动连接(由 Hub 连接/断开时登记的内存表得出)。 + /// + bool IsOnline(long userId); + + /// + /// 推送新消息给指定用户。 + /// 返回 true 表示已推送给其在线连接;返回 false 表示用户离线(未推送,消息已落库,上线后从"我的消息"拉取)。 + /// + Task PushNewMessageAsync(long userId, NotificationMessageDto message, CancellationToken ct = default); + + /// 推送未读统计,用于消息角标实时同步(用户离线时无需推送)。 + Task PushUnreadCountAsync(long userId, int unreadCount, int todoCount, CancellationToken ct = default); +} diff --git a/backend/F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs b/backend/F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs new file mode 100644 index 0000000..99ed643 --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs @@ -0,0 +1,276 @@ +using System.Text.Json; +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Notice; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities; +using F9WebAiTest.Domain.Entities.Notice; +using MediatR; + +namespace F9WebAiTest.Application.Services.Notice; + +/// +/// 业务系统触发消息创建的命令(通用化)。 +/// 用法:await _mediator.Send(new CreateNotificationCommand { NotificationTypeCode = "LeaveApproval", ... }); +/// +public class CreateNotificationCommand : IRequest +{ + public string NotificationTypeCode { get; set; } = ""; // 消息类型编码,如 "LeaveApproval" + public string BusinessType { get; set; } = ""; // 业务类型标识 + public string BusinessId { get; set; } = ""; // 业务单据ID + public string Title { get; set; } = ""; // 标题 + public string Content { get; set; } = ""; // 内容 + public NotificationPriority Priority { get; set; } = NotificationPriority.Normal; + public string BizContext { get; set; } = ""; // 业务上下文(JSON) + public List ForceReceivers { get; set; } = new(); // 强制接收人(绕过订阅) + public string RoutePath { get; set; } = ""; // 跳转路由(可选,覆盖类型模板) + public List AssigneeIds { get; set; } = new(); // 待办处理人(Category=Todo 时生效) + public DateTime? DueTime { get; set; } // 待办截止时间 + public long CreatorId { get; set; } // 创建人ID + public string CreatorName { get; set; } = ""; // 创建人姓名 +} + +/// +/// 业务侧发送消息的简洁命令(基于消息类型枚举)。 +/// 用法(参照 CQRS 惯例): +/// var ok = await mediator.Send(new SendNotificationCommand( +/// NotificationTypeEnum.LeaveApproval, +/// BusinessId: leaveId.ToString(), +/// ForceReceivers: approverIds, +/// AssigneeIds: approverIds)); +/// if (!ok) throw new F9OperationException("请假审批消息发送失败"); +/// +public record SendNotificationCommand( + NotificationTypeEnum Type, + string BusinessId = "", + string Title = "", + string Content = "", + string BizContext = "", + NotificationPriority Priority = NotificationPriority.Normal, + List ForceReceivers = null, + List AssigneeIds = null, + DateTime? DueTime = null +) : IRequest; + +public class SendNotificationCommandHandler : IRequestHandler +{ + private readonly IMediator _mediator; + + public SendNotificationCommandHandler(IMediator mediator) => _mediator = mediator; + + public async Task Handle(SendNotificationCommand command, CancellationToken cancellationToken) + { + // 枚举名即消息类型 Code,复用底层创建命令 + var msgId = await _mediator.Send(new CreateNotificationCommand + { + NotificationTypeCode = command.Type.ToString(), + BusinessType = command.Type.ToString(), + BusinessId = command.BusinessId ?? "", + Title = command.Title ?? "", + Content = command.Content ?? "", + BizContext = command.BizContext ?? "", + Priority = command.Priority, + ForceReceivers = command.ForceReceivers ?? new List(), + AssigneeIds = command.AssigneeIds ?? new List(), + DueTime = command.DueTime + }, cancellationToken); + + return msgId > 0; + } +} + +/// +/// 创建消息(业务触发的统一入口)。 +/// 创建后**立即**确定接收人、写入接收人表并实时推送(已移除后台投递服务,无轮询、无延迟)。 +/// 接收人规则:ForceReceivers 非空 → 精确送达;否则 → 按该消息类型的订阅关系广播。 +/// 可靠性:接收人幂等(MessageId + UserId 唯一);用户离线时推送无接收者但消息已落库,上线后拉取兜底。 +/// +public class CreateNotificationCommandHandler : IRequestHandler +{ + private readonly IRepository _typeRepo; + private readonly IRepository _msgRepo; + private readonly IRepository _todoRepo; + private readonly IRepository _subRepo; + private readonly IRepository _receiverRepo; + private readonly INotificationPusher _pusher; + + public CreateNotificationCommandHandler( + IRepository typeRepo, + IRepository msgRepo, + IRepository todoRepo, + IRepository subRepo, + IRepository receiverRepo, + INotificationPusher pusher) + { + _typeRepo = typeRepo; + _msgRepo = msgRepo; + _todoRepo = todoRepo; + _subRepo = subRepo; + _receiverRepo = receiverRepo; + _pusher = pusher; + } + + public async Task Handle(CreateNotificationCommand cmd, CancellationToken cancellationToken) + { + var type = await _typeRepo.FirstOrDefaultAsync(t => t.Code == cmd.NotificationTypeCode); + if (type == null) throw new ServiceException($"消息类型「{cmd.NotificationTypeCode}」不存在,请先由管理员创建"); + if (!type.IsActive) throw new ServiceException($"消息类型「{type.Name}」已关闭订阅"); + + // 生成跳转路由:命令直接指定 > 类型模板替换业务ID + var routePath = !string.IsNullOrWhiteSpace(cmd.RoutePath) + ? cmd.RoutePath + : (!string.IsNullOrWhiteSpace(type.RouteTemplate) + ? type.RouteTemplate.Replace("{businessId}", cmd.BusinessId ?? "") + : ""); + + var msg = new NotificationMessage + { + NotificationTypeId = type.Id, + MessageTypeCode = type.Code, + BusinessType = cmd.BusinessType, + BusinessId = cmd.BusinessId, + Title = cmd.Title, + Content = cmd.Content, + Priority = cmd.Priority, + Status = NotificationStatus.Sent, // 即时送达:无后台队列,创建即投递 + BizContext = cmd.BizContext ?? "", + ForceReceivers = cmd.ForceReceivers == null || cmd.ForceReceivers.Count == 0 + ? "" + : JsonSerializer.Serialize(cmd.ForceReceivers), + CreatorId = cmd.CreatorId, + CreatorName = cmd.CreatorName, + Category = type.Category, + RoutePath = routePath + }; + + await _msgRepo.AddAsync(msg); + await _msgRepo.SaveChangesAsync(); + + // 待办消息:为每个处理人生成待办事项 + if (type.Category == NotificationCategory.Todo && cmd.AssigneeIds != null && cmd.AssigneeIds.Count > 0) + { + foreach (var assigneeId in cmd.AssigneeIds.Distinct()) + { + await _todoRepo.AddAsync(new TodoItem + { + MessageId = msg.Id, + AssigneeId = assigneeId, + TodoStatus = TodoItemStatus.Pending, + DueTime = cmd.DueTime + }); + } + await _todoRepo.SaveChangesAsync(); + } + + // 即时投递 + 实时推送 + await DeliverAsync(msg, type, cancellationToken); + + return msg.Id; + } + + /// 确定接收人 → 写接收人表 → 实时推送(推送失败不影响业务,消息已落库可拉取) + private async Task DeliverAsync(NotificationMessage msg, NotificationType type, CancellationToken ct) + { + // 1. 确定接收人(精确送达 与 订阅广播 二选一) + var receiverIds = new HashSet(); + var hasForceReceivers = false; + + if (!string.IsNullOrWhiteSpace(msg.ForceReceivers)) + { + try + { + var force = JsonSerializer.Deserialize>(msg.ForceReceivers); + if (force != null && force.Count > 0) + { + hasForceReceivers = true; + foreach (var id in force) receiverIds.Add(id); + } + } + catch { /* 忽略非法 JSON */ } + } + + if (!hasForceReceivers && type.IsActive) + { + var subs = await _subRepo.FindAsync(s => s.NotificationTypeId == msg.NotificationTypeId && s.IsActive); + foreach (var s in subs) receiverIds.Add(s.UserId); + } + + if (receiverIds.Count == 0) return; + + // 2. 写接收人(幂等:MessageId + UserId 唯一) + var added = new List(); + foreach (var userId in receiverIds) + { + var exist = await _receiverRepo.FirstOrDefaultAsync(r => r.MessageId == msg.Id && r.UserId == userId); + if (exist != null) continue; + + var receiver = new NotificationReceiver + { + MessageId = msg.Id, + UserId = userId, + ReadStatus = NotificationReadStatus.Unread, + SendStatus = NotificationSendStatus.NotSent + }; + await _receiverRepo.AddAsync(receiver); + added.Add(receiver); + } + + if (added.Count == 0) return; + await _receiverRepo.SaveChangesAsync(); + + // 3. 实时推送新消息 + 未读统计 + // 在线(Hub 有活动连接)→ 推送并标记成功; + // 离线 → 不推送、标记 IsOffline,消息已落库,用户上线后从"我的消息"拉取兜底。 + var dto = BuildDto(msg, type.Name); + foreach (var receiver in added) + { + try + { + var delivered = await _pusher.PushNewMessageAsync(receiver.UserId, dto, ct); + if (delivered) + { + receiver.SendStatus = NotificationSendStatus.Success; + receiver.SendTime = DateTime.Now; + receiver.IsOffline = false; + + var unread = await _receiverRepo.CountAsync(r => + r.UserId == receiver.UserId && r.ReadStatus == NotificationReadStatus.Unread); + await _pusher.PushUnreadCountAsync(receiver.UserId, (int)unread, 0, ct); + } + else + { + // 用户离线:不推送,等待上线拉取 + receiver.SendStatus = NotificationSendStatus.NotSent; + receiver.IsOffline = true; + } + } + catch (Exception ex) + { + // 推送异常(连接刚好断开等):标记失败与离线,消息已持久化,上线后拉取兜底 + receiver.SendStatus = NotificationSendStatus.Failed; + receiver.IsOffline = true; + receiver.ErrorMsg = ex.Message; + } + + await _receiverRepo.UpdateAsync(receiver); + } + await _receiverRepo.SaveChangesAsync(); + } + + private static NotificationMessageDto BuildDto(NotificationMessage msg, string typeName) => new() + { + Id = msg.Id, + NotificationTypeId = msg.NotificationTypeId, + MessageTypeCode = msg.MessageTypeCode, + TypeName = typeName, + Title = msg.Title, + Content = msg.Content, + Priority = (int)msg.Priority, + BusinessType = msg.BusinessType, + BusinessId = msg.BusinessId, + Category = (int)msg.Category, + RoutePath = msg.RoutePath, + CreatedTime = msg.CreatedAt, + ReadStatus = 0 + }; +} diff --git a/backend/F9WebAiTest.Application/Services/Notice/NotificationService.cs b/backend/F9WebAiTest.Application/Services/Notice/NotificationService.cs new file mode 100644 index 0000000..890daf9 --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Notice/NotificationService.cs @@ -0,0 +1,420 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Notice; +using F9WebAiTest.Application.Services.Auth; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities; +using F9WebAiTest.Domain.Entities.Notice; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using RBAC = F9WebAiTest.Domain.Entities.SYS.RBAC; + +namespace F9WebAiTest.Application.Services.Notice; + +public interface INotificationService +{ + Task> GetSubscribeListAsync(long userId); + Task SubscribeAsync(long userId, long typeId, bool subscribe); + Task> GetMyMessagesAsync(long userId, int pageIndex, int pageSize, int? readStatus, int? category); + Task MarkReadAsync(long userId, long messageId); + Task MarkAllReadAsync(long userId); + Task GetUnreadCountAsync(long userId); + + // ---- 待办 ---- + Task> GetMyTodosAsync(long userId, int? status); + Task CompleteTodoAsync(long userId, long todoId, string handleResult); + + // ---- 管理端:消息类型维护 ---- + Task> GetTypeListAsync(); + Task CreateTypeAsync(NotificationTypeCreateDto dto, long creatorId); + Task UpdateTypeAsync(long id, NotificationTypeCreateDto dto); + Task DeleteTypeAsync(long id); +} + +public class NotificationService : INotificationService +{ + private readonly IRepository _typeRepo; + private readonly IRepository _subRepo; + private readonly IRepository _msgRepo; + private readonly IRepository _receiverRepo; + private readonly IRepository _todoRepo; + private readonly IRepository _roleMenuRepo; + private readonly IRepository _permissionRepo; + private readonly ISnowflakeIdGenerator _snowflake; + private readonly IAuthService _authService; + + public NotificationService( + IRepository typeRepo, + IRepository subRepo, + IRepository msgRepo, + IRepository receiverRepo, + IRepository todoRepo, + IRepository roleMenuRepo, + IRepository permissionRepo, + ISnowflakeIdGenerator snowflake, + IAuthService authService) + { + _typeRepo = typeRepo; + _subRepo = subRepo; + _msgRepo = msgRepo; + _receiverRepo = receiverRepo; + _todoRepo = todoRepo; + _roleMenuRepo = roleMenuRepo; + _permissionRepo = permissionRepo; + _snowflake = snowflake; + _authService = authService; + } + + public async Task> GetSubscribeListAsync(long userId) + { + var types = (await _typeRepo.FindAsync(t => t.IsActive)).OrderBy(t => t.Sort).ThenBy(t => t.Id).ToList(); + var perms = await _authService.GetUserPermissionsAsync(userId); + var subs = await _subRepo.FindAsync(s => s.UserId == userId && s.IsActive); + var subIds = subs.Select(s => s.NotificationTypeId).ToHashSet(); + + return types.Select(t => new NotificationTypeDto + { + Id = t.Id, + Code = t.Code, + Name = t.Name, + Description = t.Description, + RequiredPermission = t.RequiredPermission, + IsActive = t.IsActive, + CreatedBy = t.CreatedBy, + Sort = t.Sort, + Category = (int)t.Category, + Icon = t.Icon, + RouteTemplate = t.RouteTemplate, + Subscribed = subIds.Contains(t.Id), + CanSubscribe = string.IsNullOrEmpty(t.RequiredPermission) || perms.Contains(t.RequiredPermission) + }).ToList(); + } + + public async Task SubscribeAsync(long userId, long typeId, bool subscribe) + { + var type = await _typeRepo.GetByIdAsync(typeId); + if (type == null || !type.IsActive) throw new ServiceException("消息类型不存在或已关闭订阅"); + + // 权限校验:RequiredPermission 为空表示所有用户可订阅 + if (!string.IsNullOrEmpty(type.RequiredPermission)) + { + var perms = await _authService.GetUserPermissionsAsync(userId); + if (!perms.Contains(type.RequiredPermission)) + throw new ServiceException($"您没有订阅「{type.Name}」的权限", 403); + } + + var sub = await _subRepo.FirstOrDefaultAsync(s => s.UserId == userId && s.NotificationTypeId == typeId); + if (subscribe) + { + if (sub == null) + { + await _subRepo.AddAsync(new UserNotificationSubscription + { + UserId = userId, + NotificationTypeId = typeId, + IsActive = true, + SubscribedTime = DateTime.Now + }); + } + else + { + sub.IsActive = true; + sub.SubscribedTime = DateTime.Now; + sub.CancelTime = null; + await _subRepo.UpdateAsync(sub); + } + } + else + { + if (sub != null && sub.IsActive) + { + sub.IsActive = false; + sub.CancelTime = DateTime.Now; + await _subRepo.UpdateAsync(sub); + } + } + await _subRepo.SaveChangesAsync(); + } + + public async Task> GetMyMessagesAsync(long userId, int pageIndex, int pageSize, int? readStatus, int? category) + { + var receivers = await _receiverRepo.FindAsync(r => r.UserId == userId); + if (readStatus.HasValue) + receivers = receivers.Where(r => (int)r.ReadStatus == readStatus.Value).ToList(); + + var msgIds = receivers.Select(r => r.MessageId).Distinct().ToList(); + var msgs = await _msgRepo.FindAsync(m => msgIds.Contains(m.Id)); + if (category.HasValue) + msgs = msgs.Where(m => (int)m.Category == category.Value).ToList(); + var msgDict = msgs.ToDictionary(m => m.Id); + + var typeIds = msgs.Select(m => m.NotificationTypeId).Distinct().ToList(); + var types = await _typeRepo.FindAsync(t => typeIds.Contains(t.Id)); + var typeDict = types.ToDictionary(t => t.Id, t => t.Name); + + var items = receivers + .Where(r => msgDict.ContainsKey(r.MessageId)) + .Select(r => + { + var m = msgDict[r.MessageId]; + return new NotificationMessageDto + { + Id = m.Id, + NotificationTypeId = m.NotificationTypeId, + MessageTypeCode = m.MessageTypeCode, + TypeName = typeDict.GetValueOrDefault(m.NotificationTypeId, ""), + Title = m.Title, + Content = m.Content, + Priority = (int)m.Priority, + BusinessType = m.BusinessType, + BusinessId = m.BusinessId, + Category = (int)m.Category, + RoutePath = m.RoutePath, + CreatedTime = m.CreatedAt, + ReadStatus = (int)r.ReadStatus, + ReadTime = r.ReadTime + }; + }) + .OrderByDescending(x => x.CreatedTime) + .ToList(); + + var total = items.Count; + var paged = items.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList(); + + return new PageResult + { + Items = paged, + Total = total, + PageIndex = pageIndex, + PageSize = pageSize + }; + } + + public async Task MarkReadAsync(long userId, long messageId) + { + var receiver = await _receiverRepo.FirstOrDefaultAsync(r => r.MessageId == messageId && r.UserId == userId); + if (receiver == null) throw new ServiceException("消息不存在", 404); + receiver.ReadStatus = NotificationReadStatus.Read; + receiver.ReadTime = DateTime.Now; + await _receiverRepo.UpdateAsync(receiver); + await _receiverRepo.SaveChangesAsync(); + } + + public async Task MarkAllReadAsync(long userId) + { + var unread = await _receiverRepo.FindAsync(r => r.UserId == userId && r.ReadStatus == NotificationReadStatus.Unread); + foreach (var r in unread) + { + r.ReadStatus = NotificationReadStatus.Read; + r.ReadTime = DateTime.Now; + await _receiverRepo.UpdateAsync(r); + } + } + + public async Task GetUnreadCountAsync(long userId) + => (int)await _receiverRepo.CountAsync(r => r.UserId == userId && r.ReadStatus == NotificationReadStatus.Unread); + + // ==================== 待办 ==================== + + public async Task> GetMyTodosAsync(long userId, int? status) + { + var todos = await _todoRepo.FindAsync(t => t.AssigneeId == userId); + if (status.HasValue) + todos = todos.Where(t => (int)t.TodoStatus == status.Value).ToList(); + + var msgIds = todos.Select(t => t.MessageId).Distinct().ToList(); + var msgs = await _msgRepo.FindAsync(m => msgIds.Contains(m.Id)); + var msgDict = msgs.ToDictionary(m => m.Id); + + return todos + .Where(t => msgDict.ContainsKey(t.MessageId)) + .OrderByDescending(t => t.CreatedAt) + .Select(t => + { + var m = msgDict[t.MessageId]; + return new TodoItemDto + { + Id = t.Id, + MessageId = t.MessageId, + Title = m.Title, + Content = m.Content, + Priority = (int)m.Priority, + BusinessType = m.BusinessType, + BusinessId = m.BusinessId, + RoutePath = m.RoutePath, + TodoStatus = (int)t.TodoStatus, + DueTime = t.DueTime, + CompletedTime = t.CompletedTime, + CreatedTime = t.CreatedAt + }; + }) + .ToList(); + } + + public async Task CompleteTodoAsync(long userId, long todoId, string handleResult) + { + var todo = await _todoRepo.GetByIdAsync(todoId); + if (todo == null) throw new ServiceException("待办不存在", 404); + if (todo.AssigneeId != userId) throw new ServiceException("您不是该待办的处理人", 403); + if (todo.TodoStatus == TodoItemStatus.Completed) throw new ServiceException("该待办已处理"); + + todo.TodoStatus = TodoItemStatus.Completed; + todo.CompletedTime = DateTime.Now; + todo.HandleResult = handleResult ?? ""; + await _todoRepo.UpdateAsync(todo); + await _todoRepo.SaveChangesAsync(); + + // 完成待办后,标记对应消息已读(处理完了才从"未读提醒"中消失) + var receiver = await _receiverRepo.FirstOrDefaultAsync(r => r.MessageId == todo.MessageId && r.UserId == userId); + if (receiver != null && receiver.ReadStatus == NotificationReadStatus.Unread) + { + receiver.ReadStatus = NotificationReadStatus.Read; + receiver.ReadTime = DateTime.Now; + await _receiverRepo.UpdateAsync(receiver); + await _receiverRepo.SaveChangesAsync(); + } + } + + // ==================== 管理端:消息类型维护 ==================== + + public async Task> GetTypeListAsync() + { + var types = (await _typeRepo.GetAllAsync()).OrderBy(t => t.Sort).ThenBy(t => t.Id).ToList(); + return types.Select(t => new NotificationTypeDto + { + Id = t.Id, + Code = t.Code, + Name = t.Name, + Description = t.Description, + RequiredPermission = t.RequiredPermission, + IsActive = t.IsActive, + CreatedBy = t.CreatedBy, + Sort = t.Sort, + Category = (int)t.Category, + Icon = t.Icon, + RouteTemplate = t.RouteTemplate + }).ToList(); + } + + public async Task CreateTypeAsync(NotificationTypeCreateDto dto, long creatorId) + { + if (string.IsNullOrWhiteSpace(dto.Code)) throw new ServiceException("消息类型编码不能为空"); + if (string.IsNullOrWhiteSpace(dto.Name)) throw new ServiceException("消息类型名称不能为空"); + if (await _typeRepo.AnyAsync(t => t.Code == dto.Code)) throw new ServiceException($"编码「{dto.Code}」已存在"); + + var e = new NotificationType + { + Code = dto.Code.Trim(), + Name = dto.Name, + Description = dto.Description ?? "", + RequiredPermission = dto.RequiredPermission ?? "", + IsActive = dto.IsActive, + CreatedBy = creatorId, + Sort = dto.Sort, + Category = (NotificationCategory)dto.Category, + Icon = dto.Icon ?? "", + RouteTemplate = dto.RouteTemplate ?? "" + }; + await _typeRepo.AddAsync(e); + await _typeRepo.SaveChangesAsync(); + + // 同步订阅权限到 RBAC 权限单元(角色管理里可给角色分配) + await SyncSubscribePermissionAsync(e); + + return new NotificationTypeDto + { + Id = e.Id, Code = e.Code, Name = e.Name, Description = e.Description, + RequiredPermission = e.RequiredPermission, IsActive = e.IsActive, CreatedBy = e.CreatedBy, Sort = e.Sort, + Category = (int)e.Category, Icon = e.Icon, RouteTemplate = e.RouteTemplate + }; + } + + public async Task UpdateTypeAsync(long id, NotificationTypeCreateDto dto) + { + var e = await _typeRepo.GetByIdAsync(id); + if (e == null) throw new ServiceException("消息类型不存在", 404); + if (string.IsNullOrWhiteSpace(dto.Name)) throw new ServiceException("消息类型名称不能为空"); + + e.Name = dto.Name; + e.Description = dto.Description ?? ""; + e.RequiredPermission = dto.RequiredPermission ?? ""; + e.IsActive = dto.IsActive; + e.Sort = dto.Sort; + e.Category = (NotificationCategory)dto.Category; + e.Icon = dto.Icon ?? ""; + e.RouteTemplate = dto.RouteTemplate ?? ""; + await _typeRepo.UpdateAsync(e); + await _typeRepo.SaveChangesAsync(); + + // 同步订阅权限到 RBAC 权限单元 + await SyncSubscribePermissionAsync(e); + + return new NotificationTypeDto + { + Id = e.Id, Code = e.Code, Name = e.Name, Description = e.Description, + RequiredPermission = e.RequiredPermission, IsActive = e.IsActive, CreatedBy = e.CreatedBy, Sort = e.Sort, + Category = (int)e.Category, Icon = e.Icon, RouteTemplate = e.RouteTemplate + }; + } + + public async Task DeleteTypeAsync(long id) + { + var e = await _typeRepo.GetByIdAsync(id); + if (e == null) throw new ServiceException("消息类型不存在", 404); + + // 清理对应的订阅权限及其角色关联 + var permission = await _permissionRepo.FirstOrDefaultAsync(p => + p.ResourceType == Resource_Type_Enum.消息订阅 && p.ResourceId == e.Id); + if (permission != null) + { + var relations = await _roleMenuRepo.FindAsync(rp => rp.PermissionId == permission.Id); + if (relations.Any()) await _roleMenuRepo.HardDeleteRangeAsync(relations); + await _permissionRepo.HardDeleteAsync(permission); + await _permissionRepo.SaveChangesAsync(); + } + + await _typeRepo.DeleteAsync(e); + await _typeRepo.SaveChangesAsync(); + } + + /// + /// 将消息类型的订阅权限同步为 RBAC 的权限单元(ResourceType = 消息订阅), + /// 这样在「角色管理 → 分配权限」里就能给角色勾选该消息类型的订阅权限。 + /// + private async Task SyncSubscribePermissionAsync(NotificationType type) + { + // 无 RequiredPermission 表示所有用户可订阅,无需权限单元(清理旧权限) + if (string.IsNullOrEmpty(type.RequiredPermission)) + { + var old = await _permissionRepo.FirstOrDefaultAsync(p => + p.ResourceType == Resource_Type_Enum.消息订阅 && p.ResourceId == type.Id); + if (old != null) + { + var relations = await _roleMenuRepo.FindAsync(rp => rp.PermissionId == old.Id); + if (relations.Any()) await _roleMenuRepo.HardDeleteRangeAsync(relations); + await _permissionRepo.HardDeleteAsync(old); + } + await _permissionRepo.SaveChangesAsync(); + return; + } + + // 查找该消息类型的订阅权限单元(按 ResourceType + ResourceId 幂等) + var permission = await _permissionRepo.FirstOrDefaultAsync(p => + p.ResourceType == Resource_Type_Enum.消息订阅 && p.ResourceId == type.Id); + if (permission == null) + { + permission = new RBAC.Permission( + _snowflake.CreateId(), + Permission_Type_Enum.Visble, + Resource_Type_Enum.消息订阅, + type.Id.ToString(), + type.Id); + await _permissionRepo.AddAsync(permission); + } + + // 业务权限码与 RBAC 权限码保持一致(SubscribeAsync 用 RequiredPermission 校验) + permission.SetCode(type.RequiredPermission); + await _permissionRepo.UpdateAsync(permission); + await _permissionRepo.SaveChangesAsync(); + } +} diff --git a/backend/F9WebAiTest.Application/Services/Prod/HbjyService.cs b/backend/F9WebAiTest.Application/Services/Prod/HbjyService.cs new file mode 100644 index 0000000..1e17053 --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Prod/HbjyService.cs @@ -0,0 +1,272 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Prod; +using F9WebAiTest.Application.Services.Notice; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities; +using F9WebAiTest.Domain.Entities.Base; +using F9WebAiTest.Domain.Entities.Notice; +using F9WebAiTest.Domain.Entities.Prod; +using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments; +using MediatR; + +namespace F9WebAiTest.Application.Services.Prod; + +public interface IHbjyService +{ + Task> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId); + Task GetEditAsync(long lsjlId); + Task SaveAsync(HbjySaveDto dto); +} + +public class HbjyService : IHbjyService +{ + private readonly IRepository _lsjlRepo; + private readonly IRepository _hbjyLsjlRepo; + private readonly IRepository _hbjyRecordRepo; + private readonly IRepository _deptRepo; + private readonly IRepository _zkRepo; + private readonly IMediator _mediator; + + public HbjyService( + IRepository lsjlRepo, + IRepository hbjyLsjlRepo, + IRepository hbjyRecordRepo, + IRepository deptRepo, + IRepository zkRepo, + IMediator mediator) + { + _lsjlRepo = lsjlRepo; + _hbjyLsjlRepo = hbjyLsjlRepo; + _hbjyRecordRepo = hbjyRecordRepo; + _deptRepo = deptRepo; + _zkRepo = zkRepo; + _mediator = mediator; + } + + public async Task> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId) + { + var lsjls = await _lsjlRepo.GetAllAsync(); + var q = lsjls.AsQueryable(); + if (date.HasValue) q = q.Where(x => x.WorkDate.Date == date.Value.Date); + if (deptId.HasValue) q = q.Where(x => x.DeptId == deptId.Value); + + var depts = await _deptRepo.GetAllAsync(); + var deptDict = depts.ToDictionary(d => d.Id, d => d.Name); + var zks = await _zkRepo.GetAllAsync(); + var zkDict = zks.ToDictionary(z => z.Id, z => z.ZhuangKouName); + + var sums = (await _hbjyLsjlRepo.GetAllAsync()).ToDictionary(h => h.LsjlId); + + var total = q.LongCount(); + var items = q.OrderByDescending(x => x.WorkDate).ThenByDescending(x => x.Id) + .Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList(); + + return new PageResult + { + Items = items.Select(x => + { + sums.TryGetValue(x.Id, out var sum); + return new HbjyListItemDto + { + LsjlId = x.Id, + WorkDate = x.WorkDate, + DeptId = x.DeptId, + DeptName = deptDict.GetValueOrDefault(x.DeptId, ""), + Jizu = x.Jizu, + Huishu = x.Huishu, + Guige = x.Guige, + ZhuangKouName = zkDict.GetValueOrDefault(x.GyzkId, ""), + Yanse = x.Yanse, + Isbanche = x.Isbanche, + IsbancheName = x.Isbanche ? "半车" : "整车", + LuosiTime = x.LuosiTime, + Chetaishu = x.Chetaishu, + ChetaiStart = x.ChetaiStart, + ChetaiEnd = x.ChetaiEnd, + YingluPianshu = x.Chetaishu * 20, + IsChecked = sum != null, + Pianshu = sum?.Pianshu ?? 0, + Qingjie = sum?.Qingjie ?? 0, + Jiejing = sum?.Jiejing ?? 0, + Dengji = sum?.Dengji ?? "" + }; + }).ToList(), + Total = total, PageIndex = pageIndex, PageSize = pageSize + }; + } + + public async Task GetEditAsync(long lsjlId) + { + var lsjl = await _lsjlRepo.GetByIdAsync(lsjlId); + if (lsjl == null) throw new ServiceException("落丝记录不存在", 404); + + var dept = await _deptRepo.GetByIdAsync(lsjl.DeptId); + var zk = await _zkRepo.GetByIdAsync(lsjl.GyzkId); + + var sum = await _hbjyLsjlRepo.FirstOrDefaultAsync(h => h.LsjlId == lsjlId); + var records = await _hbjyRecordRepo.FindAsync(h => h.LsjlId == lsjlId); + + List items; + if (records.Count > 0) + { + items = records.OrderBy(r => r.Chetaihao).Select(ToDto).ToList(); + } + else + { + items = new List(); + for (int i = lsjl.ChetaiStart; i <= lsjl.ChetaiEnd; i++) + { + items.Add(new HbjyRecordDto { Chetaihao = i }); + } + } + + return new HbjyEditDto + { + LsjlId = lsjl.Id, + DeptName = dept?.Name ?? "", + Jizu = lsjl.Jizu, + Huishu = lsjl.Huishu, + ZhuangKouName = zk?.ZhuangKouName ?? "", + Yanse = lsjl.Yanse, + Isbanche = lsjl.Isbanche, + IsbancheName = lsjl.Isbanche ? "半车" : "整车", + Guige = lsjl.Guige, + ChetaiStart = lsjl.ChetaiStart, + ChetaiEnd = lsjl.ChetaiEnd, + Chetaishu = lsjl.Chetaishu, + YingluPianshu = lsjl.Chetaishu * 20, + Dengji = sum?.Dengji ?? "", + Records = items + }; + } + + public async Task SaveAsync(HbjySaveDto dto) + { + if (dto.LsjlId == 0) throw new ServiceException("落丝记录无效"); + var lsjl = await _lsjlRepo.GetByIdAsync(dto.LsjlId); + if (lsjl == null) throw new ServiceException("落丝记录不存在", 404); + + if (dto.Records == null || dto.Records.Count == 0) throw new ServiceException("请先录入检验数据"); + + var invalid = dto.Records.Where(i => i.Chetaihao < lsjl.ChetaiStart || i.Chetaihao > lsjl.ChetaiEnd).ToList(); + if (invalid.Count > 0) throw new ServiceException($"车台号超出范围 [{lsjl.ChetaiStart}-{lsjl.ChetaiEnd}]"); + + // 计算每车台片数 + foreach (var item in dto.Records) + { + item.Pianshu = CalcPianshu(item); + } + + var totalPianshu = dto.Records.Sum(x => x.Pianshu); + if (totalPianshu == 0) throw new ServiceException("请至少录入一个洁净等级片数"); + + // 物理删除旧明细 + var oldRecords = await _hbjyRecordRepo.FindAsync(h => h.LsjlId == dto.LsjlId); + await _hbjyRecordRepo.HardDeleteRangeAsync(oldRecords); + + foreach (var item in dto.Records) + { + await _hbjyRecordRepo.AddAsync(new HbjyRecord + { + LsjlId = dto.LsjlId, + Chetaihao = item.Chetaihao, + YdYidu = item.YdYidu, YdErdu = item.YdErdu, YdSandu = item.YdSandu, + Jj100 = item.Jj100, Jj95 = item.Jj95, Jj90 = item.Jj90, Jj85 = item.Jj85, + Jj80 = item.Jj80, Jj75 = item.Jj75, Jj70 = item.Jj70, Jj65 = item.Jj65, Jj60 = item.Jj60, + QjZycdTdc = item.QjZycdTdc, + QjCycdFs = item.QjCycdFs, QjCycdDc = item.QjCycdDc, QjCycdNfc = item.QjCycdNfc, + QjCycdDcj = item.QjCycdDcj, QjCycdZlx = item.QjCycdZlx, + QjPtcdXc = item.QjPtcdXc, QjPtcdCj = item.QjPtcdCj, QjPtcdLx = item.QjPtcdLx, QjPtcdHjls = item.QjPtcdHjls, + Pianshu = item.Pianshu + }); + } + + // 汇总 + var sum = CalcSummary(dto.Records); + var hbjyLsjl = await _hbjyLsjlRepo.FirstOrDefaultAsync(h => h.LsjlId == dto.LsjlId); + if (hbjyLsjl == null) + { + hbjyLsjl = new HbjyLsjl { LsjlId = dto.LsjlId }; + await _hbjyLsjlRepo.AddAsync(hbjyLsjl); + } + hbjyLsjl.Pianshu = sum.Pianshu; + hbjyLsjl.Qingjie = sum.Qingjie; + hbjyLsjl.Jiejing = sum.Jiejing; + hbjyLsjl.YdErdu = sum.YdErdu; + hbjyLsjl.YdSandu = sum.YdSandu; + hbjyLsjl.Dengji = dto.Dengji ?? ""; + + await _hbjyLsjlRepo.UpdateAsync(hbjyLsjl); + + // 业务系统触发消息:发现特大糙时,通过 MediatR 创建"发现特大糙"消息,由 BackgroundService 投递 + var tdcCount = dto.Records.Sum(x => x.QjZycdTdc); + if (tdcCount > 0) + { + await _mediator.Send(new CreateNotificationCommand + { + NotificationTypeCode = "TdcFound", + BusinessType = "Hbjy", + BusinessId = dto.LsjlId.ToString(), + Title = $"发现特大糙:{lsjl.Guige} 规格", + Content = $"落丝记录(机组 {lsjl.Jizu}、回数 {lsjl.Huishu})黑板检验发现 {tdcCount} 个特大糙,请关注。", + Priority = NotificationPriority.Urgent, + CreatorId = 0, + CreatorName = "系统" + }); + } + } + + // ==================== 计算逻辑 ==================== + + private static HbjyRecordDto ToDto(HbjyRecord r) => new() + { + Chetaihao = r.Chetaihao, + YdYidu = r.YdYidu, YdErdu = r.YdErdu, YdSandu = r.YdSandu, + Jj100 = r.Jj100, Jj95 = r.Jj95, Jj90 = r.Jj90, Jj85 = r.Jj85, + Jj80 = r.Jj80, Jj75 = r.Jj75, Jj70 = r.Jj70, Jj65 = r.Jj65, Jj60 = r.Jj60, + QjZycdTdc = r.QjZycdTdc, + QjCycdFs = r.QjCycdFs, QjCycdDc = r.QjCycdDc, QjCycdNfc = r.QjCycdNfc, + QjCycdDcj = r.QjCycdDcj, QjCycdZlx = r.QjCycdZlx, + QjPtcdXc = r.QjPtcdXc, QjPtcdCj = r.QjPtcdCj, QjPtcdLx = r.QjPtcdLx, QjPtcdHjls = r.QjPtcdHjls, + Pianshu = r.Pianshu + }; + + // 片数 = 洁净等级各列之和 + private static int CalcPianshu(HbjyRecordDto item) + => item.Jj100 + item.Jj95 + item.Jj90 + item.Jj85 + item.Jj80 + item.Jj75 + item.Jj70 + item.Jj65 + item.Jj60; + + // 清洁扣分(特大类1分/个,大类0.4分/个,小类0.1分/个) + private static decimal CalcQingjieDeduction(HbjyRecordDto item) + { + decimal teda = item.QjZycdTdc * 1.0M; + decimal dalei = (item.QjCycdFs + item.QjCycdDc + item.QjCycdNfc + item.QjCycdDcj + item.QjCycdZlx) * 0.4M; + decimal xiaolei = (item.QjPtcdXc + item.QjPtcdCj + item.QjPtcdLx + item.QjPtcdHjls) * 0.1M; + return teda + dalei + xiaolei; + } + + private static HbjyLsjl CalcSummary(List items) + { + int pianshu = items.Sum(x => x.Pianshu); + + // 清洁分 = (片数 - 总扣分) * 100 / 片数 + decimal totalDeduction = items.Sum(CalcQingjieDeduction); + double qingjie = pianshu == 0 ? 0 : Math.Round((double)((pianshu - totalDeduction) * 100 / pianshu), 2, MidpointRounding.AwayFromZero); + + // 洁净分 = (片数*100 - 扣分总和*5) / 片数 + int jj95 = items.Sum(x => x.Jj95), jj90 = items.Sum(x => x.Jj90), jj85 = items.Sum(x => x.Jj85); + int jj80 = items.Sum(x => x.Jj80), jj75 = items.Sum(x => x.Jj75), jj70 = items.Sum(x => x.Jj70); + int jj65 = items.Sum(x => x.Jj65), jj60 = items.Sum(x => x.Jj60); + decimal totalJiejingDeduction = 1 * jj95 + 2 * jj90 + 3 * jj85 + 4 * jj80 + 5 * jj75 + 6 * jj70 + 7 * jj65 + 8 * jj60; + double jiejing = pianshu == 0 ? 0 : Math.Round((double)((pianshu * 100 - totalJiejingDeduction * 5) / (decimal)pianshu), 2, MidpointRounding.AwayFromZero); + + return new HbjyLsjl + { + Pianshu = pianshu, + Qingjie = qingjie, + Jiejing = jiejing, + YdErdu = items.Sum(x => x.YdErdu), + YdSandu = items.Sum(x => x.YdSandu) + }; + } +} diff --git a/backend/F9WebAiTest.Application/Services/Prod/LsjlService.cs b/backend/F9WebAiTest.Application/Services/Prod/LsjlService.cs new file mode 100644 index 0000000..c34cf1e --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Prod/LsjlService.cs @@ -0,0 +1,129 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Prod; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities; +using F9WebAiTest.Domain.Entities.Base; +using F9WebAiTest.Domain.Entities.Prod; +using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments; + +namespace F9WebAiTest.Application.Services.Prod; + +public interface ILsjlService +{ + Task> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId, string keyword); + Task CreateAsync(LsjlCreateDto dto); + Task UpdateAsync(long id, LsjlCreateDto dto); + Task DeleteAsync(long id); +} + +public class LsjlService : ILsjlService +{ + private readonly IRepository _repo; + private readonly IRepository _deptRepo; + private readonly IRepository _zkRepo; + + public LsjlService(IRepository repo, IRepository deptRepo, IRepository zkRepo) + { + _repo = repo; + _deptRepo = deptRepo; + _zkRepo = zkRepo; + } + + public async Task> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId, string keyword) + { + var all = await _repo.GetAllAsync(); + var q = all.AsQueryable(); + if (date.HasValue) + q = q.Where(x => x.WorkDate.Date == date.Value.Date); + if (deptId.HasValue) + q = q.Where(x => x.DeptId == deptId.Value); + + var depts = await _deptRepo.GetAllAsync(); + var deptDict = depts.ToDictionary(d => d.Id, d => d.Name); + var zks = await _zkRepo.GetAllAsync(); + var zkDict = zks.ToDictionary(z => z.Id, z => z.ZhuangKouName); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var kwDeptIds = depts.Where(d => d.Name.Contains(keyword)).Select(d => d.Id).ToHashSet(); + var kwZkIds = zks.Where(z => z.ZhuangKouName.Contains(keyword)).Select(z => z.Id).ToHashSet(); + q = q.Where(x => kwDeptIds.Contains(x.DeptId) || kwZkIds.Contains(x.GyzkId) || x.Guige.Contains(keyword)); + } + + var total = q.LongCount(); + var items = q.OrderByDescending(x => x.WorkDate).ThenByDescending(x => x.Id) + .Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList(); + + return new PageResult + { + Items = items.Select(x => ToDto(x, deptDict, zkDict)).ToList(), + Total = total, PageIndex = pageIndex, PageSize = pageSize + }; + } + + public async Task CreateAsync(LsjlCreateDto dto) + { + if (dto.DeptId == 0) throw new ServiceException("请选择车间"); + if (dto.GyzkId == 0) throw new ServiceException("请选择庄口"); + var e = Map(new LsjlRecord(), dto); + await _repo.AddAsync(e); + await _repo.SaveChangesAsync(); + return await ToDto(e); + } + + public async Task UpdateAsync(long id, LsjlCreateDto dto) + { + var e = await _repo.GetByIdAsync(id); + if (e == null) throw new ServiceException("记录不存在", 404); + Map(e, dto); + await _repo.UpdateAsync(e); + await _repo.SaveChangesAsync(); + return await ToDto(e); + } + + public async Task DeleteAsync(long id) + { + var e = await _repo.GetByIdAsync(id); + if (e == null) throw new ServiceException("记录不存在", 404); + await _repo.DeleteAsync(e); + await _repo.SaveChangesAsync(); + } + + private static LsjlRecord Map(LsjlRecord e, LsjlCreateDto d) + { + e.WorkDate = d.WorkDate; e.DeptId = d.DeptId; e.GyzkId = d.GyzkId; e.Jizu = d.Jizu; + e.LuosiTime = d.LuosiTime; e.Yanse = d.Yanse; e.Huishu = d.Huishu; + e.ChetaiStart = d.ChetaiStart; e.ChetaiEnd = d.ChetaiEnd; e.Chetaishu = d.Chetaishu; + e.Isbanche = d.Isbanche; e.Hours = d.Hours; e.Guige = d.Guige; e.Chesu = d.Chesu; + return e; + } + + private async Task ToDto(LsjlRecord e) + { + var dept = await _deptRepo.GetByIdAsync(e.DeptId); + var zk = await _zkRepo.GetByIdAsync(e.GyzkId); + return new LsjlDto + { + Id = e.Id, WorkDate = e.WorkDate, DeptId = e.DeptId, DeptName = dept?.Name ?? "", + GyzkId = e.GyzkId, ZhuangKouName = zk?.ZhuangKouName ?? "", Jizu = e.Jizu, + LuosiTime = e.LuosiTime, Yanse = e.Yanse, Huishu = e.Huishu, + ChetaiStart = e.ChetaiStart, ChetaiEnd = e.ChetaiEnd, Chetaishu = e.Chetaishu, + Isbanche = e.Isbanche, IsbancheName = e.Isbanche ? "半车" : "整车", + Hours = e.Hours, Guige = e.Guige, Chesu = e.Chesu + }; + } + + private static LsjlDto ToDto(LsjlRecord e, Dictionary deptDict, Dictionary zkDict) + { + return new LsjlDto + { + Id = e.Id, WorkDate = e.WorkDate, DeptId = e.DeptId, DeptName = deptDict.GetValueOrDefault(e.DeptId, ""), + GyzkId = e.GyzkId, ZhuangKouName = zkDict.GetValueOrDefault(e.GyzkId, ""), Jizu = e.Jizu, + LuosiTime = e.LuosiTime, Yanse = e.Yanse, Huishu = e.Huishu, + ChetaiStart = e.ChetaiStart, ChetaiEnd = e.ChetaiEnd, Chetaishu = e.Chetaishu, + Isbanche = e.Isbanche, IsbancheName = e.Isbanche ? "半车" : "整车", + Hours = e.Hours, Guige = e.Guige, Chesu = e.Chesu + }; + } +} diff --git a/backend/F9WebAiTest.Application/Services/Prod/XdjyService.cs b/backend/F9WebAiTest.Application/Services/Prod/XdjyService.cs new file mode 100644 index 0000000..2623097 --- /dev/null +++ b/backend/F9WebAiTest.Application/Services/Prod/XdjyService.cs @@ -0,0 +1,344 @@ +using F9WebAiTest.Application.Common; +using F9WebAiTest.Application.Dtos; +using F9WebAiTest.Application.Dtos.Base; +using F9WebAiTest.Application.Dtos.Prod; +using F9WebAiTest.Application.Services.Notice; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities; +using F9WebAiTest.Domain.Entities.Base; +using F9WebAiTest.Domain.Entities.Notice; +using F9WebAiTest.Domain.Entities.Prod; +using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments; +using MediatR; + +namespace F9WebAiTest.Application.Services.Prod; + +public interface IXdjyService +{ + Task> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId); + Task GetEditAsync(long lsjlId); + Task SaveAsync(XdjySaveDto dto); +} + +public class XdjyService : IXdjyService +{ + private readonly IRepository _lsjlRepo; + private readonly IRepository _xdjyLsjlRepo; + private readonly IRepository _xdjyRecordRepo; + private readonly IRepository _guigeRepo; + private readonly IRepository _deptRepo; + private readonly IRepository _zkRepo; + private readonly IMediator _mediator; + + public XdjyService( + IRepository lsjlRepo, + IRepository xdjyLsjlRepo, + IRepository xdjyRecordRepo, + IRepository guigeRepo, + IRepository deptRepo, + IRepository zkRepo, + IMediator mediator) + { + _lsjlRepo = lsjlRepo; + _xdjyLsjlRepo = xdjyLsjlRepo; + _xdjyRecordRepo = xdjyRecordRepo; + _guigeRepo = guigeRepo; + _deptRepo = deptRepo; + _zkRepo = zkRepo; + _mediator = mediator; + } + + public async Task> GetPagedAsync(int pageIndex, int pageSize, DateTime? date, long? deptId) + { + var lsjls = await _lsjlRepo.GetAllAsync(); + var q = lsjls.AsQueryable(); + if (date.HasValue) q = q.Where(x => x.WorkDate.Date == date.Value.Date); + if (deptId.HasValue) q = q.Where(x => x.DeptId == deptId.Value); + + var depts = await _deptRepo.GetAllAsync(); + var deptDict = depts.ToDictionary(d => d.Id, d => d.Name); + var zks = await _zkRepo.GetAllAsync(); + var zkDict = zks.ToDictionary(z => z.Id, z => z.ZhuangKouName); + + var sums = (await _xdjyLsjlRepo.GetAllAsync()).ToDictionary(x => x.LsjlId); + + var total = q.LongCount(); + var items = q.OrderByDescending(x => x.WorkDate).ThenByDescending(x => x.Id) + .Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList(); + + return new PageResult + { + Items = items.Select(x => + { + sums.TryGetValue(x.Id, out var sum); + var yingcheng = x.Chetaishu * (x.Isbanche ? 2 : 4); + return new XdjyListItemDto + { + LsjlId = x.Id, + WorkDate = x.WorkDate, + DeptId = x.DeptId, + DeptName = deptDict.GetValueOrDefault(x.DeptId, ""), + Jizu = x.Jizu, + Huishu = x.Huishu, + Guige = x.Guige, + ZhuangKouName = zkDict.GetValueOrDefault(x.GyzkId, ""), + Yanse = x.Yanse, + Isbanche = x.Isbanche, + IsbancheName = x.Isbanche ? "半车" : "整车", + Chetaishu = x.Chetaishu, + ChetaiStart = x.ChetaiStart, + ChetaiEnd = x.ChetaiEnd, + LuosiTime = x.LuosiTime, + Yingchengnum = yingcheng, + IsChecked = sum != null, + Jiaoshu = sum?.Jiaoshu ?? 0, + Avgxiandu = sum?.Avgxiandu ?? 0, + Junfangcha = sum?.Junfangcha ?? 0, + Zongcha = sum?.Zongcha ?? 0, + FanxianLv = sum?.FanxianLv ?? 0, + FanweiNum = sum?.FanweiNum ?? 0, + XiaoyeNum = sum?.XiaoyeNum ?? 0, + DayeNum = sum?.DayeNum ?? 0, + TeyeNum = sum?.TeyeNum ?? 0, + CenterNum = sum?.CenterNum ?? 0, + Dengji = sum?.Dengji ?? "" + }; + }).ToList(), + Total = total, PageIndex = pageIndex, PageSize = pageSize + }; + } + + public async Task GetEditAsync(long lsjlId) + { + var lsjl = await _lsjlRepo.GetByIdAsync(lsjlId); + if (lsjl == null) throw new ServiceException("落丝记录不存在", 404); + + var dept = await _deptRepo.GetByIdAsync(lsjl.DeptId); + var zk = await _zkRepo.GetByIdAsync(lsjl.GyzkId); + var gg = await _guigeRepo.FirstOrDefaultAsync(g => g.Guige == lsjl.Guige); + if (gg == null) throw new ServiceException($"未找到规格「{lsjl.Guige}」的生丝规格配置,请先维护生丝规格"); + + var sum = await _xdjyLsjlRepo.FirstOrDefaultAsync(x => x.LsjlId == lsjlId); + var records = await _xdjyRecordRepo.FindAsync(x => x.LsjlId == lsjlId); + + List items; + if (records.Count > 0) + { + // 已检验:加载已有明细 + items = records.OrderBy(r => r.Chetaihao).Select(ToDto).ToList(); + } + else + { + // 首次检验:按车台范围初始化空明细 + items = new List(); + for (int i = lsjl.ChetaiStart; i <= lsjl.ChetaiEnd; i++) + { + items.Add(new XdjyRecordDto { Chetaihao = i }); + } + } + + return new XdjyEditDto + { + LsjlId = lsjl.Id, + DeptName = dept?.Name ?? "", + Jizu = lsjl.Jizu, + Huishu = lsjl.Huishu, + ZhuangKouName = zk?.ZhuangKouName ?? "", + Yanse = lsjl.Yanse, + Isbanche = lsjl.Isbanche, + IsbancheName = lsjl.Isbanche ? "半车" : "整车", + Guige = lsjl.Guige, + ChetaiStart = lsjl.ChetaiStart, + ChetaiEnd = lsjl.ChetaiEnd, + Chetaishu = lsjl.Chetaishu, + Yingchengnum = lsjl.Chetaishu * (lsjl.Isbanche ? 2 : 4), + Dengji = sum?.Dengji ?? "", + GuigeConfig = new ShengsiGuigeDto + { + Id = gg.Id, Guige = gg.Guige, + CenterrangeLolimit = gg.CenterrangeLolimit, CenterrangeUplimit = gg.CenterrangeUplimit, + CenterXiandu = gg.CenterXiandu, + XiaoyeLolimit = gg.XiaoyeLolimit, XiaoyeUplimit = gg.XiaoyeUplimit, + DayeLolimit = gg.DayeLolimit, DayeUplimit = gg.DayeUplimit, + TeyeLolimit = gg.TeyeLolimit, TeyeUplimit = gg.TeyeUplimit + }, + Xdjy = items + }; + } + + public async Task SaveAsync(XdjySaveDto dto) + { + if (dto.LsjlId == 0) throw new ServiceException("落丝记录无效"); + var lsjl = await _lsjlRepo.GetByIdAsync(dto.LsjlId); + if (lsjl == null) throw new ServiceException("落丝记录不存在", 404); + + var gg = await _guigeRepo.FirstOrDefaultAsync(g => g.Guige == lsjl.Guige); + if (gg == null) throw new ServiceException($"未找到规格「{lsjl.Guige}」的生丝规格配置"); + + if (dto.Items == null || dto.Items.Count == 0) throw new ServiceException("请先录入纤度数据"); + + // 校验车台号在范围内 + var invalid = dto.Items.Where(i => i.Chetaihao < lsjl.ChetaiStart || i.Chetaihao > lsjl.ChetaiEnd).ToList(); + if (invalid.Count > 0) throw new ServiceException($"车台号超出范围 [{lsjl.ChetaiStart}-{lsjl.ChetaiEnd}]"); + + // 计算每个车台指标 + var calcItems = dto.Items.Select(i => + { + var r = new XdjyRecordDto { Chetaihao = i.Chetaihao, Xd1 = i.Xd1, Xd2 = i.Xd2, Xd3 = i.Xd3, Xd4 = i.Xd4 }; + CalcChetai(r, gg); + return r; + }).ToList(); + + var jiaoshu = calcItems.Sum(x => x.XdNum); + if (jiaoshu == 0) throw new ServiceException("请至少录入一个纤度值"); + + // 物理删除旧明细(避免软删除残留导致唯一索引冲突) + var oldRecords = await _xdjyRecordRepo.FindAsync(x => x.LsjlId == dto.LsjlId); + await _xdjyRecordRepo.HardDeleteRangeAsync(oldRecords); + + foreach (var item in calcItems) + { + await _xdjyRecordRepo.AddAsync(new XdjyRecord + { + LsjlId = dto.LsjlId, + Chetaihao = item.Chetaihao, + Xd1 = item.Xd1, Xd2 = item.Xd2, Xd3 = item.Xd3, Xd4 = item.Xd4, + XdNum = item.XdNum, XdAvg = item.XdAvg, + FanweiNum = item.FanweiNum, XiaoyeNum = item.XiaoyeNum, + DayeNum = item.DayeNum, TeyeNum = item.TeyeNum, CenterNum = item.CenterNum + }); + } + + // 汇总指标 + var sum = CalcSummary(calcItems, gg); + var xdjyLsjl = await _xdjyLsjlRepo.FirstOrDefaultAsync(x => x.LsjlId == dto.LsjlId); + if (xdjyLsjl == null) + { + xdjyLsjl = new XdjyLsjl { LsjlId = dto.LsjlId }; + await _xdjyLsjlRepo.AddAsync(xdjyLsjl); + } + xdjyLsjl.Jiaoshu = sum.Jiaoshu; + xdjyLsjl.Avgxiandu = sum.Avgxiandu; + xdjyLsjl.Junfangcha = sum.Junfangcha; + xdjyLsjl.Zongcha = sum.Zongcha; + xdjyLsjl.FanxianLv = sum.FanxianLv; + xdjyLsjl.FanweiNum = sum.FanweiNum; + xdjyLsjl.XiaoyeNum = sum.XiaoyeNum; + xdjyLsjl.DayeNum = sum.DayeNum; + xdjyLsjl.TeyeNum = sum.TeyeNum; + xdjyLsjl.CenterNum = sum.CenterNum; + xdjyLsjl.Dengji = dto.Dengji ?? ""; + + await _xdjyLsjlRepo.UpdateAsync(xdjyLsjl); + + // 业务系统触发消息:发现特野时,通过 MediatR 创建"发现特野"消息,由 BackgroundService 投递 + if (sum.TeyeNum > 0) + { + await _mediator.Send(new CreateNotificationCommand + { + NotificationTypeCode = "TeyeFound", + BusinessType = "Xdjy", + BusinessId = dto.LsjlId.ToString(), + Title = $"发现特野:{lsjl.Guige} 规格", + Content = $"落丝记录(机组 {lsjl.Jizu}、回数 {lsjl.Huishu})纤度检验发现 {sum.TeyeNum} 个特野值,请关注。", + Priority = NotificationPriority.Urgent, + CreatorId = 0, + CreatorName = "系统" + }); + } + } + + // ==================== 计算逻辑(与 WinForms 版保持一致) ==================== + + private static XdjyRecordDto ToDto(XdjyRecord r) => new() + { + Chetaihao = r.Chetaihao, + Xd1 = r.Xd1, Xd2 = r.Xd2, Xd3 = r.Xd3, Xd4 = r.Xd4, + XdNum = r.XdNum, XdAvg = r.XdAvg, + FanweiNum = r.FanweiNum, XiaoyeNum = r.XiaoyeNum, + DayeNum = r.DayeNum, TeyeNum = r.TeyeNum, CenterNum = r.CenterNum + }; + + private static void CalcChetai(XdjyRecordDto item, ShengsiGuige gg) + { + item.XdNum = (item.Xd1 > 0 ? 1 : 0) + (item.Xd2 > 0 ? 1 : 0) + (item.Xd3 > 0 ? 1 : 0) + (item.Xd4 > 0 ? 1 : 0); + if (item.XdNum > 0) + { + item.XdAvg = Math.Round((item.Xd1 + item.Xd2 + item.Xd3 + item.Xd4) / item.XdNum, 2, MidpointRounding.AwayFromZero); + item.FanweiNum = Fanwei(item.Xd1, gg) + Fanwei(item.Xd2, gg) + Fanwei(item.Xd3, gg) + Fanwei(item.Xd4, gg); + item.XiaoyeNum = Xiaoye(item.Xd1, gg) + Xiaoye(item.Xd2, gg) + Xiaoye(item.Xd3, gg) + Xiaoye(item.Xd4, gg); + item.DayeNum = Daye(item.Xd1, gg) + Daye(item.Xd2, gg) + Daye(item.Xd3, gg) + Daye(item.Xd4, gg); + item.TeyeNum = Teye(item.Xd1, gg) + Teye(item.Xd2, gg) + Teye(item.Xd3, gg) + Teye(item.Xd4, gg); + item.CenterNum = Center(item.Xd1, gg) + Center(item.Xd2, gg) + Center(item.Xd3, gg) + Center(item.Xd4, gg); + } + else + { + item.XdAvg = 0; item.FanweiNum = 0; item.XiaoyeNum = 0; item.DayeNum = 0; item.TeyeNum = 0; item.CenterNum = 0; + } + } + + private static XdjyLsjl CalcSummary(List items, ShengsiGuige gg) + { + var jiaoshu = items.Sum(x => x.XdNum); + var avgXiandu = jiaoshu == 0 ? 0 : items.Sum(x => x.Xd1 + x.Xd2 + x.Xd3 + x.Xd4) / jiaoshu; + + // 均方差:sqrt(Σ(xi-avg)² / n) + double junfangcha = 0; + if (jiaoshu > 0) + { + double sumDiff = 0; + foreach (var it in items) + { + foreach (var xd in new[] { it.Xd1, it.Xd2, it.Xd3, it.Xd4 }) + { + if (xd > 0) { var d = xd - avgXiandu; sumDiff += d * d; } + } + } + junfangcha = Math.Round(Math.Sqrt(Math.Round(sumDiff / jiaoshu, 2)), 2, MidpointRounding.AwayFromZero); + } + + // 总差:抽样绞数 = ceil(n*0.02),最细/最粗各取,与总平均比较取最大差值 + double zongcha = 0; + if (jiaoshu > 0) + { + var xds = new List(); + foreach (var it in items) + { + if (it.Xd1 > 0) xds.Add(it.Xd1); + if (it.Xd2 > 0) xds.Add(it.Xd2); + if (it.Xd3 > 0) xds.Add(it.Xd3); + if (it.Xd4 > 0) xds.Add(it.Xd4); + } + xds.Sort(); + var chouyang = Math.Max(1, (int)Math.Ceiling(xds.Count * 0.02)); + var minAvg = xds.Take(chouyang).Average(); + var maxAvg = xds.Skip(xds.Count - chouyang).Take(chouyang).Average(); + var avgR = Math.Round(avgXiandu, 2, MidpointRounding.AwayFromZero); + zongcha = Math.Round(Math.Max(Math.Abs(minAvg - avgR), Math.Abs(maxAvg - avgR)), 2, MidpointRounding.AwayFromZero); + } + + var fanweiNum = items.Sum(x => x.FanweiNum); + var fanxianLv = jiaoshu == 0 ? 0 : Math.Round((double)fanweiNum * 100 / jiaoshu, 0, MidpointRounding.AwayFromZero); + + return new XdjyLsjl + { + Jiaoshu = jiaoshu, + Avgxiandu = Math.Round(avgXiandu, 2, MidpointRounding.AwayFromZero), + Junfangcha = junfangcha, + Zongcha = zongcha, + FanxianLv = fanxianLv, + FanweiNum = fanweiNum, + XiaoyeNum = items.Sum(x => x.XiaoyeNum), + DayeNum = items.Sum(x => x.DayeNum), + TeyeNum = items.Sum(x => x.TeyeNum), + CenterNum = items.Sum(x => x.CenterNum) + }; + } + + // ---- 判定(纤度 <= 0 视为未称) ---- + private static int Fanwei(double xd, ShengsiGuige gg) => xd > 0 && gg.CenterrangeLolimit <= xd && gg.CenterrangeUplimit >= xd ? 1 : 0; + private static int Xiaoye(double xd, ShengsiGuige gg) => xd > 0 && ((gg.XiaoyeLolimit >= xd && xd > gg.DayeLolimit) || (gg.XiaoyeUplimit <= xd && xd < gg.DayeUplimit)) ? 1 : 0; + private static int Daye(double xd, ShengsiGuige gg) => xd > 0 && ((gg.DayeLolimit >= xd && xd > gg.TeyeLolimit) || (gg.DayeUplimit <= xd && xd < gg.TeyeUplimit)) ? 1 : 0; + private static int Teye(double xd, ShengsiGuige gg) => xd > 0 && (gg.TeyeLolimit >= xd || gg.TeyeUplimit <= xd) ? 1 : 0; + private static int Center(double xd, ShengsiGuige gg) => xd > 0 && gg.CenterXiandu == xd ? 1 : 0; +} diff --git a/backend/F9WebAiTest.Domain/Common/BaseEntity.cs b/backend/F9WebAiTest.Domain/Common/BaseEntity.cs new file mode 100644 index 0000000..f45a2c4 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Common/BaseEntity.cs @@ -0,0 +1,13 @@ +using FreeSql.DataAnnotations; + +namespace F9WebAiTest.Domain.Common; + +/// 所有旧实体的基类(自增主键 + 软删除) +public abstract class BaseEntity : IEntity +{ + [Column(IsPrimary = true, IsIdentity = true)] + public long Id { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.Now; + public DateTime? UpdatedAt { get; set; } + public bool IsDeleted { get; set; } +} diff --git a/backend/F9WebAiTest.Domain/Common/IEntity.cs b/backend/F9WebAiTest.Domain/Common/IEntity.cs new file mode 100644 index 0000000..a5172e1 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Common/IEntity.cs @@ -0,0 +1,10 @@ +namespace F9WebAiTest.Domain.Common; + +/// 实体公共字段接口(Id/CreatedAt/UpdatedAt/IsDeleted),用于统一新旧两套实体基类 +public interface IEntity +{ + long Id { get; set; } + DateTime CreatedAt { get; set; } + DateTime? UpdatedAt { get; set; } + bool IsDeleted { get; set; } +} diff --git a/backend/F9WebAiTest.Domain/Common/IRepository.cs b/backend/F9WebAiTest.Domain/Common/IRepository.cs new file mode 100644 index 0000000..6ff9e38 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Common/IRepository.cs @@ -0,0 +1,23 @@ +using System.Linq.Expressions; + +namespace F9WebAiTest.Domain.Common; + +/// 通用仓储接口 +public interface IRepository where T : IEntity +{ + Task GetByIdAsync(long id); + Task> GetAllAsync(); + Task> FindAsync(Expression> predicate); + Task FirstOrDefaultAsync(Expression> predicate); + Task AnyAsync(Expression> predicate); + Task CountAsync(Expression> predicate); + Task AddAsync(T entity); + Task AddRangeAsync(IEnumerable entities); + Task UpdateAsync(T entity); + Task DeleteAsync(T entity); + Task DeleteRangeAsync(IEnumerable entities); + // 物理删除(不软删除,用于关联表避免唯一索引冲突) + Task HardDeleteAsync(T entity); + Task HardDeleteRangeAsync(IEnumerable entities); + Task SaveChangesAsync(); +} diff --git a/backend/F9WebAiTest.Domain/Entities/AggregateRoot.cs b/backend/F9WebAiTest.Domain/Entities/AggregateRoot.cs new file mode 100644 index 0000000..8159c08 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/AggregateRoot.cs @@ -0,0 +1,21 @@ +namespace F9WebAiTest.Domain.Entities; + +/// +/// DDD 聚合根基类:BusinessEntity + 领域事件。 +/// 聚合根是聚合的入口与唯一代表,外部只能通过聚合根访问聚合内部对象,通过仓储持久化。 +/// 参考 BBIT.F9.Domain.Entities.AggregateRoot +/// +public abstract class AggregateRoot : BusinessEntity, IAggregateRoot +{ + private readonly List _domainEvents = new(); + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + protected void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent); + + public void ClearDomainEvents() => _domainEvents.Clear(); +} + +/// 聚合根标记接口 +public interface IAggregateRoot +{ +} diff --git a/backend/F9WebAiTest.Domain/Entities/Attendance/AttendanceRecord.cs b/backend/F9WebAiTest.Domain/Entities/Attendance/AttendanceRecord.cs new file mode 100644 index 0000000..b10c635 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Attendance/AttendanceRecord.cs @@ -0,0 +1,18 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Attendance; + +/// 考勤打卡记录 +[Table(Name = "AttendanceRecords")] +[Index("IX_AttendanceRecords_EmployeeId_WorkDate", "EmployeeId, WorkDate", IsUnique = true)] +public class AttendanceRecord : BaseEntity +{ + public long EmployeeId { get; set; } + public DateTime WorkDate { get; set; } // 考勤日期 + public DateTime? SignInTime { get; set; } // 签到时间 + public DateTime? SignOutTime { get; set; } // 签退时间 + public string SignInStatus { get; set; } = ""; // 正常/迟到/缺卡 + public string SignOutStatus { get; set; } = ""; // 正常/早退/缺卡 + public string Remark { get; set; } = ""; +} diff --git a/backend/F9WebAiTest.Domain/Entities/Attendance/LeaveApprover.cs b/backend/F9WebAiTest.Domain/Entities/Attendance/LeaveApprover.cs new file mode 100644 index 0000000..1accc77 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Attendance/LeaveApprover.cs @@ -0,0 +1,18 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Attendance; + +/// 请假审批节点(多级审批流程,OrderNo 表示审批顺序) +[Table(Name = "LeaveApprovers")] +[Index("IX_LeaveApprovers_LeaveId_OrderNo", "LeaveId, OrderNo", IsUnique = true)] +public class LeaveApprover : BaseEntity +{ + public long LeaveId { get; set; } + public long ApproverId { get; set; } // 审批人ID + public string ApproverName { get; set; } = ""; // 审批人姓名(冗余) + public int OrderNo { get; set; } // 序号(从1开始,表示审批顺序) + public string Status { get; set; } = "待审批"; // 待审批/已批准/已驳回 + public string ApproveRemark { get; set; } = ""; + public DateTime? ApprovedAt { get; set; } +} diff --git a/backend/F9WebAiTest.Domain/Entities/Attendance/LeaveRequest.cs b/backend/F9WebAiTest.Domain/Entities/Attendance/LeaveRequest.cs new file mode 100644 index 0000000..f40f1a3 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Attendance/LeaveRequest.cs @@ -0,0 +1,21 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Attendance; + +/// 请假申请 +[Table(Name = "LeaveRequests")] +public class LeaveRequest : BaseEntity +{ + public long EmployeeId { get; set; } + public string Type { get; set; } = "事假"; // 事假/病假/年假/调休/婚假/产假 + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public string Reason { get; set; } = ""; + public string Status { get; set; } = "待审批"; // 待审批/已批准/已驳回 + public int CurrentOrderNo { get; set; } = 1; // 当前审批到第几步(多级审批) + public long? ApproverId { get; set; } + public string ApproverName { get; set; } = ""; + public string ApproveRemark { get; set; } = ""; + public DateTime? ApprovedAt { get; set; } +} diff --git a/backend/F9WebAiTest.Domain/Entities/Base/CraftZhuangKou.cs b/backend/F9WebAiTest.Domain/Entities/Base/CraftZhuangKou.cs new file mode 100644 index 0000000..1e3f0e9 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Base/CraftZhuangKou.cs @@ -0,0 +1,20 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Base; + +/// 工艺庄口 +[Table(Name = "CraftZhuangKous")] +public class CraftZhuangKou : BaseEntity +{ + public int BelongingYear { get; set; } // 年度 + public int BendiWaidi { get; set; } // 本地外地:1=本地 2=外地 + public string JidiWaigou { get; set; } = ""; // 基地外购 + public string ZhuangKouCode { get; set; } = ""; // 庄口编号 + public string ZhuangKouName { get; set; } = ""; // 庄口名称 + public string ProductionDisplayName { get; set; } = ""; // 生产显示名称 + public string Chandi { get; set; } = ""; // 产地 + public int Sort { get; set; } // 排序 + public string Memo { get; set; } = ""; // 备注 + public string IsJiezhuang { get; set; } = "0"; // 是否结庄 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Base/JizuChetai.cs b/backend/F9WebAiTest.Domain/Entities/Base/JizuChetai.cs new file mode 100644 index 0000000..312b8e4 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Base/JizuChetai.cs @@ -0,0 +1,14 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Base; + +/// 机组车台 +[Table(Name = "JizuChetais")] +public class JizuChetai : BaseEntity +{ + public long DepartmentId { get; set; } // 车间(部门) + public int Jizu { get; set; } // 机组号 + public int ChetaiStart { get; set; } // 开始车台号 + public int ChetaiEnd { get; set; } // 结束车台号 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Base/ShengsiDingjizhibiao.cs b/backend/F9WebAiTest.Domain/Entities/Base/ShengsiDingjizhibiao.cs new file mode 100644 index 0000000..2fd9cd1 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Base/ShengsiDingjizhibiao.cs @@ -0,0 +1,20 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Base; + +/// 生丝定级指标 +[Table(Name = "ShengsiDingjizhibiaos")] +public class ShengsiDingjizhibiao : BaseEntity +{ + public int Standard { get; set; } // 标准:0=厂标 1=国标 2=个人 + public string Guige { get; set; } = ""; // 规格 + public string Grade { get; set; } = ""; // 等级 + public double GradeScore { get; set; } // 等级分值 + public double Piancha { get; set; } // 偏差 + public double Dacha { get; set; } // 大差 + public double Erdu { get; set; } // 二度变化 + public double Sandu { get; set; } // 三度变化 + public double Qingjie { get; set; } // 清洁 + public double Jiejing { get; set; } // 洁净 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Base/ShengsiGuige.cs b/backend/F9WebAiTest.Domain/Entities/Base/ShengsiGuige.cs new file mode 100644 index 0000000..8be6fee --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Base/ShengsiGuige.cs @@ -0,0 +1,30 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Base; + +/// 生丝规格 +[Table(Name = "ShengsiGuiges")] +public class ShengsiGuige : BaseEntity +{ + public string Guige { get; set; } = ""; // 规格 + public double CenterrangeLolimit { get; set; } // 中心范围下限 + public double CenterrangeUplimit { get; set; } // 中心范围上限 + public double CenterXiandu { get; set; } // 中心纤度 + public double ValidLolimit { get; set; } // 有效下限 + public double ValidUplimit { get; set; } // 有效上限 + public double XiaoyeLolimit { get; set; } // 小野下限 + public double XiaoyeUplimit { get; set; } // 小野上限 + public double DayeLolimit { get; set; } // 大野下限 + public double DayeUplimit { get; set; } // 大野上限 + public double TeyeLolimit { get; set; } // 特野下限 + public double TeyeUplimit { get; set; } // 特野上限 + public double YexianNkLolimitLeft { get; set; } // 内控左下 + public double YexianNkLolimitRight { get; set; } // 内控左上 + public double YexianNkUplimitLeft { get; set; } // 内控右下 + public double YexianNkUplimitRight { get; set; } // 内控右上 + public double LaodongJingsaiYexianLolimit { get; set; } // 野纤下限 + public double LaodongJingsaiYexianUplimit { get; set; } // 野纤上限 + public int Sort { get; set; } // 顺序号 + public string Memo { get; set; } = ""; // 备注 +} diff --git a/backend/F9WebAiTest.Domain/Entities/BusinessEntity.cs b/backend/F9WebAiTest.Domain/Entities/BusinessEntity.cs new file mode 100644 index 0000000..a979818 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/BusinessEntity.cs @@ -0,0 +1,22 @@ +using FreeSql.DataAnnotations; + +namespace F9WebAiTest.Domain.Entities; + +/// +/// DDD 业务实体基类:Entity + 审计字段(CreatedBy/UpdateBy/DeletedAt)。 +/// 参考 BBIT.F9.Domain.Entities.BusinessEntity +/// +public abstract class BusinessEntity : Entity +{ + /// 创建人 + [Column(Name = "created_by")] + public string CreatedBy { get; set; } + + /// 修改人 + [Column(Name = "update_by")] + public string UpdateBy { get; set; } + + /// 逻辑删除时间(null=未删除) + [Column(Name = "deleted_at")] + public DateTime? DeletedAt { get; protected set; } +} diff --git a/backend/F9WebAiTest.Domain/Entities/Department.cs b/backend/F9WebAiTest.Domain/Entities/Department.cs new file mode 100644 index 0000000..c0dd3b0 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Department.cs @@ -0,0 +1,17 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities; + +/// 部门(树形结构) +[Table(Name = "Departments")] +public class Department : BaseEntity +{ + public string Name { get; set; } + public string Code { get; set; } = ""; // 部门编码 + public long? ParentId { get; set; } + public long? LeaderId { get; set; } + public string Phone { get; set; } = ""; + public int Sort { get; set; } + public int Status { get; set; } = 1; // 1 启用 0 禁用 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Employee.cs b/backend/F9WebAiTest.Domain/Entities/Employee.cs new file mode 100644 index 0000000..7f5179c --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Employee.cs @@ -0,0 +1,22 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities; + +/// 职工/用户 +[Table(Name = "Employees")] +[Index("IX_Employees_Username", "Username", IsUnique = true)] +public class Employee : BaseEntity +{ + public string Username { get; set; } // 登录账号 + public string Password { get; set; } // 密码哈希 + public string RealName { get; set; } // 真实姓名 + public string Nickname { get; set; } = ""; + public string Avatar { get; set; } = ""; + public string Gender { get; set; } = "男"; + public string Phone { get; set; } = ""; + public string Email { get; set; } = ""; + public long DepartmentId { get; set; } + public int Status { get; set; } = 1; // 1 在职 0 离职/禁用 + public string Remark { get; set; } = ""; +} diff --git a/backend/F9WebAiTest.Domain/Entities/EmployeeRole.cs b/backend/F9WebAiTest.Domain/Entities/EmployeeRole.cs new file mode 100644 index 0000000..d5b42bb --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/EmployeeRole.cs @@ -0,0 +1,13 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities; + +/// 职工-角色 关联 +[Table(Name = "EmployeeRoles")] +[Index("IX_EmployeeRoles_EmployeeId_RoleId", "EmployeeId, RoleId", IsUnique = true)] +public class EmployeeRole : BaseEntity +{ + public long EmployeeId { get; set; } + public long RoleId { get; set; } +} diff --git a/backend/F9WebAiTest.Domain/Entities/Entity.cs b/backend/F9WebAiTest.Domain/Entities/Entity.cs new file mode 100644 index 0000000..1b1763d --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Entity.cs @@ -0,0 +1,25 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities; + +/// +/// DDD 实体基类:具有唯一标识与生命周期。 +/// 实现 IEntity 以兼容通用仓储(IRepository<T>)。 +/// 主键由应用层通过雪花 ID 生成器赋值(非数据库自增)。 +/// 参考 BBIT.F9.Domain.Entities.Entity +/// +public abstract class Entity : IEntity +{ + [Column(IsPrimary = true)] + public long Id { get; set; } + + [Column(Name = "created_at")] + public DateTime CreatedAt { get; set; } + + [Column(Name = "updated_at")] + public DateTime? UpdatedAt { get; set; } + + [Column(Name = "is_deleted")] + public bool IsDeleted { get; set; } +} diff --git a/backend/F9WebAiTest.Domain/Entities/IDomainEvent.cs b/backend/F9WebAiTest.Domain/Entities/IDomainEvent.cs new file mode 100644 index 0000000..500444e --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/IDomainEvent.cs @@ -0,0 +1,15 @@ +using MediatR; + +namespace F9WebAiTest.Domain.Entities; + +/// 领域事件接口(继承 MediatR 的 INotification,便于事件总线发布) +public interface IDomainEvent : INotification +{ + DateTime OccurredOn { get; } +} + +/// 领域事件基类 +public abstract record DomainEvent : IDomainEvent +{ + public DateTime OccurredOn => DateTime.Now; +} diff --git a/backend/F9WebAiTest.Domain/Entities/Menu.cs b/backend/F9WebAiTest.Domain/Entities/Menu.cs new file mode 100644 index 0000000..a739078 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Menu.cs @@ -0,0 +1,19 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities; + +/// 菜单/权限(支持目录、菜单、按钮三种类型) +[Table(Name = "Menus")] +public class Menu : BaseEntity +{ + public long? ParentId { get; set; } + public string Name { get; set; } // 菜单名称 + public string Type { get; set; } = "menu"; // directory 目录 / menu 菜单 / button 按钮 + public string Path { get; set; } = ""; // 路由路径 + public string Component { get; set; } = "";// 组件路径 + public string Icon { get; set; } = ""; // 图标 + public string Permission { get; set; } = "";// 权限标识,如 system:user:list + public int Sort { get; set; } + public int Status { get; set; } = 1; // 1 显示 0 隐藏 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Notice/NotificationEnums.cs b/backend/F9WebAiTest.Domain/Entities/Notice/NotificationEnums.cs new file mode 100644 index 0000000..9c545d3 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Notice/NotificationEnums.cs @@ -0,0 +1,64 @@ +namespace F9WebAiTest.Domain.Entities.Notice; + +/// 消息优先级 +public enum NotificationPriority +{ + Normal = 0, // 普通 + Important = 1, // 重要 + Urgent = 2 // 紧急 +} + +/// 消息状态 +public enum NotificationStatus +{ + Ready = 0, // 准备(待投递) + Sent = 1, // 已发送 + Failed = 2, // 失败 + PartialSuccess = 3, // 部分成功 + Revoked = 4 // 已撤销 +} + +/// 阅读状态 +public enum NotificationReadStatus +{ + Unread = 0, // 未读 + Read = 1 // 已读 +} + +/// 推送状态 +public enum NotificationSendStatus +{ + NotSent = 0, // 未发送 + Success = 1, // 成功 + Failed = 2 // 失败 +} + +/// 待办状态 +public enum TodoItemStatus +{ + Pending = 0, // 待处理 + Processing = 1, // 处理中 + Completed = 2, // 已完成 + Cancelled = 3 // 已取消 +} + +/// 消息分类(决定消息中心展示形态与处理方式) +public enum NotificationCategory +{ + Notification = 0, // 通知:纯提醒,点开查看/标记已读 + Todo = 1, // 待办:需用户处理(审批/任务),关联待办事项 + Announcement = 2 // 公告:广播给全体,重点展示 +} + +/// +/// 消息类型枚举(枚举名与 NotificationType.Code 一一对应)。 +/// 业务侧通过「该枚举 + MediatR 命令」发送消息,无需关心底层实现。 +/// 新增消息类型时:1) 在此加枚举值 2) 在数据库/种子中配置同名 Code 的消息类型(标题、路由模板、接收规则等)。 +/// +public enum NotificationTypeEnum +{ + TeyeFound = 1, // 发现特野 + TdcFound = 2, // 发现特大糙 + LeaveApproval = 3, // 请假审批(待办) + LeaveResult = 4 // 请假结果通知(通知) +} diff --git a/backend/F9WebAiTest.Domain/Entities/Notice/NotificationMessage.cs b/backend/F9WebAiTest.Domain/Entities/Notice/NotificationMessage.cs new file mode 100644 index 0000000..183ae54 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Notice/NotificationMessage.cs @@ -0,0 +1,27 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Notice; + +/// 消息主表(业务系统触发创建,创建时即时投递并实时推送) +[Table(Name = "NotificationMessages")] +[Index("IX_NotificationMessages_MessageTypeCode", "MessageTypeCode")] +public class NotificationMessage : BaseEntity +{ + public long NotificationTypeId { get; set; } // 消息类型ID + public string MessageTypeCode { get; set; } = ""; // 消息类型编码(冗余,路由查询免联表) + public string BusinessType { get; set; } = ""; // 业务类型标识 + public string BusinessId { get; set; } = ""; // 业务单据ID + public string Title { get; set; } = ""; // 标题 + public string Content { get; set; } = ""; // 内容 + public NotificationPriority Priority { get; set; } = NotificationPriority.Normal; // 优先级 + public NotificationStatus Status { get; set; } = NotificationStatus.Ready; // 状态 + public int RetryCount { get; set; } // 重试次数 + public int MaxRetryCount { get; set; } = 3; // 最大重试次数 + public string BizContext { get; set; } = ""; // 业务上下文(JSON) + public string ForceReceivers { get; set; } = ""; // 强制接收人(JSON 数组,如 "[1001,1002]"),空=仅按订阅 + public long CreatorId { get; set; } // 创建人ID + public string CreatorName { get; set; } = ""; // 创建人姓名 + public NotificationCategory Category { get; set; } = NotificationCategory.Notification; // 消息分类(冗余,前端免联表) + public string RoutePath { get; set; } = ""; // Web 端跳转路由(最终值,由模板+业务ID生成) +} diff --git a/backend/F9WebAiTest.Domain/Entities/Notice/NotificationReceiver.cs b/backend/F9WebAiTest.Domain/Entities/Notice/NotificationReceiver.cs new file mode 100644 index 0000000..2ae5a27 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Notice/NotificationReceiver.cs @@ -0,0 +1,20 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Notice; + +/// 消息接收人(投递时生成,记录阅读状态与推送结果:在线推送成功 / 离线待拉取) +[Table(Name = "NotificationReceivers")] +[Index("IX_NotificationReceivers_MessageId_UserId", "MessageId, UserId", IsUnique = true)] +[Index("IX_NotificationReceivers_UserId_ReadStatus", "UserId, ReadStatus")] +public class NotificationReceiver : BaseEntity +{ + public long MessageId { get; set; } // 消息ID + public long UserId { get; set; } // 接收人用户ID + public NotificationReadStatus ReadStatus { get; set; } = NotificationReadStatus.Unread; // 阅读状态 + public DateTime? ReadTime { get; set; } // 阅读时间 + public bool IsOffline { get; set; } // 推送时是否离线 + public NotificationSendStatus SendStatus { get; set; } = NotificationSendStatus.NotSent; // 推送状态 + public DateTime? SendTime { get; set; } // 推送时间 + public string ErrorMsg { get; set; } = ""; // 错误信息 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Notice/NotificationType.cs b/backend/F9WebAiTest.Domain/Entities/Notice/NotificationType.cs new file mode 100644 index 0000000..4abaa24 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Notice/NotificationType.cs @@ -0,0 +1,21 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Notice; + +/// 消息类型(管理员维护,定义订阅所需权限) +[Table(Name = "NotificationTypes")] +[Index("IX_NotificationTypes_Code", "Code", IsUnique = true)] +public class NotificationType : BaseEntity +{ + public string Code { get; set; } = ""; // 消息类型编码,唯一,如 "TeyeFound" + public string Name { get; set; } = ""; // 消息类型名称,如 "发现特野" + public string Description { get; set; } = ""; // 类型描述,展示在订阅页 + public string RequiredPermission { get; set; } = ""; // 订阅该类型所需权限码,空=所有用户可订阅 + public bool IsActive { get; set; } = true; // 是否开放订阅 + public long CreatedBy { get; set; } // 创建人ID + public int Sort { get; set; } // 排序号 + public NotificationCategory Category { get; set; } = NotificationCategory.Notification; // 消息分类 + public string Icon { get; set; } = ""; // 图标(Element Plus 图标名,如 "Bell") + public string RouteTemplate { get; set; } = ""; // Web 端跳转路由模板,支持 {businessId} 占位符 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Notice/TodoItem.cs b/backend/F9WebAiTest.Domain/Entities/Notice/TodoItem.cs new file mode 100644 index 0000000..84cb00f --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Notice/TodoItem.cs @@ -0,0 +1,17 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Notice; + +/// 待办事项(消息可携带待办) +[Table(Name = "TodoItems")] +[Index("IX_TodoItems_AssigneeId_TodoStatus", "AssigneeId, TodoStatus")] +public class TodoItem : BaseEntity +{ + public long MessageId { get; set; } // 关联消息ID + public long AssigneeId { get; set; } // 处理人用户ID + public TodoItemStatus TodoStatus { get; set; } = TodoItemStatus.Pending; // 待办状态 + public DateTime? DueTime { get; set; } // 截止时间 + public DateTime? CompletedTime { get; set; } // 完成时间 + public string HandleResult { get; set; } = ""; // 处理结果 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Notice/UserNotificationSubscription.cs b/backend/F9WebAiTest.Domain/Entities/Notice/UserNotificationSubscription.cs new file mode 100644 index 0000000..a6d76c9 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Notice/UserNotificationSubscription.cs @@ -0,0 +1,16 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Notice; + +/// 用户订阅(用户主动订阅/取消,保留历史) +[Table(Name = "UserNotificationSubscriptions")] +[Index("IX_UserNotificationSubscriptions_UserId_NotificationTypeId", "UserId, NotificationTypeId", IsUnique = true)] +public class UserNotificationSubscription : BaseEntity +{ + public long UserId { get; set; } // 订阅用户ID + public long NotificationTypeId { get; set; } // 消息类型ID + public bool IsActive { get; set; } = true; // 订阅状态:true=已订阅 false=已取消 + public DateTime SubscribedTime { get; set; } = DateTime.Now; // 订阅时间 + public DateTime? CancelTime { get; set; } // 取消订阅时间 +} diff --git a/backend/F9WebAiTest.Domain/Entities/OperationLog.cs b/backend/F9WebAiTest.Domain/Entities/OperationLog.cs new file mode 100644 index 0000000..b2507b6 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/OperationLog.cs @@ -0,0 +1,16 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities; + +/// 操作日志 +[Table(Name = "OperationLogs")] +public class OperationLog : BaseEntity +{ + public long? EmployeeId { get; set; } + public string EmployeeName { get; set; } = ""; + public string Module { get; set; } = ""; + public string Action { get; set; } = ""; + public string Description { get; set; } = ""; + public string Ip { get; set; } = ""; +} diff --git a/backend/F9WebAiTest.Domain/Entities/Prod/HbjyLsjl.cs b/backend/F9WebAiTest.Domain/Entities/Prod/HbjyLsjl.cs new file mode 100644 index 0000000..69d062c --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Prod/HbjyLsjl.cs @@ -0,0 +1,18 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Prod; + +/// 黑板检验-落丝汇总(每个落丝记录一条) +[Table(Name = "HbjyLsjls")] +[Index("IX_HbjyLsjls_LsjlId", "LsjlId", IsUnique = true)] +public class HbjyLsjl : BaseEntity +{ + public long LsjlId { get; set; } // 落丝记录 + public int Pianshu { get; set; } // 片数 + public double Qingjie { get; set; } // 清洁分 + public double Jiejing { get; set; } // 洁净分 + public int YdErdu { get; set; } // 二度变化片数 + public int YdSandu { get; set; } // 三度变化片数 + public string Dengji { get; set; } = ""; // 等级 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Prod/HbjyRecord.cs b/backend/F9WebAiTest.Domain/Entities/Prod/HbjyRecord.cs new file mode 100644 index 0000000..776e60a --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Prod/HbjyRecord.cs @@ -0,0 +1,40 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Prod; + +/// 黑板检验-车台明细(每车台一行) +[Table(Name = "HbjyRecords")] +[Index("IX_HbjyRecords_LsjlId_Chetaihao", "LsjlId, Chetaihao", IsUnique = true)] +public class HbjyRecord : BaseEntity +{ + public long LsjlId { get; set; } // 落丝记录 + public int Chetaihao { get; set; } // 车台号 + public int YdYidu { get; set; } // 一度变化片数 + public int YdErdu { get; set; } // 二度变化片数 + public int YdSandu { get; set; } // 三度变化片数 + // 洁净等级片数(100~60) + public int Jj100 { get; set; } + public int Jj95 { get; set; } + public int Jj90 { get; set; } + public int Jj85 { get; set; } + public int Jj80 { get; set; } + public int Jj75 { get; set; } + public int Jj70 { get; set; } + public int Jj65 { get; set; } + public int Jj60 { get; set; } + // 清洁疵点-特大类 + public int QjZycdTdc { get; set; } // 特大糙 + // 清洁疵点-大类 + public int QjCycdFs { get; set; } // 废丝 + public int QjCycdDc { get; set; } // 大糙 + public int QjCycdNfc { get; set; } // 粘付糙 + public int QjCycdDcj { get; set; } // 大长结 + public int QjCycdZlx { get; set; } // 重螺旋 + // 清洁疵点-小类 + public int QjPtcdXc { get; set; } // 小糙 + public int QjPtcdCj { get; set; } // 长结 + public int QjPtcdLx { get; set; } // 螺旋 + public int QjPtcdHjls { get; set; } // 环结及裂丝 + public int Pianshu { get; set; } // 本车台片数 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Prod/LsjlRecord.cs b/backend/F9WebAiTest.Domain/Entities/Prod/LsjlRecord.cs new file mode 100644 index 0000000..f01ffc2 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Prod/LsjlRecord.cs @@ -0,0 +1,25 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities.Base; + +namespace F9WebAiTest.Domain.Entities.Prod; + +/// 落丝记录(前缫生产) +[Table(Name = "LsjlRecords")] +public class LsjlRecord : BaseEntity +{ + public DateTime WorkDate { get; set; } // 日期 + public long DeptId { get; set; } // 车间 + public long GyzkId { get; set; } // 工艺庄口 + public int Jizu { get; set; } // 机组 + public string LuosiTime { get; set; } = ""; // 落丝时间 + public string Yanse { get; set; } = ""; // 颜色 + public int Huishu { get; set; } // 回数 + public int ChetaiStart { get; set; } // 开始车台号 + public int ChetaiEnd { get; set; } // 结束车台号 + public int Chetaishu { get; set; } // 车台数 + public bool Isbanche { get; set; } // 是否半车 + public double Hours { get; set; } // 缫丝时长 + public string Guige { get; set; } = ""; // 规格 + public double Chesu { get; set; } // 车速 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Prod/XdjyLsjl.cs b/backend/F9WebAiTest.Domain/Entities/Prod/XdjyLsjl.cs new file mode 100644 index 0000000..91e9428 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Prod/XdjyLsjl.cs @@ -0,0 +1,23 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Prod; + +/// 纤度检验-落丝汇总(每个落丝记录一条) +[Table(Name = "XdjyLsjls")] +[Index("IX_XdjyLsjls_LsjlId", "LsjlId", IsUnique = true)] +public class XdjyLsjl : BaseEntity +{ + public long LsjlId { get; set; } // 落丝记录 + public int Jiaoshu { get; set; } // 已称绞数 + public double Avgxiandu { get; set; } // 平均纤度 + public double Junfangcha { get; set; } // 均方差 + public double Zongcha { get; set; } // 总差 + public double FanxianLv { get; set; } // 范纤率(%) + public int FanweiNum { get; set; } // 范围数 + public int XiaoyeNum { get; set; } // 小野数 + public int DayeNum { get; set; } // 大野数 + public int TeyeNum { get; set; } // 特野数 + public int CenterNum { get; set; } // 中心数 + public string Dengji { get; set; } = ""; // 等级 +} diff --git a/backend/F9WebAiTest.Domain/Entities/Prod/XdjyRecord.cs b/backend/F9WebAiTest.Domain/Entities/Prod/XdjyRecord.cs new file mode 100644 index 0000000..3c50d28 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Prod/XdjyRecord.cs @@ -0,0 +1,24 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities.Prod; + +/// 纤度检验-车台明细(每车台一行,4 个纤度值) +[Table(Name = "XdjyRecords")] +[Index("IX_XdjyRecords_LsjlId_Chetaihao", "LsjlId, Chetaihao", IsUnique = true)] +public class XdjyRecord : BaseEntity +{ + public long LsjlId { get; set; } // 落丝记录 + public int Chetaihao { get; set; } // 车台号 + public double Xd1 { get; set; } // 纤度1 + public double Xd2 { get; set; } // 纤度2 + public double Xd3 { get; set; } // 纤度3 + public double Xd4 { get; set; } // 纤度4 + public int XdNum { get; set; } // 已称绞数 + public double XdAvg { get; set; } // 平均纤度 + public int FanweiNum { get; set; } // 范围 + public int XiaoyeNum { get; set; } // 小野 + public int DayeNum { get; set; } // 大野 + public int TeyeNum { get; set; } // 特野 + public int CenterNum { get; set; } // 中心 +} diff --git a/backend/F9WebAiTest.Domain/Entities/RefreshToken.cs b/backend/F9WebAiTest.Domain/Entities/RefreshToken.cs new file mode 100644 index 0000000..6cd7c4b --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/RefreshToken.cs @@ -0,0 +1,15 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities; + +/// 刷新令牌 +[Table(Name = "RefreshTokens")] +public class RefreshToken : BaseEntity +{ + public long EmployeeId { get; set; } + public string Token { get; set; } + public DateTime ExpiresAt { get; set; } + public bool IsRevoked { get; set; } + public string CreatedByIp { get; set; } = ""; +} diff --git a/backend/F9WebAiTest.Domain/Entities/Role.cs b/backend/F9WebAiTest.Domain/Entities/Role.cs new file mode 100644 index 0000000..676d15e --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/Role.cs @@ -0,0 +1,16 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities; + +/// 角色 +[Table(Name = "Roles")] +[Index("IX_Roles_Code", "Code", IsUnique = true)] +public class Role : BaseEntity +{ + public string Name { get; set; } // 角色名称 + public string Code { get; set; } // 角色编码(唯一) + public string Description { get; set; } = ""; + public int Sort { get; set; } + public int Status { get; set; } = 1; +} diff --git a/backend/F9WebAiTest.Domain/Entities/RoleMenu.cs b/backend/F9WebAiTest.Domain/Entities/RoleMenu.cs new file mode 100644 index 0000000..5018726 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/RoleMenu.cs @@ -0,0 +1,13 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Domain.Entities; + +/// 角色-菜单 关联 +[Table(Name = "RoleMenus")] +[Index("IX_RoleMenus_RoleId_MenuId", "RoleId, MenuId", IsUnique = true)] +public class RoleMenu : BaseEntity +{ + public long RoleId { get; set; } + public long MenuId { get; set; } +} diff --git a/backend/F9WebAiTest.Domain/Entities/SYS/Departments/Department.cs b/backend/F9WebAiTest.Domain/Entities/SYS/Departments/Department.cs new file mode 100644 index 0000000..e36286b --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/SYS/Departments/Department.cs @@ -0,0 +1,79 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Enums.SYS.Departments; +using F9WebAiTest.Domain.Events.SYS.Departments; + +namespace F9WebAiTest.Domain.Entities.SYS.Departments; + +/// +/// 部门:用户与部门是独立聚合,仅保存 DepartmentId 关联(避免双向强耦合)。 +/// 部门用户列表仅用于查询展示,通过读模型实现。 +/// +[Table(Name = "sys_departments")] +public class Department : AggregateRoot +{ + [Column(Name = "code")] + public string Code { get; private set; } + + [Column(Name = "name")] + public string Name { get; private set; } + + [Column(Name = "parent_id")] + public long ParentId { get; private set; } + + [Column(Name = "manage_type")] + public Department_ManageType_Enum ManageType { get; private set; } + + [Column(Name = "duty_type")] + public Department_DutyType_Enum DutyType { get; private set; } + + [Column(Name = "description")] + public string? Description { get; private set; } + + [Column(Name = "is_active")] + public bool IsActive { get; private set; } + + [Column(Name = "sort_order")] + public int SortOrder { get; private set; } + + private readonly List _userIds = []; + public IReadOnlyCollection UserIds => _userIds.AsReadOnly(); + + public Department() { } + + public Department(long id, string code, string name, long parentId, + Department_ManageType_Enum manageType, Department_DutyType_Enum dutyType, int sortOrder, string? description = null) + { + Id = id; + Code = code; + Name = name; + ParentId = parentId; + ManageType = manageType; + DutyType = dutyType; + SortOrder = sortOrder; + Description = description; + IsActive = true; + AddDomainEvent(new Department_ChangedDomainEvent(Id)); + } + + public void AddUser(long userId) + { + if (!_userIds.Contains(userId)) + _userIds.Add(userId); + } + + public void RemoveUser(long userId) => _userIds.Remove(userId); + + public void Activate() + { + IsActive = true; + AddDomainEvent(new Department_ChangedDomainEvent(Id)); + } + + public void Deactivate() + { + IsActive = false; + AddDomainEvent(new Department_ChangedDomainEvent(Id)); + } + + public void Deleted() => AddDomainEvent(new Department_ChangedDomainEvent(Id)); +} diff --git a/backend/F9WebAiTest.Domain/Entities/SYS/Departments/DepartmentUser.cs b/backend/F9WebAiTest.Domain/Entities/SYS/Departments/DepartmentUser.cs new file mode 100644 index 0000000..2b612c7 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/SYS/Departments/DepartmentUser.cs @@ -0,0 +1,27 @@ +using FreeSql.DataAnnotations; + +namespace F9WebAiTest.Domain.Entities.SYS.Departments; + +/// 部门用户关联:部门与用户的关联记录。 +[Table(Name = "sys_department_users")] +public class DepartmentUser : BusinessEntity +{ + [Column(Name = "department_id")] + public long DepartmentId { get; private set; } + + [Column(Name = "user_id")] + public long UserId { get; private set; } + + [Column(Name = "assigned_at")] + public DateTime AssignedAt { get; private set; } + + public DepartmentUser() { } + + public DepartmentUser(long id, long departmentId, long userId) + { + Id = id; + DepartmentId = departmentId; + UserId = userId; + AssignedAt = DateTime.UtcNow; + } +} diff --git a/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/Menu.cs b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/Menu.cs new file mode 100644 index 0000000..e87d831 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/Menu.cs @@ -0,0 +1,95 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using F9WebAiTest.Domain.Events.SYS.RBAC; + +namespace F9WebAiTest.Domain.Entities.SYS.RBAC; + +/// 资源:权限作用的对象,如菜单项、按钮、报表等。 +[Table(Name = "sys_rbac_menus")] +public class Menu : AggregateRoot +{ + [Column(Name = "icon_svg")] + public string IconSvg { get; private set; } // 图标 + + [Column(Name = "code")] + public string Code { get; private set; } // 唯一标识 + + [Column(Name = "name")] + public string Name { get; private set; } // 显示名称 + + [Column(Name = "menu_type")] + public MenuType_Enum MenuType { get; private set; } // 菜单类型:分组/页面 + + [Column(Name = "assembly_name")] + public string? AssemblyName { get; private set; } + + [Column(Name = "type_full_name")] + public string? TypeFullName { get; private set; } + + [Column(Name = "parent_id")] + public long ParentId { get; private set; } // 支持菜单层级 + + [Column(Name = "level")] + public int Level { get; private set; } // 层级 + + [Column(Name = "is_active")] + public bool IsActive { get; private set; } + + [Column(Name = "sort_order")] + public int SortOrder { get; private set; } + + [Column(Name = "path")] + public string Path { get; private set; } = ""; // Web 路由路径 + + [Column(Name = "component")] + public string Component { get; private set; } = ""; // 前端组件路径 + + [Column(Name = "permission")] + public string Permission { get; private set; } = ""; // 权限标识,如 sys:user:list + + public Menu() { } + + public Menu(long id, string name, string iconSvg, MenuType_Enum menuType = MenuType_Enum.默认, + string? assemblyName = null, string? typeFullName = null, long parentId = 0, int sortOrder = 0, int parentLevel = 0) + { + Id = id; + Name = name; + IconSvg = iconSvg; + MenuType = menuType; + AssemblyName = assemblyName; + TypeFullName = typeFullName; + ParentId = parentId; + Level = parentId == 0 ? 1 : parentLevel + 1; + IsActive = true; + CreatedAt = DateTime.Now; + SortOrder = sortOrder; + AddDomainEvent(new Menu_ChangedDomainEvent(Id)); + } + + public void GenerateNewCode() => Code = $"menu.{DateTime.Now.Ticks}"; + + /// 设置 Web 路由信息(路径/组件/权限码) + public void SetWebInfo(string path, string component, string permission) + { + Path = path ?? ""; + Component = component ?? ""; + Permission = permission ?? ""; + } + + public IReadOnlySet SupportedPermissionType => + new HashSet { Permission_Type_Enum.Visble }; + + public void SoftDelete() => AddDomainEvent(new Menu_ChangedDomainEvent(Id)); + + public void Activate() + { + IsActive = true; + AddDomainEvent(new Menu_ChangedDomainEvent(Id)); + } + + public void Deactivate() + { + IsActive = false; + AddDomainEvent(new Menu_ChangedDomainEvent(Id)); + } +} diff --git a/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/Permission.cs b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/Permission.cs new file mode 100644 index 0000000..6cc403a --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/Permission.cs @@ -0,0 +1,47 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Enums.SYS.RBAC; + +namespace F9WebAiTest.Domain.Entities.SYS.RBAC; + +/// 权限:最小的权限单元,如“查看用户列表”、“导出报表”。 +[Table(Name = "sys_rbac_permissions")] +public class Permission : AggregateRoot +{ + /// 编号,格式:资源:操作,例如 user:add, report:sales:view + [Column(Name = "code")] + public string Code { get; private set; } + + [Column(Name = "permission_type")] + public Permission_Type_Enum PermissionType { get; private set; } + + [Column(Name = "resource_type")] + public Resource_Type_Enum ResourceType { get; private set; } + + [Column(Name = "resource_id")] + public long ResourceId { get; private set; } // 关联资源(菜单、报表等) + + public Permission() { } + + public Permission(long id, Permission_Type_Enum permissionType, Resource_Type_Enum resourceType, string resourceIdentity, long resourceId) + { + Id = id; + PermissionType = permissionType; + ResourceType = resourceType; + ResourceId = resourceId; + CreatedAt = DateTime.Now; + Code = $"{GetResorceTypeIdentity}:{resourceIdentity}:{permissionType.ToString().ToLower()}"; + } + + public string GetResorceTypeIdentity => ResourceType switch + { + Resource_Type_Enum.菜单 => "menu", + Resource_Type_Enum.报表 => "report", + Resource_Type_Enum.操作 => "operation", + Resource_Type_Enum.部门 => "department", + Resource_Type_Enum.消息订阅 => "subscribe", + _ => throw new ArgumentOutOfRangeException() + }; + + /// 自定义权限码(用于消息订阅等业务权限码与 RBAC 权限码保持一致) + public void SetCode(string code) => Code = code; +} diff --git a/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/Role.cs b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/Role.cs new file mode 100644 index 0000000..c0dd114 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/Role.cs @@ -0,0 +1,65 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using F9WebAiTest.Domain.Events.SYS.RBAC; + +namespace F9WebAiTest.Domain.Entities.SYS.RBAC; + +/// 角色:一组权限的集合。 +[Table(Name = "sys_rbac_roles")] +public class Role : AggregateRoot +{ + [Column(Name = "name")] + public string Name { get; private set; } // 如 "财务专员", "系统管理员" + + [Column(Name = "code")] + public string Code { get; private set; } // 如:role:xxxx + + [Column(Name = "sort_order")] + public int SortOrder { get; private set; } + + [Column(Name = "description")] + public string Description { get; private set; } + + [Column(Name = "is_active")] + public bool IsActive { get; private set; } + + private readonly List _permissionIds = []; + public IReadOnlyCollection PermissionIds => _permissionIds.AsReadOnly(); + + public Role() { } + + public Role(long id, string name, string code, int sortOrder, string description) + { + Id = id; + Name = name; + Code = code; + SortOrder = sortOrder; + IsActive = true; + Description = description; + CreatedAt = DateTime.Now; + } + + public void Activate() => IsActive = true; + public void Deactivate() => IsActive = false; + + public void AddPermission(long permissionId, Resource_Type_Enum type) + { + if (!_permissionIds.Contains(permissionId)) + _permissionIds.Add(permissionId); + + if (type == Resource_Type_Enum.菜单) + AddDomainEvent(new RoleMenu_ChangedDomainEvent(Id)); + else if (type == Resource_Type_Enum.部门) + AddDomainEvent(new RoleDepartment_ChangedDomainEvent(Id)); + } + + public void RemovePermission(long permissionId, Resource_Type_Enum type) + { + _permissionIds.Remove(permissionId); + + if (type == Resource_Type_Enum.菜单) + AddDomainEvent(new RoleMenu_ChangedDomainEvent(Id)); + else if (type == Resource_Type_Enum.部门) + AddDomainEvent(new RoleDepartment_ChangedDomainEvent(Id)); + } +} diff --git a/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/RolePermission.cs b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/RolePermission.cs new file mode 100644 index 0000000..443a68d --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/RolePermission.cs @@ -0,0 +1,24 @@ +using FreeSql.DataAnnotations; + +namespace F9WebAiTest.Domain.Entities.SYS.RBAC; + +/// 角色权限关联:角色与权限的多对多关系。 +[Table(Name = "sys_rbac_role_permissions")] +public class RolePermission : BusinessEntity +{ + [Column(Name = "role_id")] + public long RoleId { get; private set; } + + [Column(Name = "permission_id")] + public long PermissionId { get; private set; } + + public RolePermission() { } + + public RolePermission(long id, long roleId, long permissionId) + { + Id = id; + RoleId = roleId; + PermissionId = permissionId; + CreatedAt = DateTime.UtcNow; + } +} diff --git a/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/User.cs b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/User.cs new file mode 100644 index 0000000..1ac1d26 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/User.cs @@ -0,0 +1,98 @@ +using FreeSql.DataAnnotations; +using F9WebAiTest.Domain.Events.SYS.RBAC; + +namespace F9WebAiTest.Domain.Entities.SYS.RBAC; + +/// 用户:可以拥有多个角色。 +[Table(Name = "sys_rbac_users")] +public class User : AggregateRoot +{ + [Column(Name = "department_id")] + public long DepartmentId { get; private set; } + + [Column(Name = "user_name")] + public string UserName { get; private set; } + + [Column(Name = "phone_number")] + public string PhoneNumber { get; private set; } + + [Column(Name = "login_name")] + public string LoginName { get; private set; } + + [Column(Name = "password_hash")] + public string PasswordHash { get; private set; } + + [Column(Name = "is_active")] + public bool IsActive { get; private set; } + + [Column(Name = "sort_order")] + public int SortOrder { get; private set; } + + [Column(Name = "lastlogin_at")] + public DateTime? LastLoginAt { get; private set; } + + [Column(Name = "nickname")] + public string Nickname { get; private set; } = ""; + + [Column(Name = "avatar")] + public string Avatar { get; private set; } = ""; + + [Column(Name = "email")] + public string Email { get; private set; } = ""; + + [Column(Name = "gender")] + public string Gender { get; private set; } = "男"; + + public User() { } + + public User(long id, string userName, string phoneNumber, string loginName, long departmentId, int sortOrder) + { + Id = id; + UserName = userName; + PhoneNumber = phoneNumber; + LoginName = loginName; + DepartmentId = departmentId; + IsActive = true; + SortOrder = sortOrder; + } + + public void Update(string userName, string phoneNumber, string loginName, long departmentId, int sortOrder) + { + UserName = userName; + PhoneNumber = phoneNumber; + LoginName = loginName; + DepartmentId = departmentId; + IsActive = true; + SortOrder = sortOrder; + } + + public void Activate() => IsActive = true; + public void Deactivate() => IsActive = false; + + /// 获取默认密码明文 + public string GetDefaultPasswordText() => "123456"; + + /// 设置密码(哈希后的密文) + public void SetPassword(string password) => PasswordHash = password; + + /// 更新个人资料(昵称/头像/邮箱/手机号/性别) + public void UpdateProfile(string nickname, string avatar, string email, string phoneNumber, string gender) + { + Nickname = nickname ?? Nickname; + Avatar = avatar ?? Avatar; + Email = email ?? Email; + PhoneNumber = phoneNumber ?? PhoneNumber; + Gender = gender ?? Gender; + } + + /// 登录成功,记录登录时间并发布登录事件 + public void LoginSuccess(string deviceId, string account, bool success, long userId, string userName) + { + LastLoginAt = DateTime.Now; + AddDomainEvent(new UserLoginDomainEvent(deviceId, account, success, userId, userName)); + } + + public void AddRole(long roleId) => AddDomainEvent(new UserRole_ChangedDomainEvent(Id)); + + public void RemoveRole(long roleId) => AddDomainEvent(new UserRole_ChangedDomainEvent(Id)); +} diff --git a/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/UserRole.cs b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/UserRole.cs new file mode 100644 index 0000000..ec6867d --- /dev/null +++ b/backend/F9WebAiTest.Domain/Entities/SYS/RBAC/UserRole.cs @@ -0,0 +1,27 @@ +using FreeSql.DataAnnotations; + +namespace F9WebAiTest.Domain.Entities.SYS.RBAC; + +/// +/// 用户角色关联:用户与角色的多对多关系。 +/// DDD 中作为独立关联实体,应用层直接操作其仓储(不通过 User 聚合)。 +/// +[Table(Name = "sys_rbac_user_roles")] +public class UserRole : BusinessEntity +{ + [Column(Name = "user_id")] + public long UserId { get; private set; } + + [Column(Name = "role_id")] + public long RoleId { get; private set; } + + public UserRole() { } + + public UserRole(long id, long userId, long roleId) + { + Id = id; + UserId = userId; + RoleId = roleId; + CreatedAt = DateTime.UtcNow; + } +} diff --git a/backend/F9WebAiTest.Domain/Enums/SYS/Departments/Department_DutyType_Enum.cs b/backend/F9WebAiTest.Domain/Enums/SYS/Departments/Department_DutyType_Enum.cs new file mode 100644 index 0000000..297a656 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Enums/SYS/Departments/Department_DutyType_Enum.cs @@ -0,0 +1,22 @@ +namespace F9WebAiTest.Domain.Enums.SYS.Departments; + +/// 部门职责类型 +public enum Department_DutyType_Enum +{ + 其他 = 5, + 仓储 = 10, + 选剥茧 = 15, + 煮茧 = 20, + 前缫 = 25, + 后缫 = 30, + 设备办 = 35, + 副产品 = 40, + 质检 = 45, + 丝绵 = 50, + 人力资源 = 55, + + 丝业中心 = 100, + 前缫管理 = 105, + 后缫检验 = 110, + 后缫整理 = 115 +} diff --git a/backend/F9WebAiTest.Domain/Enums/SYS/Departments/Department_ManageType_Enum.cs b/backend/F9WebAiTest.Domain/Enums/SYS/Departments/Department_ManageType_Enum.cs new file mode 100644 index 0000000..f6bb8c6 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Enums/SYS/Departments/Department_ManageType_Enum.cs @@ -0,0 +1,12 @@ +namespace F9WebAiTest.Domain.Enums.SYS.Departments; + +/// 部门管理类型 +public enum Department_ManageType_Enum +{ + 公司 = 5, + 部门 = 10, + 车间 = 15, + 科室 = 20, + 岗位 = 25, + 虚拟 = 30 +} diff --git a/backend/F9WebAiTest.Domain/Enums/SYS/RBAC/MenuType_Enum.cs b/backend/F9WebAiTest.Domain/Enums/SYS/RBAC/MenuType_Enum.cs new file mode 100644 index 0000000..ec2c738 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Enums/SYS/RBAC/MenuType_Enum.cs @@ -0,0 +1,9 @@ +namespace F9WebAiTest.Domain.Enums.SYS.RBAC; + +/// 菜单类型 +public enum MenuType_Enum +{ + 默认 = 0, + 分组 = 1, + 页面 = 2 +} diff --git a/backend/F9WebAiTest.Domain/Enums/SYS/RBAC/Permission_Type_Enum.cs b/backend/F9WebAiTest.Domain/Enums/SYS/RBAC/Permission_Type_Enum.cs new file mode 100644 index 0000000..3597fb6 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Enums/SYS/RBAC/Permission_Type_Enum.cs @@ -0,0 +1,11 @@ +namespace F9WebAiTest.Domain.Enums.SYS.RBAC; + +/// 权限类型 +public enum Permission_Type_Enum +{ + /// 查看 + Visble = 1, + + /// 使能(是否可操作) + Enable = 2 +} diff --git a/backend/F9WebAiTest.Domain/Enums/SYS/RBAC/Resource_Type_Enum.cs b/backend/F9WebAiTest.Domain/Enums/SYS/RBAC/Resource_Type_Enum.cs new file mode 100644 index 0000000..e52a9b3 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Enums/SYS/RBAC/Resource_Type_Enum.cs @@ -0,0 +1,13 @@ +namespace F9WebAiTest.Domain.Enums.SYS.RBAC; + +/// 资源类型(权限作用的对象) +public enum Resource_Type_Enum +{ + 菜单 = 1, + // 按钮、功能 + 操作 = 2, + 报表 = 3, + 部门 = 4, + // 消息订阅(如请假审批、考勤通知等消息类型的订阅权限) + 消息订阅 = 5 +} diff --git a/backend/F9WebAiTest.Domain/Events/SYS/Departments/Department_ChangedDomainEvent.cs b/backend/F9WebAiTest.Domain/Events/SYS/Departments/Department_ChangedDomainEvent.cs new file mode 100644 index 0000000..c92032e --- /dev/null +++ b/backend/F9WebAiTest.Domain/Events/SYS/Departments/Department_ChangedDomainEvent.cs @@ -0,0 +1,5 @@ +using F9WebAiTest.Domain.Entities; + +namespace F9WebAiTest.Domain.Events.SYS.Departments; + +public record Department_ChangedDomainEvent(long depId) : DomainEvent; diff --git a/backend/F9WebAiTest.Domain/Events/SYS/RBAC/Menu_ChangedDomainEvent.cs b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/Menu_ChangedDomainEvent.cs new file mode 100644 index 0000000..0374c29 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/Menu_ChangedDomainEvent.cs @@ -0,0 +1,5 @@ +using F9WebAiTest.Domain.Entities; + +namespace F9WebAiTest.Domain.Events.SYS.RBAC; + +public record Menu_ChangedDomainEvent(long menuId) : DomainEvent; diff --git a/backend/F9WebAiTest.Domain/Events/SYS/RBAC/RoleDepartment_ChangedDomainEvent.cs b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/RoleDepartment_ChangedDomainEvent.cs new file mode 100644 index 0000000..e6d1d47 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/RoleDepartment_ChangedDomainEvent.cs @@ -0,0 +1,5 @@ +using F9WebAiTest.Domain.Entities; + +namespace F9WebAiTest.Domain.Events.SYS.RBAC; + +public record RoleDepartment_ChangedDomainEvent(long roleId) : DomainEvent; diff --git a/backend/F9WebAiTest.Domain/Events/SYS/RBAC/RoleMenu_ChangedDomainEvent.cs b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/RoleMenu_ChangedDomainEvent.cs new file mode 100644 index 0000000..a4d94a1 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/RoleMenu_ChangedDomainEvent.cs @@ -0,0 +1,5 @@ +using F9WebAiTest.Domain.Entities; + +namespace F9WebAiTest.Domain.Events.SYS.RBAC; + +public record RoleMenu_ChangedDomainEvent(long roleId) : DomainEvent; diff --git a/backend/F9WebAiTest.Domain/Events/SYS/RBAC/UserCreatedDomainEvent.cs b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/UserCreatedDomainEvent.cs new file mode 100644 index 0000000..2be5d5b --- /dev/null +++ b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/UserCreatedDomainEvent.cs @@ -0,0 +1,8 @@ +using F9WebAiTest.Domain.Entities; + +namespace F9WebAiTest.Domain.Events.SYS.RBAC; + +public record UserCreatedDomainEvent( + long UserId, + string UserName, + long DepartmentId) : DomainEvent; diff --git a/backend/F9WebAiTest.Domain/Events/SYS/RBAC/UserLoginDomainEvent.cs b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/UserLoginDomainEvent.cs new file mode 100644 index 0000000..ae7eb1f --- /dev/null +++ b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/UserLoginDomainEvent.cs @@ -0,0 +1,10 @@ +using F9WebAiTest.Domain.Entities; + +namespace F9WebAiTest.Domain.Events.SYS.RBAC; + +public record UserLoginDomainEvent( + string DeviceId, + string Account, + bool Success, + long UserId, + string UserName) : DomainEvent; diff --git a/backend/F9WebAiTest.Domain/Events/SYS/RBAC/UserRole_ChangedDomainEvent.cs b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/UserRole_ChangedDomainEvent.cs new file mode 100644 index 0000000..a1eec82 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Events/SYS/RBAC/UserRole_ChangedDomainEvent.cs @@ -0,0 +1,5 @@ +using F9WebAiTest.Domain.Entities; + +namespace F9WebAiTest.Domain.Events.SYS.RBAC; + +public record UserRole_ChangedDomainEvent(long userId) : DomainEvent; diff --git a/backend/F9WebAiTest.Domain/F9WebAiTest.Domain.csproj b/backend/F9WebAiTest.Domain/F9WebAiTest.Domain.csproj new file mode 100644 index 0000000..46c7f54 --- /dev/null +++ b/backend/F9WebAiTest.Domain/F9WebAiTest.Domain.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + disable + F9WebAiTest.Domain + + + + + + + + diff --git a/backend/F9WebAiTest.Domain/Interfaces/IDInterface.cs b/backend/F9WebAiTest.Domain/Interfaces/IDInterface.cs new file mode 100644 index 0000000..f77a245 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Interfaces/IDInterface.cs @@ -0,0 +1,6 @@ +namespace F9WebAiTest.Domain.Interfaces; + +/// 标记需要依赖注入的接口(写仓储接口等) +public interface IDInterface +{ +} diff --git a/backend/F9WebAiTest.Domain/Interfaces/SYS/Departments/IDepartmentUserRepository.cs b/backend/F9WebAiTest.Domain/Interfaces/SYS/Departments/IDepartmentUserRepository.cs new file mode 100644 index 0000000..6748209 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Interfaces/SYS/Departments/IDepartmentUserRepository.cs @@ -0,0 +1,11 @@ +using F9WebAiTest.Domain.Entities.SYS.Departments; + +namespace F9WebAiTest.Domain.Interfaces.SYS.Departments; + +/// 部门用户关联写仓储 +public interface IDepartmentUserRepository : IDInterface +{ + Task AssignAsync(DepartmentUser depUser); + + Task RemoveAsync(long depId, long userId); +} diff --git a/backend/F9WebAiTest.Domain/Interfaces/SYS/Departments/IDepartmentWriteRepository.cs b/backend/F9WebAiTest.Domain/Interfaces/SYS/Departments/IDepartmentWriteRepository.cs new file mode 100644 index 0000000..f1e62c5 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Interfaces/SYS/Departments/IDepartmentWriteRepository.cs @@ -0,0 +1,15 @@ +using F9WebAiTest.Domain.Entities.SYS.Departments; + +namespace F9WebAiTest.Domain.Interfaces.SYS.Departments; + +/// 部门写仓储(聚合根仓储) +public interface IDepartmentWriteRepository : IDInterface +{ + /// 返回聚合根 + Task GetByIdAsync(long id, CancellationToken cancellationToken); + + Task AddAsync(Department dep); + Task UpdateAsync(Department dep); + Task SoftDeleteAsync(long id); + Task ActiveOrDeactive(long id, bool active); +} diff --git a/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Menus/IMenuWriteRepository.cs b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Menus/IMenuWriteRepository.cs new file mode 100644 index 0000000..b885111 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Menus/IMenuWriteRepository.cs @@ -0,0 +1,13 @@ +using F9WebAiTest.Domain.Entities.SYS.RBAC; + +namespace F9WebAiTest.Domain.Interfaces.SYS.RBAC.Menus; + +/// 菜单写仓储(聚合根仓储) +public interface IMenuWriteRepository : IDInterface +{ + Task GetByIdAsync(long id); + Task AddAsync(Menu menu); + Task UpdateAsync(Menu menu); + Task SoftDeleteAsync(long id); + Task ActiveOrDeactive(long id, bool active); +} diff --git a/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Permissions/IPermissionWriteRepository.cs b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Permissions/IPermissionWriteRepository.cs new file mode 100644 index 0000000..6805748 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Permissions/IPermissionWriteRepository.cs @@ -0,0 +1,13 @@ +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Enums.SYS.RBAC; + +namespace F9WebAiTest.Domain.Interfaces.SYS.RBAC.Permissions; + +/// 权限写仓储 +public interface IPermissionWriteRepository : IDInterface +{ + Task GetByIdAsync(Permission_Type_Enum permissionType, Resource_Type_Enum resourceType, long resourceId); + Task AddRangeAsync(Permission[] entites); + Task DeleteByResourceIdAndTypeAsync(Permission_Type_Enum permissionType, Resource_Type_Enum resourceType, long resourceId); + Task DeleteByResourceIdAsync(long resourceId); +} diff --git a/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Roles/IRolePermissionWriteRepository.cs b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Roles/IRolePermissionWriteRepository.cs new file mode 100644 index 0000000..560dc6f --- /dev/null +++ b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Roles/IRolePermissionWriteRepository.cs @@ -0,0 +1,11 @@ +using F9WebAiTest.Domain.Entities.SYS.RBAC; + +namespace F9WebAiTest.Domain.Interfaces.SYS.RBAC.Roles; + +/// 角色权限关联写仓储 +public interface IRolePermissionWriteRepository : IDInterface +{ + Task GetByIdAsync(long id); + Task AssignAsync(RolePermission rolePermission); + Task RemoveAsync(long id); +} diff --git a/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Roles/IRoleWriteRepository.cs b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Roles/IRoleWriteRepository.cs new file mode 100644 index 0000000..7b10769 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Roles/IRoleWriteRepository.cs @@ -0,0 +1,13 @@ +using F9WebAiTest.Domain.Entities.SYS.RBAC; + +namespace F9WebAiTest.Domain.Interfaces.SYS.RBAC.Roles; + +/// 角色写仓储(聚合根仓储) +public interface IRoleWriteRepository : IDInterface +{ + Task GetByIdAsync(long id); + Task AddAsync(Role role); + Task UpdateAsync(Role role); + Task SoftDeleteAsync(long id); + Task ActiveOrDeactive(long id, bool active); +} diff --git a/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Users/IUserRoleWriteRepository.cs b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Users/IUserRoleWriteRepository.cs new file mode 100644 index 0000000..88911e6 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Users/IUserRoleWriteRepository.cs @@ -0,0 +1,14 @@ +using F9WebAiTest.Domain.Entities.SYS.RBAC; + +namespace F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; + +/// +/// 用户角色关联写仓储:不把 AssignRole 放在 IUserWriteRepository(避免污染聚合仓储), +/// 而是定义独立的细粒度接口,应用层依赖此抽象,基础设施层实现。 +/// +public interface IUserRoleWriteRepository : IDInterface +{ + Task AssignAsync(UserRole userRole); + + Task RemoveAsync(long userid, long roleId); +} diff --git a/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Users/IUserWriteRepository.cs b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Users/IUserWriteRepository.cs new file mode 100644 index 0000000..d107040 --- /dev/null +++ b/backend/F9WebAiTest.Domain/Interfaces/SYS/RBAC/Users/IUserWriteRepository.cs @@ -0,0 +1,18 @@ +using F9WebAiTest.Domain.Entities.SYS.RBAC; + +namespace F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; + +/// 用户写仓储(聚合根仓储) +public interface IUserWriteRepository : IDInterface +{ + /// 返回聚合根 + Task GetByIdAsync(long id); + + Task AddAsync(User user); + Task UpdateAsync(User user); + Task SoftDeleteAsync(long id); + Task ActiveOrDeactive(long id, bool active); + + Task UpdatePassworkdAsync(long id, string passwordHash); + Task UpdateLastLoginAtAsync(long id, DateTime dt); +} diff --git a/backend/F9WebAiTest.Infrastructure/Data/BusinessSeedData.cs b/backend/F9WebAiTest.Infrastructure/Data/BusinessSeedData.cs new file mode 100644 index 0000000..f2f3d22 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/Data/BusinessSeedData.cs @@ -0,0 +1,501 @@ +using FreeSql; +using F9WebAiTest.Domain.Entities; +using F9WebAiTest.Domain.Entities.Base; + +namespace F9WebAiTest.Infrastructure.Data; + +/// 业务基础数据种子(部门/工艺庄口/生丝规格/生丝定级指标/机组车台) +public static class BusinessSeedData +{ + public static void Seed(IFreeSql fsql) + { + SeedMenus(fsql); + SeedCraftZhuangKou(fsql); + SeedShengsiGuige(fsql); + SeedShengsiDingjizhibiao(fsql); + SeedDepartmentsAndJizuChetai(fsql); + SeedEmployees(fsql); + } + + // ==================== 部门 + 机组车台(真实组织架构) ==================== + private static readonly (long Id, string Name, long ParentId, int Sort, string Code)[] DeptData = + { + (10920411881541632, "安泰茧丝绸", 0, 0, "123"), + (10929439026188288, "丝业生产中心", 10920411881541632, 100, "100"), + (19667471837433856, "主干运维", 10920411881541632, 9999, "9999"), + (22305637455106048, "人力资源部", 10920411881541632, 800, "800"), + (22305758536273920, "财务部", 10920411881541632, 400, "400"), + (22305841730293760, "营销中心", 10920411881541632, 500, "500"), + (22305998261719040, "计划运营部", 10920411881541632, 200, "200"), + (22306589042020352, "品管部", 10920411881541632, 700, "700"), + (22306677265010688, "采购部", 10920411881541632, 300, "300"), + (26900271946797056, "安全环保后勤部", 10920411881541632, 1000, "1000"), + (26901050904547328, "经理会", 10920411881541632, 1500, "1500"), + (26901341515288576, "监察室", 10920411881541632, 3000, "3000"), + (26901475166785536, "融资部", 10920411881541632, 2000, "2000"), + (34638098675535872, "武胜蚕业公司", 10920411881541632, 4000, "4000"), + (35981234102341632, "安泰丝绸人南店", 10920411881541632, 6200, "6200"), + (35981427686248448, "绸业生产中心", 10920411881541632, 5000, "5000"), + (35981496963567616, "夹江蚕业公司", 10920411881541632, 4100, "4100"), + (35982738976673792, "安泰丝绸融恒店", 10920411881541632, 6000, "6000"), + (35982891737419776, "帛锦汇", 10920411881541632, 6100, "6100"), + (35982956409393152, "蚕业技术服务部", 10920411881541632, 4900, "4900"), + (36277179469795328, "广安蚕业公司", 10920411881541632, 4200, "4200"), + (10929739564847104, "仓储", 10929439026188288, 1000, "1000"), + (22307875854487552, "质检科", 10929439026188288, 1100, "1100"), + (22308241237086208, "试样室", 10929439026188288, 1200, "1200"), + (22308361408090112, "选剥茧车间", 10929439026188288, 1300, "1300"), + (25256354205667328, "前缫车间", 10929439026188288, 1350, "1350"), + (22308805823959040, "后缫车间", 10929439026188288, 1800, "1800"), + (22309110082965504, "设备车间", 10929439026188288, 2100, "2100"), + (22309340027293696, "副产品车间", 10929439026188288, 2200, "2200"), + (68179345423732736, "临时", 10929439026188288, 3000, "3000"), + (16043931070369792, "前缫一车间", 25256354205667328, 1400, "1400"), + (16047387197837312, "前缫二车间", 25256354205667328, 1500, "1500"), + (22308600831545344, "前缫三车间", 25256354205667328, 1600, "1600"), + (22308683820044288, "前缫四车间", 25256354205667328, 1700, "1700"), + (25379428280307712, "煮茧室", 25256354205667328, 1350, "1350"), + (22308918461992960, "检验室", 22308805823959040, 1900, "1900"), + (22309003073687552, "整理室", 22308805823959040, 2000, "2000"), + (24029387057795072, "丝棉车间", 22309340027293696, 0, "999"), + }; + + private static readonly (long DeptId, int Jizu, int Start, int End)[] JizuData = + { + (16043931070369792, 1, 1, 20), (16043931070369792, 2, 21, 40), (16043931070369792, 3, 41, 60), + (16043931070369792, 4, 61, 80), (16043931070369792, 5, 81, 100), (16043931070369792, 6, 101, 120), + (16047387197837312, 1, 1, 20), (16047387197837312, 2, 21, 40), (16047387197837312, 3, 41, 60), + (16047387197837312, 4, 61, 80), (16047387197837312, 5, 81, 100), (16047387197837312, 6, 101, 120), + (22308600831545344, 1, 1, 20), (22308600831545344, 2, 21, 40), (22308600831545344, 3, 41, 60), + (22308600831545344, 4, 61, 80), (22308600831545344, 5, 81, 100), (22308600831545344, 6, 101, 120), + (22308683820044288, 1, 1, 20), (22308683820044288, 2, 21, 40), (22308683820044288, 3, 41, 60), + (22308683820044288, 4, 61, 80), (22308683820044288, 5, 81, 100), (22308683820044288, 6, 101, 120), + }; + + private static void SeedDepartmentsAndJizuChetai(IFreeSql fsql) + { + // 检测是否已有真实部门(以"安泰茧丝绸"为标记) + if (fsql.Select().Any(d => d.Name == "安泰茧丝绸")) return; + + // 物理清空假数据(总公司/技术部等)及关联 + fsql.Ado.ExecuteNonQuery("DELETE FROM JizuChetais"); + fsql.Ado.ExecuteNonQuery("DELETE FROM AttendanceRecords"); + fsql.Ado.ExecuteNonQuery("DELETE FROM LeaveRequests"); + fsql.Ado.ExecuteNonQuery("DELETE FROM Employees"); + fsql.Ado.ExecuteNonQuery("DELETE FROM Departments"); + + // 第一遍:创建所有部门(ParentId 暂不设) + var idMap = new Dictionary(); + foreach (var d in DeptData) + { + var dept = new Department { Name = d.Name, Code = d.Code, Sort = d.Sort }; + var newId = DbSeeder.InsertAndGetId(fsql, dept); + idMap[d.Id] = newId; + } + + // 第二遍:设置 ParentId + foreach (var d in DeptData) + { + if (d.ParentId != 0 && idMap.ContainsKey(d.ParentId)) + { + var dept = fsql.Select().Where(x => x.Id == idMap[d.Id]).First(); + dept.ParentId = idMap[d.ParentId]; + fsql.Update().SetSource(dept).ExecuteAffrows(); + } + } + + // 机组车台 + foreach (var j in JizuData) + { + if (idMap.ContainsKey(j.DeptId)) + { + fsql.Insert(new JizuChetai + { + DepartmentId = idMap[j.DeptId], + Jizu = j.Jizu, + ChetaiStart = j.Start, + ChetaiEnd = j.End + }).ExecuteAffrows(); + } + } + } + + // ==================== 职工 ==================== + private static readonly (long DeptId, string Name, string Phone, string Login)[] EmpData = + { + (16043931070369792, "主干-何启旭", "18840854628", "18840854628"), + (10920411881541632, "测试用户", "0", "test"), + (10920411881541632, "秦燊", "13982683923", "13982683923"), + (10920411881541632, "陈勇", "18908283908", "18908283908"), + (22305758536273920, "汪菊", "18782619006", "18782619006"), + (22305841730293760, "陈林", "18580851234", "18580851234"), + (10929439026188288, "李正平", "15378405846", "15378405846"), + (22305637455106048, "朱莹", "18908283738", "18908283738"), + (22305637455106048, "毛银春", "18180268728", "18180268728"), + (22305637455106048, "陆国君", "13696173949", "13696173949"), + (22305637455106048, "华静", "13882636653", "13882636653"), + (22305637455106048, "余思洁", "18398508220", "18398508220"), + (22305758536273920, "孙瑞", "15982664679", "15982664679"), + (22305758536273920, "唐琳", "18048103256", "18048103256"), + (22305758536273920, "蒋宜", "18982626800", "18982626800"), + (22305758536273920, "王清华", "15282625323", "15282625323"), + (22305998261719040, "李西宴", "13688261711", "13688261711"), + (22305998261719040, "文杨", "13699654826", "13699654826"), + (22306589042020352, "曹庆", "13541993164", "13541993164"), + (22306677265010688, "蒙海波", "15884923167", "15884923167"), + (22305841730293760, "陈菊", "13696182387", "13696182387"), + (22305841730293760, "刘婷", "18382610528", "18382610528"), + (19667471837433856, "主干-张建", "15398380348", "15398380348"), + (19667471837433856, "主干-严鹏", "19162687174", "19162687174"), + (10929739564847104, "游建梅", "13629091099", "13629091099"), + (22307875854487552, "邓滟旖", "18181821168", "18181821168"), + (10929439026188288, "李志颖", "14708217874", "14708217874"), + (22308241237086208, "曹志君", "13882695209", "13882695209"), + (22308241237086208, "胡小琴", "18160129785", "18160129785"), + (22308361408090112, "唐小红", "15983458330", "15983458330"), + (25256354205667328, "余华", "13547542219", "13547542219"), + (22308683820044288, "陈建红", "15182698365", "15182698365"), + (22308683820044288, "谢军辉", "19957339779", "19957339779"), + (22308600831545344, "陈丽华", "15982605862", "15982605862"), + (16047387197837312, "杨静", "15982696700", "15982696700"), + (25256354205667328, "刘芳", "13882653025", "13882653025"), + (22308805823959040, "刘春兰", "13982624682", "13982624682"), + (22309110082965504, "朱再民", "15328288332", "15328288332"), + (22309340027293696, "舒文", "13551613659", "13551613659"), + (10929439026188288, "汪瑜琳", "13541553564", "13541553564"), + (19667471837433856, "范鸿才", "18981977117", "18981977117"), + (22305758536273920, "谭波", "18282636380", "18282636380"), + (22308918461992960, "董宗华", "18384503138", "18384503138"), + (22308918461992960, "易林梅", "18381112479", "18381112479"), + (22309003073687552, "舒红群", "18398526317", "18398526317"), + (25379428280307712, "彭卫东", "18382668003", "18382668003"), + (25379428280307712, "王远辉", "15982666491", "15982666491"), + (25379428280307712, "姚光林", "19160637815", "19160637815"), + (19667471837433856, "李学兴", "18117362915", "18117362915"), + (16043931070369792, "李远玲", "13982638574", "13982638574"), + (26900271946797056, "杨福锋", "13982638165", "13982638165"), + (26901341515288576, "杨利容", "15928659870", "15928659870"), + (19667471837433856, "主干-张罗", "18109064045", "18109064045"), + (26900271946797056, "袁鑫", "14781753888", "14781753888"), + (19667471837433856, "刘香君", "15808107846", "15808107846"), + (22308805823959040, "沈跃成", "13822244141", "13822244141"), + (19667471837433856, "杨凤琼", "18011528128", "18011528128"), + (22308600831545344, "谭爱君", "13699658634", "13699658634"), + }; + + private static void SeedEmployees(IFreeSql fsql) + { + var defaultPwd = BCrypt.Net.BCrypt.HashPassword("123456"); + var deptNameMap = DeptData.ToDictionary(d => d.Id, d => d.Name); + + foreach (var e in EmpData) + { + if (fsql.Select().Any(x => x.Username == e.Login)) continue; + + long deptId = 0; + if (deptNameMap.TryGetValue(e.DeptId, out var deptName)) + { + var dept = fsql.Select().Where(d => d.Name == deptName).First(); + if (dept != null) deptId = dept.Id; + } + + fsql.Insert(new Employee + { + Username = e.Login, + Password = defaultPwd, + RealName = e.Name, + Nickname = e.Name, + Phone = e.Phone, + DepartmentId = deptId, + Status = 1 + }).ExecuteAffrows(); + } + } + + // ==================== 菜单 ==================== + private static void SeedMenus(IFreeSql fsql) + { + var baseDirId = fsql.Select().Where(m => m.Path == "/base").First()?.Id ?? 0; + if (baseDirId == 0) + { + baseDirId = DbSeeder.InsertAndGetId(fsql, new Menu { Name = "基础数据", Type = "directory", Path = "/base", Icon = "Files", Sort = 3 }); + } + + AddMenu(fsql, baseDirId, "工艺庄口", "/base/zhuangkou", "base/zhuangkou/index", "Collection", "base:zhuangkou:list", 1); + AddMenu(fsql, baseDirId, "生丝规格", "/base/guige", "base/guige/index", "Grid", "base:guige:list", 2); + AddMenu(fsql, baseDirId, "生丝定级指标", "/base/dingjizhibiao", "base/dingjizhibiao/index", "TrendCharts", "base:dingjizhibiao:list", 3); + AddMenu(fsql, baseDirId, "机组车台", "/base/jizuchetai", "base/jizuchetai/index", "SetUp", "base:jizuchetai:list", 4); + + // 生产管理目录 + var prodDirId = fsql.Select().Where(m => m.Path == "/prod").First()?.Id ?? 0; + if (prodDirId == 0) + { + prodDirId = DbSeeder.InsertAndGetId(fsql, new Menu { Name = "生产管理", Type = "directory", Path = "/prod", Icon = "Cpu", Sort = 4 }); + } + AddMenu(fsql, prodDirId, "落丝记录", "/prod/lsjl", "prod/lsjl/index", "Document", "prod:lsjl:list", 1); + AddMenu(fsql, prodDirId, "纤度检验", "/prod/xdjy", "prod/xdjy/index", "EditPen", "prod:xdjy:list", 2); + AddMenu(fsql, prodDirId, "黑板检验", "/prod/hbjy", "prod/hbjy/index", "Notebook", "prod:hbjy:list", 3); + + var adminRole = fsql.Select().Where(r => r.Code == "admin").First(); + if (adminRole != null) + { + var baseMenus = fsql.Select().Where(m => m.Path.StartsWith("/base") || m.Path.StartsWith("/prod")).ToList(); + foreach (var m in baseMenus) + { + if (!fsql.Select().Any(rm => rm.RoleId == adminRole.Id && rm.MenuId == m.Id)) + fsql.Insert(new RoleMenu { RoleId = adminRole.Id, MenuId = m.Id }).ExecuteAffrows(); + } + } + } + + private static void AddMenu(IFreeSql fsql, long parentId, string name, string path, string component, string icon, string permission, int sort) + { + if (!fsql.Select().Any(m => m.Path == path)) + { + fsql.Insert(new Menu { ParentId = parentId, Name = name, Type = "menu", Path = path, Component = component, Icon = icon, Permission = permission, Sort = sort }).ExecuteAffrows(); + } + } + + private static void SeedCraftZhuangKou(IFreeSql fsql) + { + if (fsql.Select().Any()) return; + fsql.Insert(new CraftZhuangKou[] + { + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 1, JidiWaigou = "基地", ZhuangKouCode = "SZJ-2026-武工艺20", ZhuangKouName = "2026年基地春一批(青松皓月)", ProductionDisplayName = "2026年基地春一批(青松皓月)", Chandi = "基地", Sort = 20 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 1, JidiWaigou = "基地", ZhuangKouCode = "SZJ-2026-武工艺24", ZhuangKouName = "2026年基地春(雄)", ProductionDisplayName = "2026年基地春(雄)", Chandi = "大面积", Sort = 24 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 2, JidiWaigou = "外购", ZhuangKouCode = "SZJ-2026-武工艺-22", ZhuangKouName = "2026年会东春(7532)", ProductionDisplayName = "2026年会东春(7532)", Chandi = "会东", Sort = 22 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 2, JidiWaigou = "外购", ZhuangKouCode = "SZJ-2026-武工艺-23", ZhuangKouName = "2026年珙县九丝春(雄)", ProductionDisplayName = "2026年珙县九丝春(雄)", Chandi = "珙县", Sort = 23 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 2, JidiWaigou = "外购", ZhuangKouCode = "SZJ-2026-武工艺-7", ZhuangKouName = "2026会东二类茧(7532)宁兴", ProductionDisplayName = "2026会东二类茧(7532)宁兴", Chandi = "会东", Sort = 7 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 1, JidiWaigou = "基地", ZhuangKouCode = "SZJ-武工艺-2026-25", ZhuangKouName = "2026年广安春(青松皓月)", ProductionDisplayName = "2026年广安春(青松皓月)", Chandi = "基地", Sort = 25 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 2, JidiWaigou = "外购", ZhuangKouCode = "SZJ-2026-武工艺-26", ZhuangKouName = "2026年珙县罗渡春(雄)", ProductionDisplayName = "2026年珙县罗渡春(雄)", Chandi = "珙县", Sort = 26 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 1, JidiWaigou = "基地", ZhuangKouCode = "SZJ-武工艺-2026-9", ZhuangKouName = "2026年广安春(青松皓月)宁兴", ProductionDisplayName = "2026年广安春(青松皓月)宁兴", Chandi = "广安", Sort = 9 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 1, JidiWaigou = "基地", ZhuangKouCode = "SZJ-武工艺-2026-28", ZhuangKouName = "2026年基地春三批(青松皓月)", ProductionDisplayName = "2026年基地春三批(青松皓月)", Chandi = "基地", Sort = 28 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 1, JidiWaigou = "基地", ZhuangKouCode = "SZJ-武工艺-2026-11", ZhuangKouName = "2026年基地春三批(青松皓月)宁兴", ProductionDisplayName = "2026年基地春三批(青松皓月)宁兴", Chandi = "基地", Sort = 11 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 2, JidiWaigou = "外购", ZhuangKouCode = "SZJ-武工艺-2026-29", ZhuangKouName = "2026年珙县宝山等混六庄春一批(雄)", ProductionDisplayName = "2026年珙县宝山等混六庄春一批(雄)", Chandi = "珙县", Sort = 29 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 2, JidiWaigou = "外购", ZhuangKouCode = "SZJ-武工艺-2026-27", ZhuangKouName = "2026年珙县曹营等混四庄春一批(雄)", ProductionDisplayName = "2026年珙县曹营等混四庄春一批(雄)", Chandi = "珙县", Sort = 27 }, + new CraftZhuangKou { BelongingYear = 2026, BendiWaidi = 2, JidiWaigou = "外购", ZhuangKouCode = "SZJ-武工艺-2026-10", ZhuangKouName = "2026年珙县罗渡春(雄)宁兴", ProductionDisplayName = "2026年珙县罗渡春(雄)宁兴", Chandi = "珙县", Sort = 10 } + }).ExecuteAffrows(); + } + + private static void SeedShengsiGuige(IFreeSql fsql) + { + if (fsql.Select().Any()) return; + fsql.Insert(new ShengsiGuige[] + { + new ShengsiGuige { Guige = "20/22", CenterrangeLolimit = 19.5, CenterrangeUplimit = 21.5, CenterXiandu = 20.5, ValidLolimit = 15.0, ValidUplimit = 27.0, XiaoyeLolimit = 17.5, XiaoyeUplimit = 24.5, DayeLolimit = 16.5, DayeUplimit = 25.5, TeyeLolimit = 15.5, TeyeUplimit = 26.5, YexianNkLolimitLeft = 15.0, YexianNkLolimitRight = 17.5, YexianNkUplimitLeft = 24.0, YexianNkUplimitRight = 27.5, LaodongJingsaiYexianLolimit = 17.5, LaodongJingsaiYexianUplimit = 23.5, Sort = 0 }, + new ShengsiGuige { Guige = "27/29", CenterrangeLolimit = 26.5, CenterrangeUplimit = 28.5, CenterXiandu = 27.5, ValidLolimit = 23.0, ValidUplimit = 34.0, XiaoyeLolimit = 25.0, XiaoyeUplimit = 32.0, DayeLolimit = 24.0, DayeUplimit = 33.0, TeyeLolimit = 23.0, TeyeUplimit = 34.0, YexianNkLolimitLeft = 22.0, YexianNkLolimitRight = 24.5, YexianNkUplimitLeft = 31.0, YexianNkUplimitRight = 34.5, LaodongJingsaiYexianLolimit = 24.5, LaodongJingsaiYexianUplimit = 30.5, Sort = 100 }, + new ShengsiGuige { Guige = "40/44", CenterrangeLolimit = 40.5, CenterrangeUplimit = 42.5, CenterXiandu = 41.5, ValidLolimit = 23.0, ValidUplimit = 34.0, XiaoyeLolimit = 35.0, XiaoyeUplimit = 49.0, DayeLolimit = 33.0, DayeUplimit = 51.0, TeyeLolimit = 31.0, TeyeUplimit = 53.0, YexianNkLolimitLeft = 0.0, YexianNkLolimitRight = 0.0, YexianNkUplimitLeft = 0.0, YexianNkUplimitRight = 0.0, LaodongJingsaiYexianLolimit = 40.0, LaodongJingsaiYexianUplimit = 42.0, Sort = 200 } + }).ExecuteAffrows(); + } + + private static void SeedShengsiDingjizhibiao(IFreeSql fsql) + { + if (fsql.Select().Any()) return; + + var list = new List(); + void Add(int std, string guige, string grade, double score, double piancha, double dacha, double erdu, double sandu, double qingjie, double jiejing) + { + list.Add(new ShengsiDingjizhibiao { Standard = std, Guige = guige, Grade = grade, GradeScore = score, Piancha = piancha, Dacha = dacha, Erdu = erdu, Sandu = sandu, Qingjie = qingjie, Jiejing = jiejing }); + } + + // ===== 厂标 (standard=0) ===== + Add(0, "20/22", "A", 7.0, 2.0, 6.0, 6.0, 2.0, 90.0, 86.0); + Add(0, "20/22", "A50", 7.5, 1.85, 5.6, 6.0, 2.0, 91.5, 87.0); + Add(0, "20/22", "2A", 8.0, 1.7, 5.2, 5.0, 1.0, 93.0, 88.0); + Add(0, "20/22", "2A50", 8.5, 1.58, 4.8, 5.0, 0.0, 94.5, 89.0); + Add(0, "20/22", "3A", 9.0, 1.45, 4.4, 4.0, 0.0, 95.0, 90.0); + Add(0, "20/22", "3A50", 9.5, 1.33, 4.0, 4.0, 0.0, 96.0, 91.0); + Add(0, "20/22", "4A", 10.0, 1.2, 3.6, 3.0, 0.0, 96.5, 92.0); + Add(0, "20/22", "4A50", 10.5, 1.13, 3.3, 3.0, 0.0, 97.0, 93.0); + Add(0, "20/22", "5A", 11.0, 1.05, 3.0, 2.0, 0.0, 97.5, 94.0); + Add(0, "20/22", "5A50", 11.5, 0.98, 2.8, 2.0, 0.0, 97.8, 94.5); + Add(0, "20/22", "6A", 12.0, 0.9, 2.6, 1.0, 0.0, 98.0, 95.0); + Add(0, "27/29", "A", 7.0, 2.05, 6.1, 6.0, 0.0, 90.0, 86.0); + Add(0, "27/29", "A50", 7.5, 1.9, 5.6, 6.0, 0.0, 91.5, 87.0); + Add(0, "27/29", "2A", 8.0, 1.75, 5.1, 5.0, 0.0, 93.0, 88.0); + Add(0, "27/29", "2A50", 8.5, 1.65, 4.7, 5.0, 0.0, 94.5, 89.0); + Add(0, "27/29", "3A", 9.0, 1.55, 4.3, 4.0, 0.0, 95.0, 90.0); + Add(0, "27/29", "3A50", 9.5, 1.45, 4.0, 4.0, 0.0, 96.0, 91.0); + Add(0, "27/29", "4A", 10.0, 1.3, 3.7, 3.0, 0.0, 96.5, 92.0); + Add(0, "27/29", "4A50", 10.5, 1.25, 3.5, 3.0, 0.0, 97.0, 93.0); + Add(0, "27/29", "5A", 11.0, 1.15, 3.3, 2.0, 0.0, 97.5, 94.0); + Add(0, "27/29", "5A50", 11.5, 1.1, 3.1, 2.0, 0.0, 97.8, 94.5); + Add(0, "27/29", "6A", 12.0, 1.05, 3.0, 1.0, 0.0, 98.0, 95.0); + Add(0, "40/44", "A", 7.0, 2.85, 8.45, 0.0, 0.0, 0.0, 0.0); + Add(0, "40/44", "A50", 7.5, 2.69, 7.88, 0.0, 0.0, 0.0, 0.0); + Add(0, "40/44", "2A", 8.0, 2.53, 7.3, 0.0, 0.0, 0.0, 0.0); + Add(0, "40/44", "2A50", 8.5, 2.36, 6.79, 0.0, 0.0, 0.0, 0.0); + Add(0, "40/44", "3A", 9.0, 2.18, 6.28, 0.0, 0.0, 0.0, 0.0); + Add(0, "40/44", "3A50", 9.5, 2.04, 5.89, 0.0, 0.0, 0.0, 0.0); + Add(0, "40/44", "4A", 10.0, 1.9, 5.5, 0.0, 0.0, 0.0, 0.0); + Add(0, "40/44", "4A50", 10.5, 1.8, 5.2, 0.0, 0.0, 0.0, 0.0); + Add(0, "40/44", "5A", 11.0, 1.7, 4.9, 0.0, 0.0, 0.0, 0.0); + Add(0, "40/44", "5A50", 11.5, 1.6, 4.6, 0.0, 0.0, 0.0, 0.0); + Add(0, "40/44", "6A", 12.0, 1.5, 4.3, 0.0, 0.0, 0.0, 0.0); + Add(0, "20/22", "等外", 6.0, 9.99, 99.99, 100.0, 100.0, 0.0, 0.0); + Add(0, "27/29", "等外", 6.0, 9.99, 99.99, 100.0, 100.0, 0.0, 0.0); + Add(0, "40/44", "等外", 6.0, 9.99, 99.99, 100.0, 100.0, 0.0, 0.0); + + // ===== 个人 (standard=2) ===== + Add(2, "20/22", "A00", 7.0, 2.0, 6.0, 6.0, 2.0, 90.0, 86.0); + Add(2, "20/22", "A10", 7.1, 1.97, 5.92, 6.0, 2.0, 90.3, 86.2); + Add(2, "20/22", "A20", 7.2, 1.94, 5.84, 6.0, 2.0, 90.6, 86.4); + Add(2, "20/22", "A30", 7.3, 1.91, 5.76, 6.0, 2.0, 90.9, 86.6); + Add(2, "20/22", "A40", 7.4, 1.88, 5.68, 6.0, 2.0, 91.2, 86.8); + Add(2, "20/22", "A50", 7.5, 1.85, 5.6, 6.0, 2.0, 91.5, 87.0); + Add(2, "20/22", "A60", 7.6, 1.82, 5.52, 5.8, 1.8, 91.8, 87.2); + Add(2, "20/22", "A70", 7.7, 1.79, 5.44, 5.6, 1.6, 92.1, 87.4); + Add(2, "20/22", "A80", 7.8, 1.76, 5.36, 5.4, 1.4, 92.4, 87.6); + Add(2, "20/22", "A90", 7.9, 1.73, 5.28, 5.2, 1.2, 92.7, 87.8); + Add(2, "20/22", "2A00", 8.0, 1.7, 5.2, 5.0, 1.0, 93.0, 88.0); + Add(2, "20/22", "2A10", 8.1, 1.68, 5.12, 5.0, 0.8, 93.3, 88.2); + Add(2, "20/22", "2A20", 8.2, 1.65, 5.04, 5.0, 0.6, 93.6, 88.4); + Add(2, "20/22", "2A30", 8.3, 1.63, 4.96, 5.0, 0.4, 93.9, 88.6); + Add(2, "20/22", "2A40", 8.4, 1.6, 4.88, 5.0, 0.2, 94.2, 88.8); + Add(2, "20/22", "2A50", 8.5, 1.58, 4.8, 5.0, 0.0, 94.5, 89.0); + Add(2, "20/22", "2A60", 8.6, 1.55, 4.72, 4.8, 0.0, 94.6, 89.2); + Add(2, "20/22", "2A70", 8.7, 1.53, 4.64, 4.6, 0.0, 94.7, 89.4); + Add(2, "20/22", "2A80", 8.8, 1.5, 4.56, 4.4, 0.0, 94.8, 89.6); + Add(2, "20/22", "2A90", 8.9, 1.48, 4.48, 4.2, 0.0, 94.9, 89.8); + Add(2, "20/22", "3A00", 9.0, 1.45, 4.4, 4.0, 0.0, 95.0, 90.0); + Add(2, "20/22", "3A10", 9.1, 1.43, 4.32, 4.0, 0.0, 95.2, 90.2); + Add(2, "20/22", "3A20", 9.2, 1.4, 4.24, 4.0, 0.0, 95.4, 90.4); + Add(2, "20/22", "3A30", 9.3, 1.38, 4.16, 4.0, 0.0, 95.6, 90.6); + Add(2, "20/22", "3A40", 9.4, 1.35, 4.08, 4.0, 0.0, 95.8, 90.8); + Add(2, "20/22", "3A50", 9.5, 1.33, 4.0, 4.0, 0.0, 96.0, 91.0); + Add(2, "20/22", "3A60", 9.6, 1.3, 3.92, 3.8, 0.0, 96.1, 91.2); + Add(2, "20/22", "3A70", 9.7, 1.28, 3.84, 3.6, 0.0, 96.2, 91.4); + Add(2, "20/22", "3A80", 9.8, 1.25, 3.76, 3.4, 0.0, 96.3, 91.6); + Add(2, "20/22", "3A90", 9.9, 1.23, 3.68, 3.2, 0.0, 96.4, 91.8); + Add(2, "20/22", "4A00", 10.0, 1.2, 3.6, 3.0, 0.0, 96.5, 92.0); + Add(2, "20/22", "4A10", 10.1, 1.19, 3.54, 3.0, 0.0, 96.6, 92.2); + Add(2, "20/22", "4A20", 10.2, 1.17, 3.48, 3.0, 0.0, 96.7, 92.4); + Add(2, "20/22", "4A30", 10.3, 1.16, 3.42, 3.0, 0.0, 96.8, 92.6); + Add(2, "20/22", "4A40", 10.4, 1.14, 3.36, 3.0, 0.0, 96.9, 92.8); + Add(2, "20/22", "4A50", 10.5, 1.13, 3.3, 3.0, 0.0, 97.0, 93.0); + Add(2, "20/22", "4A60", 10.6, 1.11, 3.24, 2.8, 0.0, 97.1, 93.2); + Add(2, "20/22", "4A70", 10.7, 1.1, 3.18, 2.6, 0.0, 97.2, 93.4); + Add(2, "20/22", "4A80", 10.8, 1.08, 3.12, 2.4, 0.0, 97.3, 93.6); + Add(2, "20/22", "4A90", 10.9, 1.07, 3.06, 2.2, 0.0, 97.4, 93.8); + Add(2, "20/22", "5A00", 11.0, 1.05, 3.0, 2.0, 0.0, 97.5, 94.0); + Add(2, "20/22", "5A10", 11.1, 1.04, 2.96, 2.0, 0.0, 97.56, 94.1); + Add(2, "20/22", "5A20", 11.2, 1.03, 2.92, 2.0, 0.0, 97.62, 94.2); + Add(2, "20/22", "5A30", 11.3, 1.01, 2.88, 2.0, 0.0, 97.68, 94.3); + Add(2, "20/22", "5A40", 11.4, 0.99, 2.84, 2.0, 0.0, 97.74, 94.4); + Add(2, "20/22", "5A50", 11.5, 0.98, 2.8, 2.0, 0.0, 97.8, 94.5); + Add(2, "20/22", "5A60", 11.6, 0.96, 2.76, 1.8, 0.0, 97.84, 94.6); + Add(2, "20/22", "5A70", 11.7, 0.95, 2.72, 1.6, 0.0, 97.88, 94.7); + Add(2, "20/22", "5A80", 11.8, 0.93, 2.68, 1.4, 0.0, 97.92, 94.8); + Add(2, "20/22", "5A90", 11.9, 0.92, 2.64, 1.2, 0.0, 97.96, 94.9); + Add(2, "20/22", "6A00", 12.0, 0.9, 2.6, 1.0, 0.0, 98.0, 95.0); + + Add(2, "27/29", "A00", 7.0, 2.05, 6.0, 12.0, 0.0, 90.0, 86.0); + Add(2, "27/29", "A10", 7.1, 2.02, 5.9, 11.6, 0.0, 90.3, 86.2); + Add(2, "27/29", "A20", 7.2, 1.99, 5.8, 11.2, 0.0, 90.6, 86.4); + Add(2, "27/29", "A30", 7.3, 1.96, 5.7, 10.8, 0.0, 90.9, 86.6); + Add(2, "27/29", "A40", 7.4, 1.93, 5.6, 10.4, 0.0, 91.2, 86.8); + Add(2, "27/29", "A50", 7.5, 1.9, 5.5, 10.0, 0.0, 91.5, 87.0); + Add(2, "27/29", "A60", 7.6, 1.87, 5.4, 9.6, 0.0, 91.8, 87.2); + Add(2, "27/29", "A70", 7.7, 1.84, 5.3, 9.2, 0.0, 92.1, 87.4); + Add(2, "27/29", "A80", 7.8, 1.81, 5.2, 8.8, 0.0, 92.4, 87.6); + Add(2, "27/29", "A90", 7.9, 1.78, 5.1, 8.4, 0.0, 92.7, 87.8); + Add(2, "27/29", "2A00", 8.0, 1.75, 5.0, 8.0, 0.0, 93.0, 88.0); + Add(2, "27/29", "2A10", 8.1, 1.73, 4.92, 7.7, 0.0, 93.2, 88.2); + Add(2, "27/29", "2A20", 8.2, 1.69, 4.76, 7.1, 0.0, 93.6, 88.6); + Add(2, "27/29", "2A30", 8.3, 1.67, 4.68, 6.8, 0.0, 93.8, 88.8); + Add(2, "27/29", "2A40", 8.4, 1.65, 4.6, 6.5, 0.0, 94.0, 89.0); + Add(2, "27/29", "2A50", 8.5, 1.63, 4.52, 6.2, 0.0, 94.2, 89.2); + Add(2, "27/29", "2A60", 8.6, 1.61, 4.44, 5.9, 0.0, 94.4, 89.4); + Add(2, "27/29", "2A70", 8.7, 1.59, 4.36, 5.6, 0.0, 94.6, 89.6); + Add(2, "27/29", "2A80", 8.8, 1.57, 4.28, 5.3, 0.0, 94.8, 89.8); + Add(2, "27/29", "2A90", 8.9, 1.55, 4.2, 5.0, 0.0, 95.0, 90.0); + Add(2, "27/29", "3A00", 9.0, 1.53, 4.14, 4.8, 0.0, 95.15, 90.2); + Add(2, "27/29", "3A10", 9.1, 1.51, 4.08, 4.6, 0.0, 95.3, 90.4); + Add(2, "27/29", "3A20", 9.2, 1.49, 4.02, 4.4, 0.0, 95.45, 90.6); + Add(2, "27/29", "3A30", 9.3, 1.47, 3.96, 4.2, 0.0, 95.6, 90.8); + Add(2, "27/29", "3A40", 9.4, 1.45, 3.9, 4.0, 0.0, 95.75, 91.0); + Add(2, "27/29", "3A50", 9.5, 1.43, 3.84, 3.8, 0.0, 95.9, 91.2); + Add(2, "27/29", "3A60", 9.6, 1.41, 3.78, 3.6, 0.0, 96.05, 91.4); + Add(2, "27/29", "3A70", 9.7, 1.39, 3.72, 3.4, 0.0, 96.2, 91.6); + Add(2, "27/29", "3A80", 9.8, 1.37, 3.66, 3.2, 0.0, 96.35, 91.8); + Add(2, "27/29", "3A90", 9.9, 1.35, 3.6, 3.0, 0.0, 96.5, 92.0); + Add(2, "27/29", "4A00", 10.0, 1.33, 3.66, 2.85, 0.0, 96.6, 92.2); + Add(2, "27/29", "4A10", 10.1, 1.31, 3.62, 2.7, 0.0, 96.7, 92.4); + Add(2, "27/29", "4A20", 10.2, 1.29, 3.58, 2.55, 0.0, 96.8, 92.6); + Add(2, "27/29", "4A30", 10.3, 1.27, 3.54, 2.4, 0.0, 96.9, 92.8); + Add(2, "27/29", "4A40", 10.4, 1.25, 3.5, 2.25, 0.0, 97.0, 93.0); + Add(2, "27/29", "4A50", 10.5, 1.23, 3.46, 2.1, 0.0, 97.1, 93.2); + Add(2, "27/29", "4A60", 10.6, 1.21, 3.42, 1.95, 0.0, 97.2, 93.4); + Add(2, "27/29", "4A70", 10.7, 1.19, 3.38, 1.8, 0.0, 97.3, 93.6); + Add(2, "27/29", "4A80", 10.8, 1.17, 3.34, 1.65, 0.0, 97.4, 93.8); + Add(2, "27/29", "4A90", 10.9, 1.15, 3.3, 1.5, 0.0, 97.5, 94.0); + Add(2, "27/29", "5A00", 11.0, 1.14, 3.27, 1.45, 0.0, 97.55, 94.1); + Add(2, "27/29", "5A10", 11.1, 1.13, 3.24, 1.4, 0.0, 97.6, 94.2); + Add(2, "27/29", "5A20", 11.2, 1.12, 3.21, 1.35, 0.0, 97.65, 94.3); + Add(2, "27/29", "5A30", 11.3, 1.11, 3.18, 1.3, 0.0, 97.7, 94.4); + Add(2, "27/29", "5A40", 11.4, 1.1, 3.15, 1.25, 0.0, 97.75, 94.5); + Add(2, "27/29", "5A50", 11.5, 1.09, 3.12, 1.2, 0.0, 97.8, 94.6); + Add(2, "27/29", "5A60", 11.6, 1.08, 3.09, 1.15, 0.0, 97.85, 94.7); + Add(2, "27/29", "5A70", 11.7, 1.07, 3.06, 1.1, 0.0, 97.9, 94.8); + Add(2, "27/29", "5A80", 11.8, 1.06, 3.03, 1.05, 0.0, 97.95, 94.9); + Add(2, "27/29", "5A90", 11.9, 1.05, 3.0, 1.0, 0.0, 98.0, 95.0); + Add(2, "27/29", "6A00", 12.0, 1.05, 3.0, 1.0, 0.0, 98.0, 95.0); + + Add(2, "40/44", "A00", 7.0, 2.6, 7.55, 8.0, 0.0, 90.0, 86.0); + Add(2, "40/44", "A10", 7.1, 2.57, 7.43, 7.7, 0.0, 90.3, 86.2); + Add(2, "40/44", "A20", 7.2, 2.53, 7.31, 7.4, 0.0, 90.6, 86.4); + Add(2, "40/44", "A30", 7.3, 2.5, 7.19, 7.1, 0.0, 90.9, 86.6); + Add(2, "40/44", "A40", 7.4, 2.46, 7.07, 6.8, 0.0, 91.2, 86.8); + Add(2, "40/44", "A50", 7.5, 2.43, 6.95, 6.5, 0.0, 91.5, 87.0); + Add(2, "40/44", "A60", 7.6, 2.39, 6.83, 6.2, 0.0, 91.8, 87.2); + Add(2, "40/44", "A70", 7.7, 2.36, 6.71, 5.9, 0.0, 92.1, 87.4); + Add(2, "40/44", "A80", 7.8, 2.32, 6.59, 5.6, 0.0, 92.4, 87.6); + Add(2, "40/44", "A90", 7.9, 2.29, 6.47, 5.3, 0.0, 92.7, 87.8); + Add(2, "40/44", "2A00", 8.0, 2.25, 6.35, 5.0, 0.0, 93.0, 88.0); + Add(2, "40/44", "2A10", 8.1, 2.22, 6.24, 4.8, 0.0, 93.2, 88.2); + Add(2, "40/44", "2A20", 8.2, 2.18, 6.13, 4.6, 0.0, 93.4, 88.4); + Add(2, "40/44", "2A30", 8.3, 2.15, 6.02, 4.4, 0.0, 93.6, 88.6); + Add(2, "40/44", "2A40", 8.4, 2.11, 5.91, 4.2, 0.0, 93.8, 88.8); + Add(2, "40/44", "2A50", 8.5, 2.08, 5.8, 4.0, 0.0, 94.0, 89.0); + Add(2, "40/44", "2A60", 8.6, 2.04, 5.69, 3.8, 0.0, 94.2, 89.2); + Add(2, "40/44", "2A70", 8.7, 2.01, 5.58, 3.6, 0.0, 94.4, 89.4); + Add(2, "40/44", "2A80", 8.8, 1.97, 5.47, 3.4, 0.0, 94.6, 89.6); + Add(2, "40/44", "2A90", 8.9, 1.94, 5.36, 3.2, 0.0, 94.8, 89.8); + Add(2, "40/44", "3A00", 9.0, 1.9, 5.25, 3.0, 0.0, 95.0, 90.0); + Add(2, "40/44", "3A10", 9.1, 1.87, 5.16, 1.95, 0.0, 95.15, 90.2); + Add(2, "40/44", "3A20", 9.2, 1.83, 5.06, 1.9, 0.0, 95.3, 90.4); + Add(2, "40/44", "3A30", 9.3, 1.8, 4.97, 1.85, 0.0, 95.45, 90.6); + Add(2, "40/44", "3A40", 9.4, 1.76, 4.87, 1.8, 0.0, 95.6, 90.8); + Add(2, "40/44", "3A50", 9.5, 1.73, 4.78, 1.75, 0.0, 95.75, 91.0); + Add(2, "40/44", "3A60", 9.6, 1.69, 4.68, 1.7, 0.0, 95.9, 91.2); + Add(2, "40/44", "3A70", 9.7, 1.66, 4.59, 1.65, 0.0, 96.05, 91.4); + Add(2, "40/44", "3A80", 9.8, 1.62, 4.49, 1.6, 0.0, 96.2, 91.6); + Add(2, "40/44", "3A90", 9.9, 1.59, 4.4, 1.55, 0.0, 96.35, 91.8); + Add(2, "40/44", "4A00", 10.0, 1.55, 4.3, 1.5, 0.0, 96.5, 92.0); + Add(2, "40/44", "4A10", 10.1, 1.53, 4.24, 1.45, 0.0, 96.6, 92.2); + Add(2, "40/44", "4A20", 10.2, 1.51, 4.18, 1.4, 0.0, 96.7, 92.4); + Add(2, "40/44", "4A30", 10.3, 1.49, 4.12, 1.35, 0.0, 96.8, 92.6); + Add(2, "40/44", "4A40", 10.4, 1.47, 4.06, 1.3, 0.0, 96.9, 92.8); + Add(2, "40/44", "4A50", 10.5, 1.45, 4.0, 1.25, 0.0, 97.0, 93.0); + Add(2, "40/44", "4A60", 10.6, 1.43, 3.94, 1.2, 0.0, 97.1, 93.2); + Add(2, "40/44", "4A70", 10.7, 1.41, 3.88, 1.15, 0.0, 97.2, 93.4); + Add(2, "40/44", "4A80", 10.8, 1.39, 3.82, 1.1, 0.0, 97.3, 93.6); + Add(2, "40/44", "4A90", 10.9, 1.37, 3.76, 1.05, 0.0, 97.4, 93.8); + Add(2, "40/44", "5A00", 11.0, 1.35, 3.7, 1.0, 0.0, 97.5, 94.0); + Add(2, "40/44", "5A10", 11.1, 1.33, 3.64, 0.0, 0.0, 97.55, 94.1); + Add(2, "40/44", "5A20", 11.2, 1.31, 3.58, 0.0, 0.0, 97.6, 94.2); + Add(2, "40/44", "5A30", 11.3, 1.29, 3.52, 0.0, 0.0, 97.65, 94.3); + Add(2, "40/44", "5A40", 11.4, 1.27, 3.46, 0.0, 0.0, 97.7, 94.4); + Add(2, "40/44", "5A50", 11.5, 1.25, 3.4, 0.0, 0.0, 97.75, 94.5); + Add(2, "40/44", "5A60", 11.6, 1.23, 3.34, 0.0, 0.0, 97.8, 94.6); + Add(2, "40/44", "5A70", 11.7, 1.21, 3.28, 0.0, 0.0, 97.85, 94.7); + Add(2, "40/44", "5A80", 11.8, 1.19, 3.22, 0.0, 0.0, 97.9, 94.8); + Add(2, "40/44", "5A90", 11.9, 1.17, 3.16, 0.0, 0.0, 97.95, 94.9); + Add(2, "40/44", "6A00", 12.0, 1.15, 3.1, 0.0, 0.0, 98.0, 95.0); + Add(2, "20/22", "等外", 6.0, 9.99, 99.99, 100.0, 100.0, 0.0, 0.0); + Add(2, "27/29", "等外", 6.0, 9.99, 99.99, 100.0, 100.0, 0.0, 0.0); + Add(2, "40/44", "等外", 6.0, 9.99, 99.99, 100.0, 100.0, 0.0, 0.0); + + fsql.Insert(list).ExecuteAffrows(); + } +} diff --git a/backend/F9WebAiTest.Infrastructure/Data/DbSeeder.cs b/backend/F9WebAiTest.Infrastructure/Data/DbSeeder.cs new file mode 100644 index 0000000..fceecec --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/Data/DbSeeder.cs @@ -0,0 +1,509 @@ +using System.Data; +using FreeSql; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities; +using F9WebAiTest.Domain.Entities.Base; +using F9WebAiTest.Domain.Entities.Notice; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using SysMenu = F9WebAiTest.Domain.Entities.SYS.RBAC.Menu; +using SysPermission = F9WebAiTest.Domain.Entities.SYS.RBAC.Permission; +using SysRolePermission = F9WebAiTest.Domain.Entities.SYS.RBAC.RolePermission; + +namespace F9WebAiTest.Infrastructure.Data; + +public static class DbSeeder +{ + public static void Seed(IFreeSql fsql, ISnowflakeIdGenerator snowflake) + { + // 1. 建表(CodeFirst SyncStructure,增量同步,兼容旧库) + SyncStructure(fsql); + + // 2. 默认角色 + if (!fsql.Select().Any()) + { + fsql.Insert(new Role { Name = "超级管理员", Code = "admin", Description = "拥有所有权限", Sort = 1 }).ExecuteAffrows(); + fsql.Insert(new Role { Name = "普通用户", Code = "user", Description = "普通员工", Sort = 2 }).ExecuteAffrows(); + } + + // 3. 默认菜单(系统管理) + if (!fsql.Select().Any()) + { + var systemId = InsertAndGetId(fsql, new Menu { Name = "系统管理", Type = "directory", Path = "/system", Icon = "Setting", Sort = 1 }); + var deptId = InsertAndGetId(fsql, new Menu { Name = "部门管理", Type = "menu", Path = "/system/department", Component = "system/department/index", Icon = "OfficeBuilding", Permission = "system:department:list", Sort = 1, ParentId = systemId }); + var empId = InsertAndGetId(fsql, new Menu { Name = "职工管理", Type = "menu", Path = "/system/employee", Component = "system/employee/index", Icon = "User", Permission = "system:employee:list", Sort = 2, ParentId = systemId }); + var roleId = InsertAndGetId(fsql, new Menu { Name = "角色管理", Type = "menu", Path = "/system/role", Component = "system/role/index", Icon = "UserFilled", Permission = "system:role:list", Sort = 3, ParentId = systemId }); + var menuId = InsertAndGetId(fsql, new Menu { Name = "菜单管理", Type = "menu", Path = "/system/menu", Component = "system/menu/index", Icon = "Menu", Permission = "system:menu:list", Sort = 4, ParentId = systemId }); + var logId = InsertAndGetId(fsql, new Menu { Name = "操作日志", Type = "menu", Path = "/system/log", Component = "system/log/index", Icon = "Document", Permission = "system:log:list", Sort = 5, ParentId = systemId }); + fsql.Insert(new Menu[] + { + new Menu { Name = "部门操作", Type = "button", Permission = "system:department:btn", Sort = 1, ParentId = deptId }, + new Menu { Name = "职工操作", Type = "button", Permission = "system:employee:btn", Sort = 1, ParentId = empId }, + new Menu { Name = "角色操作", Type = "button", Permission = "system:role:btn", Sort = 1, ParentId = roleId }, + new Menu { Name = "菜单操作", Type = "button", Permission = "system:menu:btn", Sort = 1, ParentId = menuId }, + }).ExecuteAffrows(); + } + + // 4. 考勤菜单(增量) + SeedAttendanceMenus(fsql); + + // 5. 业务基础数据(部门树 + 机组车台 + 庄口 + 规格 + 定级指标 + 菜单) + BusinessSeedData.Seed(fsql); + + // 6. 通知:消息类型种子 + 菜单 + SeedNotificationData(fsql, snowflake); + + // 6.5 数据迁移:旧 RBAC 表 → 新 DDD RBAC 表(先迁移,确保 admin 角色/用户已在新表) + MigrateRbacData(fsql); + + // 6.6 F9 风格 RBAC 菜单(用户/角色/菜单/部门管理,写入新 sys_rbac_menus 表) + SeedSysRbacMenus(fsql, snowflake); + + // 6.7 为菜单创建权限单元(Permission),并修正旧菜单权限关联(统一权限模型) + FixMenuPermissions(fsql, snowflake); + + // 7. 管理员账号(幂等) + var admin = fsql.Select().Where(e => e.Username == "admin").First(); + if (admin == null) + { + var dept = fsql.Select().Where(d => d.Name == "主干运维").First() + ?? fsql.Select().First(); + var adminId = InsertAndGetId(fsql, new Employee + { + Username = "admin", + Password = BCrypt.Net.BCrypt.HashPassword("123456"), + RealName = "系统管理员", + Nickname = "管理员", + DepartmentId = dept.Id, + Email = "admin@company.com", + Phone = "13800000000", + Status = 1 + }); + admin = fsql.Select().Where(e => e.Id == adminId).First(); + } + + // 8. admin 关联 admin 角色 + var adminRole = fsql.Select().Where(r => r.Code == "admin").First(); + if (!fsql.Select().Any(er => er.EmployeeId == admin.Id && er.RoleId == adminRole.Id)) + { + fsql.Insert(new EmployeeRole { EmployeeId = admin.Id, RoleId = adminRole.Id }).ExecuteAffrows(); + } + + // 9. admin 角色分配所有菜单(幂等补充) + var allMenus = fsql.Select().ToList(); + foreach (var m in allMenus) + { + if (!fsql.Select().Any(rm => rm.RoleId == adminRole.Id && rm.MenuId == m.Id)) + { + fsql.Insert(new RoleMenu { RoleId = adminRole.Id, MenuId = m.Id }).ExecuteAffrows(); + } + } + } + + /// CodeFirst 建表(新表建表,旧表同步失败则跳过)+ 旧库加列兜底 + private static void SyncStructure(IFreeSql fsql) + { + // 扫描 BaseEntity(旧)与 Entity(新 DDD 基类)两类实体的具体子类建表 + var entityTypes = typeof(BaseEntity).Assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && + (t.IsSubclassOf(typeof(BaseEntity)) || t.IsSubclassOf(typeof(Entity)))); + foreach (var t in entityTypes) + { + try + { + fsql.CodeFirst.SyncStructure(t); + } + catch (Exception ex) + { + // 旧表同步失败(如唯一索引与历史数据冲突),跳过,保持旧库兼容 + Console.WriteLine($"[DbSeeder] 表 {t.Name} 结构同步跳过:{ex.Message}"); + } + } + + // 旧库加列兜底(SyncStructure 不会为已有表加列) + EnsureColumn(fsql, "LeaveRequests", "CurrentOrderNo", "INTEGER NOT NULL DEFAULT 1"); + EnsureColumn(fsql, "NotificationTypes", "Category", "INTEGER NOT NULL DEFAULT 0"); + EnsureColumn(fsql, "NotificationTypes", "Icon", "TEXT NOT NULL DEFAULT ''"); + EnsureColumn(fsql, "NotificationTypes", "RouteTemplate", "TEXT NOT NULL DEFAULT ''"); + EnsureColumn(fsql, "NotificationMessages", "Category", "INTEGER NOT NULL DEFAULT 0"); + EnsureColumn(fsql, "NotificationMessages", "RoutePath", "TEXT NOT NULL DEFAULT ''"); + } + + /// 为已有表补加列(列已存在则忽略) + private static void EnsureColumn(IFreeSql fsql, string table, string column, string definition) + { + try + { + fsql.Ado.ExecuteNonQuery($"ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition};"); + } + catch + { + // 列已存在,忽略 + } + } + + /// 插入并返回自增主键 ID(SQLite 用 last_insert_rowid 可靠获取) + public static long InsertAndGetId(IFreeSql fsql, T entity) where T : BaseEntity + { + fsql.Insert(entity).ExecuteAffrows(); + return Convert.ToInt64(fsql.Ado.ExecuteScalar("SELECT last_insert_rowid();")); + } + + /// 通知种子:消息类型 + 消息中心菜单 + private static void SeedNotificationData(IFreeSql fsql, ISnowflakeIdGenerator snowflake) + { + // 消息类型:发现特野 + if (!fsql.Select().Any(t => t.Code == "TeyeFound")) + { + fsql.Insert(new NotificationType + { + Code = "TeyeFound", Name = "发现特野", Description = "纤度检验发现特野时自动推送提醒", + RequiredPermission = "", IsActive = true, CreatedBy = 0, Sort = 1, + Category = NotificationCategory.Notification, Icon = "Warning", RouteTemplate = "/prod/xdjy" + }).ExecuteAffrows(); + } + + // 消息类型:发现特大糙 + if (!fsql.Select().Any(t => t.Code == "TdcFound")) + { + fsql.Insert(new NotificationType + { + Code = "TdcFound", Name = "发现特大糙", Description = "黑板检验发现特大糙时自动推送提醒", + RequiredPermission = "", IsActive = true, CreatedBy = 0, Sort = 2, + Category = NotificationCategory.Notification, Icon = "Warning", RouteTemplate = "/prod/hbjy" + }).ExecuteAffrows(); + } + + // 消息类型:请假审批(订阅权限用独立码,避免与业务按钮权限冲突) + if (!fsql.Select().Any(t => t.Code == "LeaveApproval")) + { + fsql.Insert(new NotificationType + { + Code = "LeaveApproval", Name = "请假审批", Description = "员工提交请假申请后,推送待办给审批人", + RequiredPermission = "notification:subscribe:leaveapproval", IsActive = true, CreatedBy = 0, Sort = 3, + Category = NotificationCategory.Todo, Icon = "Checked", RouteTemplate = "/attendance/approve?leaveId={businessId}" + }).ExecuteAffrows(); + } + + // 消息类型:请假结果通知 + if (!fsql.Select().Any(t => t.Code == "LeaveResult")) + { + fsql.Insert(new NotificationType + { + Code = "LeaveResult", Name = "请假结果通知", Description = "请假审批完成后通知申请人结果", + RequiredPermission = "", IsActive = true, CreatedBy = 0, Sort = 4, + Category = NotificationCategory.Notification, Icon = "Message", RouteTemplate = "/attendance/my" + }).ExecuteAffrows(); + } + + // 消息类型:广播通知(有权限的用户主动发送的广播,推送给所有订阅者) + if (!fsql.Select().Any(t => t.Code == "Broadcast")) + { + fsql.Insert(new NotificationType + { + Code = "Broadcast", Name = "广播通知", Description = "用户发送的广播通知,推送给所有订阅该类型的用户", + RequiredPermission = "", IsActive = true, CreatedBy = 0, Sort = 5, + Category = NotificationCategory.Announcement, Icon = "Bell", RouteTemplate = "" + }).ExecuteAffrows(); + } + + // 广播发送权限单元(ResourceType=操作,Code=notification:broadcast,admin 自动拥有,普通用户可在角色管理分配) + var broadcastPerm = fsql.Select() + .Where(p => p.Code == "notification:broadcast").First(); + if (broadcastPerm == null) + { + var perm = new SysPermission(snowflake.CreateId(), Permission_Type_Enum.Enable, Resource_Type_Enum.操作, "broadcast", 0); + perm.SetCode("notification:broadcast"); + fsql.Insert(perm).ExecuteAffrows(); + } + + // 菜单:消息中心 + var notiDirId = fsql.Select().Where(m => m.Path == "/notification").First()?.Id ?? 0; + if (notiDirId == 0) + { + notiDirId = InsertAndGetId(fsql, new Menu { Name = "消息中心", Type = "directory", Path = "/notification", Icon = "Bell", Sort = 5 }); + } + + AddNotificationMenu(fsql, notiDirId, "我的消息", "/notification/messages", "notification/messages/index", "Message", "notification:messages:list", 1); + AddNotificationMenu(fsql, notiDirId, "消息订阅", "/notification/subscribe", "notification/subscribe/index", "BellFilled", "notification:subscribe:list", 2); + AddNotificationMenu(fsql, notiDirId, "消息类型管理", "/notification/type", "notification/type/index", "SetUp", "notification:type:list", 3); + + // 清理历史遗留:发现特野/发现特大糙 改为「所有人可订阅」 + foreach (var (code, oldPerm) in new[] { ("TeyeFound", "notification:subscribe:foundteye"), ("TdcFound", "notification:subscribe:foundtedacao") }) + { + var t = fsql.Select().Where(x => x.Code == code).First(); + if (t == null) continue; + + // 1. RequiredPermission 改为空(所有人可订阅) + if (!string.IsNullOrEmpty(t.RequiredPermission)) + { + t.RequiredPermission = ""; + fsql.Update().SetSource(t).ExecuteAffrows(); + } + + // 2. 删除订阅权限单元及其角色关联(新 Permission 表) + var perm = fsql.Select() + .Where(p => p.ResourceType == Resource_Type_Enum.消息订阅 && p.ResourceId == t.Id).First(); + if (perm != null) + { + fsql.Delete().Where(rp => rp.PermissionId == perm.Id).ExecuteAffrows(); + fsql.Delete().Where(p => p.Id == perm.Id).ExecuteAffrows(); + } + + // 3. 删除历史遗留的旧"订阅按钮"菜单及其角色关联(新表 sys_rbac_menus + 旧表 Menus) + fsql.Ado.ExecuteNonQuery($@" + DELETE FROM sys_rbac_role_permissions WHERE permission_id IN + (SELECT id FROM sys_rbac_menus WHERE permission = '{oldPerm}'); + DELETE FROM sys_rbac_menus WHERE permission = '{oldPerm}'; + DELETE FROM RoleMenus WHERE MenuId IN (SELECT Id FROM Menus WHERE Permission = '{oldPerm}'); + DELETE FROM Menus WHERE Permission = '{oldPerm}';"); + } + + // 清理所有旧的"订阅按钮"菜单(已被 Permission 表的消息订阅权限替代,避免与消息订阅权限重复冗余) + fsql.Ado.ExecuteNonQuery(@" + DELETE FROM sys_rbac_role_permissions WHERE permission_id IN + (SELECT id FROM sys_rbac_menus WHERE name LIKE '订阅:%' AND menu_type = 0); + DELETE FROM sys_rbac_menus WHERE name LIKE '订阅:%' AND menu_type = 0; + DELETE FROM RoleMenus WHERE MenuId IN (SELECT Id FROM Menus WHERE Name LIKE '订阅:%' AND Type = 'button'); + DELETE FROM Menus WHERE Name LIKE '订阅:%' AND Type = 'button';"); + + // 清理 RequiredPermission 为空的消息类型的订阅权限(历史遗留,所有人可订阅无需权限单元) + foreach (var t in fsql.Select().Where(x => x.RequiredPermission == "").ToList()) + { + var perm = fsql.Select() + .Where(p => p.ResourceType == Resource_Type_Enum.消息订阅 && p.ResourceId == t.Id).First(); + if (perm != null) + { + fsql.Delete().Where(rp => rp.PermissionId == perm.Id).ExecuteAffrows(); + fsql.Delete().Where(p => p.Id == perm.Id).ExecuteAffrows(); + } + } + + // 同步订阅权限到 RBAC 权限单元(ResourceType=消息订阅,Code=RequiredPermission) + foreach (var t in fsql.Select().ToList()) + { + if (string.IsNullOrEmpty(t.RequiredPermission)) continue; + var existPerm = fsql.Select() + .Where(p => p.ResourceType == Resource_Type_Enum.消息订阅 && p.ResourceId == t.Id).First(); + if (existPerm == null) + { + var perm = new SysPermission(snowflake.CreateId(), Permission_Type_Enum.Visble, Resource_Type_Enum.消息订阅, t.Code, t.Id); + perm.SetCode(t.RequiredPermission); + fsql.Insert(perm).ExecuteAffrows(); + } + else if (existPerm.Code != t.RequiredPermission) + { + // 修正 Code 为业务权限码(覆盖旧的自动生成码) + existPerm.SetCode(t.RequiredPermission); + fsql.Update().SetSource(existPerm).ExecuteAffrows(); + } + } + + // admin 角色分配消息中心菜单权限 + var adminRole = fsql.Select().Where(r => r.Code == "admin").First(); + if (adminRole != null) + { + var notiMenus = fsql.Select().Where(m => m.Path.StartsWith("/notification")).ToList(); + foreach (var m in notiMenus) + { + if (!fsql.Select().Any(rm => rm.RoleId == adminRole.Id && rm.MenuId == m.Id)) + fsql.Insert(new RoleMenu { RoleId = adminRole.Id, MenuId = m.Id }).ExecuteAffrows(); + } + } + } + + private static void AddNotificationMenu(IFreeSql fsql, long parentId, string name, string path, string component, string icon, string permission, int sort) + { + if (!fsql.Select().Any(m => m.Path == path)) + { + fsql.Insert(new Menu { ParentId = parentId, Name = name, Type = "menu", Path = path, Component = component, Icon = icon, Permission = permission, Sort = sort }).ExecuteAffrows(); + } + } + + private static void SeedAttendanceMenus(IFreeSql fsql) + { + var attDirId = fsql.Select().Where(m => m.Path == "/attendance").First()?.Id ?? 0; + if (attDirId == 0) + { + attDirId = InsertAndGetId(fsql, new Menu { Name = "考勤管理", Type = "directory", Path = "/attendance", Icon = "Clock", Sort = 2 }); + } + + void AddMenu(string name, string path, string component, string icon, string permission, int sort) + { + if (!fsql.Select().Any(m => m.Path == path)) + { + fsql.Insert(new Menu { Name = name, Type = "menu", Path = path, Component = component, Icon = icon, Permission = permission, Sort = sort, ParentId = attDirId }).ExecuteAffrows(); + } + } + AddMenu("考勤打卡", "/attendance/punch", "attendance/punch/index", "AlarmClock", "attendance:punch:list", 1); + AddMenu("我的考勤", "/attendance/my", "attendance/my/index", "Calendar", "attendance:my:list", 2); + AddMenu("请假申请", "/attendance/leave", "attendance/leave/index", "Suitcase", "attendance:leave:list", 3); + AddMenu("考勤记录", "/attendance/records", "attendance/records/index", "List", "attendance:records:list", 4); + AddMenu("请假审批", "/attendance/approve", "attendance/approve/index", "Checked", "attendance:approve:list", 5); + + void AddButton(string menuPath, string name, string permission) + { + var menu = fsql.Select().Where(m => m.Path == menuPath).First(); + if (menu != null && !fsql.Select().Any(m => m.Permission == permission)) + { + fsql.Insert(new Menu { Name = name, Type = "button", Permission = permission, Sort = 1, ParentId = menu.Id }).ExecuteAffrows(); + } + } + AddButton("/attendance/punch", "打卡操作", "attendance:punch:btn"); + AddButton("/attendance/leave", "请假操作", "attendance:leave:btn"); + AddButton("/attendance/approve", "审批操作", "attendance:approve:btn"); + + var adminRole = fsql.Select().Where(r => r.Code == "admin").First(); + if (adminRole != null) + { + var attMenus = fsql.Select().Where(m => m.Path.StartsWith("/attendance") || m.Permission.StartsWith("attendance:")).ToList(); + foreach (var m in attMenus) + { + if (!fsql.Select().Any(rm => rm.RoleId == adminRole.Id && rm.MenuId == m.Id)) + fsql.Insert(new RoleMenu { RoleId = adminRole.Id, MenuId = m.Id }).ExecuteAffrows(); + } + } + } + + /// 数据迁移:旧 RBAC 表 → 新 DDD RBAC 表(幂等,仅迁移尚不存在的记录) + private static void MigrateRbacData(IFreeSql fsql) + { + try + { + // 1. 部门:Departments → sys_departments + fsql.Ado.ExecuteNonQuery(@" + INSERT INTO sys_departments (id, code, name, parent_id, manage_type, duty_type, description, is_active, sort_order, created_by, created_at, update_by, updated_at, is_deleted) + SELECT Id, COALESCE(Code, ''), Name, COALESCE(ParentId, 0), 10, 5, '', Status, Sort, '', COALESCE(CreatedAt, datetime('now')), '', UpdatedAt, IsDeleted + FROM Departments WHERE Id NOT IN (SELECT id FROM sys_departments);"); + + // 2. 角色:Roles → sys_rbac_roles + fsql.Ado.ExecuteNonQuery(@" + INSERT INTO sys_rbac_roles (id, name, code, sort_order, description, is_active, created_by, created_at, update_by, updated_at, is_deleted) + SELECT Id, Name, Code, Sort, COALESCE(Description, ''), Status, '', COALESCE(CreatedAt, datetime('now')), '', UpdatedAt, IsDeleted + FROM Roles WHERE Id NOT IN (SELECT id FROM sys_rbac_roles);"); + + // 3. 菜单:Menus → sys_rbac_menus + fsql.Ado.ExecuteNonQuery(@" + INSERT INTO sys_rbac_menus (id, icon_svg, code, name, menu_type, assembly_name, type_full_name, parent_id, level, is_active, sort_order, path, component, permission, created_by, created_at, update_by, updated_at, is_deleted) + SELECT Id, COALESCE(Icon, ''), '', Name, CASE WHEN Type = 'directory' THEN 1 WHEN Type = 'menu' THEN 2 ELSE 0 END, '', '', COALESCE(ParentId, 0), 1, Status, Sort, COALESCE(Path, ''), COALESCE(Component, ''), COALESCE(Permission, ''), '', COALESCE(CreatedAt, datetime('now')), '', UpdatedAt, IsDeleted + FROM Menus WHERE Id NOT IN (SELECT id FROM sys_rbac_menus);"); + + // 4. 用户:Employees → sys_rbac_users + fsql.Ado.ExecuteNonQuery(@" + INSERT INTO sys_rbac_users (id, department_id, user_name, phone_number, login_name, password_hash, is_active, sort_order, lastlogin_at, nickname, avatar, email, gender, created_by, created_at, update_by, updated_at, is_deleted) + SELECT Id, DepartmentId, RealName, COALESCE(Phone, ''), Username, Password, Status, 0, NULL, COALESCE(Nickname, ''), COALESCE(Avatar, ''), COALESCE(Email, ''), COALESCE(Gender, '男'), '', COALESCE(CreatedAt, datetime('now')), '', UpdatedAt, IsDeleted + FROM Employees WHERE Id NOT IN (SELECT id FROM sys_rbac_users);"); + + // 5. 用户角色:EmployeeRoles → sys_rbac_user_roles + fsql.Ado.ExecuteNonQuery(@" + INSERT INTO sys_rbac_user_roles (id, user_id, role_id, created_by, created_at, update_by, updated_at, is_deleted) + SELECT Id, EmployeeId, RoleId, '', COALESCE(CreatedAt, datetime('now')), '', UpdatedAt, IsDeleted + FROM EmployeeRoles WHERE Id NOT IN (SELECT id FROM sys_rbac_user_roles);"); + + // 6. 角色权限:RoleMenus → sys_rbac_role_permissions + fsql.Ado.ExecuteNonQuery(@" + INSERT INTO sys_rbac_role_permissions (id, role_id, permission_id, created_by, created_at, update_by, updated_at, is_deleted) + SELECT Id, RoleId, MenuId, '', COALESCE(CreatedAt, datetime('now')), '', UpdatedAt, IsDeleted + FROM RoleMenus WHERE Id NOT IN (SELECT id FROM sys_rbac_role_permissions);"); + + Console.WriteLine("[DbSeeder] RBAC 数据迁移完成"); + } + catch (Exception ex) + { + Console.WriteLine($"[DbSeeder] RBAC 数据迁移跳过:{ex.Message}"); + } + } + + /// F9 风格 RBAC 前端菜单种子(写入新 sys_rbac_menus 表,驱动前端导航) + private static void SeedSysRbacMenus(IFreeSql fsql, ISnowflakeIdGenerator snowflake) + { + try + { + // 删除旧"系统管理"菜单(/system/*,对接已下线的旧接口),仅保留新的 /sys 管理菜单 + fsql.Ado.ExecuteNonQuery("DELETE FROM sys_rbac_role_permissions WHERE permission_id IN (SELECT id FROM sys_rbac_menus WHERE path LIKE '/system%')"); + fsql.Ado.ExecuteNonQuery("DELETE FROM sys_rbac_menus WHERE path LIKE '/system%'"); + + // sys 目录 + long sysDirId; + var sysDir = fsql.Ado.ExecuteDataTable("SELECT id FROM sys_rbac_menus WHERE path = '/sys'"); + if (sysDir.Rows.Count == 0) + { + sysDirId = snowflake.CreateId(); + fsql.Ado.ExecuteNonQuery($"INSERT INTO sys_rbac_menus (id, icon_svg, name, menu_type, parent_id, level, is_active, sort_order, path, component, permission) VALUES ({sysDirId}, 'UserFilled', '系统管理(RBAC)', 1, 0, 1, 0, 6, '/sys', '', '')"); + } + else + { + sysDirId = Convert.ToInt64(sysDir.Rows[0]["id"]); + } + + void AddMenu(string name, string path, string component, string icon, string permission, int sort) + { + var exist = fsql.Ado.ExecuteDataTable($"SELECT id FROM sys_rbac_menus WHERE path = '{path}'"); + if (exist.Rows.Count > 0) return; + var id = snowflake.CreateId(); + fsql.Ado.ExecuteNonQuery($"INSERT INTO sys_rbac_menus (id, icon_svg, name, menu_type, parent_id, level, is_active, sort_order, path, component, permission) VALUES ({id}, '{icon}', '{name}', 2, {sysDirId}, 2, 0, {sort}, '{path}', '{component}', '{permission}')"); + } + + AddMenu("用户管理", "/sys/user", "sys/user/index", "User", "sys:user:list", 1); + AddMenu("角色管理", "/sys/role", "sys/role/index", "UserFilled", "sys:role:list", 2); + AddMenu("菜单管理", "/sys/menu", "sys/menu/index", "Menu", "sys:menu:list", 3); + AddMenu("部门管理", "/sys/department", "sys/department/index", "OfficeBuilding", "sys:department:list", 4); + + // admin 角色分配 sys 菜单权限 + var adminRole = fsql.Ado.ExecuteDataTable("SELECT id FROM sys_rbac_roles WHERE code = 'admin'"); + if (adminRole.Rows.Count > 0) + { + var roleId = Convert.ToInt64(adminRole.Rows[0]["id"]); + var menus = fsql.Ado.ExecuteDataTable("SELECT id FROM sys_rbac_menus WHERE path LIKE '/sys%'"); + foreach (DataRow row in menus.Rows) + { + var menuId = Convert.ToInt64(row["id"]); + var exist = fsql.Ado.ExecuteDataTable($"SELECT id FROM sys_rbac_role_permissions WHERE role_id = {roleId} AND permission_id = {menuId}"); + if (exist.Rows.Count == 0) + { + fsql.Ado.ExecuteNonQuery($"INSERT INTO sys_rbac_role_permissions (id, role_id, permission_id) VALUES ({snowflake.CreateId()}, {roleId}, {menuId})"); + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[DbSeeder] RBAC 菜单种子跳过:{ex.Message}"); + } + } + + /// 为菜单创建权限单元(Permission),并修正旧菜单权限关联(RolePermission.PermissionId 从 Menu.Id 改为 Permission.Id) + private static void FixMenuPermissions(IFreeSql fsql, ISnowflakeIdGenerator snowflake) + { + try + { + foreach (var menu in fsql.Select().ToList()) + { + // 1. 查或创建菜单权限单元(ResourceType=菜单,ResourceId=Menu.Id) + var perm = fsql.Select() + .Where(p => p.ResourceType == Resource_Type_Enum.菜单 && p.ResourceId == menu.Id).First(); + if (perm == null) + { + var resourceIdentity = string.IsNullOrEmpty(menu.Code) ? menu.Id.ToString() : menu.Code; + perm = new SysPermission(snowflake.CreateId(), Permission_Type_Enum.Visble, Resource_Type_Enum.菜单, resourceIdentity, menu.Id); + if (!string.IsNullOrEmpty(menu.Permission)) + perm.SetCode(menu.Permission); + fsql.Insert(perm).ExecuteAffrows(); + } + else if (!string.IsNullOrEmpty(menu.Permission) && perm.Code != menu.Permission) + { + perm.SetCode(menu.Permission); + fsql.Update().SetSource(perm).ExecuteAffrows(); + } + + // 2. 修正旧菜单权限关联(permission_id 旧值 = Menu.Id,改为 Permission.Id) + fsql.Ado.ExecuteNonQuery($"UPDATE sys_rbac_role_permissions SET permission_id = {perm.Id} WHERE permission_id = {menu.Id};"); + } + Console.WriteLine("[DbSeeder] 菜单权限单元同步完成"); + } + catch (Exception ex) + { + Console.WriteLine($"[DbSeeder] 菜单权限单元同步跳过:{ex.Message}"); + } + } +} diff --git a/backend/F9WebAiTest.Infrastructure/DependencyInjection.cs b/backend/F9WebAiTest.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..49a9aec --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/DependencyInjection.cs @@ -0,0 +1,83 @@ +using FreeSql; +using FreeSql.Aop; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.Departments; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Menus; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Roles; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Users; +using F9WebAiTest.Application.Interfaces.Security; +using F9WebAiTest.Application.Interfaces.SnowflakeID; +using F9WebAiTest.Application.Interfaces.UOW; +using F9WebAiTest.Domain.Common; +using F9WebAiTest.Domain.Entities; +using F9WebAiTest.Domain.Interfaces.SYS.Departments; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Menus; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Permissions; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Roles; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; +using F9WebAiTest.Infrastructure.FreeSqls.Persistence; +using F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.Departments; +using F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Menus; +using F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Permissions; +using F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Roles; +using F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Users; +using F9WebAiTest.Infrastructure.InterfaceImplements.Security; +using F9WebAiTest.Infrastructure.InterfaceImplements.SnowflakeID; +using F9WebAiTest.Infrastructure.Repositories; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace F9WebAiTest.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + // 创建 FreeSql 实例(SQLite),关闭自动同步结构(只建新表,不修改旧库) + var connStr = configuration.GetConnectionString("Default"); + var fsql = new FreeSqlBuilder() + .UseConnectionString(DataType.Sqlite, connStr) + .UseAutoSyncStructure(false) + .Build(); + + // 软删除全局过滤器:自动应用到新旧两套实体基类 + fsql.GlobalFilter.Apply("软删除", a => a.IsDeleted == false); + fsql.GlobalFilter.Apply("软删除", a => a.IsDeleted == false); + + // 枚举统一映射为 int(与旧库 INTEGER 存储保持一致) + fsql.Aop.ConfigEntityProperty += (_, e) => + { + if (e.Property.PropertyType.IsEnum) + e.ModifyResult.MapType = typeof(int); + }; + + services.AddSingleton(fsql); + services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); + + // ==================== F9 风格 RBAC(DDD + CQRS)依赖注册 ==================== + + // 雪花 ID(有状态,单例) + services.AddSingleton(); + // 密码哈希 + services.AddScoped(); + // 工作单元(事务) + services.AddScoped(); + + // 写仓储(Domain 接口) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // 读仓储(Application 接口) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/backend/F9WebAiTest.Infrastructure/F9WebAiTest.Infrastructure.csproj b/backend/F9WebAiTest.Infrastructure/F9WebAiTest.Infrastructure.csproj new file mode 100644 index 0000000..7bb534f --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/F9WebAiTest.Infrastructure.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + disable + F9WebAiTest.Infrastructure + + + + + + + + + + + + + + + + + diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Persistence/UnitOfWork.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Persistence/UnitOfWork.cs new file mode 100644 index 0000000..0406f95 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Persistence/UnitOfWork.cs @@ -0,0 +1,46 @@ +using FreeSql; +using F9WebAiTest.Application.Interfaces.UOW; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Persistence; + +/// +/// 工作单元实现:基于 FreeSql 的 UnitOfWorkManager,事务内 Orm 通过 AsyncLocal 暴露给仓储。 +/// 仓储通过 UnitOfWork.CurrentOrm 获取事务感知的 Orm。 +/// +public class UnitOfWork_F9 : IUnitOfWork_F9 +{ + private readonly IFreeSql _fsql; + private readonly UnitOfWorkManager _uowManager; + private static readonly AsyncLocal _currentOrm = new(); + + /// 当前事务内的 Orm(非事务上下文返回 null) + public static IFreeSql CurrentOrm => _currentOrm.Value; + + public UnitOfWork_F9(IFreeSql fsql) + { + _fsql = fsql; + _uowManager = new UnitOfWorkManager(fsql); + } + + public async Task ExecuteWithUow(Func> func) + { + using var uow = _uowManager.Begin(); + _currentOrm.Value = uow.Orm; + try + { + var result = await func(); + if (result) uow.Commit(); + else uow.Rollback(); + return result; + } + catch + { + uow.Rollback(); + throw; + } + finally + { + _currentOrm.Value = null; + } + } +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/F9RepositoryBase.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/F9RepositoryBase.cs new file mode 100644 index 0000000..9a7fec1 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/F9RepositoryBase.cs @@ -0,0 +1,15 @@ +using FreeSql; +using F9WebAiTest.Infrastructure.FreeSqls.Persistence; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories; + +/// F9 风格仓储基类:提供事务感知的 Orm(事务内用事务 Orm,事务外用外部 Orm) +public abstract class F9RepositoryBase +{ + protected readonly IFreeSql _fsql; + + protected F9RepositoryBase(IFreeSql fsql) => _fsql = fsql; + + /// 事务感知的 Orm + protected IFreeSql Orm => UnitOfWork_F9.CurrentOrm ?? _fsql; +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/Departments/DepartmentReadRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/Departments/DepartmentReadRepository.cs new file mode 100644 index 0000000..4c2139b --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/Departments/DepartmentReadRepository.cs @@ -0,0 +1,17 @@ +using FreeSql; +using F9WebAiTest.Application.Dtos.SYS.Departments; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.Departments; +using F9WebAiTest.Domain.Entities.SYS.Departments; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.Departments; + +internal class DepartmentReadRepository : F9RepositoryBase, IDepartmentReadRepository +{ + public DepartmentReadRepository(IFreeSql fsql) : base(fsql) { } + + public async Task> GetAllAsync() + => await Orm.Select() + .Where(x => x.DeletedAt == null) + .OrderBy(x => x.SortOrder) + .ToListAsync(x => new DepartmentReadDto_View(x.Id, x.Code, x.Name, x.ParentId, x.ManageType, x.DutyType, x.Description, x.IsActive, x.SortOrder)); +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/Departments/DepartmentUserRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/Departments/DepartmentUserRepository.cs new file mode 100644 index 0000000..645eec5 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/Departments/DepartmentUserRepository.cs @@ -0,0 +1,21 @@ +using FreeSql; +using F9WebAiTest.Domain.Entities.SYS.Departments; +using F9WebAiTest.Domain.Interfaces.SYS.Departments; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.Departments; + +internal class DepartmentUserRepository : F9RepositoryBase, IDepartmentUserRepository +{ + public DepartmentUserRepository(IFreeSql fsql) : base(fsql) { } + + public async Task AssignAsync(DepartmentUser depUser) + { + depUser.CreatedAt = DateTime.Now; + return await Orm.Insert(depUser).ExecuteAffrowsAsync() > 0; + } + + public async Task RemoveAsync(long depId, long userId) + => await Orm.Delete() + .Where(x => x.DepartmentId == depId && x.UserId == userId) + .ExecuteAffrowsAsync() > 0; +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/Departments/DepartmentWriteRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/Departments/DepartmentWriteRepository.cs new file mode 100644 index 0000000..2e5d205 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/Departments/DepartmentWriteRepository.cs @@ -0,0 +1,38 @@ +using FreeSql; +using F9WebAiTest.Domain.Entities.SYS.Departments; +using F9WebAiTest.Domain.Interfaces.SYS.Departments; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.Departments; + +internal class DepartmentWriteRepository : F9RepositoryBase, IDepartmentWriteRepository +{ + public DepartmentWriteRepository(IFreeSql fsql) : base(fsql) { } + + public async Task GetByIdAsync(long id, CancellationToken cancellationToken) + => await Orm.Select().Where(x => x.Id == id ).FirstAsync(cancellationToken); + + public async Task AddAsync(Department dep) + { + dep.CreatedAt = DateTime.Now; + return await Orm.Insert(dep).ExecuteAffrowsAsync() > 0; + } + + public async Task UpdateAsync(Department dep) + { + dep.UpdatedAt = DateTime.Now; + return await Orm.Update().SetSource(dep).ExecuteAffrowsAsync() > 0; + } + + public async Task SoftDeleteAsync(long id) + => await Orm.Update() + .Set(x => x.IsDeleted, true) + .Where(x => x.Id == id ) + .ExecuteAffrowsAsync() > 0; + + public async Task ActiveOrDeactive(long id, bool active) + => await Orm.Update() + .Set(x => x.IsActive, active) + .Set(x => x.UpdatedAt, DateTime.Now) + .Where(x => x.Id == id) + .ExecuteAffrowsAsync() > 0; +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Menus/MenuReadRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Menus/MenuReadRepository.cs new file mode 100644 index 0000000..b12dbc8 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Menus/MenuReadRepository.cs @@ -0,0 +1,61 @@ +using FreeSql; +using F9WebAiTest.Application.Dtos.SYS.RBAC.Menus; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Menus; +using F9WebAiTest.Domain.Entities.SYS.RBAC; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Menus; + +internal class MenuReadRepository : F9RepositoryBase, IMenuReadRepository +{ + public MenuReadRepository(IFreeSql fsql) : base(fsql) { } + + public async Task GetMenuByIdForEditAsync(long id) + => await Orm.Select() + .Where(x => x.Id == id && x.DeletedAt == null) + .FirstAsync(x => new MenuReadDto_Menu_Edit(x.Id, x.Code, x.Name, x.IconSvg, x.MenuType, x.AssemblyName, x.TypeFullName, x.IsActive, x.ParentId, x.SortOrder)); + + public async Task ExistMenuByTypeFullnameExcludeIdAsync(string typeFullname, long? excludeId) + { + var query = Orm.Select().Where(x => x.TypeFullName == typeFullname && x.DeletedAt == null); + if (excludeId.HasValue) + query = query.Where(x => x.Id != excludeId.Value); + return await query.AnyAsync(); + } + + public async Task> GetAllMenuAsync(string like, long? parentId) + { + var all = await Orm.Select().Where(x => x.DeletedAt == null).ToListAsync(); + var dict = all.ToDictionary(x => x.Id, x => x.Name); + + var list = all.AsEnumerable(); + if (parentId.HasValue) + list = list.Where(x => x.ParentId == parentId.Value); + if (!string.IsNullOrWhiteSpace(like)) + list = list.Where(x => x.Name.Contains(like) || x.Code.Contains(like)); + + return list.OrderBy(x => x.SortOrder).Select(x => ToTable(x, dict)).ToList(); + } + + public async Task> GetTreeMenuAsync() + { + var all = await Orm.Select().Where(x => x.DeletedAt == null).OrderBy(x => x.SortOrder).ToListAsync(); + var dict = all.ToDictionary(x => x.Id, x => x.Name); + return BuildTree(all, 0, dict); + } + + private static MenuReadDto_Menu_Table ToTable(Menu x, Dictionary dict) + => new(x.Id, x.ParentId, x.Code, x.Name, x.IconSvg, x.MenuType.ToString(), + x.AssemblyName, x.TypeFullName, dict.GetValueOrDefault(x.ParentId, ""), x.Level, + x.IsActive ? "启用" : "禁用", x.SortOrder); + + private static List BuildTree(List all, long parentId, Dictionary dict) + => all.Where(x => x.ParentId == parentId).OrderBy(x => x.SortOrder).Select(x => + { + var children = BuildTree(all, x.Id, dict); + return new MenuReadDto_Menu_Table( + x.Id, x.ParentId, x.Code, x.Name, x.IconSvg, x.MenuType.ToString(), + x.AssemblyName, x.TypeFullName, dict.GetValueOrDefault(x.ParentId, ""), x.Level, + x.IsActive ? "启用" : "禁用", x.SortOrder, false, + children.Count > 0 ? children.ToArray() : default); + }).ToList(); +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Menus/MenuWriteRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Menus/MenuWriteRepository.cs new file mode 100644 index 0000000..97131c1 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Menus/MenuWriteRepository.cs @@ -0,0 +1,38 @@ +using FreeSql; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Menus; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Menus; + +internal class MenuWriteRepository : F9RepositoryBase, IMenuWriteRepository +{ + public MenuWriteRepository(IFreeSql fsql) : base(fsql) { } + + public async Task GetByIdAsync(long id) + => await Orm.Select().Where(x => x.Id == id ).FirstAsync(); + + public async Task AddAsync(Menu menu) + { + menu.CreatedAt = DateTime.Now; + return await Orm.Insert(menu).ExecuteAffrowsAsync() > 0; + } + + public async Task UpdateAsync(Menu menu) + { + menu.UpdatedAt = DateTime.Now; + return await Orm.Update().SetSource(menu).ExecuteAffrowsAsync() > 0; + } + + public async Task SoftDeleteAsync(long id) + => await Orm.Update() + .Set(x => x.IsDeleted, true) + .Where(x => x.Id == id ) + .ExecuteAffrowsAsync() > 0; + + public async Task ActiveOrDeactive(long id, bool active) + => await Orm.Update() + .Set(x => x.IsActive, active) + .Set(x => x.UpdatedAt, DateTime.Now) + .Where(x => x.Id == id) + .ExecuteAffrowsAsync() > 0; +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Permissions/PermissionWriteRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Permissions/PermissionWriteRepository.cs new file mode 100644 index 0000000..e93d6a8 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Permissions/PermissionWriteRepository.cs @@ -0,0 +1,27 @@ +using FreeSql; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Enums.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Permissions; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Permissions; + +internal class PermissionWriteRepository : F9RepositoryBase, IPermissionWriteRepository +{ + public PermissionWriteRepository(IFreeSql fsql) : base(fsql) { } + + public async Task GetByIdAsync(Permission_Type_Enum permissionType, Resource_Type_Enum resourceType, long resourceId) + => await Orm.Select() + .Where(x => x.PermissionType == permissionType && x.ResourceType == resourceType && x.ResourceId == resourceId ) + .FirstAsync(); + + public async Task AddRangeAsync(Permission[] entites) + => await Orm.Insert(entites).ExecuteAffrowsAsync() > 0; + + public async Task DeleteByResourceIdAndTypeAsync(Permission_Type_Enum permissionType, Resource_Type_Enum resourceType, long resourceId) + => await Orm.Delete() + .Where(x => x.PermissionType == permissionType && x.ResourceType == resourceType && x.ResourceId == resourceId) + .ExecuteAffrowsAsync() > 0; + + public async Task DeleteByResourceIdAsync(long resourceId) + => await Orm.Delete().Where(x => x.ResourceId == resourceId).ExecuteAffrowsAsync() > 0; +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Roles/RolePermissionWriteRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Roles/RolePermissionWriteRepository.cs new file mode 100644 index 0000000..e1b4b70 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Roles/RolePermissionWriteRepository.cs @@ -0,0 +1,22 @@ +using FreeSql; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Roles; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Roles; + +internal class RolePermissionWriteRepository : F9RepositoryBase, IRolePermissionWriteRepository +{ + public RolePermissionWriteRepository(IFreeSql fsql) : base(fsql) { } + + public async Task GetByIdAsync(long id) + => await Orm.Select().Where(x => x.Id == id).FirstAsync(); + + public async Task AssignAsync(RolePermission rolePermission) + { + rolePermission.CreatedAt = DateTime.Now; + return await Orm.Insert(rolePermission).ExecuteAffrowsAsync() > 0; + } + + public async Task RemoveAsync(long id) + => await Orm.Delete().Where(x => x.Id == id).ExecuteAffrowsAsync() > 0; +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Roles/RoleReadRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Roles/RoleReadRepository.cs new file mode 100644 index 0000000..6493cda --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Roles/RoleReadRepository.cs @@ -0,0 +1,66 @@ +using FreeSql; +using F9WebAiTest.Application.Dtos.SYS.RBAC.Roles; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Roles; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Enums.SYS.RBAC; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Roles; + +internal class RoleReadRepository : F9RepositoryBase, IRoleReadRepository +{ + public RoleReadRepository(IFreeSql fsql) : base(fsql) { } + + public async Task GetByIdAsync(long id) + => await Orm.Select() + .Where(x => x.Id == id && x.DeletedAt == null) + .FirstAsync(x => new RoleReadDto_Edit(x.Id, x.Name, x.Code, x.SortOrder, x.Description)); + + public async Task> GetAllAsync() + { + var roles = await Orm.Select() + .Where(x => x.DeletedAt == null) + .OrderBy(x => x.SortOrder) + .ToListAsync(x => new { x.Id, x.Name, x.Code, x.SortOrder, x.IsActive }); + + var roleIds = roles.Select(x => x.Id).ToList(); + var permCounts = await GetPermissionCountsAsync(roleIds); + + return roles.Select(x => + { + var c = permCounts.GetValueOrDefault(x.Id); + return new RoleReadDto_View(x.Id, x.Name, x.Code, x.SortOrder, c.Menu, c.Report, c.Dep, x.IsActive); + }).ToList(); + } + + public async Task> GetAllForCacheAsync() + => await Orm.Select() + .Where(x => x.DeletedAt == null) + .OrderBy(x => x.SortOrder) + .ToListAsync(x => new RoleReadDto_Cache(x.Id, x.Name, x.Code, x.SortOrder)); + + public async Task> GetRolePermissionsByResourceType(long roleId, Resource_Type_Enum resourceType) + => await Orm.Select() + .InnerJoin((rp, p) => rp.PermissionId == p.Id && p.DeletedAt == null) + .Where((rp, p) => rp.RoleId == roleId && p.ResourceType == resourceType) + .ToListAsync((rp, p) => new RolePermissionReadDto_View(rp.Id, rp.RoleId, rp.PermissionId, p.ResourceId, p.PermissionType)); + + private async Task> GetPermissionCountsAsync(List roleIds) + { + var result = new Dictionary(); + if (roleIds.Count == 0) return result; + + var perms = await Orm.Select() + .InnerJoin((rp, p) => rp.PermissionId == p.Id && p.DeletedAt == null) + .Where((rp, p) => roleIds.Contains(rp.RoleId)) + .ToListAsync((rp, p) => new { rp.RoleId, p.ResourceType }); + + foreach (var g in perms.GroupBy(x => x.RoleId)) + { + result[g.Key] = ( + g.Count(x => x.ResourceType == Resource_Type_Enum.菜单), + g.Count(x => x.ResourceType == Resource_Type_Enum.报表), + g.Count(x => x.ResourceType == Resource_Type_Enum.部门)); + } + return result; + } +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Roles/RoleWriteRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Roles/RoleWriteRepository.cs new file mode 100644 index 0000000..9c55043 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Roles/RoleWriteRepository.cs @@ -0,0 +1,38 @@ +using FreeSql; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Roles; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Roles; + +internal class RoleWriteRepository : F9RepositoryBase, IRoleWriteRepository +{ + public RoleWriteRepository(IFreeSql fsql) : base(fsql) { } + + public async Task GetByIdAsync(long id) + => await Orm.Select().Where(x => x.Id == id ).FirstAsync(); + + public async Task AddAsync(Role role) + { + role.CreatedAt = DateTime.Now; + return await Orm.Insert(role).ExecuteAffrowsAsync() > 0; + } + + public async Task UpdateAsync(Role role) + { + role.UpdatedAt = DateTime.Now; + return await Orm.Update().SetSource(role).ExecuteAffrowsAsync() > 0; + } + + public async Task SoftDeleteAsync(long id) + => await Orm.Update() + .Set(x => x.IsDeleted, true) + .Where(x => x.Id == id ) + .ExecuteAffrowsAsync() > 0; + + public async Task ActiveOrDeactive(long id, bool active) + => await Orm.Update() + .Set(x => x.IsActive, active) + .Set(x => x.UpdatedAt, DateTime.Now) + .Where(x => x.Id == id) + .ExecuteAffrowsAsync() > 0; +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Users/UserReadRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Users/UserReadRepository.cs new file mode 100644 index 0000000..1edd429 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Users/UserReadRepository.cs @@ -0,0 +1,96 @@ +using FreeSql; +using F9WebAiTest.Application.Dtos.Common; +using F9WebAiTest.Application.Dtos.SYS.RBAC.Users; +using F9WebAiTest.Application.Interfaces.Repositories.SYS.RBAC.Users; +using F9WebAiTest.Domain.Entities.SYS.Departments; +using F9WebAiTest.Domain.Entities.SYS.RBAC; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Users; + +internal class UserReadRepository : F9RepositoryBase, IUserReadRepository +{ + public UserReadRepository(IFreeSql fsql) : base(fsql) { } + + public async Task GetByLoginNameAsync(string loginName) + => await Orm.Select() + .Where(x => x.LoginName == loginName && x.DeletedAt == null) + .FirstAsync(x => new UserReadDto_Login(x.Id, x.UserName, x.LoginName, x.PasswordHash, x.DepartmentId)); + + public async Task GetByPhoneAsync(string phone) + => await Orm.Select() + .Where(x => x.PhoneNumber == phone && x.DeletedAt == null) + .FirstAsync(x => new UserReadDto_Login(x.Id, x.UserName, x.LoginName, x.PasswordHash, x.DepartmentId)); + + public async Task GetByIdAsync(long id) + => await Orm.Select() + .Where(x => x.Id == id && x.DeletedAt == null) + .FirstAsync(x => new UserReadDto_Edit(x.Id, x.UserName, x.PhoneNumber, x.LoginName, x.DepartmentId, x.SortOrder)); + + public async Task> GetByIdsAsync(List ids) + => await Orm.Select() + .Where(x => ids.Contains(x.Id) && x.DeletedAt == null) + .ToListAsync(x => new UserReadDto_Edit(x.Id, x.UserName, x.PhoneNumber, x.LoginName, x.DepartmentId, x.SortOrder)); + + public async Task> GetPagedAsync(long departmentId, string like, int page = 1, int pageSize = 10) + { + var query = Orm.Select() + .LeftJoin((u, d) => u.DepartmentId == d.Id) + .Where((u, d) => u.DeletedAt == null); + + if (departmentId > 0) + query = query.Where((u, d) => u.DepartmentId == departmentId); + if (!string.IsNullOrWhiteSpace(like)) + query = query.Where((u, d) => u.UserName.Contains(like) || u.LoginName.Contains(like) || u.PhoneNumber.Contains(like)); + + var total = await query.CountAsync(); + + var list = await query + .OrderBy((u, d) => u.SortOrder) + .Page(page, pageSize) + .ToListAsync((u, d) => new + { + u.Id, u.UserName, u.PhoneNumber, u.LoginName, + DepartmentName = d.Name, + u.LastLoginAt, u.SortOrder, u.IsActive + }); + + // 批量查角色名,避免 N+1 + var userIds = list.Select(x => x.Id).ToList(); + var roleDict = await GetRoleNameDictAsync(userIds); + + var items = list.Select(x => new UserReadDto_View( + x.Id, x.UserName, x.PhoneNumber, x.LoginName, + x.DepartmentName, + roleDict.TryGetValue(x.Id, out var roleName) ? roleName : "", + x.LastLoginAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "", + x.SortOrder, x.IsActive)).ToList(); + + return new PagedResult(items, page, pageSize, total); + } + + public async Task> GetUserRolesByUserId(long userId) + => await Orm.Select() + .InnerJoin((x, y) => x.RoleId == y.Id && y.DeletedAt == null) + .Where((x, y) => x.UserId == userId) + .ToListAsync((x, y) => new UserRoleReadDto_Cache(x.Id, x.UserId, x.RoleId, y.Name)); + + public async Task> GetByRoleIdsAsync(List roleIds) + => await Orm.Select() + .InnerJoin((u, x, y) => u.Id == x.UserId) + .InnerJoin((u, x, y) => x.RoleId == y.Id && y.DeletedAt == null) + .Where((u, x, y) => roleIds.Contains(x.RoleId) && u.DeletedAt == null) + .ToListAsync((u, x, y) => new UserReadDto_Edit(u.Id, u.UserName, u.PhoneNumber, u.LoginName, u.DepartmentId, u.SortOrder)); + + private async Task> GetRoleNameDictAsync(List userIds) + { + if (userIds.Count == 0) return new Dictionary(); + + var list = await Orm.Select() + .InnerJoin((x, y) => x.RoleId == y.Id && y.DeletedAt == null) + .Where((x, y) => userIds.Contains(x.UserId)) + .ToListAsync((x, y) => new { x.UserId, y.Name }); + + return list.GroupBy(x => x.UserId) + .ToDictionary(g => g.Key, g => string.Join(",", g.Select(v => v.Name))); + } +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Users/UserRoleWriteRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Users/UserRoleWriteRepository.cs new file mode 100644 index 0000000..067f7aa --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Users/UserRoleWriteRepository.cs @@ -0,0 +1,21 @@ +using FreeSql; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Users; + +internal class UserRoleWriteRepository : F9RepositoryBase, IUserRoleWriteRepository +{ + public UserRoleWriteRepository(IFreeSql fsql) : base(fsql) { } + + public async Task AssignAsync(UserRole userRole) + { + userRole.CreatedAt = DateTime.Now; + return await Orm.Insert(userRole).ExecuteAffrowsAsync() > 0; + } + + public async Task RemoveAsync(long userid, long roleId) + => await Orm.Delete() + .Where(x => x.UserId == userid && x.RoleId == roleId) + .ExecuteAffrowsAsync() > 0; +} diff --git a/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Users/UserWriteRepository.cs b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Users/UserWriteRepository.cs new file mode 100644 index 0000000..69985e9 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/FreeSqls/Repositories/SYS/RBAC/Users/UserWriteRepository.cs @@ -0,0 +1,51 @@ +using FreeSql; +using F9WebAiTest.Domain.Entities.SYS.RBAC; +using F9WebAiTest.Domain.Interfaces.SYS.RBAC.Users; + +namespace F9WebAiTest.Infrastructure.FreeSqls.Repositories.SYS.RBAC.Users; + +internal class UserWriteRepository : F9RepositoryBase, IUserWriteRepository +{ + public UserWriteRepository(IFreeSql fsql) : base(fsql) { } + + public async Task GetByIdAsync(long id) + => await Orm.Select().Where(x => x.Id == id ).FirstAsync(); + + public async Task AddAsync(User user) + { + user.CreatedAt = DateTime.Now; + return await Orm.Insert(user).ExecuteAffrowsAsync() > 0; + } + + public async Task UpdateAsync(User user) + { + user.UpdatedAt = DateTime.Now; + return await Orm.Update().SetSource(user).ExecuteAffrowsAsync() > 0; + } + + public async Task SoftDeleteAsync(long id) + => await Orm.Update() + .Set(x => x.IsDeleted, true) + .Where(x => x.Id == id ) + .ExecuteAffrowsAsync() > 0; + + public async Task ActiveOrDeactive(long id, bool active) + => await Orm.Update() + .Set(x => x.IsActive, active) + .Set(x => x.UpdatedAt, DateTime.Now) + .Where(x => x.Id == id) + .ExecuteAffrowsAsync() > 0; + + public async Task UpdatePassworkdAsync(long id, string passwordHash) + => await Orm.Update() + .Set(x => x.PasswordHash, passwordHash) + .Set(x => x.UpdatedAt, DateTime.Now) + .Where(x => x.Id == id) + .ExecuteAffrowsAsync() > 0; + + public async Task UpdateLastLoginAtAsync(long id, DateTime dt) + => await Orm.Update() + .Set(x => x.LastLoginAt, dt) + .Where(x => x.Id == id) + .ExecuteAffrowsAsync() > 0; +} diff --git a/backend/F9WebAiTest.Infrastructure/InterfaceImplements/Security/PasswordHasher.cs b/backend/F9WebAiTest.Infrastructure/InterfaceImplements/Security/PasswordHasher.cs new file mode 100644 index 0000000..34cc253 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/InterfaceImplements/Security/PasswordHasher.cs @@ -0,0 +1,27 @@ +using System.Security.Cryptography; +using System.Text; +using F9WebAiTest.Application.Interfaces.Security; + +namespace F9WebAiTest.Infrastructure.InterfaceImplements.Security; + +/// 密码哈希服务实现(BCrypt) +public class PasswordHasher : IPasswordHasher +{ + public string Hash256(string input) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input ?? "")); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + public string GetMd5Hash(string input) + { + var bytes = MD5.HashData(Encoding.UTF8.GetBytes(input ?? "")); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + public string HashPassword(string password) + => BCrypt.Net.BCrypt.HashPassword(password); + + public bool VerifyPassword(string password, string hashedPassword) + => BCrypt.Net.BCrypt.Verify(password, hashedPassword); +} diff --git a/backend/F9WebAiTest.Infrastructure/InterfaceImplements/SnowflakeID/SnowflakeIdGenerator.cs b/backend/F9WebAiTest.Infrastructure/InterfaceImplements/SnowflakeID/SnowflakeIdGenerator.cs new file mode 100644 index 0000000..5e66774 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/InterfaceImplements/SnowflakeID/SnowflakeIdGenerator.cs @@ -0,0 +1,59 @@ +using F9WebAiTest.Application.Interfaces.SnowflakeID; + +namespace F9WebAiTest.Infrastructure.InterfaceImplements.SnowflakeID; + +/// +/// 雪花 ID 生成器(自实现,41 时间戳 + 10 worker + 12 序列)。 +/// 参考 BBIT.F9.Infrastructure.InterfaceImplements.SnowflakeID.SnowflakeIdGenerator +/// +public class SnowflakeIdGenerator : ISnowflakeIdGenerator +{ + private readonly object _lock = new(); + private readonly long _workerId; + private readonly long _epoch; + private long _lastTimestamp = -1L; + private long _sequence; + + private const long WorkerIdBits = 10L; + private const long SequenceBits = 12L; + private const long WorkerIdShift = SequenceBits; + private const long TimestampShift = SequenceBits + WorkerIdBits; + private const long SequenceMask = -1L ^ (-1L << (int)SequenceBits); + + public SnowflakeIdGenerator() + { + _workerId = 1; // 单节点默认 workerId = 1 + _epoch = new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds(); + } + + public long CreateId() + { + lock (_lock) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _epoch; + if (timestamp < _lastTimestamp) + throw new InvalidOperationException("时钟回拨,拒绝生成 ID"); + + if (timestamp == _lastTimestamp) + { + _sequence = (_sequence + 1) & SequenceMask; + if (_sequence == 0) + { + // 序列溢出,等待下一毫秒 + while (timestamp <= _lastTimestamp) + timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _epoch; + } + } + else + { + _sequence = 0; + } + + _lastTimestamp = timestamp; + + return (timestamp << (int)TimestampShift) + | (_workerId << (int)WorkerIdShift) + | _sequence; + } + } +} diff --git a/backend/F9WebAiTest.Infrastructure/Repositories/Repository.cs b/backend/F9WebAiTest.Infrastructure/Repositories/Repository.cs new file mode 100644 index 0000000..bbabe56 --- /dev/null +++ b/backend/F9WebAiTest.Infrastructure/Repositories/Repository.cs @@ -0,0 +1,88 @@ +using System.Linq.Expressions; +using FreeSql; +using F9WebAiTest.Domain.Common; + +namespace F9WebAiTest.Infrastructure.Repositories; + +/// 通用仓储实现(基于 FreeSql) +public class Repository : IRepository where T : class, IEntity +{ + protected readonly IFreeSql _fsql; + + public Repository(IFreeSql fsql) + { + _fsql = fsql; + } + + public virtual async Task GetByIdAsync(long id) + => await _fsql.Select().Where(e => e.Id == id).FirstAsync(); + + public virtual async Task> GetAllAsync() + => await _fsql.Select().ToListAsync(); + + public virtual async Task> FindAsync(Expression> predicate) + => await _fsql.Select().Where(predicate).ToListAsync(); + + public virtual async Task FirstOrDefaultAsync(Expression> predicate) + => await _fsql.Select().Where(predicate).FirstAsync(); + + public virtual async Task AnyAsync(Expression> predicate) + => await _fsql.Select().Where(predicate).AnyAsync(); + + public virtual async Task CountAsync(Expression> predicate) + => await _fsql.Select().Where(predicate).CountAsync(); + + public virtual async Task AddAsync(T entity) + { + entity.CreatedAt = DateTime.Now; + await _fsql.Insert(entity).ExecuteAffrowsAsync(); + // FreeSql 在 SQLite 下不回填自增主键,手动用 last_insert_rowid 获取并回填 + if (entity.Id == 0) + { + entity.Id = Convert.ToInt64(await _fsql.Ado.ExecuteScalarAsync("SELECT last_insert_rowid();")); + } + return entity; + } + + public virtual async Task AddRangeAsync(IEnumerable entities) + { + foreach (var e in entities) e.CreatedAt = DateTime.Now; + await _fsql.Insert(entities).ExecuteAffrowsAsync(); + } + + public virtual async Task UpdateAsync(T entity) + { + entity.UpdatedAt = DateTime.Now; + await _fsql.Update().SetSource(entity).ExecuteAffrowsAsync(); + } + + public virtual async Task DeleteAsync(T entity) + { + entity.IsDeleted = true; + entity.UpdatedAt = DateTime.Now; + await _fsql.Update().SetSource(entity).ExecuteAffrowsAsync(); + } + + public virtual async Task DeleteRangeAsync(IEnumerable entities) + { + foreach (var e in entities) + { + e.IsDeleted = true; + e.UpdatedAt = DateTime.Now; + } + await _fsql.Update().SetSource(entities).ExecuteAffrowsAsync(); + } + + public virtual async Task HardDeleteAsync(T entity) + => await _fsql.Delete().Where(e => e.Id == entity.Id).ExecuteAffrowsAsync(); + + public virtual async Task HardDeleteRangeAsync(IEnumerable entities) + { + var ids = entities.Select(e => e.Id).ToList(); + if (ids.Count > 0) + await _fsql.Delete().Where(e => ids.Contains(e.Id)).ExecuteAffrowsAsync(); + } + + public virtual Task SaveChangesAsync() + => Task.FromResult(1); // FreeSql 即时提交,无 SaveChanges 语义,保持接口兼容 +} diff --git a/backend/F9WebAiTest.sln b/backend/F9WebAiTest.sln new file mode 100644 index 0000000..8ad42fe --- /dev/null +++ b/backend/F9WebAiTest.sln @@ -0,0 +1,36 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "F9WebAiTest.Domain", "F9WebAiTest.Domain\F9WebAiTest.Domain.csproj", "{A1111111-1111-1111-1111-111111111111}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "F9WebAiTest.Infrastructure", "F9WebAiTest.Infrastructure\F9WebAiTest.Infrastructure.csproj", "{B2222222-2222-2222-2222-222222222222}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "F9WebAiTest.Application", "F9WebAiTest.Application\F9WebAiTest.Application.csproj", "{C3333333-3333-3333-3333-333333333333}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "F9WebAiTest.Api", "F9WebAiTest.Api\F9WebAiTest.Api.csproj", "{D4444444-4444-4444-4444-444444444444}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU + {B2222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2222222-2222-2222-2222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2222222-2222-2222-2222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU + {C3333333-3333-3333-3333-333333333333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3333333-3333-3333-3333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3333333-3333-3333-3333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3333333-3333-3333-3333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU + {D4444444-4444-4444-4444-444444444444}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4444444-4444-4444-4444-444444444444}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4444444-4444-4444-4444-444444444444}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4444444-4444-4444-4444-444444444444}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/backend/Properties/launchSettings.json b/backend/Properties/launchSettings.json new file mode 100644 index 0000000..0afa1ad --- /dev/null +++ b/backend/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://0.0.0.0:3000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/client/cert.crt b/client/cert.crt new file mode 100644 index 0000000..f8bb8dc --- /dev/null +++ b/client/cert.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIICzzCCAbegAwIBAgIJTbg/GofriKv+MA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNV +BAMTCTEwLjAuNC43MjAeFw0yNjA4MjUwODI4NDRaFw0yNzA4MjUwODI4NDRaMBQx +EjAQBgNVBAMTCTEwLjAuNC43MjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBALxCDGIxqvu8vn/8HScdURY5+Rg53dAelEKs+O1vzisCJ2qslS5P3dsg7ftc +UjhwgVMUplf8JASRMj21/bobmpeHPuiMqDz2v0uvL20zw24imt46qaR+PKwcWu6m +dBs/4650+ThmRhYtN3ZxK4D9aHo+t2L5+v3+GbL63oq5d9od+e39YZPNmCG/RYOC +IDy5+fnNNxvnEwwRZ5FyCViWDoMoor66EJoEFul/EVHw7k/tOmCrYMvB3nBugahi +/kAvcxYys/sPzF18d6iJA991VqArGS95EjRzvPFqMRng4zdJhezjgIHJQpZMdJT/ +La9UqtFtz5QMftOLGO90BMWpSScCAwEAAaMkMCIwIAYDVR0RBBkwF4IJbG9jYWxo +b3N0hwQKAARIhwR/AAABMA0GCSqGSIb3DQEBBQUAA4IBAQBADaHSuakP/QuzU3eE +GqExbFQD3uZucLaryqMr3oRFExFP82V5SVxg7I/JYlvYEfE9nVjII26CgRhagBYF +759dsJUq54qtZSpeADnLRkJ1JHkJIYrn4O8VaT1XWFCBLTyR4o3PddH8SRHxoEd7 +q3XLOcyrh1UW2MLBZIwRx397M+ZB15s+UL5gNJaNscdRKCkymzbBF7I20CCPS+Hl +XKRuTOepo8AZso/XqwkVYjNiZuDlPP4pxr917EUckVx8EQYVXi3cUw7MyMup7V/q +ajjcNUGIKAh4UHXBhF9bwAn97n/qTecnNzUSm+HrTN46rN/Afgl/zdwfu78OgYY9 +90u3 +-----END CERTIFICATE----- \ No newline at end of file diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..5a8d132 --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + 社区生活圈 + + +
+ + + diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..61a5ebf --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,1943 @@ +{ + "name": "life-circle-client", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "life-circle-client", + "version": "1.0.0", + "dependencies": { + "@microsoft/signalr": "^8.0.7", + "axios": "^1.7.7", + "vue": "^3.4.38", + "vue-router": "^4.4.3" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.3", + "selfsigned": "^5.5.0", + "vite": "^5.4.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@microsoft/signalr": { + "version": "8.0.7", + "resolved": "https://registry.npmmirror.com/@microsoft/signalr/-/signalr-8.0.7.tgz", + "integrity": "sha512-PHcdMv8v5hJlBkRHAuKG5trGViQEkPYee36LnJQx4xHOQ5LL4X0nEWIxOp5cCtZ7tu+30quz5V3k0b1YNuc6lw==", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.4.5" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "dev": true, + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "dev": true, + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "dev": true, + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.4", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmmirror.com/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "dev": true, + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmmirror.com/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "dev": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/selfsigned": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/selfsigned/-/selfsigned-5.5.0.tgz", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "dev": true, + "dependencies": { + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmmirror.com/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "dev": true, + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmmirror.com/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..62ae15c --- /dev/null +++ b/client/package.json @@ -0,0 +1,22 @@ +{ + "name": "life-circle-client", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@microsoft/signalr": "^8.0.7", + "axios": "^1.7.7", + "vue": "^3.4.38", + "vue-router": "^4.4.3" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.3", + "selfsigned": "^5.5.0", + "vite": "^5.4.3" + } +} diff --git a/client/src/App.vue b/client/src/App.vue new file mode 100644 index 0000000..b413f99 --- /dev/null +++ b/client/src/App.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/client/src/api.js b/client/src/api.js new file mode 100644 index 0000000..88356d1 --- /dev/null +++ b/client/src/api.js @@ -0,0 +1,31 @@ +import axios from 'axios'; + +const api = axios.create({ + baseURL: '/api', + timeout: 10000 +}); + +// 请求拦截:附加 token +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// 响应拦截:统一错误处理 +api.interceptors.response.use( + (res) => res.data, + (err) => { + const message = err.response?.data?.message || '请求失败,请稍后重试'; + if (err.response?.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/login'; + } + return Promise.reject(new Error(message)); + } +); + +export default api; diff --git a/client/src/main.js b/client/src/main.js new file mode 100644 index 0000000..5f1612b --- /dev/null +++ b/client/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import router from './router'; +import './style.css'; + +createApp(App).use(router).mount('#app'); diff --git a/client/src/router.js b/client/src/router.js new file mode 100644 index 0000000..b7a97fc --- /dev/null +++ b/client/src/router.js @@ -0,0 +1,34 @@ +import { createRouter, createWebHistory } from 'vue-router'; + +const routes = [ + { path: '/', redirect: '/posts' }, + { path: '/login', name: 'login', component: () => import('./views/Login.vue'), meta: { guest: true } }, + { path: '/posts', name: 'posts', component: () => import('./views/Posts.vue') }, + { path: '/notices', name: 'notices', component: () => import('./views/Notices.vue') }, + { path: '/secondhand', name: 'secondhand', component: () => import('./views/Secondhand.vue') }, + { path: '/housekeeping', name: 'housekeeping', component: () => import('./views/Housekeeping.vue') }, + { path: '/chat', name: 'chat', component: () => import('./views/ChatList.vue') }, + { path: '/chat/:id', name: 'chat-detail', component: () => import('./views/ChatDetail.vue') }, + { path: '/live', name: 'live', component: () => import('./views/LiveList.vue') }, + { path: '/live/host/:id', name: 'live-host', component: () => import('./views/LiveHost.vue') }, + { path: '/live/watch/:id', name: 'live-watch', component: () => import('./views/LiveWatch.vue') }, + { path: '/profile', name: 'profile', component: () => import('./views/Profile.vue') } +]; + +const router = createRouter({ + history: createWebHistory(), + routes +}); + +// 路由守卫:未登录跳转登录页 +router.beforeEach((to) => { + const token = localStorage.getItem('token'); + if (!token && !to.meta.guest) { + return { name: 'login' }; + } + if (token && to.meta.guest) { + return { name: 'posts' }; + } +}); + +export default router; diff --git a/client/src/style.css b/client/src/style.css new file mode 100644 index 0000000..5c4f67f --- /dev/null +++ b/client/src/style.css @@ -0,0 +1,179 @@ +:root { + --primary: #2f7d5c; + --primary-light: #3f9d76; + --accent: #f0a13c; + --bg: #f5f7f6; + --card: #ffffff; + --text: #2c3e50; + --text-light: #7f8c8d; + --border: #e6ebe9; + --danger: #e74c3c; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; +} + +a { + text-decoration: none; + color: inherit; +} + +button { + cursor: pointer; + font-family: inherit; +} + +input, textarea, select { + font-family: inherit; + font-size: 14px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + outline: none; + transition: border-color 0.2s; + width: 100%; +} + +input:focus, textarea:focus, select:focus { + border-color: var(--primary); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 20px; + border: none; + border-radius: 8px; + background: var(--primary); + color: #fff; + font-size: 14px; + font-weight: 500; + transition: background 0.2s, transform 0.1s; +} + +.btn:hover { + background: var(--primary-light); +} + +.btn:active { + transform: scale(0.98); +} + +.btn-ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} + +.btn-ghost:hover { + background: #f0f2f1; +} + +.btn-danger { + background: var(--danger); +} + +.btn-sm { + padding: 6px 12px; + font-size: 13px; + border-radius: 6px; +} + +.card { + background: var(--card); + border-radius: 12px; + padding: 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.page { + max-width: 720px; + margin: 0 auto; + padding: 20px 16px; +} + +.page-title { + font-size: 22px; + font-weight: 700; + margin-bottom: 20px; +} + +.empty { + text-align: center; + color: var(--text-light); + padding: 40px 0; +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--primary-light); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 600; + flex-shrink: 0; + overflow: hidden; +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.time { + color: var(--text-light); + font-size: 12px; +} + +.toast { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + z-index: 9999; + animation: fadeIn 0.3s; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateX(-50%) translateY(-10px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +.tag { + display: inline-block; + padding: 2px 10px; + border-radius: 20px; + font-size: 12px; + background: #e8f3ee; + color: var(--primary); +} + +.badge { + background: var(--accent); + color: #fff; + font-size: 12px; + padding: 2px 8px; + border-radius: 10px; +} diff --git a/client/src/utils.js b/client/src/utils.js new file mode 100644 index 0000000..1d1d92b --- /dev/null +++ b/client/src/utils.js @@ -0,0 +1,29 @@ +export function toast(message) { + const el = document.createElement('div'); + el.className = 'toast'; + el.textContent = message; + document.body.appendChild(el); + setTimeout(() => el.remove(), 2000); +} + +export function formatTime(str) { + if (!str) return ''; + // 兼容 .NET 的 ISO 时间(可能带 Z 或不带)、SQLite 的 "yyyy-MM-dd HH:mm:ss" + let dateStr = str; + if (typeof str === 'string' && str.includes(' ')) { + dateStr = str.replace(' ', 'T'); + } + const date = new Date(dateStr); + if (isNaN(date.getTime())) return str; + const now = new Date(); + const diff = (now - date) / 1000; + if (diff < 60) return '刚刚'; + if (diff < 3600) return Math.floor(diff / 60) + '分钟前'; + if (diff < 86400) return Math.floor(diff / 3600) + '小时前'; + if (diff < 86400 * 7) return Math.floor(diff / 86400) + '天前'; + // 超过一周显示具体日期 + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} diff --git a/client/src/views/ChatDetail.vue b/client/src/views/ChatDetail.vue new file mode 100644 index 0000000..1681f9d --- /dev/null +++ b/client/src/views/ChatDetail.vue @@ -0,0 +1,489 @@ + + + + + diff --git a/client/src/views/ChatList.vue b/client/src/views/ChatList.vue new file mode 100644 index 0000000..a354a96 --- /dev/null +++ b/client/src/views/ChatList.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/client/src/views/Housekeeping.vue b/client/src/views/Housekeeping.vue new file mode 100644 index 0000000..98517b2 --- /dev/null +++ b/client/src/views/Housekeeping.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/client/src/views/LiveHost.vue b/client/src/views/LiveHost.vue new file mode 100644 index 0000000..1619388 --- /dev/null +++ b/client/src/views/LiveHost.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/client/src/views/LiveList.vue b/client/src/views/LiveList.vue new file mode 100644 index 0000000..69f94ad --- /dev/null +++ b/client/src/views/LiveList.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/client/src/views/LiveWatch.vue b/client/src/views/LiveWatch.vue new file mode 100644 index 0000000..1b70143 --- /dev/null +++ b/client/src/views/LiveWatch.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/client/src/views/Login.vue b/client/src/views/Login.vue new file mode 100644 index 0000000..9d207a5 --- /dev/null +++ b/client/src/views/Login.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/client/src/views/Notices.vue b/client/src/views/Notices.vue new file mode 100644 index 0000000..d7184f5 --- /dev/null +++ b/client/src/views/Notices.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/client/src/views/Posts.vue b/client/src/views/Posts.vue new file mode 100644 index 0000000..9ef5816 --- /dev/null +++ b/client/src/views/Posts.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/client/src/views/Profile.vue b/client/src/views/Profile.vue new file mode 100644 index 0000000..01d46c6 --- /dev/null +++ b/client/src/views/Profile.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/client/src/views/Secondhand.vue b/client/src/views/Secondhand.vue new file mode 100644 index 0000000..668bcb7 --- /dev/null +++ b/client/src/views/Secondhand.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 0000000..01c0d97 --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import fs from 'fs'; +import path from 'path'; + +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: 5173, + https: { + key: fs.readFileSync(path.resolve(__dirname, 'key.pem')), + cert: fs.readFileSync(path.resolve(__dirname, 'cert.pem')) + }, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true + }, + '/hubs': { + target: 'http://localhost:3000', + changeOrigin: true, + ws: true + } + } + } +}); diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..273bcbe --- /dev/null +++ b/server/db.js @@ -0,0 +1,89 @@ +import Database from 'better-sqlite3'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const db = new Database(path.join(__dirname, 'life_circle.db')); + +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// 初始化数据表 +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + nickname TEXT NOT NULL, + avatar TEXT DEFAULT '', + phone TEXT DEFAULT '', + building TEXT DEFAULT '', + bio TEXT DEFAULT '', + role TEXT DEFAULT 'user', + created_at TEXT DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + images TEXT DEFAULT '[]', + category TEXT DEFAULT '动态', + likes INTEGER DEFAULT 0, + comments INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now', 'localtime')), + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now', 'localtime')), + FOREIGN KEY (post_id) REFERENCES posts(id), + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS likes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + UNIQUE(post_id, user_id), + FOREIGN KEY (post_id) REFERENCES posts(id), + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS notices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT NOT NULL, + type TEXT DEFAULT '通知', + created_at TEXT DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS secondhand ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + price REAL DEFAULT 0, + images TEXT DEFAULT '[]', + status TEXT DEFAULT '在售', + created_at TEXT DEFAULT (datetime('now', 'localtime')), + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS housekeeping ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + price TEXT DEFAULT '', + phone TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now', 'localtime')), + FOREIGN KEY (user_id) REFERENCES users(id) + ); +`); + +export default db; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..60c04cb --- /dev/null +++ b/server/index.js @@ -0,0 +1,55 @@ +import express from 'express'; +import cors from 'cors'; +import authRouter from './routes/auth.js'; +import postsRouter from './routes/posts.js'; +import noticesRouter from './routes/notices.js'; +import secondhandRouter from './routes/secondhand.js'; +import housekeepingRouter from './routes/housekeeping.js'; +import db from './db.js'; +import bcrypt from 'bcryptjs'; + +const app = express(); +app.use(cors()); +app.use(express.json({ limit: '10mb' })); + +// 初始化管理员账号 +function seedAdmin() { + const admin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin'); + if (!admin) { + const hash = bcrypt.hashSync('admin123', 10); + db.prepare('INSERT INTO users (username, password, nickname, role, building) VALUES (?, ?, ?, ?, ?)') + .run('admin', hash, '社区管理员', 'admin', '物业中心'); + console.log('已创建管理员账号: admin / admin123'); + } + // 初始化示例通知 + const count = db.prepare('SELECT COUNT(*) as c FROM notices').get().c; + if (count === 0) { + db.prepare('INSERT INTO notices (title, content, type) VALUES (?, ?, ?)') + .run('停水通知', '因管道维修,本周六 8:00-12:00 小区将停水,请各位业主提前储水。', '通知'); + db.prepare('INSERT INTO notices (title, content, type) VALUES (?, ?, ?)') + .run('关于规范电动车停放的通知', '请将电动车停放在指定充电棚,严禁上楼充电,感谢配合。', '通知'); + } +} +seedAdmin(); + +app.use('/api/auth', authRouter); +app.use('/api/posts', postsRouter); +app.use('/api/notices', noticesRouter); +app.use('/api/secondhand', secondhandRouter); +app.use('/api/housekeeping', housekeepingRouter); + +// 静态文件(前端构建产物) +import path from 'path'; +import { fileURLToPath } from 'url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +app.use(express.static(path.join(__dirname, '../client/dist'))); + +app.use((err, req, res, next) => { + console.error(err); + res.status(500).json({ message: '服务器内部错误' }); +}); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`生活圈系统后端已启动: http://localhost:${PORT}`); +}); diff --git a/server/life_circle.db b/server/life_circle.db new file mode 100644 index 0000000..db7a745 Binary files /dev/null and b/server/life_circle.db differ diff --git a/server/middleware.js b/server/middleware.js new file mode 100644 index 0000000..d6e417c --- /dev/null +++ b/server/middleware.js @@ -0,0 +1,28 @@ +import jwt from 'jsonwebtoken'; + +const SECRET = 'life-circle-secret-key-2026'; + +export function auth(req, res, next) { + const header = req.headers.authorization; + if (!header) { + return res.status(401).json({ message: '未登录' }); + } + const token = header.split(' ')[1]; + try { + const payload = jwt.verify(token, SECRET); + req.user = payload; + next(); + } catch (e) { + return res.status(401).json({ message: '登录已过期,请重新登录' }); + } +} + +export function sign(user) { + return jwt.sign( + { id: user.id, username: user.username, nickname: user.nickname, role: user.role }, + SECRET, + { expiresIn: '7d' } + ); +} + +export { SECRET }; diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..ce10af2 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1411 @@ +{ + "name": "life-circle-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "life-circle-server", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.3.0", + "cors": "^2.8.5", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmmirror.com/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmmirror.com/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.95.0", + "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.95.0.tgz", + "integrity": "sha512-T9iGctuocf0qIWFFOTxPzjT5q0SILqaBYXt272tlBHvTKC5+3JnkMirLxNJNkXHtFyBjU2Jx+NL4Zipr0B/c6Q==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..235c118 --- /dev/null +++ b/server/package.json @@ -0,0 +1,18 @@ +{ + "name": "life-circle-server", + "version": "1.0.0", + "description": "社区生活圈系统后端服务", + "main": "index.js", + "type": "module", + "scripts": { + "start": "node index.js", + "dev": "node --watch index.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.3.0", + "cors": "^2.8.5", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2" + } +} diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..ddcff12 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,75 @@ +import { Router } from 'express'; +import bcrypt from 'bcryptjs'; +import db from '../db.js'; +import { auth, sign } from '../middleware.js'; + +const router = Router(); + +// 注册 +router.post('/register', (req, res) => { + const { username, password, nickname, phone, building } = req.body; + if (!username || !password || !nickname) { + return res.status(400).json({ message: '请填写完整信息' }); + } + if (username.length < 3 || password.length < 6) { + return res.status(400).json({ message: '用户名至少3位,密码至少6位' }); + } + const exists = db.prepare('SELECT id FROM users WHERE username = ?').get(username); + if (exists) { + return res.status(400).json({ message: '用户名已存在' }); + } + const hash = bcrypt.hashSync(password, 10); + const info = db + .prepare('INSERT INTO users (username, password, nickname, phone, building) VALUES (?, ?, ?, ?, ?)') + .run(username, hash, nickname, phone || '', building || ''); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid); + res.json({ token: sign(user), user: safeUser(user) }); +}); + +// 登录 +router.post('/login', (req, res) => { + const { username, password } = req.body; + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + if (!user || !bcrypt.compareSync(password, user.password)) { + return res.status(401).json({ message: '用户名或密码错误' }); + } + res.json({ token: sign(user), user: safeUser(user) }); +}); + +// 获取当前用户 +router.get('/me', auth, (req, res) => { + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); + if (!user) return res.status(404).json({ message: '用户不存在' }); + res.json(safeUser(user)); +}); + +// 更新个人资料 +router.put('/me', auth, (req, res) => { + const { nickname, phone, building, bio } = req.body; + db.prepare('UPDATE users SET nickname = ?, phone = ?, building = ?, bio = ? WHERE id = ?') + .run(nickname, phone || '', building || '', bio || '', req.user.id); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); + res.json(safeUser(user)); +}); + +// 修改密码 +router.put('/password', auth, (req, res) => { + const { oldPassword, newPassword } = req.body; + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); + if (!bcrypt.compareSync(oldPassword, user.password)) { + return res.status(400).json({ message: '原密码错误' }); + } + if (newPassword.length < 6) { + return res.status(400).json({ message: '新密码至少6位' }); + } + const hash = bcrypt.hashSync(newPassword, 10); + db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, req.user.id); + res.json({ message: '修改成功' }); +}); + +function safeUser(u) { + const { password, ...rest } = u; + return rest; +} + +export default router; diff --git a/server/routes/housekeeping.js b/server/routes/housekeeping.js new file mode 100644 index 0000000..5956450 --- /dev/null +++ b/server/routes/housekeeping.js @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import db from '../db.js'; +import { auth } from '../middleware.js'; + +const router = Router(); + +// 家政服务列表 +router.get('/', auth, (req, res) => { + const items = db + .prepare('SELECT h.*, u.nickname, u.avatar FROM housekeeping h JOIN users u ON h.user_id = u.id ORDER BY h.created_at DESC') + .all(); + res.json(items); +}); + +// 发布家政服务 +router.post('/', auth, (req, res) => { + const { title, description, price, phone } = req.body; + if (!title || !description) { + return res.status(400).json({ message: '请填写服务名称和描述' }); + } + const info = db + .prepare('INSERT INTO housekeeping (user_id, title, description, price, phone) VALUES (?, ?, ?, ?, ?)') + .run(req.user.id, title, description, price || '', phone || ''); + res.json(db.prepare('SELECT * FROM housekeeping WHERE id = ?').get(info.lastInsertRowid)); +}); + +// 删除服务 +router.delete('/:id', auth, (req, res) => { + const item = db.prepare('SELECT * FROM housekeeping WHERE id = ?').get(req.params.id); + if (!item) return res.status(404).json({ message: '服务不存在' }); + if (item.user_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ message: '无权删除' }); + } + db.prepare('DELETE FROM housekeeping WHERE id = ?').run(req.params.id); + res.json({ message: '删除成功' }); +}); + +export default router; diff --git a/server/routes/notices.js b/server/routes/notices.js new file mode 100644 index 0000000..90ff191 --- /dev/null +++ b/server/routes/notices.js @@ -0,0 +1,28 @@ +import { Router } from 'express'; +import db from '../db.js'; +import { auth } from '../middleware.js'; + +const router = Router(); + +// 物业通知列表 +router.get('/', (req, res) => { + const notices = db.prepare('SELECT * FROM notices ORDER BY created_at DESC').all(); + res.json(notices); +}); + +// 发布通知(管理员) +router.post('/', auth, (req, res) => { + if (req.user.role !== 'admin') { + return res.status(403).json({ message: '仅管理员可发布通知' }); + } + const { title, content, type } = req.body; + if (!title || !content) { + return res.status(400).json({ message: '请填写标题和内容' }); + } + const info = db + .prepare('INSERT INTO notices (title, content, type) VALUES (?, ?, ?)') + .run(title, content, type || '通知'); + res.json(db.prepare('SELECT * FROM notices WHERE id = ?').get(info.lastInsertRowid)); +}); + +export default router; diff --git a/server/routes/posts.js b/server/routes/posts.js new file mode 100644 index 0000000..e9b7c7f --- /dev/null +++ b/server/routes/posts.js @@ -0,0 +1,90 @@ +import { Router } from 'express'; +import db from '../db.js'; +import { auth } from '../middleware.js'; + +const router = Router(); + +// 获取动态列表(含用户信息、是否已点赞) +router.get('/', auth, (req, res) => { + const { category } = req.query; + let sql = ` + SELECT p.*, u.nickname, u.avatar, + (SELECT COUNT(*) FROM likes l WHERE l.post_id = p.id) as like_count, + EXISTS(SELECT 1 FROM likes l WHERE l.post_id = p.id AND l.user_id = ?) as liked + FROM posts p JOIN users u ON p.user_id = u.id + `; + const params = [req.user.id]; + if (category && category !== '全部') { + sql += ' WHERE p.category = ?'; + params.push(category); + } + sql += ' ORDER BY p.created_at DESC'; + const posts = db.prepare(sql).all(...params); + res.json(posts.map(p => ({ ...p, images: JSON.parse(p.images || '[]') }))); +}); + +// 发布动态 +router.post('/', auth, (req, res) => { + const { content, images, category } = req.body; + if (!content || !content.trim()) { + return res.status(400).json({ message: '内容不能为空' }); + } + const info = db + .prepare('INSERT INTO posts (user_id, content, images, category) VALUES (?, ?, ?, ?)') + .run(req.user.id, content, JSON.stringify(images || []), category || '动态'); + const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(info.lastInsertRowid); + res.json({ ...post, images: JSON.parse(post.images || '[]') }); +}); + +// 删除动态 +router.delete('/:id', auth, (req, res) => { + const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id); + if (!post) return res.status(404).json({ message: '动态不存在' }); + if (post.user_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ message: '无权删除' }); + } + db.prepare('DELETE FROM comments WHERE post_id = ?').run(req.params.id); + db.prepare('DELETE FROM likes WHERE post_id = ?').run(req.params.id); + db.prepare('DELETE FROM posts WHERE id = ?').run(req.params.id); + res.json({ message: '删除成功' }); +}); + +// 点赞/取消点赞 +router.post('/:id/like', auth, (req, res) => { + const post = db.prepare('SELECT id FROM posts WHERE id = ?').get(req.params.id); + if (!post) return res.status(404).json({ message: '动态不存在' }); + const existing = db + .prepare('SELECT id FROM likes WHERE post_id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + if (existing) { + db.prepare('DELETE FROM likes WHERE id = ?').run(existing.id); + res.json({ liked: false }); + } else { + db.prepare('INSERT INTO likes (post_id, user_id) VALUES (?, ?)').run(req.params.id, req.user.id); + res.json({ liked: true }); + } +}); + +// 获取评论 +router.get('/:id/comments', auth, (req, res) => { + const comments = db + .prepare('SELECT c.*, u.nickname, u.avatar FROM comments c JOIN users u ON c.user_id = u.id WHERE c.post_id = ? ORDER BY c.created_at ASC') + .all(req.params.id); + res.json(comments); +}); + +// 发表评论 +router.post('/:id/comments', auth, (req, res) => { + const { content } = req.body; + if (!content || !content.trim()) { + return res.status(400).json({ message: '评论不能为空' }); + } + db.prepare('INSERT INTO comments (post_id, user_id, content) VALUES (?, ?, ?)') + .run(req.params.id, req.user.id, content); + const list = db + .prepare('SELECT c.*, u.nickname, u.avatar FROM comments c JOIN users u ON c.user_id = u.id WHERE c.post_id = ? ORDER BY c.created_at ASC') + .all(req.params.id); + res.json(list); +}); + +export default router; diff --git a/server/routes/secondhand.js b/server/routes/secondhand.js new file mode 100644 index 0000000..447c631 --- /dev/null +++ b/server/routes/secondhand.js @@ -0,0 +1,50 @@ +import { Router } from 'express'; +import db from '../db.js'; +import { auth } from '../middleware.js'; + +const router = Router(); + +// 二手闲置列表 +router.get('/', auth, (req, res) => { + const items = db + .prepare('SELECT s.*, u.nickname, u.avatar FROM secondhand s JOIN users u ON s.user_id = u.id ORDER BY s.created_at DESC') + .all(); + res.json(items.map(i => ({ ...i, images: JSON.parse(i.images || '[]') }))); +}); + +// 发布闲置 +router.post('/', auth, (req, res) => { + const { title, description, price, images } = req.body; + if (!title || !description) { + return res.status(400).json({ message: '请填写标题和描述' }); + } + const info = db + .prepare('INSERT INTO secondhand (user_id, title, description, price, images) VALUES (?, ?, ?, ?, ?)') + .run(req.user.id, title, description, price || 0, JSON.stringify(images || [])); + res.json(db.prepare('SELECT * FROM secondhand WHERE id = ?').get(info.lastInsertRowid)); +}); + +// 更新状态(下架/售出) +router.put('/:id', auth, (req, res) => { + const item = db.prepare('SELECT * FROM secondhand WHERE id = ?').get(req.params.id); + if (!item) return res.status(404).json({ message: '物品不存在' }); + if (item.user_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ message: '无权操作' }); + } + const { status } = req.body; + db.prepare('UPDATE secondhand SET status = ? WHERE id = ?').run(status, req.params.id); + res.json(db.prepare('SELECT * FROM secondhand WHERE id = ?').get(req.params.id)); +}); + +// 删除闲置 +router.delete('/:id', auth, (req, res) => { + const item = db.prepare('SELECT * FROM secondhand WHERE id = ?').get(req.params.id); + if (!item) return res.status(404).json({ message: '物品不存在' }); + if (item.user_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ message: '无权删除' }); + } + db.prepare('DELETE FROM secondhand WHERE id = ?').run(req.params.id); + res.json({ message: '删除成功' }); +}); + +export default router; diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..76c5273 --- /dev/null +++ b/start.bat @@ -0,0 +1,21 @@ +@echo off +chcp 65001 >nul +echo ============================================ +echo 启动 f9-web-ai-test(后端 + 前端) +echo ============================================ +echo. + +echo [1/2] 启动后端 (.NET 8, 端口 3000)... +start "F9WebAiTest-Backend" cmd /k "cd /d C:\Users\32453\CodeBuddy\20260825142915\backend\F9WebAiTest.Api && dotnet run" + +echo [2/2] 启动前端 (Vue3, 端口 5173)... +start "F9WebAiTest-Frontend" cmd /k "cd /d C:\Users\32453\CodeBuddy\20260825142915\web && npm run dev" + +echo. +echo 启动完成! +echo 后端: http://localhost:3000 (Swagger: http://localhost:3000/swagger) +echo 前端: http://localhost:5173 +echo 默认账号: admin / 123456 +echo. +echo 请稍等几秒让服务启动,然后浏览器打开 http://localhost:5173 +pause diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..7228f3f --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + 智慧缫丝系统 + + +
+ + + diff --git a/web/nswag.json b/web/nswag.json new file mode 100644 index 0000000..b6346ff --- /dev/null +++ b/web/nswag.json @@ -0,0 +1,72 @@ +{ + "runtime": "Net80", + "defaultVariables": null, + "documentGenerator": { + "fromDocument": { + "url": "http://localhost:3000/swagger/v1/swagger.json", + "output": null, + "newLineBehavior": "Auto" + } + }, + "codeGenerators": { + "openApiToTypeScriptClient": { + "className": "ApiClient", + "moduleName": "", + "namespace": "", + "typeScriptVersion": 4.3, + "template": "Axios", + "promiseType": "Promise", + "httpClass": "HttpClient", + "withCredentials": false, + "useSingletonProvider": false, + "injectionTokenType": "OpaqueToken", + "rxJsVersion": 6.0, + "dateTimeType": "Date", + "nullValue": "Undefined", + "generateClientClasses": true, + "generateClientInterfaces": false, + "generateOptionalParameters": false, + "exportTypes": true, + "wrapDtoExceptions": false, + "exceptionClass": "ApiException", + "clientBaseClass": null, + "wrapResponses": false, + "wrapResponseMethods": [], + "generateResponseClasses": true, + "responseClass": "SwaggerResponse", + "protectedMethods": [], + "configurationClass": null, + "useTransformOptionsMethod": false, + "useTransformResultMethod": false, + "generateDtoTypes": true, + "operationGenerationMode": "MultipleClientsFromOperationId", + "markOptionalProperties": true, + "generateCloneMethod": false, + "typeStyle": "Interface", + "enumStyle": "Enum", + "useLeafType": false, + "classTypes": [], + "extendedClasses": [], + "extensionCode": null, + "generateDefaultValues": true, + "excludedTypeNames": [], + "excludedParameterNames": [], + "handleReferences": false, + "generateTypeCheckFunctions": false, + "generateConstructorInterface": true, + "convertConstructorInterfaceData": false, + "importRequiredTypes": true, + "useGetBaseUrlMethod": false, + "baseUrlTokenName": "API_BASE_URL", + "queryNullValue": "", + "useAbortSignal": false, + "inlineNamedDictionaries": false, + "inlineNamedAny": false, + "includeHttpContext": false, + "templateDirectory": null, + "serviceHost": null, + "serviceSchemes": null, + "output": "src/api/client.ts" + } + } +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..3e9e25e --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1862 @@ +{ + "name": "f9-web-ai-test", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "f9-web-ai-test", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "@microsoft/signalr": "^10.0.11", + "axios": "^1.7.7", + "element-plus": "^2.8.4", + "pinia": "^2.2.2", + "vue": "^3.4.38", + "vue-router": "^4.4.3" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.3", + "vite": "^5.4.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==" + }, + "node_modules/@microsoft/signalr": { + "version": "10.0.11", + "resolved": "https://registry.npmmirror.com/@microsoft/signalr/-/signalr-10.0.11.tgz", + "integrity": "sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw==", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==" + }, + "node_modules/@vueuse/core": { + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz", + "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.4.0", + "@vueuse/shared": "14.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz", + "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.4.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz", + "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.5", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.5.tgz", + "integrity": "sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.8.0", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.4.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.9" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmmirror.com/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz", + "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..f9e6afe --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "f9-web-ai-test", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "gen:api": "nswag run nswag.json" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "@microsoft/signalr": "^10.0.11", + "axios": "^1.7.7", + "element-plus": "^2.8.4", + "pinia": "^2.2.2", + "vue": "^3.4.38", + "vue-router": "^4.4.3" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.3", + "vite": "^5.4.3" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..3e2ee3f --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,1873 @@ +//---------------------- +// +// Generated using the NSwag toolchain v14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// +//---------------------- + +/* tslint:disable */ +/* eslint-disable */ +// ReSharper disable InconsistentNaming + +import axios, { AxiosError } from 'axios'; +import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, CancelToken } from 'axios'; + +export class ApiClient { + protected instance: AxiosInstance; + protected baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(baseUrl?: string, instance?: AxiosInstance) { + + this.instance = instance || axios.create(); + + this.baseUrl = baseUrl ?? "http://localhost:3000"; + + } + + login(dto: LoginDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/auth/login"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "POST", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processLogin(_response); + }); + } + + protected processLogin(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + refresh(dto: RefreshTokenDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/auth/refresh"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "POST", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processRefresh(_response); + }); + } + + protected processRefresh(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + logout(dto: RefreshTokenDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/auth/logout"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "POST", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processLogout(_response); + }); + } + + protected processLogout(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + getCurrentUser( cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/auth/me"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "GET", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetCurrentUser(_response); + }); + } + + protected processGetCurrentUser(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + getMenus( cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/auth/menus"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "GET", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetMenus(_response); + }); + } + + protected processGetMenus(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + getPermissions( cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/auth/permissions"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "GET", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetPermissions(_response); + }); + } + + protected processGetPermissions(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + changePassword(dto: ChangePasswordDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/auth/password"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "PUT", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processChangePassword(_response); + }); + } + + protected processChangePassword(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + updateProfile(dto: UpdateProfileDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/auth/profile"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "PUT", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processUpdateProfile(_response); + }); + } + + protected processUpdateProfile(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } +} + +export class ApiClient { + protected instance: AxiosInstance; + protected baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(baseUrl?: string, instance?: AxiosInstance) { + + this.instance = instance || axios.create(); + + this.baseUrl = baseUrl ?? "http://localhost:3000"; + + } + + getTree( cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/department/tree"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "GET", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetTree(_response); + }); + } + + protected processGetTree(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + create(dto: CreateDepartmentDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/department"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "POST", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processCreate(_response); + }); + } + + protected processCreate(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + update(id: number, dto: UpdateDepartmentDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/department/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "PUT", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processUpdate(_response); + }); + } + + protected processUpdate(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + delete(id: number, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/department/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "DELETE", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processDelete(_response); + }); + } + + protected processDelete(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } +} + +export class ApiClient { + protected instance: AxiosInstance; + protected baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(baseUrl?: string, instance?: AxiosInstance) { + + this.instance = instance || axios.create(); + + this.baseUrl = baseUrl ?? "http://localhost:3000"; + + } + + getPaged(keyword: string | null | undefined, departmentId: number | null | undefined, status: number | null | undefined, pageIndex: number | undefined, pageSize: number | undefined, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/employee?"; + if (keyword !== undefined && keyword !== null) + url_ += "Keyword=" + encodeURIComponent("" + keyword) + "&"; + if (departmentId !== undefined && departmentId !== null) + url_ += "DepartmentId=" + encodeURIComponent("" + departmentId) + "&"; + if (status !== undefined && status !== null) + url_ += "Status=" + encodeURIComponent("" + status) + "&"; + if (pageIndex === null) + throw new Error("The parameter 'pageIndex' cannot be null."); + else if (pageIndex !== undefined) + url_ += "PageIndex=" + encodeURIComponent("" + pageIndex) + "&"; + if (pageSize === null) + throw new Error("The parameter 'pageSize' cannot be null."); + else if (pageSize !== undefined) + url_ += "PageSize=" + encodeURIComponent("" + pageSize) + "&"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "GET", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetPaged(_response); + }); + } + + protected processGetPaged(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + create(dto: CreateEmployeeDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/employee"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "POST", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processCreate(_response); + }); + } + + protected processCreate(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + getById(id: number, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/employee/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "GET", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetById(_response); + }); + } + + protected processGetById(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + update(id: number, dto: UpdateEmployeeDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/employee/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "PUT", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processUpdate(_response); + }); + } + + protected processUpdate(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + delete(id: number, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/employee/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "DELETE", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processDelete(_response); + }); + } + + protected processDelete(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + resetPassword(id: number, dto: ResetPasswordDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/employee/{id}/password"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "PUT", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processResetPassword(_response); + }); + } + + protected processResetPassword(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } +} + +export class ApiClient { + protected instance: AxiosInstance; + protected baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(baseUrl?: string, instance?: AxiosInstance) { + + this.instance = instance || axios.create(); + + this.baseUrl = baseUrl ?? "http://localhost:3000"; + + } + + getTree( cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/menu/tree"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "GET", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetTree(_response); + }); + } + + protected processGetTree(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + getAll( cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/menu/all"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "GET", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetAll(_response); + }); + } + + protected processGetAll(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + create(dto: CreateMenuDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/menu"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "POST", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processCreate(_response); + }); + } + + protected processCreate(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + update(id: number, dto: UpdateMenuDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/menu/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "PUT", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processUpdate(_response); + }); + } + + protected processUpdate(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + delete(id: number, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/menu/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "DELETE", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processDelete(_response); + }); + } + + protected processDelete(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } +} + +export class ApiClient { + protected instance: AxiosInstance; + protected baseUrl: string; + protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined; + + constructor(baseUrl?: string, instance?: AxiosInstance) { + + this.instance = instance || axios.create(); + + this.baseUrl = baseUrl ?? "http://localhost:3000"; + + } + + getAll( cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/role"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "GET", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetAll(_response); + }); + } + + protected processGetAll(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + create(dto: CreateRoleDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/role"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "POST", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processCreate(_response); + }); + } + + protected processCreate(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + getById(id: number, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/role/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "GET", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetById(_response); + }); + } + + protected processGetById(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + update(id: number, dto: UpdateRoleDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/role/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(dto); + + let options_: AxiosRequestConfig = { + data: content_, + responseType: "blob", + method: "PUT", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processUpdate(_response); + }); + } + + protected processUpdate(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + delete(id: number, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/role/{id}"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "DELETE", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processDelete(_response); + }); + } + + protected processDelete(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + getRoleMenus(id: number, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/role/{id}/menus"; + if (id === undefined || id === null) + throw new Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + responseType: "blob", + method: "GET", + url: url_, + headers: { + "Accept": "application/octet-stream" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetRoleMenus(_response); + }); + } + + protected processGetRoleMenus(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200 || status === 206) { + const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; + let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; + let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; + if (fileName) { + fileName = decodeURIComponent(fileName); + } else { + fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; + fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; + } + return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } +} + +export interface LoginDto { + username?: string | undefined; + password?: string | undefined; +} + +export interface RefreshTokenDto { + refreshToken?: string | undefined; +} + +export interface ChangePasswordDto { + oldPassword?: string | undefined; + newPassword?: string | undefined; +} + +export interface UpdateProfileDto { + nickname?: string | undefined; + avatar?: string | undefined; + phone?: string | undefined; + email?: string | undefined; +} + +export interface CreateDepartmentDto { + name?: string | undefined; + parentId?: number | undefined; + leaderId?: number | undefined; + phone?: string | undefined; + sort?: number; +} + +export interface UpdateDepartmentDto { + name?: string | undefined; + parentId?: number | undefined; + leaderId?: number | undefined; + phone?: string | undefined; + sort?: number; + status?: number; +} + +export interface CreateEmployeeDto { + username?: string | undefined; + password?: string | undefined; + realName?: string | undefined; + nickname?: string | undefined; + gender?: string | undefined; + phone?: string | undefined; + email?: string | undefined; + departmentId?: number; + remark?: string | undefined; + roleIds?: number[] | undefined; +} + +export interface UpdateEmployeeDto { + realName?: string | undefined; + nickname?: string | undefined; + gender?: string | undefined; + phone?: string | undefined; + email?: string | undefined; + departmentId?: number; + status?: number; + remark?: string | undefined; + roleIds?: number[] | undefined; +} + +export interface ResetPasswordDto { + newPassword?: string | undefined; +} + +export interface CreateMenuDto { + parentId?: number | undefined; + name?: string | undefined; + type?: string | undefined; + path?: string | undefined; + component?: string | undefined; + icon?: string | undefined; + permission?: string | undefined; + sort?: number; +} + +export interface UpdateMenuDto { + parentId?: number | undefined; + name?: string | undefined; + type?: string | undefined; + path?: string | undefined; + component?: string | undefined; + icon?: string | undefined; + permission?: string | undefined; + sort?: number; + status?: number; +} + +export interface CreateRoleDto { + name?: string | undefined; + code?: string | undefined; + description?: string | undefined; + sort?: number; + menuIds?: number[] | undefined; +} + +export interface UpdateRoleDto { + name?: string | undefined; + description?: string | undefined; + sort?: number; + status?: number; + menuIds?: number[] | undefined; +} + +export interface FileResponse { + data: Blob; + status: number; + fileName?: string; + headers?: { [name: string]: any }; +} + +export class ApiException extends Error { + override message: string; + status: number; + response: string; + headers: { [key: string]: any; }; + result: any; + + constructor(message: string, status: number, response: string, headers: { [key: string]: any; }, result: any) { + super(); + + this.message = message; + this.status = status; + this.response = response; + this.headers = headers; + this.result = result; + } + + protected isApiException = true; + + static isApiException(obj: any): obj is ApiException { + return obj.isApiException === true; + } +} + +function throwException(message: string, status: number, response: string, headers: { [key: string]: any; }, result?: any): any { + if (result !== null && result !== undefined) + throw result; + else + throw new ApiException(message, status, response, headers, null); +} + +function isAxiosError(obj: any): obj is AxiosError { + return obj && obj.isAxiosError === true; +} \ No newline at end of file diff --git a/web/src/directives/permission.js b/web/src/directives/permission.js new file mode 100644 index 0000000..b4ac80c --- /dev/null +++ b/web/src/directives/permission.js @@ -0,0 +1,24 @@ +import { useUserStore } from '../store/user'; + +// 权限判断工具函数 +export function hasPermission(value) { + const userStore = useUserStore(); + const permissions = userStore.permissions || []; + // admin 角色拥有所有权限 + const roles = userStore.userInfo?.roles || []; + if (roles.includes('admin')) return true; + + if (Array.isArray(value)) { + return value.some((p) => permissions.includes(p)); + } + return permissions.includes(value); +} + +// 权限指令 v-permission="'system:user:add'" +export default { + mounted(el, binding) { + if (!hasPermission(binding.value)) { + el.parentNode && el.parentNode.removeChild(el); + } + } +}; diff --git a/web/src/layout/EmptyLayout.vue b/web/src/layout/EmptyLayout.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/web/src/layout/EmptyLayout.vue @@ -0,0 +1,3 @@ + diff --git a/web/src/layout/index.vue b/web/src/layout/index.vue new file mode 100644 index 0000000..df1fb56 --- /dev/null +++ b/web/src/layout/index.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..c6b250b --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,57 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import ElementPlus from 'element-plus'; +import 'element-plus/dist/index.css'; +import zhCn from 'element-plus/es/locale/lang/zh-cn'; +import * as ElementPlusIconsVue from '@element-plus/icons-vue'; +import App from './App.vue'; +import router, { buildRoutes, markRoutesLoaded } from './router'; +import permissionDirective from './directives/permission'; +import { getAccessToken } from './utils/request'; +import request from './utils/request'; +import './styles/index.css'; +import './styles/theme.css'; + +const app = createApp(App); + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component); +} + +// 注册权限指令 +app.directive('permission', permissionDirective); + +app.use(createPinia()); + +// 在挂载前同步初始化动态路由(避免 "No match" 警告) +async function bootstrap() { + if (getAccessToken()) { + try { + const [menusRes, permsRes] = await Promise.all([ + request.get('/auth/menus'), + request.get('/auth/permissions') + ]); + const routes = buildRoutes(menusRes.data); + routes.forEach((r) => { + if (!router.hasRoute(r.name)) { + router.addRoute('Layout', r); + } + }); + // 缓存到 store + const { useUserStore } = await import('./store/user'); + const store = useUserStore(); + store.menus = menusRes.data; + store.permissions = permsRes.data; + markRoutesLoaded(); + } catch (e) { + // 菜单加载失败,可能是 token 失效,交给路由守卫跳登录 + } + } + + app.use(router); + app.use(ElementPlus, { locale: zhCn }); + app.mount('#app'); +} + +bootstrap(); diff --git a/web/src/router/index.js b/web/src/router/index.js new file mode 100644 index 0000000..fc91232 --- /dev/null +++ b/web/src/router/index.js @@ -0,0 +1,169 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import { getAccessToken } from '../utils/request'; +import request from '../utils/request'; + +// 静态路由 +export const constantRoutes = [ + { + path: '/login', + name: 'Login', + component: () => import('../views/login/index.vue'), + meta: { title: '登录', hidden: true } + }, + { + path: '/', + name: 'Layout', + component: () => import('../layout/index.vue'), + redirect: '/dashboard', + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('../views/dashboard/index.vue'), + meta: { title: '首页', icon: 'HomeFilled' } + }, + // 请假审批:静态注册,所有登录用户可访问(待办消息"去处理"跳转入口,不依赖菜单权限) + { + path: 'attendance/approve', + name: 'AttendanceApprove', + component: () => import('../views/attendance/approve/index.vue'), + meta: { title: '请假审批', icon: 'Checked' } + } + ] + } +]; + +// 已静态注册的路由(不随菜单动态注册,避免重复) +const STATIC_ROUTES = ['/attendance/approve']; + +// 组件映射:后端菜单的 component 字段 -> 前端组件 +export const componentMap = { + 'system/department/index': () => import('../views/system/department/index.vue'), + 'system/employee/index': () => import('../views/system/employee/index.vue'), + 'system/role/index': () => import('../views/system/role/index.vue'), + 'system/menu/index': () => import('../views/system/menu/index.vue'), + 'system/log/index': () => import('../views/system/log/index.vue'), + 'attendance/punch/index': () => import('../views/attendance/punch/index.vue'), + 'attendance/my/index': () => import('../views/attendance/my/index.vue'), + 'attendance/leave/index': () => import('../views/attendance/leave/index.vue'), + 'attendance/records/index': () => import('../views/attendance/records/index.vue'), + 'attendance/approve/index': () => import('../views/attendance/approve/index.vue'), + 'base/zhuangkou/index': () => import('../views/base/zhuangkou/index.vue'), + 'base/guige/index': () => import('../views/base/guige/index.vue'), + 'base/dingjizhibiao/index': () => import('../views/base/dingjizhibiao/index.vue'), + 'base/jizuchetai/index': () => import('../views/base/jizuchetai/index.vue'), + 'prod/lsjl/index': () => import('../views/prod/lsjl/index.vue'), + 'prod/xdjy/index': () => import('../views/prod/xdjy/index.vue'), + 'prod/hbjy/index': () => import('../views/prod/hbjy/index.vue'), + 'notification/messages/index': () => import('../views/notification/messages/index.vue'), + 'notification/subscribe/index': () => import('../views/notification/subscribe/index.vue'), + 'notification/type/index': () => import('../views/notification/type/index.vue'), + 'sys/user/index': () => import('../views/sys/user/index.vue'), + 'sys/role/index': () => import('../views/sys/role/index.vue'), + 'sys/menu/index': () => import('../views/sys/menu/index.vue'), + 'sys/department/index': () => import('../views/sys/department/index.vue') +}; + +// 空壳布局组件(用于目录节点,透传子路由) +const EmptyLayout = () => import('../layout/EmptyLayout.vue'); + +const router = createRouter({ + history: createWebHistory(), + routes: constantRoutes +}); + +// 白名单 +const whiteList = ['/login']; + +// 动态路由是否已注册 +let routesLoaded = false; + +// 标记路由已加载(main.js bootstrap 调用) +export function markRoutesLoaded() { + routesLoaded = true; +} + +router.beforeEach(async (to, from, next) => { + const token = getAccessToken(); + if (token) { + if (to.path === '/login') { + next({ path: '/' }); + return; + } + // 兜底:如果 bootstrap 未注册路由(比如登录后首次跳转),这里加载 + if (!routesLoaded) { + try { + const [menusRes, permsRes] = await Promise.all([ + request.get('/auth/menus'), + request.get('/auth/permissions') + ]); + const menus = menusRes.data; + const routes = buildRoutes(menus); + routes.forEach((r) => { + if (!router.hasRoute(r.name)) { + router.addRoute('Layout', r); + } + }); + const { useUserStore } = await import('../store/user'); + const store = useUserStore(); + store.menus = menus; + store.permissions = permsRes.data; + routesLoaded = true; + // 重新导航到目标路由(此时路由已注册) + next({ ...to, replace: true }); + return; + } catch (e) { + next('/login'); + return; + } + } + next(); + } else { + if (whiteList.includes(to.path)) { + next(); + } else { + next(`/login?redirect=${to.path}`); + } + } +}); + +// 重置路由加载状态(退出登录时调用) +export function resetRoutes() { + routesLoaded = false; +} + +// 将后端菜单树转换为 Vue Router 路由(平铺注册到 Layout 下,用绝对路径) +export function buildRoutes(menus) { + const routes = []; + const walk = (list) => { + list.forEach((menu) => { + // 只处理目录和菜单类型(按钮不生成路由) + if (menu.type === 'directory') { + routes.push({ + path: menu.path, + name: menu.path.replace(/\//g, '-') + '-' + menu.id, + component: EmptyLayout, + meta: { title: menu.name, icon: menu.icon } + }); + } else if (menu.type === 'menu') { + // 已静态注册的路由跳过,避免重复 + if (STATIC_ROUTES.includes(menu.path)) return; + routes.push({ + path: menu.path, + name: menu.path.replace(/\//g, '-') + '-' + menu.id, + component: componentMap[menu.component] || (() => import('../views/system/empty.vue')), + meta: { title: menu.name, icon: menu.icon } + }); + } + // 递归处理子节点(平铺) + if (menu.children && menu.children.length > 0) { + walk(menu.children); + } + }); + }; + + walk(menus); + return routes; +} + +export default router; diff --git a/web/src/store/user.js b/web/src/store/user.js new file mode 100644 index 0000000..78d2b1b --- /dev/null +++ b/web/src/store/user.js @@ -0,0 +1,43 @@ +import { defineStore } from 'pinia'; +import request, { getAccessToken, setTokens, clearTokens } from '../utils/request'; + +export const useUserStore = defineStore('user', { + state: () => ({ + token: getAccessToken() || '', + userInfo: JSON.parse(localStorage.getItem('user') || 'null'), + menus: [], + permissions: [] + }), + getters: { + isLoggedIn: (state) => !!state.token + }, + actions: { + async login(loginForm) { + const res = await request.post('/auth/login', loginForm); + const { accessToken, refreshToken, user } = res.data; + setTokens(accessToken, refreshToken); + this.token = accessToken; + this.userInfo = user; + localStorage.setItem('user', JSON.stringify(user)); + return res; + }, + async fetchMenus() { + const res = await request.get('/auth/menus'); + this.menus = res.data; + return res.data; + }, + async fetchPermissions() { + const res = await request.get('/auth/permissions'); + this.permissions = res.data; + return res.data; + }, + logout() { + clearTokens(); + this.token = ''; + this.userInfo = null; + this.menus = []; + this.permissions = []; + localStorage.removeItem('user'); + } + } +}); diff --git a/web/src/styles/index.css b/web/src/styles/index.css new file mode 100644 index 0000000..e98c049 --- /dev/null +++ b/web/src/styles/index.css @@ -0,0 +1,20 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #app { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; +} + +body { + background: #f6f4ef; + -webkit-font-smoothing: antialiased; +} + +a { + text-decoration: none; + color: inherit; +} diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css new file mode 100644 index 0000000..da603af --- /dev/null +++ b/web/src/styles/theme.css @@ -0,0 +1,33 @@ +/* ============================================================ + 智慧缫丝系统主题 + 蚕桑黛绿(沉稳专业)+ 丝绸金(光泽点缀)+ 暖白(丝绸质感) + ============================================================ */ +:root { + /* Element Plus 主色改为黛绿 */ + --el-color-primary: #1e6b52; + --el-color-primary-light-3: #4d8f74; + --el-color-primary-light-5: #82b39d; + --el-color-primary-light-7: #bad5c8; + --el-color-primary-light-8: #d6e7de; + --el-color-primary-light-9: #ecf4f0; + --el-color-primary-dark-2: #175544; + + /* 成功色统一为黛绿系 */ + --el-color-success: #1e6b52; + --el-color-success-light-3: #4d8f74; + --el-color-success-light-5: #82b39d; + --el-color-success-light-7: #bad5c8; + --el-color-success-light-8: #d6e7de; + --el-color-success-light-9: #ecf4f0; + --el-color-success-dark-2: #175544; + + /* 自定义全局变量 */ + --lc-primary: #1e6b52; /* 主色:黛绿 */ + --lc-primary-dark: #175544; /* 深黛绿 */ + --lc-primary-deep: #0c3b2e; /* 墨绿(侧边栏) */ + --lc-accent: #c9a54a; /* 丝绸金 */ + --lc-accent-light: #e8c877; + --lc-bg: #f6f4ef; /* 暖白背景(丝绸质感) */ + --lc-text: #2c3a35; /* 主文字 */ + --lc-text-secondary: #6b7a74; /* 次要文字 */ +} diff --git a/web/src/utils/dept.js b/web/src/utils/dept.js new file mode 100644 index 0000000..66f8a27 --- /dev/null +++ b/web/src/utils/dept.js @@ -0,0 +1,14 @@ +// 新部门接口(/sys/department)返回扁平列表,这里转换为 el-tree 所需树结构 +export function buildDeptTree(list) { + const map = {}; + (list || []).forEach((d) => { + map[d.id] = { id: d.id, name: d.name, code: d.code, parentId: d.parentId, children: [] }; + }); + const roots = []; + (list || []).forEach((d) => { + const node = map[d.id]; + if (d.parentId && map[d.parentId]) map[d.parentId].children.push(node); + else roots.push(node); + }); + return roots; +} diff --git a/web/src/utils/notification.js b/web/src/utils/notification.js new file mode 100644 index 0000000..272af0c --- /dev/null +++ b/web/src/utils/notification.js @@ -0,0 +1,99 @@ +import { ref } from 'vue'; +import * as signalR from '@microsoft/signalr'; +import { getAccessToken } from './request'; +import request from './request'; + +// 全局未读数量(layout 徽章 与 消息页共用) +export const unreadCount = ref(0); + +let connection = null; +const messageHandlers = []; +const businessChangeHandlers = []; + +/** 加载未读数量 */ +export async function loadUnreadCount() { + try { + const res = await request.get('/notification/unread-count'); + unreadCount.value = res.data.count; + } catch (e) {} +} + +/** + * 初始化 SignalR 通知连接(幂等,重复调用返回同一连接) + * @param {Function} onMessage 收到新消息时的回调 (msg) => void + */ +export function initNotification(onMessage) { + if (connection) { + if (onMessage) messageHandlers.push(onMessage); + return connection; + } + + if (onMessage) messageHandlers.push(onMessage); + + connection = new signalR.HubConnectionBuilder() + .withUrl('/hubs/notification', { + accessTokenFactory: () => getAccessToken() || '' + }) + .withAutomaticReconnect() + .configureLogging(signalR.LogLevel.Warning) + .build(); + + connection.on('ReceiveNotification', (msg) => { + unreadCount.value++; // 全局未读 +1 + messageHandlers.forEach((h) => { try { h(msg); } catch (e) {} }); + }); + + // 业务状态变更事件(多端同步:某端操作后,其它端刷新已打开的页面) + connection.on('BusinessChanged', (e) => { + businessChangeHandlers.forEach((h) => { try { h(e); } catch (err) {} }); + }); + + connection.start().catch(() => {}); + return connection; +} + +/** + * 订阅业务状态变更事件(用于多端同步刷新)。 + * @param {Function} handler (e) => void,e 含 businessType / businessId / action + */ +export function onBusinessChanged(handler) { + if (handler) businessChangeHandlers.push(handler); + initNotification(); // 确保连接已建立(幂等) +} + +/** + * 打开消息:标记已读 + 跳转到业务页面(消息自带 routePath 时)。 + * @param {Object} msg 消息对象(含 id/readStatus/routePath/category) + * @param {Object} router vue-router 实例 + * @returns {boolean} 是否发生了页面跳转 + */ +export async function openMessage(msg, router) { + // 待办消息(category=1)点击"去处理"不标记已读,保持未读提醒; + // 只有真正完成待办后(后端完成待办时会标记已读)才消失 + if (msg.readStatus === 0 && msg.category !== 1) { + try { + await request.put(`/notification/messages/${msg.id}/read`); + msg.readStatus = 1; + loadUnreadCount(); + } catch (e) {} + } + if (msg.routePath) { + router.push(msg.routePath); + return true; + } + return false; +} + +/** 停止连接(登出时调用) */ +export function stopNotification() { + if (connection) { + connection.stop().catch(() => {}); + connection = null; + } + messageHandlers.length = 0; + unreadCount.value = 0; +} + +export function getNotificationConnection() { + return connection; +} diff --git a/web/src/utils/request.js b/web/src/utils/request.js new file mode 100644 index 0000000..902ee1c --- /dev/null +++ b/web/src/utils/request.js @@ -0,0 +1,112 @@ +import axios from 'axios'; +import { ElMessage } from 'element-plus'; + +const request = axios.create({ + baseURL: '/api', + timeout: 15000 +}); + +// 令牌存取 +const TOKEN_KEY = 'access_token'; +const REFRESH_KEY = 'refresh_token'; + +export function getAccessToken() { + return localStorage.getItem(TOKEN_KEY); +} +export function setTokens(accessToken, refreshToken) { + localStorage.setItem(TOKEN_KEY, accessToken); + localStorage.setItem(REFRESH_KEY, refreshToken); +} +export function clearTokens() { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_KEY); +} +export function getRefreshToken() { + return localStorage.getItem(REFRESH_KEY); +} + +// 请求拦截:附加 access token +request.interceptors.request.use((config) => { + const token = getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// 刷新令牌(避免并发重复刷新) +let isRefreshing = false; +let pendingQueue = []; + +async function refreshAccessToken() { + const refreshToken = getRefreshToken(); + if (!refreshToken) return null; + try { + const res = await axios.post('/api/auth/refresh', { refreshToken }); + if (res.data?.code === 200) { + setTokens(res.data.data.accessToken, res.data.data.refreshToken); + return res.data.data.accessToken; + } + return null; + } catch (e) { + return null; + } +} + +// 响应拦截 +request.interceptors.response.use( + (res) => { + const data = res.data; + if (data.code === 200) { + return data; + } + ElMessage.error(data.message || '请求失败'); + return Promise.reject(new Error(data.message || '请求失败')); + }, + async (err) => { + const { response, config } = err; + + // 401:尝试用 refresh token 刷新 access token 后重试 + if (response?.status === 401 && !config._retry && getRefreshToken()) { + if (isRefreshing) { + // 等待正在进行的刷新完成 + return new Promise((resolve) => { + pendingQueue.push((newToken) => { + if (newToken) { + config.headers.Authorization = `Bearer ${newToken}`; + resolve(request(config)); + } else { + resolve(Promise.reject(err)); + } + }); + }); + } + + config._retry = true; + isRefreshing = true; + const newToken = await refreshAccessToken(); + isRefreshing = false; + + if (newToken) { + pendingQueue.forEach((cb) => cb(newToken)); + pendingQueue = []; + config.headers.Authorization = `Bearer ${newToken}`; + return request(config); + } else { + pendingQueue.forEach((cb) => cb(null)); + pendingQueue = []; + clearTokens(); + localStorage.removeItem('user'); + ElMessage.error('登录已过期,请重新登录'); + window.location.href = '/login'; + return Promise.reject(err); + } + } + + const msg = response?.data?.message || '请求失败,请稍后重试'; + ElMessage.error(msg); + return Promise.reject(err); + } +); + +export default request; diff --git a/web/src/views/attendance/approve/index.vue b/web/src/views/attendance/approve/index.vue new file mode 100644 index 0000000..c698ec6 --- /dev/null +++ b/web/src/views/attendance/approve/index.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/web/src/views/attendance/leave/index.vue b/web/src/views/attendance/leave/index.vue new file mode 100644 index 0000000..c4b7ab4 --- /dev/null +++ b/web/src/views/attendance/leave/index.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/web/src/views/attendance/my/index.vue b/web/src/views/attendance/my/index.vue new file mode 100644 index 0000000..3dfbf25 --- /dev/null +++ b/web/src/views/attendance/my/index.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/web/src/views/attendance/punch/index.vue b/web/src/views/attendance/punch/index.vue new file mode 100644 index 0000000..653d015 --- /dev/null +++ b/web/src/views/attendance/punch/index.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/web/src/views/attendance/records/index.vue b/web/src/views/attendance/records/index.vue new file mode 100644 index 0000000..542cb33 --- /dev/null +++ b/web/src/views/attendance/records/index.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/web/src/views/base/dingjizhibiao/index.vue b/web/src/views/base/dingjizhibiao/index.vue new file mode 100644 index 0000000..9b412e4 --- /dev/null +++ b/web/src/views/base/dingjizhibiao/index.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/web/src/views/base/guige/index.vue b/web/src/views/base/guige/index.vue new file mode 100644 index 0000000..c0ec8dc --- /dev/null +++ b/web/src/views/base/guige/index.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/web/src/views/base/jizuchetai/index.vue b/web/src/views/base/jizuchetai/index.vue new file mode 100644 index 0000000..ce4919c --- /dev/null +++ b/web/src/views/base/jizuchetai/index.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/web/src/views/base/zhuangkou/index.vue b/web/src/views/base/zhuangkou/index.vue new file mode 100644 index 0000000..f586642 --- /dev/null +++ b/web/src/views/base/zhuangkou/index.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/web/src/views/dashboard/index.vue b/web/src/views/dashboard/index.vue new file mode 100644 index 0000000..aa9f6dd --- /dev/null +++ b/web/src/views/dashboard/index.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/web/src/views/login/index.vue b/web/src/views/login/index.vue new file mode 100644 index 0000000..bab6879 --- /dev/null +++ b/web/src/views/login/index.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/web/src/views/notification/messages/index.vue b/web/src/views/notification/messages/index.vue new file mode 100644 index 0000000..80ef136 --- /dev/null +++ b/web/src/views/notification/messages/index.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/web/src/views/notification/subscribe/index.vue b/web/src/views/notification/subscribe/index.vue new file mode 100644 index 0000000..ad9ab09 --- /dev/null +++ b/web/src/views/notification/subscribe/index.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/web/src/views/notification/type/index.vue b/web/src/views/notification/type/index.vue new file mode 100644 index 0000000..7ab487a --- /dev/null +++ b/web/src/views/notification/type/index.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/web/src/views/prod/hbjy/index.vue b/web/src/views/prod/hbjy/index.vue new file mode 100644 index 0000000..8470368 --- /dev/null +++ b/web/src/views/prod/hbjy/index.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/web/src/views/prod/lsjl/index.vue b/web/src/views/prod/lsjl/index.vue new file mode 100644 index 0000000..a946a59 --- /dev/null +++ b/web/src/views/prod/lsjl/index.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/web/src/views/prod/xdjy/index.vue b/web/src/views/prod/xdjy/index.vue new file mode 100644 index 0000000..e49757b --- /dev/null +++ b/web/src/views/prod/xdjy/index.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/web/src/views/sys/department/index.vue b/web/src/views/sys/department/index.vue new file mode 100644 index 0000000..15d6911 --- /dev/null +++ b/web/src/views/sys/department/index.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/web/src/views/sys/menu/index.vue b/web/src/views/sys/menu/index.vue new file mode 100644 index 0000000..ffaae90 --- /dev/null +++ b/web/src/views/sys/menu/index.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/web/src/views/sys/role/index.vue b/web/src/views/sys/role/index.vue new file mode 100644 index 0000000..cf6451d --- /dev/null +++ b/web/src/views/sys/role/index.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/web/src/views/sys/user/index.vue b/web/src/views/sys/user/index.vue new file mode 100644 index 0000000..6dbe458 --- /dev/null +++ b/web/src/views/sys/user/index.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/web/src/views/system/department/index.vue b/web/src/views/system/department/index.vue new file mode 100644 index 0000000..09f4b73 --- /dev/null +++ b/web/src/views/system/department/index.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/web/src/views/system/employee/index.vue b/web/src/views/system/employee/index.vue new file mode 100644 index 0000000..77afe55 --- /dev/null +++ b/web/src/views/system/employee/index.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/web/src/views/system/empty.vue b/web/src/views/system/empty.vue new file mode 100644 index 0000000..7b8b46c --- /dev/null +++ b/web/src/views/system/empty.vue @@ -0,0 +1,3 @@ + diff --git a/web/src/views/system/log/index.vue b/web/src/views/system/log/index.vue new file mode 100644 index 0000000..f7d7b32 --- /dev/null +++ b/web/src/views/system/log/index.vue @@ -0,0 +1,14 @@ + + + diff --git a/web/src/views/system/menu/index.vue b/web/src/views/system/menu/index.vue new file mode 100644 index 0000000..bad7ae1 --- /dev/null +++ b/web/src/views/system/menu/index.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/web/src/views/system/role/index.vue b/web/src/views/system/role/index.vue new file mode 100644 index 0000000..73ace2f --- /dev/null +++ b/web/src/views/system/role/index.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..c8a27ff --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true + }, + '/hubs': { + target: 'http://localhost:3000', + changeOrigin: true, + ws: true + } + } + } +}); diff --git a/web/消息推送系统软件设计说明书.docx b/web/消息推送系统软件设计说明书.docx new file mode 100644 index 0000000..bc3a533 Binary files /dev/null and b/web/消息推送系统软件设计说明书.docx differ diff --git a/消息推送系统_迁移指南.docx b/消息推送系统_迁移指南.docx new file mode 100644 index 0000000..4578076 Binary files /dev/null and b/消息推送系统_迁移指南.docx differ diff --git a/消息推送系统_迁移指南.md b/消息推送系统_迁移指南.md new file mode 100644 index 0000000..b2fd037 --- /dev/null +++ b/消息推送系统_迁移指南.md @@ -0,0 +1,644 @@ +# 消息推送系统 迁移指南(迁移到已有 RBAC 系统) + +> 适用场景:目标系统与当前系统**架构一致**(.NET 8 分层 + FreeSql + MediatR + SignalR + `IRepository` 泛型仓储 + JWT)。 +> 本文按**可执行顺序**拆分为 14 步,每步含:**要建什么 → 关键代码要点 → 验证方法**。 +> 当前实现基线:`F9WebAiTest` 生活圈管理系统(消息推送 V2.0,即时推送,无后台服务)。 + +--- + +## 0. 迁移前须知 + +### 0.1 目标系统前置条件自查(缺一不可) + +| # | 依赖项 | 具体要求 | 不满足时的处理 | +|---|--------|---------|--------------| +| 1 | 运行时 | .NET 8 / ASP.NET Core | 必须 | +| 2 | ORM | FreeSql(`IFreeSql` 单例),支持 CodeFirst | 若用 EF Core,需重写仓储调用(数据访问层代码量约 200 行) | +| 3 | 仓储 | `IRepository` 泛型仓储,含 `GetByIdAsync / GetAllAsync / FindAsync / FirstOrDefaultAsync / AnyAsync / CountAsync / AddAsync / AddRangeAsync / UpdateAsync / DeleteAsync / HardDeleteAsync / HardDeleteRangeAsync / SaveChangesAsync` | 方法名不同需批量替换(约 40 处调用) | +| 4 | MediatR | 已接入 `AddMediatR(...)` | 必须(消息创建入口) | +| 5 | SignalR | .NET 8 内置 `Microsoft.AspNetCore.SignalR` | 必须(实时推送) | +| 6 | JWT | token 中含**用户 ID 声明** | 当前实现用 `"id"`;若目标系统用 `ClaimTypes.NameIdentifier` / `"userId"`,需改 **3 处**(见 4.1) | +| 7 | RBAC | `Permission`、`RolePermission` 实体;按用户查权限码的能力(`IAuthService.GetUserPermissionsAsync(userId)`);枚举含 `Resource_Type_Enum.消息订阅`、`Permission_Type_Enum.Visble` | 缺失则**订阅权限校验**与**角色分配订阅权限**两处功能需降级(可先改为“所有人可订阅”) | +| 8 | 主键生成 | 雪花 ID `ISnowflakeIdGenerator` | 或改用目标系统的主键策略 | +| 9 | 前端 | Vue3 + Vite + Element Plus + Pinia + axios 封装(`request`)+ 依赖 `@microsoft/signalr` | 缺 `@microsoft/signalr` 需 `npm i @microsoft/signalr` | +| 10 | 实体基类 | `BaseEntity`(含 `Id` + 审计字段 + 软删除 `IsDeleted`) | 必须(唯一索引/软删过滤依赖它) | + +### 0.2 命名空间与类型适配表(迁移时全局替换) + +| 当前系统 | 目标系统改为 | +|---------|-------------| +| `F9WebAiTest.Domain.Entities.Notice` | `{目标}.Domain.Entities.Notice`(或你们既有实体目录) | +| `F9WebAiTest.Application.Dtos.Notice` | `{目标}.Application.Dtos.Notice` | +| `F9WebAiTest.Application.Services.Notice` | `{目标}.Application.Services.Notice` | +| `F9WebAiTest.Api.Hubs` / `F9WebAiTest.Api.Services` | `{目标}.Api.Hubs` / `{目标}.Api.Services` | +| `BaseEntity` / `IRepository` / `ServiceException` | 目标系统的同名/等价类型 | +| `ApiResult` / `PageResult` | 目标系统的返回包装(**分页字段名可能不同**) | +| `SysPermission` / `SysRolePermission`(种子中的别名) | 目标系统权限/角色权限实体实际名 | +| `ISnowflakeIdGenerator` | 目标系统主键生成器 | +| JWT claim `"id"` | 目标系统用户 ID 声明名 | +| 前端 `request`(axios 封装)、`getAccessToken` | 目标系统对应工具 | + +--- + +## 1. 迁移文件总览 + +**后端(12 个文件)** + +| # | 源路径(F9WebAiTest) | 层 | 迁移动作 | +|---|---------------------|----|---------| +| 1 | `Domain/Entities/Notice/NotificationEnums.cs` | Domain | 新增 | +| 2 | `Domain/Entities/Notice/NotificationType.cs` | Domain | 新增 | +| 3 | `Domain/Entities/Notice/NotificationMessage.cs` | Domain | 新增 | +| 4 | `Domain/Entities/Notice/NotificationReceiver.cs` | Domain | 新增 | +| 5 | `Domain/Entities/Notice/TodoItem.cs` | Domain | 新增 | +| 6 | `Domain/Entities/Notice/UserNotificationSubscription.cs` | Domain | 新增 | +| 7 | `Application/Dtos/Notice/NotificationDtos.cs` | Application | 新增(9 个 DTO) | +| 8 | `Application/Services/Notice/INotificationPusher.cs` | Application | 新增(推送抽象,3 方法) | +| 9 | `Application/Services/Notice/NotificationService.cs` | Application | 新增(`INotificationService` 接口 + 实现,12 方法) | +| 10 | `Application/Services/Notice/NotificationMediatR.cs` | Application | 新增(创建命令 + **投递实现**) | +| 11 | `Api/Hubs/NotificationHub.cs` | Api | 新增(Hub + 在线表 + UserIdProvider) | +| 12 | `Api/Services/NotificationPusher.cs` | Api | 新增(推送实现) | +| 13 | `Api/Controllers/Notice/NotificationControllers.cs` | Api | 新增(2 控制器 / 15 端点) | +| 14 | `Infrastructure/Data/DbSeeder.cs` 的 `SeedNotificationData` | Infrastructure | **片段移植**(种子数据) | +| 15 | `Application/DependencyInjection.cs` + `Api/Program.cs` | 装配 | **片段修改**(DI 注册) | + +**前端(4 个文件)** + +| # | 源路径 | 动作 | +|---|--------|------| +| 16 | `web/src/utils/notification.js` | 新增 | +| 17 | `web/src/views/notification/messages/index.vue` | 新增 | +| 18 | `web/src/views/notification/subscribe/index.vue` | 新增 | +| 19 | `web/src/views/notification/type/index.vue` | 新增 | +| 20 | 路由 `componentMap`(或目标系统动态组件映射表) | **必改**(否则页面打不开,见 Step 14) | + +--- + +## 2. 分步实施 + +### Step 1|领域层:枚举 + +**做什么**:新增 `NotificationEnums.cs`,定义 6 个枚举。 + +```csharp +public enum NotificationPriority { Normal = 0, Important = 1, Urgent = 2 } // 优先级 +public enum NotificationStatus { Ready = 0, Sent = 1, Failed = 2, PartialSuccess = 3, Revoked = 4 } +public enum NotificationReadStatus { Unread = 0, Read = 1 } // 阅读状态 +public enum NotificationSendStatus { NotSent = 0, Success = 1, Failed = 2 } // 推送状态 +public enum TodoItemStatus { Pending = 0, Processing = 1, Completed = 2, Cancelled = 3 } +public enum NotificationCategory { Notification = 0, Todo = 1, Announcement = 2 } +public enum NotificationTypeEnum { TeyeFound = 1, TdcFound = 2, LeaveApproval = 3, LeaveResult = 4 } // 枚举名 = 消息类型 Code +``` + +**要点**:`NotificationTypeEnum` 的**枚举名必须与消息类型的 `Code` 一致**(业务侧按枚举名发送)。 + +**验证**:编译通过。 + +--- + +### Step 2|领域层:5 个实体 + +**做什么**:`Notice/` 目录下新增 5 个实体文件(每个实体独立文件)。 + +| 实体 | 表名 | 关键字段 | 索引(**必须**) | +|------|------|---------|----------------| +| `NotificationType` | `NotificationTypes` | `Code`(唯一)、`Name`、`Description`、`RequiredPermission`、`IsActive`、`CreatedBy`、`Sort`、`Category`、`Icon`、`RouteTemplate` | `Code` 唯一 | +| `NotificationMessage` | `NotificationMessages` | `NotificationTypeId`、`MessageTypeCode`(冗余)、`BusinessType`、`BusinessId`、`Title`、`Content`、`Priority`、`Status`、`RetryCount`、`MaxRetryCount`、`BizContext`、`ForceReceivers`(JSON)、`CreatorId`、`CreatorName`、`Category`(冗余)、`RoutePath` | `MessageTypeCode` 普通索引 | +| `NotificationReceiver` | `NotificationReceivers` | `MessageId`、`UserId`、`ReadStatus`、`ReadTime`、`IsOffline`、`SendStatus`、`SendTime`、`ErrorMsg` | **`MessageId + UserId` 唯一**、`UserId + ReadStatus` | +| `TodoItem` | `TodoItems` | `MessageId`、`AssigneeId`、`TodoStatus`、`DueTime`、`CompletedTime`、`HandleResult` | `AssigneeId + TodoStatus` | +| `UserNotificationSubscription` | `UserNotificationSubscriptions` | `UserId`、`NotificationTypeId`、`IsActive`、`SubscribedTime`、`CancelTime` | **`UserId + NotificationTypeId` 唯一** | + +**代码要点**: + +```csharp +[Table(Name = "NotificationMessages")] +[Index("IX_NotificationMessages_MessageTypeCode", "MessageTypeCode")] +public class NotificationMessage : BaseEntity +{ + public long NotificationTypeId { get; set; } + public string MessageTypeCode { get; set; } = ""; + public NotificationPriority Priority { get; set; } = NotificationPriority.Normal; + public NotificationStatus Status { get; set; } = NotificationStatus.Ready; + public string ForceReceivers { get; set; } = ""; // JSON 数组,如 "[1001,1002]",空 = 按订阅广播 + // …其余字段见源文件 +} +``` + +**要点**: +- 实体为**纯属性 POCO**(状态由应用服务赋值后 `UpdateAsync` 持久化),不要加仓储接口——统一用 `IRepository`。 +- 枚举字段在 FreeSql 中统一映射为 `int`(目标系统若没配,实体枚举会存成字符串,导致与其他表不一致)。 + +**验证**:CodeFirst 同步后 5 张表存在,且两个唯一索引已建立(见 Step 3)。 + +--- + +### Step 3|数据库:建表 + +**方式 A(推荐,与当前一致)**:FreeSql CodeFirst + +```csharp +// 在目标系统的表结构同步处(当前系统在 DbSeeder 内) +fsql.CodeFirst.SyncStructure(); +fsql.CodeFirst.SyncStructure(); +fsql.CodeFirst.SyncStructure(); +fsql.CodeFirst.SyncStructure(); +fsql.CodeFirst.SyncStructure(); +``` + +**方式 B(旧库已有表时)**:只补列 + +```csharp +EnsureColumn(fsql, "NotificationTypes", "Category", "INTEGER NOT NULL DEFAULT 0"); +EnsureColumn(fsql, "NotificationTypes", "Icon", "TEXT NOT NULL DEFAULT ''"); +EnsureColumn(fsql, "NotificationTypes", "RouteTemplate", "TEXT NOT NULL DEFAULT ''"); +EnsureColumn(fsql, "NotificationMessages", "Category", "INTEGER NOT NULL DEFAULT 0"); +EnsureColumn(fsql, "NotificationMessages", "RoutePath", "TEXT NOT NULL DEFAULT ''"); +``` + +**要点**:`SyncStructure` **不会为已存在的表新增列**,旧库必须走"补列"。 + +**验证**: + +```sql +SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Notification%'; +SELECT name FROM sqlite_master WHERE type='table' AND name='TodoItems'; +-- 唯一索引 +SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='NotificationReceivers'; +``` + +--- + +### Step 4|应用层:DTO(9 个) + +**做什么**:新增 `Application/Dtos/Notice/NotificationDtos.cs`。 + +| DTO | 类型 | 用途 | 关键字段 | +|-----|------|------|---------| +| `NotificationTypeDto` | 出参 | 类型列表/订阅列表 | `Id, Code, Name, Description, RequiredPermission, IsActive, CreatedBy, Sort, Category, Icon, RouteTemplate, **Subscribed**, **CanSubscribe**` | +| `NotificationTypeCreateDto` | 入参 | 创建/更新类型 | `Code, Name, Description, RequiredPermission, IsActive, Sort, Category, Icon, RouteTemplate` | +| `SubscribeDto` | 入参 | 订阅/退订 | `NotificationTypeId, Subscribe` | +| `NotificationMessageDto` | 出参 **+ SignalR 推送载荷** | 消息列表/实时推送 | `Id, NotificationTypeId, MessageTypeCode, TypeName, Title, Content, Priority, BusinessType, BusinessId, Category, RoutePath, CreatedTime, ReadStatus, ReadTime` | +| `UnreadCountDto` | 出参 | 未读数 | `Count` | +| `TodoItemDto` | 出参 | 待办列表 | `Id, MessageId, Title, Content, Priority, BusinessType, BusinessId, RoutePath, TodoStatus, DueTime, CompletedTime, CreatedTime` | +| `CompleteTodoDto` | 入参 | 完成待办 | `HandleResult` | +| `BroadcastNotificationDto` | 入参 | 广播 | `Title, Content, Priority, RoutePath` | +| `TriggerDto` | 入参 | 测试触发 | `Code, Title, Content, BusinessType, BusinessId, Priority, RoutePath, AssigneeIds` | + +**要点**: +- `NotificationMessageDto` **同时是 SignalR 推送载荷**(服务端直接推送该对象,前端按同名字段消费)——字段名必须与前端一致。 +- **待办类消息没有独立 DTO**:`TodoItemDto` 的 `Title/Content/RoutePath` 取自关联消息(查询时拼装)。 + +**验证**:编译通过;`NotificationMessageDto` 字段与前端 `messages/index.vue` 使用字段逐一对齐。 + +--- + +### Step 5|应用层:推送抽象接口 `INotificationPusher` + +**做什么**:新增 `Application/Services/Notice/INotificationPusher.cs`(**接口放应用层**)。 + +```csharp +public interface INotificationPusher +{ + bool IsOnline(long userId); + Task PushNewMessageAsync(long userId, NotificationMessageDto message, CancellationToken ct = default); + Task PushUnreadCountAsync(long userId, int unreadCount, int todoCount, CancellationToken ct = default); +} +``` + +**为什么必须有这层抽象**:投递逻辑在 Application 层,但 SignalR 的 `IHubContext` 属于 Api 层。若 Application 直接引用会形成循环依赖。接口在 Application、实现在 Api(依赖倒置)。 + +**验证**:编译通过(实现留到 Step 9)。 + +--- + +### Step 6|应用层:消息服务 `INotificationService` + `NotificationService` + +**做什么**:新增 `Application/Services/Notice/NotificationService.cs`(接口与实现同文件)。 + +**接口(12 方法)**: + +```csharp +public interface INotificationService +{ + // 订阅 + Task> GetSubscribeListAsync(long userId); + Task SubscribeAsync(long userId, long typeId, bool subscribe); + // 消息 + Task> GetMyMessagesAsync(long userId, int pageIndex, int pageSize, int? readStatus, int? category); + Task MarkReadAsync(long userId, long messageId); + Task MarkAllReadAsync(long userId); + Task GetUnreadCountAsync(long userId); + // 待办 + Task> GetMyTodosAsync(long userId, int? status); + Task CompleteTodoAsync(long userId, long todoId, string handleResult); + // 管理端:类型维护 + Task> GetTypeListAsync(); + Task CreateTypeAsync(NotificationTypeCreateDto dto, long creatorId); + Task UpdateTypeAsync(long id, NotificationTypeCreateDto dto); + Task DeleteTypeAsync(long id); +} +``` + +**依赖注入(构造函数)**: + +```csharp +IRepository _typeRepo; +IRepository _subRepo; +IRepository _msgRepo; +IRepository _receiverRepo; +IRepository _todoRepo; +IRepository _roleMenuRepo; // 目标系统权限关联实体 +IRepository _permissionRepo; // 目标系统权限实体 +ISnowflakeIdGenerator _snowflake; +IAuthService _authService; // GetUserPermissionsAsync(userId) +``` + +**实现要点(4 处易错点)**: + +1. **查询以接收人表为主表**:`GetMyMessagesAsync` 先查 `NotificationReceiver(UserId)`,再联消息与类型名,`ReadStatus` 取自接收人(同一消息对不同用户状态不同)。 +2. **未读数**:`CountAsync(r => r.UserId == userId && r.ReadStatus == Unread)`。 +3. **完成待办后标记该消息已读**:避免"处理完了还挂未读红点"。 +4. **RBAC 联动 `SyncSubscribePermissionAsync`**(`CreateTypeAsync` / `UpdateTypeAsync` 后调用,`DeleteTypeAsync` 反向清理): + - `RequiredPermission` 为空 → 删除该类型对应的权限单元及其角色关联(所有人可订阅,无需权限) + - 非空 → 按 `ResourceType = Resource_Type_Enum.消息订阅 && ResourceId = type.Id` 查或建 `Permission`,并 `permission.SetCode(type.RequiredPermission)` 覆盖权限码 + - **必须覆盖权限码**:否则默认生成的码(如 `subscribe:3:visble`)与 `RequiredPermission` 不一致,订阅校验永远失败 + +**验证**:调 `GET /api/notification/subscribe` 返回类型列表且含 `subscribed` / `canSubscribe`。 + +--- + +### Step 7|应用层:MediatR 命令 + 投递实现(核心步骤) + +**做什么**:新增 `Application/Services/Notice/NotificationMediatR.cs`。 + +**两类命令**: + +```csharp +// ① 底层命令(按 Code,返回消息ID) +public class CreateNotificationCommand : IRequest +{ + public string NotificationTypeCode { get; set; } = ""; + public string BusinessType { get; set; } = ""; + public string BusinessId { get; set; } = ""; + public string Title { get; set; } = ""; + public string Content { get; set; } = ""; + public NotificationPriority Priority { get; set; } = NotificationPriority.Normal; + public string BizContext { get; set; } = ""; + public List ForceReceivers { get; set; } = new(); // 精确送达(绕过订阅) + public string RoutePath { get; set; } = ""; // 覆盖路由模板 + public List AssigneeIds { get; set; } = new(); // 待办处理人(Category=Todo) + public DateTime? DueTime { get; set; } + public long CreatorId { get; set; } + public string CreatorName { get; set; } = ""; +} + +// ② 简洁命令(业务侧推荐):枚举名即 Code +public record SendNotificationCommand( + NotificationTypeEnum Type, + string BusinessId = "", string Title = "", string Content = "", string BizContext = "", + NotificationPriority Priority = NotificationPriority.Normal, + List ForceReceivers = null, List AssigneeIds = null, DateTime? DueTime = null) : IRequest; +``` + +**`CreateNotificationCommandHandler` 处理流程(6 步,一次请求内同步完成)**: + +``` +① 按 Code 查 NotificationType → 不存在/已停用则抛异常 +② 生成 RoutePath:命令直接指定 > 类型模板替换 {businessId} +③ 建消息(Status 直接 = Sent),ForceReceivers 序列化为 JSON 存库 +④ Category = Todo 且有 AssigneeIds → 为每个处理人建 TodoItem(Pending) +⑤ DeliverAsync(投递):见下 +⑥ 返回消息 ID +``` + +**`DeliverAsync` 投递逻辑(投递的全部实现就在这里)**: + +``` +a. 确定接收人: + ForceReceivers 非空 → 精确送达(绕过订阅) + 否则 → 查该类型 IsActive 的订阅记录,收集 UserId +b. 幂等写接收人:按 MessageId + UserId 判重,只写不存在的 +c. 保存接收人(先落库,再推送 —— 保证离线可拉取) +d. 逐个推送并按结果回写: + pusher.PushNewMessageAsync(userId, dto) 返回 true + → SendStatus = Success、SendTime = now、IsOffline = false + → 追加推送未读统计 PushUnreadCountAsync(userId, unread, todo) + 返回 false(用户离线) + → SendStatus = NotSent、IsOffline = true(不推送) + 抛异常(连接瞬断) + → SendStatus = Failed、IsOffline = true、ErrorMsg = ex.Message +e. 回写 receiver 并保存 +``` + +**注意**:推送给**在线**用户时,未读数要**先保存接收人再统计**,否则统计不含本条。 + +**验证**:调 `POST /api/notification/trigger` 创建一条消息,检查: +- `NotificationMessages` 有记录且 `Status = 1(Sent)` +- `NotificationReceivers` 有对应接收人,`SendStatus`/`IsOffline` 与用户当时是否在线一致 + +--- + +### Step 8|表示层:SignalR Hub + 在线表 + 用户标识 + +**做什么**:新增 `Api/Hubs/NotificationHub.cs`(**3 个类型放同一文件**)。 + +```csharp +/// 在线用户登记表(进程内内存,用于在线/离线判定) +public static class OnlineUserRegistry +{ + private static readonly ConcurrentDictionary _users = new(); + public static void Add(long userId) => _users[userId] = 0; + public static void Remove(long userId) => _users.TryRemove(userId, out _); + public static bool IsOnline(long userId) => _users.ContainsKey(userId); +} + +/// 通知 Hub:连接/断开时维护在线表 +public class NotificationHub : Hub +{ + public override async Task OnConnectedAsync() + { + var userId = GetUserId(); + if (userId > 0) OnlineUserRegistry.Add(userId); + await base.OnConnectedAsync(); + } + public override async Task OnDisconnectedAsync(Exception exception) + { + var userId = GetUserId(); + if (userId > 0) OnlineUserRegistry.Remove(userId); + await base.OnDisconnectedAsync(exception); + } + private long GetUserId() // ★ 与目标系统 JWT 声明保持一致 + => long.TryParse(Context.User?.FindFirst("id")?.Value, out var id) ? id : 0; +} + +/// 用户标识提供器:决定 Clients.User(...) 用什么匹配连接 +public class NotificationUserIdProvider : IUserIdProvider +{ + public string GetUserId(HubConnectionContext connection) // ★ 同上,必须与 GetUserId 同源 + => connection.User?.FindFirst("id")?.Value ?? connection.ConnectionId; +} +``` + +**要点**: +- 用户 ID 的**声明名必须三处一致**:Hub `GetUserId`、`UserIdProvider`、REST 控制器 `GetUserId()`,且与登录签发 token 时的声明名一致。 +- 在线表是**进程内内存**:单实例正确;多实例需 Redis + SignalR 背板(见 4.8)。 + +**验证**:连上 Hub 后,`OnlineUserRegistry.IsOnline(userId)` 为 true;断开为 false。 + +--- + +### Step 9|表示层:推送实现 `NotificationPusher` + +**做什么**:新增 `Api/Services/NotificationPusher.cs`,实现 Step 5 的接口。 + +```csharp +public class NotificationPusher : INotificationPusher +{ + private readonly IHubContext _hubContext; + public NotificationPusher(IHubContext hubContext) => _hubContext = hubContext; + + public bool IsOnline(long userId) => OnlineUserRegistry.IsOnline(userId); + + public async Task PushNewMessageAsync(long userId, NotificationMessageDto message, CancellationToken ct = default) + { + if (!OnlineUserRegistry.IsOnline(userId)) return false; // 离线:不推送 + await _hubContext.Clients.User(userId.ToString()) + .SendAsync("ReceiveNotification", message, ct); // 事件名必须与前端一致 + return true; + } + + public Task PushUnreadCountAsync(long userId, int unreadCount, int todoCount, CancellationToken ct = default) + => _hubContext.Clients.User(userId.ToString()) + .SendAsync("UnreadCountChanged", new { unreadCount, todoCount }, ct); +} +``` + +**关键设计(迁移时必须保留)**:**不能靠 try/catch 判断是否送达** —— SignalR 对"该用户无连接"的 `Clients.User(...)` 调用是**静默成功**的,不判在线会把离线用户误记为推送成功。 + +**验证**:在线用户收到 `ReceiveNotification`;离线用户 `receiver.IsOffline = true`。 + +--- + +### Step 10|表示层:REST 控制器 + +**做什么**:新增 `Api/Controllers/Notice/NotificationControllers.cs`(2 个控制器)。 + +| 控制器 | 路由前缀 | 端点 | +|--------|---------|------| +| `NotificationTypeController` | `/api/notification/type` | `GET` 列表 / `POST` 创建 / `PUT {id}` 更新 / `DELETE {id}` 删除 | +| `NotificationController` | `/api/notification` | `GET subscribe`、`POST subscribe`、`GET messages`、`GET todos`、`POST todos/{id}/complete`、`PUT messages/{id}/read`、`PUT messages/read-all`、`GET unread-count`、`POST trigger`、`POST broadcast` | + +**要点**: +- 全部 `[Authorize]`;用户 ID 从 token 声明取(`User.FindFirst("id")`)。 +- `unread-count` 返回 `{ count }`(前端按此字段读取)。 +- `broadcast` 需校验权限码 `notification:broadcast`。 + +**验证**:Swagger 中可见全部端点;`GET /api/notification/unread-count` 返回 200。 + +--- + +### Step 11|装配:依赖注入与 SignalR 配置 + +**① 应用层注册**(`Application/DependencyInjection.cs`) + +```csharp +services.AddScoped(); +services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(NotificationService).Assembly)); +``` + +**② 启动配置**(`Program.cs`) + +```csharp +// SignalR + 用户标识 + 即时推送器(无后台投递服务) +builder.Services.AddSignalR(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +// JWT:让 Hub 能从 query 取 token(WebSocket 不能带自定义请求头) +.AddJwtBearer(options => { + options.Events = new JwtBearerEvents { + OnMessageReceived = context => { + var accessToken = context.Request.Query["access_token"]; + if (!string.IsNullOrEmpty(accessToken) && + context.HttpContext.Request.Path.StartsWithSegments("/hubs")) + context.Token = accessToken; // 仅对 Hub 路径生效 + return Task.CompletedTask; + } + }; +}); + +// 终点映射 +app.MapHub("/hubs/notification"); +``` + +**③ 基础设施层**:无需新增注册(复用 `AddScoped(typeof(IRepository<>), typeof(Repository<>))`)。 + +**要点**:若目标系统的 JWT 已配置 `OnMessageReceived`,需**追加** `/hubs` 分支(不要覆盖原有逻辑)。 + +**验证**:启动无异常;`POST /hubs/notification/negotiate` 未带 token 返回 401;带合法 token 返回 200。 + +--- + +### Step 12|种子数据(消息类型 + 权限 + 菜单) + +**做什么**:把 `DbSeeder.SeedNotificationData` 片段移植到目标系统的种子类。 + +**① 消息类型(5 个,幂等:按 Code 判存)** + +| Code | 名称 | 分类 | 订阅权限码 | 路由模板 | +|------|------|------|-----------|---------| +| `TeyeFound` | 发现特野 | 通知 | 空(所有人可订阅) | `/prod/xdjy` | +| `TdcFound` | 发现特大糙 | 通知 | 空 | `/prod/hbjy` | +| `LeaveApproval` | 请假审批 | **待办** | `notification:subscribe:leaveapproval` | `/attendance/approve?leaveId={businessId}` | +| `LeaveResult` | 请假结果通知 | 通知 | 空 | `/attendance/my` | +| `Broadcast` | 广播通知 | 公告 | 空 | 空 | + +**② 广播发送权限单元**:`Permission{ ResourceType=操作, Code="notification:broadcast" }`(admin 自动拥有)。 + +**③ 菜单(1 目录 + 3 菜单)**: + +``` +消息中心(directory, path=/notification, icon=Bell) + ├─ 我的消息 path=/notification/messages component=notification/messages/index + ├─ 消息订阅 path=/notification/subscribe component=notification/subscribe/index + └─ 消息类型管理 path=/notification/type component=notification/type/index +``` + +**④ admin 角色分配消息中心菜单**(否则 admin 看不到菜单)。 + +**⑤ 订阅权限同步**:遍历所有 `RequiredPermission` 非空的消息类型,按 `ResourceType=消息订阅 + ResourceId` 幂等创建/更新 `Permission`(`Code = RequiredPermission`),使「角色管理 → 分配权限」可勾选。 + +**前置**:目标系统枚举中需存在 `Resource_Type_Enum.消息订阅`(缺失则需新增枚举值,或改为复用目标系统已有的"操作/资源"类型)。 + +**验证**:`NotificationTypes` 有 5 条;`RoleMenus` 中 admin 关联了 4 条消息中心菜单。 + +--- + +### Step 13|前端:SignalR 客户端 + +**做什么**:新增 `web/src/utils/notification.js`。 + +**核心实现**: + +```javascript +export const unreadCount = ref(0); // 全局未读数(角标与消息页共用) + +export function initNotification(onMessage) { + if (connection) { if (onMessage) messageHandlers.push(onMessage); return connection; } + if (onMessage) messageHandlers.push(onMessage); + + connection = new signalR.HubConnectionBuilder() + .withUrl('/hubs/notification', { accessTokenFactory: () => getAccessToken() || '' }) + .withAutomaticReconnect() + .configureLogging(signalR.LogLevel.Warning) + .build(); + + connection.on('ReceiveNotification', (msg) => { // 事件名与后端一致 + unreadCount.value++; + messageHandlers.forEach(h => { try { h(msg); } catch (e) {} }); + }); + + connection.start().catch(() => {}); + return connection; +} +``` + +导出清单:`unreadCount`、`initNotification(onMessage)`、`loadUnreadCount()`、`openMessage(msg, router)`、`onBusinessChanged(handler)`(可选)、`stopNotification()`。 + +**要点**: +- `accessTokenFactory` 必须提供 token(WebSocket 不能用请求头)。 +- 开发环境 `/hubs` 需在 Vite 代理中配置 **`ws: true`**(否则 WebSocket 握手失败)。 +- `openMessage`:非待办且未读 → `PUT /notification/messages/{id}/read` → 按 `msg.routePath` 跳转。 + +**验证**:浏览器 Network → WS 出现 `/hubs/notification` 且状态 101。 + +--- + +### Step 14|前端:页面 + 路由/菜单注册 + +**做什么**: +1. 新增 3 个页面:`views/notification/{messages,subscribe,type}/index.vue` +2. **在路由的组件映射表中注册这 3 个组件**(当前 F9WebAiTest 的 `componentMap` / 目标系统的动态组件解析表): + +```javascript +'notification/messages/index': () => import('../views/notification/messages/index.vue'), +'notification/subscribe/index': () => import('../views/notification/subscribe/index.vue'), +'notification/type/index': () => import('../views/notification/type/index.vue') +``` + +3. **(可选,推荐)顶部铃铛角标**:在 `layout` 侧边/顶部放一个铃铛 + `unreadCount` 角标,点击进消息中心;登录后调用 `initNotification(...)` 与 `loadUnreadCount()`,登出调用 `stopNotification()`。 + +**⚠️ 重要提醒**:当前 F9WebAiTest 系统中,**后端的菜单种子(Step 12)已写入 `component=notification/.../index`,但前端 `router` 的组件映射表尚未注册这 3 个组件**——因此菜单点开可能报组件未找到。迁移到目标系统时**务必补上第 2 条**(顺带建议回补当前系统)。 + +**验证**:菜单「消息中心」下三个页面可正常打开;收到消息时角标 +1、消息页可见新消息。 + +--- + +## 3. 迁移后验收清单 + +| # | 验收项 | 期望结果 | +|---|--------|---------| +| 1 | 5 张表 + 唯一索引创建 | 表与索引均存在 | +| 2 | 种子:5 个消息类型 | `NotificationTypes` 5 条 | +| 3 | 种子:菜单与 admin 授权 | admin 可见「消息中心」4 个菜单 | +| 4 | 接口:类型 CRUD / 订阅 / 消息 / 待办 / 未读 | 全部 200,数据正确 | +| 5 | Hub 连接与鉴权 | 未带 token → 401;带 token → 101 | +| 6 | 在线表 | 连接后 `IsOnline = true`;断开 = false | +| 7 | 在线实时推送 | 另一端发消息 → 1 秒内收到 `ReceiveNotification` | +| 8 | 离线标记 | 离线用户接收人记录 `IsOffline = true`、`SendStatus = 0/NotSent` | +| 9 | 上线拉取兜底 | 离线期间的消息在消息中心可见、未读数正确 | +| 10 | 已读 | 标记已读后未读数减少 | +| 11 | 待办闭环 | 待办可查询、可完成;完成后消息转已读 | +| 12 | 订阅权限联动 | 受限类型:无权限用户订阅被拒(403);角色分配权限后可订阅 | +| 13 | 广播 | 有 `notification:broadcast` 权限者发送成功,订阅者收到 | +| 14 | 前端页面与角标 | 三页面可打开,角标实时更新 | + +--- + +## 4. 已知坑与适配点(迁移时逐条核对) + +| # | 坑 | 处理 | +|---|----|------| +| 4.1 | **JWT 用户 ID 声明名不同** | 改 3 处:`NotificationHub.GetUserId`、`NotificationUserIdProvider.GetUserId`、控制器 `GetUserId()`;必须与登录签发 token 的声明一致(当前为 `"id"`) | +| 4.2 | **枚举字段未映射为 int** | 在 FreeSql 配置中加 `Aop.ConfigEntityProperty`:枚举 → `typeof(int)`;否则枚举存字符串,与旧库/其它表不一致 | +| 4.3 | **软删除全局过滤器缺失** | 需为实体基类注册 `GlobalFilter.Apply("软删除", a => a.IsDeleted == false)`,否则查询结果与唯一索引行为异常 | +| 4.4 | **唯一索引未创建** | `MessageId+UserId`(接收人幂等)、`UserId+NotificationTypeId`(订阅幂等)必须建,否则重复投递/重复订阅 | +| 4.5 | **WebSocket token 传递** | `OnMessageReceived` 必须有 `/hubs` 分支读 `access_token`;否则 Hub 连不上(普通接口正常,容易误判) | +| 4.6 | **Vite 代理未开 ws** | `'/hubs': { target, changeOrigin: true, ws: true }` | +| 4.7 | **`Clients.User` 命中不到** | 前提是 `IUserIdProvider` 与登录 token 声明一致;取不到时会退化为 `ConnectionId`,导致定向推送全部失效 | +| 4.8 | **多实例部署** | 在线表是进程内内存,多实例下在线判定不准 → 需 Redis + SignalR 背板(`AddStackExchangeRedis`),并把在线判定交给背板 | +| 4.9 | **同步推送影响请求耗时** | 接收人很多(如全员广播)时,推送在同一请求内执行;量大时改为后台队列/批量推送(投递入口集中在 `CreateNotificationCommandHandler`,改造点单一) | +| 4.10 | **种子插入 NOT NULL 字段** | `BaseEntity.CreatedAt` 等审计字段若非空,插入种子时需赋值(当前系统曾因 `created_at` NOT NULL 导致种子失败) | +| 4.11 | **前端组件未注册** | 菜单的 `component` 路径必须在前端组件映射表中注册,否则页面打不开(见 Step 14) | +| 4.12 | **路由跳转目标不存在** | 消息类型 `RouteTemplate` 指向的业务页面需在目标系统存在(如 `/prod/xdjy`、`/attendance/approve`),否则点击消息跳转 404 | +| 4.13 | **CORS** | 前端与后端不同源时需允许跨域;SignalR 若启用 `AllowCredentials` 则不能 `AllowAnyOrigin`(二者互斥) | +| 4.14 | **命名空间/类型差异** | 按 0.2 表格全局替换后再编译,避免逐个报错 | + +--- + +## 5. 迁移顺序速查(TL;DR) + +``` +后端 + 1. 枚举 NotificationEnums.cs + 2. 5 个实体 Notice/*.cs(含唯一索引) + 3. 建表 SyncStructure / 补列 + 4. 9 个 DTO NotificationDtos.cs + 5. 推送接口 INotificationPusher.cs + 6. 消息服务 NotificationService.cs(含 RBAC 权限联动) + 7. 创建命令+投递 NotificationMediatR.cs ★核心 + 8. Hub+在线表+标识 NotificationHub.cs + 9. 推送实现 NotificationPusher.cs +10. 控制器 NotificationControllers.cs +11. DI 与 SignalR DI + Program.cs(含 access_token query) +12. 种子 消息类型 + 广播权限 + 菜单 + admin 授权 + 订阅权限同步 +前端 +13. SignalR 客户端 utils/notification.js +14. 3 个页面 + 组件映射注册 +(可选)顶部角标 +``` + +> 每完成一步都编译一次;Step 7 完成后即可用 `POST /api/notification/trigger` 做端到端验证,再往下做前端。 + +--- + +**文档结束**(迁移指南 v1.0,对应消息推送系统 V2.0 实现) diff --git a/消息推送系统与RBAC审批设计说明书.docx b/消息推送系统与RBAC审批设计说明书.docx new file mode 100644 index 0000000..2f67db6 Binary files /dev/null and b/消息推送系统与RBAC审批设计说明书.docx differ diff --git a/消息推送系统软件设计说明书.docx b/消息推送系统软件设计说明书.docx new file mode 100644 index 0000000..12adf8c Binary files /dev/null and b/消息推送系统软件设计说明书.docx differ diff --git a/消息推送系统软件设计说明书.md b/消息推送系统软件设计说明书.md new file mode 100644 index 0000000..2a1f98b --- /dev/null +++ b/消息推送系统软件设计说明书.md @@ -0,0 +1,817 @@ +# 消息推送系统 软件设计说明书 + +| 项目名称 | 生活圈管理系统(F9WebAiTest)- 消息推送子系统 | +|---------|------------------------------------------| +| 文档类型 | 软件设计说明书(SDD) | +| 版本号 | **V2.0** | +| 编写日期 | 2026-09-15 | +| 后端技术栈 | .NET 8 / FreeSql(CodeFirst)/ MediatR / SignalR / JWT / SQLite | +| 前端技术栈 | Vue 3 / Vite / Element Plus / Pinia / @microsoft/signalr | + +--- + +## 修订记录 + +| 版本 | 日期 | 变更说明 | +|------|------|---------| +| V1.1 | 2026-09-09 | 初版:后台投递服务(BackgroundService 每 3 秒轮询扫描 Ready 消息)异步投递 | +| **V2.0** | **2026-09-15** | **取消后台投递服务与轮询,改为"创建消息即同步投递 + 立即推送"**;投递逻辑由 Api 层后台服务迁入 Application 层命令处理器;新增 `INotificationPusher` 推送抽象;明确在线/离线判定机制;同步修订可靠性设计与接口说明 | + +### V2.0 架构变更说明(重点) + +| | V1.1(旧) | V2.0(现) | +|---|---|---| +| 投递触发 | 后台服务每 3 秒轮询 `Status = Ready` 的消息 | 业务创建消息的**同一次请求内**完成投递 | +| 投递位置 | `F9WebAiTest.Api/Services/NotificationDeliveryService.cs`(BackgroundService) | `F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs`(命令处理器) | +| 消息状态 | 创建 `Ready` → 投递后 `Sent` | 创建即 `Sent`(无中间态) | +| 推送方式 | 后台服务持有 `IHubContext` 直接推送 | 经 `INotificationPusher` 抽象推送(接口在应用层、实现在 Api 层) | +| 延迟 | 0 ~ 3 秒(取决于轮询周期) | **无延迟**(创建即推送) | +| 失败重试 | `RetryCount++`,超 `MaxRetryCount` 标记 `Failed` | **无自动重试**(见 7.3 取舍说明) | + +**为什么取消后台服务**:投递本身是一次内存 + 一次数据库写入的轻量操作,用后台轮询换取"解耦"的收益远小于代价——引入了 0~3 秒延迟、常驻线程与定时器、Ready 状态的中间态管理、以及 SQLite 上的高频无谓查询。改为同步投递后,链路更短、行为更可预期、代码量显著减少。 + +--- + +## 1. 引言 + +### 1.1 编写目的 + +本文档描述消息推送子系统的总体架构、数据模型、**各模块的具体实现方式、所用技术与关键流程**,特别是**用户在线/离线如何判定**、离线用户如何兜底,作为开发、测试、维护与后续扩展的依据。 + +### 1.2 项目背景 + +业务系统(请假审批、考勤、质量检验等)在运行过程中需要向相关人员推送提醒、通知与待办。为统一承载这些消息,构建一套通用、可扩展、多端协同的消息推送子系统。 + +### 1.3 术语定义 + +| 术语 | 英文/类型 | 说明 | +|------|----------|------| +| 消息类型 | NotificationType | 消息的元数据模板:编码、名称、分类、订阅权限码、跳转路由模板 | +| 消息 | NotificationMessage | 一条具体的消息实例,由业务触发创建 | +| 接收人 | NotificationReceiver | 消息 × 用户 的关联记录,含该用户的推送状态与阅读状态 | +| 订阅 | UserNotificationSubscription | 用户对某个消息类型的订阅关系 | +| 待办 | TodoItem | 待办类消息附带的事项,含处理人、状态、处理结果 | +| 强制接收人 | ForceReceivers | 绕过订阅、精准指定送达的用户 ID 集合(JSON 数组) | +| 在线 | Online | 该用户在 SignalR Hub 上存在**活动连接**(见 4.6) | +| 离线 | Offline | 该用户在 Hub 上无活动连接,推送无接收者 | +| 拉取兜底 | Pull Fallback | 离线用户上线后通过 REST 接口读取历史消息,保证消息不丢 | + +--- + +## 2. 系统概述 + +### 2.1 设计目标 + +1. **统一触达**:业务侧一行命令即可发送消息,不关心投递细节。 +2. **双送达机制**:订阅式广播(按消息类型订阅关系)+ 强制接收(精准必达)。 +3. **实时推送**:在线用户 SignalR **即时**推送,离线用户持久化后拉取兜底。 +4. **三类消息**:通知(Notification)/ 待办(Todo)/ 公告(Announcement)。 +5. **待办闭环**:待办类消息自动生成 `TodoItem`,支持查询与完成。 +6. **多端跳转与同步**:消息携带业务标识与路由,多端一致。 +7. **架构简洁**:无后台任务、无轮询、无中间队列。 + +### 2.2 功能范围 + +| 模块 | 功能点 | +|------|--------| +| 消息类型管理 | 类型增删改查、分类、订阅权限码、跳转路由模板 | +| 消息订阅 | 可订阅列表(含权限判定)、订阅/退订 | +| 消息发送 | 业务侧经 MediatR 命令触发(枚举式 / 底层式两种命令) | +| 消息投递 | 创建即投递:确定接收人 → 写接收人表 → 立即推送 | +| 消息读取 | 我的消息(分页 + 分类/已读筛选)、标记已读、全部已读、未读数 | +| 待办管理 | 我的待办、完成待办 | +| 实时推送 | 新消息推送、未读统计推送、业务变更事件推送 | +| 广播通知 | 拥有权限的用户发送广播(订阅广播) | + +### 2.3 运行环境与技术栈 + +| 项目 | 选型 | 用途 | +|------|------|------| +| 后端框架 | .NET 8(ASP.NET Core) | Web API 宿主 | +| ORM | **FreeSql**(`UseConnectionString(DataType.Sqlite, ...)`) | 实体映射、CodeFirst 建表、仓储实现 | +| 数据结构 | SQLite(`f9_web_ai_test_rbac.db`) | 单文件数据库,便于开发与部署 | +| 应用层模式 | **MediatR** | 命令/查询分发(CQRS 风格) | +| 实时通信 | **SignalR**(WebSocket,自动降级 SSE / Long Polling) | 服务端 → 客户端实时推送 | +| 鉴权 | JWT(`System.IdentityModel.Tokens.Jwt`) | REST 与 Hub 统一鉴权 | +| API 文档 | NSwag(OpenAPI + Swagger UI) | 接口文档与客户端生成 | +| 在线登记 | `System.Collections.Concurrent.ConcurrentDictionary` | 进程内在线用户表 | +| 前端 | Vue 3 + Vite + Element Plus + Pinia | 管理端界面 | +| 前端实时 | `@microsoft/signalr` | SignalR 客户端 | + +### 2.4 架构总览 + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ 业务子系统(Application) │ +│ 请假审批 AttendanceService 考勤 质量检验 … │ +│ │ │ +│ │ await mediator.Send(new SendNotificationCommand(...)) │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ CreateNotificationCommandHandler ← 投递入口(同步执行) │ │ +│ │ ① 校验消息类型 ② 建消息(Sent) ③ 生成待办(Todo) │ │ +│ │ ④ 确定接收人 ⑤ 写接收人表 ⑥ 立即推送 │ │ +│ └───────────────────────────┬──────────────────────────────────┘ │ +│ │ INotificationPusher(应用层接口) │ +└──────────────────────────────┼─────────────────────────────────────────┘ + │ 实现(Api 层) + ▼ + ┌──────────────────────────────────┐ + │ NotificationPusher │ + │ · IsOnline() ← 在线表判定 │ + │ · PushNewMessageAsync() │ + │ · PushUnreadCountAsync() │ + └──────────────┬───────────────────┘ + │ IHubContext + ▼ + ┌───────────────────────────────────────────────────┐ + │ SignalR Hub /hubs/notification │ + │ OnConnectedAsync → OnlineUserRegistry.Add │ + │ OnDisconnectedAsync → OnlineUserRegistry.Remove │ + │ UserIdentifier → JWT "id" 声明 │ + └───────────────┬───────────────────────────────────┘ + │ 事件:ReceiveNotification / UnreadCountChanged + ▼ + ┌───────────────────────────────────────────────────┐ + │ 前端 notification.js(SignalR 客户端,自动重连) │ + │ unreadCount+1 → 弹窗 / 角标 / 页面刷新 │ + └───────────────────────────────────────────────────┘ +``` + +--- + +## 3. 数据模型 + +### 3.1 枚举定义 + +文件:`F9WebAiTest.Domain/Entities/Notice/NotificationEnums.cs` + +```csharp +public enum NotificationPriority { Normal = 0, Important = 1, Urgent = 2 } // 优先级 +public enum NotificationStatus { Ready = 0, Sent = 1, Failed = 2, PartialSuccess = 3, Revoked = 4 } +public enum NotificationReadStatus { Unread = 0, Read = 1 } // 阅读状态 +public enum NotificationSendStatus { NotSent = 0, Success = 1, Failed = 2 } // 推送状态 +public enum TodoItemStatus { Pending = 0, Processing = 1, Completed = 2, Cancelled = 3 } +public enum NotificationCategory { Notification = 0, Todo = 1, Announcement = 2 } +public enum NotificationTypeEnum { TeyeFound = 1, TdcFound = 2, LeaveApproval = 3, LeaveResult = 4 } +``` + +> `NotificationStatus`:V2.0 下消息创建即为 `Sent`,`Ready`/`Failed`/`Revoked` 保留以兼容历史数据与后续扩展。 + +### 3.2 实体一览(5 张表) + +目录:`F9WebAiTest.Domain/Entities/Notice/`(每个实体独立文件) + +| 实体 | 表名 | 关键字段 | 用途 | +|------|------|---------|------| +| `NotificationType` | `NotificationTypes` | `Code`(唯一)、`Name`、`Category`、`RequiredPermission`、`RouteTemplate`、`IsActive`、`Sort` | 消息类型模板 | +| `NotificationMessage` | `NotificationMessages` | `NotificationTypeId`、`MessageTypeCode`、`Title`、`Content`、`Priority`、`Status`、`ForceReceivers`、`Category`、`RoutePath` | 消息主表 | +| `NotificationReceiver` | `NotificationReceivers` | `MessageId`、`UserId`、`ReadStatus`、`ReadTime`、`IsOffline`、`SendStatus`、`SendTime`、`ErrorMsg` | 每人一条,独立推送/阅读状态 | +| `TodoItem` | `TodoItems` | `MessageId`、`AssigneeId`、`TodoStatus`、`DueTime`、`CompletedTime`、`HandleResult` | 待办事项 | +| `UserNotificationSubscription` | `UserNotificationSubscriptions` | `UserId`、`NotificationTypeId`、`IsActive`、`SubscribedTime`、`CancelTime` | 订阅关系 | + +### 3.3 关系 + +``` +NotificationType 1 ── N NotificationMessage 1 ── N NotificationReceiver N ── 1 User + │ │ + │ 1 │ 1 + └── N UserNotificationSubscription └── N TodoItem (AssigneeId → User) +``` + +### 3.4 关键约束(幂等保障) + +| 约束 | 位置 | 作用 | +|------|------|------| +| `Code` 唯一 | `NotificationTypes` | 类型编码唯一,业务按 Code 发送 | +| `MessageId + UserId` 唯一 | `NotificationReceivers` | **接收人幂等**,重复投递不会产生重复记录 | +| `UserId + NotificationTypeId` 唯一 | `UserNotificationSubscriptions` | 订阅幂等 | +| 软删除 | 全部实体(`BaseEntity`) | 逻辑删除,审计可追溯 | + +建表方式:FreeSql CodeFirst(`Publish` + 种子由 `Infrastructure/Data/DbSeeder.cs` 完成)。 + +### 3.5 实体 ↔ DTO ↔ 接口 对照总表 + +> 说明:本项目 DTO 以 `class` 定义在 `F9WebAiTest.Application/Dtos/Notice/NotificationDtos.cs`(集中文件); +> 实体为**纯属性 POCO**(无领域方法,状态变更由应用服务直接赋值后 `UpdateAsync` 持久化)。 + +| 实体(表) | 出参 DTO(读) | 入参 DTO / 命令(写) | 数据访问接口 | 业务处理接口 | +|---|---|---|---|---| +| `NotificationType`(`NotificationTypes`) | `NotificationTypeDto` | `NotificationTypeCreateDto` | `IRepository` | `INotificationService.CreateTypeAsync / UpdateTypeAsync / DeleteTypeAsync / GetTypeListAsync / GetSubscribeListAsync`(内部 `SyncSubscribePermissionAsync` 同步 RBAC 权限单元) | +| `NotificationMessage`(`NotificationMessages`) | `NotificationMessageDto`(列表 + **SignalR 推送载荷**) | `CreateNotificationCommand`、`SendNotificationCommand`、`BroadcastNotificationDto`、`TriggerDto` | `IRepository` | `CreateNotificationCommandHandler`(创建 + 即时投递)、`INotificationService.GetMyMessagesAsync` | +| `NotificationReceiver`(`NotificationReceivers`) | `NotificationMessageDto.ReadStatus / ReadTime`、`UnreadCountDto` | 无(由投递过程内部生成,不接受外部入参) | `IRepository` | `CreateNotificationCommandHandler.DeliverAsync`(写入 + 回写推送状态)、`INotificationService.MarkReadAsync / MarkAllReadAsync / GetUnreadCountAsync` | +| `TodoItem`(`TodoItems`) | `TodoItemDto` | `CompleteTodoDto` | `IRepository` | `CreateNotificationCommandHandler`(按 `AssigneeIds` 生成待办)、`INotificationService.GetMyTodosAsync / CompleteTodoAsync` | +| `UserNotificationSubscription`(`UserNotificationSubscriptions`) | `NotificationTypeDto.Subscribed`(并入类型列表) | `SubscribeDto` | `IRepository` | `INotificationService.SubscribeAsync`(含 `RequiredPermission` 权限校验) | + +### 3.6 DTO 明细(字段级) + +文件:`F9WebAiTest.Application/Dtos/Notice/NotificationDtos.cs`(`TriggerDto` 定义在控制器文件内) + +**① 消息类型** + +| DTO | 字段 | 类型 | 说明 | +|-----|------|------|------| +| `NotificationTypeDto`(出参) | Id / Code / Name / Description | long / string ×3 | 基础信息 | +| | RequiredPermission | string | 订阅所需权限码,空 = 所有人可订阅 | +| | IsActive / CreatedBy / Sort | bool / long / int | 开放订阅 / 创建人 / 排序 | +| | Category / Icon / RouteTemplate | int / string / string | 分类(0通知1待办2公告) / 图标 / 路由模板 | +| | **Subscribed** | bool | **当前用户是否已订阅**(`GetSubscribeListAsync` 填充) | +| | **CanSubscribe** | bool | **当前用户是否有权订阅**(权限码比对结果) | +| `NotificationTypeCreateDto`(入参) | Code / Name / Description / RequiredPermission / IsActive / Sort / Category / Icon / RouteTemplate | 同上(无 Id/CreatedBy/Subscribed/CanSubscribe) | 创建与更新共用 | + +**② 订阅** + +| DTO | 字段 | 类型 | 说明 | +|-----|------|------|------| +| `SubscribeDto` | NotificationTypeId | long | 目标消息类型 | +| | Subscribe | bool | true=订阅,false=退订 | + +**③ 消息** + +| DTO | 字段 | 类型 | 说明 | +|-----|------|------|------| +| `NotificationMessageDto` | Id / NotificationTypeId | long | 消息ID / 类型ID | +| | MessageTypeCode / TypeName | string | 类型编码(冗余,免联表)/ 类型名称 | +| | Title / Content | string | 标题 / 内容 | +| | Priority | int | 0普通 1重要 2紧急 | +| | BusinessType / BusinessId | string | 业务类型 / 业务单据ID(多端跳转依据) | +| | Category | int | 0通知 1待办 2公告 | +| | RoutePath | string | 最终跳转路由(模板 + 业务ID 生成) | +| | CreatedTime | DateTime | 创建时间 | +| | **ReadStatus** / ReadTime | int / DateTime? | **当前用户**阅读状态(来自接收人表) | +| `UnreadCountDto` | Count | int | 未读消息数 | + +**④ 待办** + +| DTO | 字段 | 类型 | 说明 | +|-----|------|------|------| +| `TodoItemDto` | Id / MessageId | long | 待办ID / 关联消息ID | +| | Title / Content / Priority | string / string / int | 取自关联消息 | +| | BusinessType / BusinessId / RoutePath | string | 取自关联消息(供"去处理"跳转) | +| | TodoStatus | int | 0待处理 1处理中 2已完成 3已取消 | +| | DueTime / CompletedTime / CreatedTime | DateTime? / DateTime? / DateTime | 截止 / 完成 / 创建时间 | +| `CompleteTodoDto` | HandleResult | string | 处理结果说明 | + +**⑤ 广播与测试** + +| DTO | 字段 | 说明 | +|-----|------|------| +| `BroadcastNotificationDto` | Title、Content、Priority(`NotificationPriority?`)、RoutePath | 广播入参(需 `notification:broadcast` 权限) | +| `TriggerDto` | Code(默认 `TeyeFound`)、Title、Content、BusinessType、BusinessId、Priority、RoutePath、AssigneeIds | 测试/联调触发(`POST /api/notification/trigger`) | + +**通用返回包装** + +| 类型 | 说明 | +|------|------| +| `ApiResult` | `{ success, message, code, data }`,`ApiResult.Ok/Fail` | +| `PageResult` | `{ items, total, pageIndex, pageSize }`(`NotificationMessageDto` 分页) | + +### 3.7 接口清单与实现绑定 + +**① 领域层 —— 通用仓储(所有实体共用)** + +| 接口 | 方法 | 实现类 | 注册 | +|------|------|--------|------| +| `IRepository`(`Domain/Common/IRepository.cs`) | `GetByIdAsync`、`GetAllAsync`、`FindAsync`、`FirstOrDefaultAsync`、`AnyAsync`、`CountAsync`、`AddAsync`、`AddRangeAsync`、`UpdateAsync`、`DeleteAsync`、`DeleteRangeAsync`、`HardDeleteAsync`、`HardDeleteRangeAsync`、`SaveChangesAsync` | `Repository`(`Infrastructure/Repositories`) | `AddScoped(typeof(IRepository<>), typeof(Repository<>))` | + +> 5 个通知实体**不各自定义仓储接口**,统一使用泛型 `IRepository`(FreeSql 实现,内含软删除全局过滤器)。 + +**② 应用层 —— 通知服务** + +| 接口 | 方法(11) | 实现类 | 生命周期 | +|------|-----------|--------|---------| +| `INotificationService`(`Application/Services/Notice/NotificationService.cs`) | `GetSubscribeListAsync`、`SubscribeAsync`、`GetMyMessagesAsync`、`MarkReadAsync`、`MarkAllReadAsync`、`GetUnreadCountAsync`、`GetMyTodosAsync`、`CompleteTodoAsync`、`GetTypeListAsync`、`CreateTypeAsync`、`UpdateTypeAsync`、`DeleteTypeAsync` | `NotificationService` | `AddScoped()` | + +依赖(构造函数注入):`IRepository`、`IRepository`、`IRepository`、`IRepository`、`IRepository`、`IRepository`、`IRepository`、`ISnowflakeIdGenerator`、`IAuthService`。 + +**③ 应用层 —— 推送抽象** + +| 接口 | 方法(3) | 实现类(所在层) | 生命周期 | +|------|----------|----------------|---------| +| `INotificationPusher`(`Application/Services/Notice/INotificationPusher.cs`) | `bool IsOnline(long userId)`、`Task PushNewMessageAsync(long userId, NotificationMessageDto, CancellationToken)`、`Task PushUnreadCountAsync(long userId, int, int, CancellationToken)` | `NotificationPusher`(**Api 层**,`Api/Services/NotificationPusher.cs`) | `Program.cs`:`AddScoped()` | + +**④ 应用层 —— MediatR 命令(消息创建的接口实现)** + +| 类型 | 实现的接口 | 处理器 | 返回 | +|------|-----------|--------|------| +| `CreateNotificationCommand` | `IRequest` | `CreateNotificationCommandHandler : IRequestHandler` | 消息 ID | +| `SendNotificationCommand`(record) | `IRequest` | `SendNotificationCommandHandler : IRequestHandler` | 是否成功(内部转调 Create) | + +注册:`services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(NotificationService).Assembly))`。 + +**⑤ 表示层 —— SignalR 与控制器** + +| 类型 | 实现的接口/基类 | 职责 | 注册 | +|------|---------------|------|------| +| `NotificationHub` | `Hub`(`Microsoft.AspNetCore.SignalR`) | 连接生命周期 → 在线表登记与移除 | `AddSignalR()` + `MapHub("/hubs/notification")` | +| `NotificationUserIdProvider` | `IUserIdProvider` | 用户标识 = JWT `"id"` 声明(决定 `Clients.User` 命中规则) | `AddSingleton()` | +| `NotificationTypeController` | `ControllerBase`(`[Authorize]`) | 类型管理 REST | `AddControllers()` | +| `NotificationController` | `ControllerBase`(`[Authorize]`) | 订阅/消息/待办/未读/触发/广播 REST | 依赖 `INotificationService`、`IMediator`、`IAuthService` | + +**⑥ 业务变更事件(多端同步)** + +| 类型 | 实现的接口 | 说明 | +|------|-----------|------| +| `BusinessChangedEvent` | `INotification`(MediatR 通知) | 业务状态变化事件 | +| `BusinessChangedEventHandler` | `INotificationHandler` | 经 SignalR 推送 `BusinessChanged` 事件;注册 `AddScoped, BusinessChangedEventHandler>()` | + +**⑦ 依赖的框架级接口** + +| 接口 | 用途 | 实现/注册 | +|------|------|----------| +| `ISnowflakeIdGenerator` | 雪花 ID(消息/接收人/待办/权限单元主键) | `SnowflakeIdGenerator`,`AddSingleton` | +| `IAuthService` | `GetUserPermissionsAsync(userId)` → 订阅权限校验 | `AuthService`,`AddScoped` | +| `IJwtService` | 签发 access/refresh token(含 `"id"` 声明) | `JwtService`,`AddScoped` | + +--- + +## 4. 模块设计(各模块如何实现) + +### 4.1 领域层:实体与枚举 + +- 位置:`F9WebAiTest.Domain/Entities/Notice/` +- 实现要点:每个实体独立文件、继承 `BaseEntity`(含 `Id`、审计字段与软删除);实体为**纯数据 POCO**(属性 + 默认值),状态变更由应用服务赋值(如 `receiver.ReadStatus = Read`)后经仓储 `UpdateAsync` 持久化。 +- 字段映射:`[Table(Name = "...")]` + `[Index(...)]` 特性声明表名与索引;枚举统一映射为 `int`(`Infrastructure/DependencyInjection.cs` 的 `Aop.ConfigEntityProperty`)。 +- 技术:纯 POCO,无框架依赖(仅 `FreeSql.DataAnnotations` 特性,领域层不引用基础设施)。 + +### 4.2 应用层:消息创建命令 —— 投递核心 ★ + +- 位置:`F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs` +- 类型: + - `SendNotificationCommand`(**简洁命令**,基于 `NotificationTypeEnum`,业务侧推荐) + - `CreateNotificationCommand`(**底层命令**,按字符串 Code,灵活控制) + - `SendNotificationCommandHandler`:枚举名 → Code,转调底层命令(复用同一套投递逻辑) + - `CreateNotificationCommandHandler`:**投递实现** + +**处理流程(一次请求内同步完成)**: + +``` +① 校验消息类型:按 Code 查 NotificationType,不存在/已关闭订阅 → 抛 ServiceException +② 生成跳转路由:命令指定的 RoutePath 优先,否则用类型模板替换 {businessId} +③ 创建消息:状态直接置 NotificationStatus.Sent(无后台队列、无 Ready 中间态) + ForceReceivers 序列化为 JSON 存入消息(空 = 走订阅广播) +④ 待办类消息(Category = Todo 且有 AssigneeIds):为每个处理人生成 TodoItem(Pending) +⑤ 投递 DeliverAsync: + a. 确定接收人: + ForceReceivers 非空 → 精确送达(绕过订阅) + 否则 → 查该类型 IsActive 的订阅记录,取 UserId 集合 + b. 幂等写接收人:按 MessageId + UserId 判重,仅写入新增的 + c. 逐个推送(见 4.3/4.4),并按结果回写推送状态 +⑥ 返回消息 ID +``` + +**接收人回写状态(V2.0 明确区分三种结果)**: + +| 推送结果 | `SendStatus` | `IsOffline` | 说明 | +|---------|-------------|------------|------| +| 在线且推送成功 | `Success` + `SendTime` | `false` | 同时推送未读统计 | +| 用户离线 | `NotSent` | `true` | 不推送,等待上线拉取 | +| 推送抛异常(连接刚断开) | `Failed` + `ErrorMsg` | `true` | 消息已持久化,仍可拉取 | + +**分层实现要点**:命令处理器位于 **Application 层**,此处不能引用 Api 层的 `IHubContext`(SignalR 属于表示层),因此推送能力被抽象为 `INotificationPusher`(见 4.3)。 + +### 4.3 应用层:推送抽象 `INotificationPusher` + +- 位置:`F9WebAiTest.Application/Services/Notice/INotificationPusher.cs` + +```csharp +public interface INotificationPusher +{ + bool IsOnline(long userId); // 在线判定 + Task PushNewMessageAsync(long userId, NotificationMessageDto message, CancellationToken ct = default); + Task PushUnreadCountAsync(long userId, int unreadCount, int todoCount, CancellationToken ct = default); +} +``` + +**为什么接口在应用层、实现在 Api 层**:SignalR 的 `IHubContext` 由 ASP.NET Core 提供,属于表示层;依赖方向必须是 `Api → Application`,若 Application 直接依赖 `IHubContext` 会形成循环依赖。抽象后,依赖倒置,Application 只依赖自己定义的接口,DI 容器在启动时把 Api 层实现注入进去。 + +### 4.4 表示层:推送实现 `NotificationPusher` + +- 位置:`F9WebAiTest.Api/Services/NotificationPusher.cs` +- 注册:`Program.cs` → `builder.Services.AddScoped()` + +```csharp +public bool IsOnline(long userId) => OnlineUserRegistry.IsOnline(userId); + +public async Task PushNewMessageAsync(long userId, NotificationMessageDto message, CancellationToken ct = default) +{ + if (!OnlineUserRegistry.IsOnline(userId)) return false; // 离线:不推送,返回 false + await _hubContext.Clients.User(userId.ToString()) + .SendAsync("ReceiveNotification", message, ct); // 在线:定向推送 + return true; +} + +public Task PushUnreadCountAsync(long userId, int unreadCount, int todoCount, CancellationToken ct = default) + => _hubContext.Clients.User(userId.ToString()) + .SendAsync("UnreadCountChanged", new { unreadCount, todoCount }, ct); +``` + +**技术要点**: +- `Clients.User(userId)` —— SignalR 按 **UserIdentifier** 定向推送,可一次命中该用户的**所有在线连接**(支持同一用户多端/多标签页同时在线)。 +- **必须显式判在线**:SignalR 对"该用户当前无连接"的 `Clients.User(...)` 调用是**静默成功**的(不抛异常、无返回)。若只靠 try/catch,离线用户会被误记为 `SendStatus = Success`。因此推送前用在线表判定,离线直接返回 `false` 交由上层标记 `IsOffline`。 + +### 4.5 表示层:SignalR Hub 与用户标识 + +- 位置:`F9WebAiTest.Api/Hubs/NotificationHub.cs` +- 端点映射:`Program.cs` → `app.MapHub("/hubs/notification")` + +```csharp +public class NotificationHub : Hub +{ + public override async Task OnConnectedAsync() + { + var userId = GetUserId(); + if (userId > 0) OnlineUserRegistry.Add(userId); // 连接建立 → 登记在线 + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception exception) + { + var userId = GetUserId(); + if (userId > 0) OnlineUserRegistry.Remove(userId); // 连接断开 → 移除在线 + await base.OnDisconnectedAsync(exception); + } + + private long GetUserId() // 用户ID来源:JWT 的 "id" 声明 + => long.TryParse(Context.User?.FindFirst("id")?.Value, out var id) ? id : 0; +} + +/// 自定义 UserIdentifier:决定 Clients.User(...) 用哪个标识匹配连接 +public class NotificationUserIdProvider : IUserIdProvider +{ + public string GetUserId(HubConnectionContext connection) + => connection.User?.FindFirst("id")?.Value ?? connection.ConnectionId; +} +``` + +**用户标识如何统一**(这是定向推送能命中的前提): + +| 环节 | 取值方式 | +|------|---------| +| 登录签发 JWT | `JwtService.GenerateAccessToken`:`new Claim("id", user.Id.ToString())` | +| REST 控制器 | `User.FindFirst("id").Value` | +| SignalR 连接标识 | `NotificationUserIdProvider` → JWT `"id"` 声明 | +| 推送目标 | `Clients.User(userId.ToString())` | + +四者一致,因此"REST 里的当前用户"与"SignalR 里的连接"指向同一用户。 + +**WebSocket 携带 JWT**:WebSocket 无法自定义请求头,浏览器 SignalR 客户端自动把 token 拼到查询串 `?access_token=xxx`;后端在 `Program.cs` 的 `JwtBearerEvents.OnMessageReceived` 中针对 `/hubs` 路径从 query 读取: + +```csharp +options.Events = new JwtBearerEvents { + OnMessageReceived = context => { + var accessToken = context.Request.Query["access_token"]; + if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs")) + context.Token = accessToken; // 仅对 Hub 路径生效,不影响普通接口 + return Task.CompletedTask; + } +}; +``` + +### 4.6 用户在线 / 离线判定机制 ★ + +**判定方式:进程内在线登记表(内存)** + +```csharp +public static class OnlineUserRegistry +{ + private static readonly ConcurrentDictionary _users = new(); + + public static void Add(long userId) => _users[userId] = 0; + public static void Remove(long userId) => _users.TryRemove(userId, out _); + public static bool IsOnline(long userId) => _users.ContainsKey(userId); +} +``` + +| 问题 | 答案 | +|------|------| +| 在哪判定? | `NotificationPusher.IsOnline(userId)` → `OnlineUserRegistry.IsOnline(userId)` | +| 何时登记? | `NotificationHub.OnConnectedAsync`(SignalR 连接建立时) | +| 何时移除? | `NotificationHub.OnDisconnectedAsync`(连接断开、超时、客户端关闭浏览器/窗口时) | +| 为何用 `ConcurrentDictionary`? | Hub 回调在多个连接线程并发执行,必须线程安全;`byte` 作为占位值(仅需键) | +| 为何不查数据库? | 在线是**连接级瞬时状态**,不适合落库;内存表读取为 O(1),无 IO | +| 为何不用 `Clients.User` 是否报错来判断? | SignalR 对无连接用户是**静默成功**,无法据此区分在线/离线(见 4.4) | +| 一个用户多端在线? | `Add` 幂等(同一 userId 只登记一次);`Clients.User` 会推给该用户**全部**连接,任一端断开不影响其它端(断开即 `Remove`,若仍有其它端在线则出现偏差——见下方限制) | +| 多实例部署? | ❌ **不适用**:在线表是进程内内存,A 实例无法知道用户在 B 实例上在线。需引入 Redis + SignalR 背板(见 9.3) | + +**已如实说明的实现限制**:`Remove` 在任一连接断开时直接移除,若用户有两个连接(如两个浏览器窗口),关闭其中一个会导致在线表短暂误判为离线,直到其余连接的心跳/重连重新登记。业务影响可控(该场景下消息仍会落库,用户重新触发任意连接动作或重连即恢复)。若需严格的多连接计数,可将 `_users` 的值由 `byte` 改为连接数 `int`(`Add` 时 `+1`、`Remove` 时 `-1`,归零再移除)——这是后续可选的优化点。 + +### 4.7 未读统计推送(`UnreadCountChanged`) + +- 触发时机:新消息推送成功后(在线用户) +- 载荷:`{ unreadCount, todoCount }` +- 实现:`NotificationPusher.PushUnreadCountAsync`,未读数由 `IRepository.CountAsync(r => r.UserId == userId && r.ReadStatus == Unread)` 统计(此时接收人已落库,数值包含本条) +- 前端:`web/src/utils/notification.js` 目前通过 `ReceiveNotification` 自增 `unreadCount`;若需**服务端权威值**,可直接订阅 `UnreadCountChanged`(事件已就绪) + +### 4.8 消息读取与已读 + +- 位置:`F9WebAiTest.Application/Services/Notice/NotificationService.cs` +- 接口(REST):我的消息分页查询(按 `readStatus` / `category` 筛选)、`MarkReadAsync`、`MarkAllReadAsync`、`GetUnreadCountAsync` +- 实现要点:**查询以接收人表为主表**(每人一条状态),联表消息与类型名;已读时写 `ReadStatus = Read` 与 `ReadTime` + +### 4.9 待办机制 + +- 待办类消息(`Category = Todo`)在创建消息、生成待办后,走同一套"确定接收人 → 推送"逻辑(通常用 `ForceReceivers` 精确送审批人) +- `TodoItem` 状态:`Pending → Processing → Completed / Cancelled` +- 审批流转复用同一条消息与待办记录,避免多级审批产生冗余消息 + +### 4.10 业务变更事件(多端同步) + +- 位置:`F9WebAiTest.Application/Services/BusinessChangedEvent.cs` + `F9WebAiTest.Api/Services/BusinessChangedEventHandler.cs` +- 机制:业务变更时发布 `BusinessChangedEvent`(实现 `INotificationHandler` 的处理器),经 SignalR 推送 `BusinessChanged` 事件 +- 前端:`onBusinessChanged(handler)` 订阅,某端操作后其它端刷新已打开的页面 +- 与消息推送共用同一个 Hub 连接与用户标识 + +### 4.11 前端模块 + +- 位置:`web/src/utils/notification.js`;页面:`web/src/views/notification/{messages,subscribe,type}/` + +```javascript +// 建立连接(幂等):token 由 accessTokenFactory 提供,自动重连 +connection = new signalR.HubConnectionBuilder() + .withUrl('/hubs/notification', { accessTokenFactory: () => getAccessToken() || '' }) + .withAutomaticReconnect() + .build(); + +connection.on('ReceiveNotification', (msg) => { unreadCount.value++; /* 弹窗 + 页面刷新 */ }); +connection.on('BusinessChanged', (e) => { /* 多端同步刷新 */ }); +connection.start().catch(() => {}); +``` + +| 导出 | 作用 | +|------|------| +| `unreadCount` | 全局未读数(Pinia 之外用 `ref` 共享,供角标与消息页使用) | +| `initNotification(onMessage)` | 建立连接并注册回调(幂等,重复调用复用同一连接) | +| `loadUnreadCount()` | 拉取未读数(首次进入、标记已读后校准) | +| `openMessage(msg, router)` | 打开消息:非待办且未读 → 标记已读 → 按 `routePath` 跳转业务页 | +| `onBusinessChanged(handler)` | 订阅业务变更(多端同步) | +| `stopNotification()` | 登出时停止连接并清空状态 | + +**技术要点**: +- `accessTokenFactory`:浏览器 WebSocket 不能带自定义头,SignalR 客户端自动以 `access_token` 查询串传递,后端 `OnMessageReceived` 已适配。 +- `withAutomaticReconnect()`:网络抖动/服务重启后自动重连,重连成功后 Hub 会再次登记在线,恢复推送能力。 +- 消息跳转:`openMessage` 读取消息自带 `routePath` 直达业务页面(待办类不在此处标记已读,保持提醒直到真正处理完成)。 + +--- + +## 5. 关键流程(时序) + +### 5.1 在线用户:创建 → 推送 → 展示 + +``` +业务代码(如请假审批 ApproveLeave) + │ mediator.Send(new SendNotificationCommand(LeaveResult, ...)) + ▼ +CreateNotificationCommandHandler + ├─ 校验类型 / 生成路由 + ├─ 写 NotificationMessage (Status = Sent) + ├─ 待办类:写 TodoItem + ├─ 确定接收人(ForceReceivers 或 订阅广播) + ├─ 写 NotificationReceiver(幂等) + └─ NotificationPusher.PushNewMessageAsync(userId, dto) + ├─ OnlineUserRegistry.IsOnline(userId) == true + ├─ Clients.User(userId).SendAsync("ReceiveNotification", dto) ← 实时 + └─ 回写 receiver:SendStatus=Success, SendTime=now + └─ PushUnreadCountAsync → "UnreadCountChanged" { unreadCount, todoCount } + ▼ +前端 notification.js + ├─ unreadCount++ → 顶部角标更新 + ├─ 弹窗提示(标题 + 内容) + └─ 消息页/待办页订阅回调刷新 + ▼ +用户点击消息 → openMessage() + ├─ PUT /api/notification/messages/{id}/read + └─ router.push(msg.routePath) → 直达业务页面 +``` + +### 5.2 离线用户:落库 + 拉取兜底 + +``` +业务创建消息 → 接收人含离线用户 U + └─ IsOnline(U) == false + ├─ 不推送,receiver.IsOffline = true、SendStatus = NotSent + └─ 消息与接收人记录均已持久化 ✅ +用户 U 之后登录 / 打开页面 + └─ GET /api/notification/unread-count → 未读数正确(含离线期间的消息) + └─ GET /api/notification/messages → 消息列表完整可见 + └─ SignalR 连接建立 → OnlineUserRegistry.Add(U) → 后续消息恢复实时推送 +``` + +> **重要语义**:实时推送只在"消息产生那一刻收件人在线"时触达。离线期间的消息**不会在用户上线时补弹窗**,而是通过未读数与消息列表体现(避免上线瞬间弹出一堆历史消息)。如需"上线补推",可在 Hub 的 `OnConnectedAsync` 中查询未读并推送一次通知——属于可扩展项。 + +### 5.3 待办闭环 + +``` +提交请假 → SendNotificationCommand(LeaveApproval, ForceReceivers:[审批人], AssigneeIds:[审批人]) + → 消息 + TodoItem(Pending) + 推送给审批人 +审批人处理 → POST /api/notification/todos/{id}/complete + → TodoItem → Completed,写 HandleResult + → 有下一级:再发待办给下一审批人 + → 流程结束:SendNotificationCommand(LeaveResult, ForceReceivers:[申请人]) +``` + +--- + +## 6. 接口设计 + +### 6.1 REST 接口 + +**消息类型管理**(`/api/notification/type`,需登录) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/notification/type` | 类型列表 | +| POST | `/api/notification/type` | 创建类型 | +| PUT | `/api/notification/type/{id}` | 更新类型 | +| DELETE | `/api/notification/type/{id}` | 删除类型 | + +**用户端**(`/api/notification`,需登录) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/notification/subscribe` | 可订阅类型列表(含订阅状态与可订阅判定) | +| POST | `/api/notification/subscribe` | 订阅 / 退订(`{ notificationTypeId, subscribe }`) | +| GET | `/api/notification/messages` | 我的消息(`pageIndex`/`pageSize`/`readStatus`/`category`) | +| GET | `/api/notification/todos` | 我的待办(`status` 可选) | +| POST | `/api/notification/todos/{id}/complete` | 完成待办(`{ handleResult }`) | +| PUT | `/api/notification/messages/{id}/read` | 标记已读 | +| PUT | `/api/notification/messages/read-all` | 全部已读 | +| GET | `/api/notification/unread-count` | 未读数量(返回 `{ count }`) | +| POST | `/api/notification/trigger` | 触发测试消息(演示/联调用) | +| POST | `/api/notification/broadcast` | 发送广播(需 `notification:broadcast` 权限) | + +统一响应包装:`ApiResult`(`Ok` / `Fail`),分页为 `PageResult`。 + +### 6.2 SignalR 事件契约 + +端点:`/hubs/notification`;鉴权:JWT(query `access_token`)。 + +| 事件 | 方向 | 载荷 | 说明 | +|------|------|------|------| +| `ReceiveNotification` | 服务端 → 客户端 | `NotificationMessageDto` | 新消息实时推送 | +| `UnreadCountChanged` | 服务端 → 客户端 | `{ unreadCount, todoCount }` | 未读统计变化 | +| `BusinessChanged` | 服务端 → 客户端 | `{ businessType, businessId, action }` | 业务数据变更(多端同步) | + +`NotificationMessageDto` 主要字段:`Id`、`NotificationTypeId`、`MessageTypeCode`、`TypeName`、`Title`、`Content`、`Priority`、`BusinessType`、`BusinessId`、`Category`、`RoutePath`、`CreatedTime`、`ReadStatus`。 + +### 6.3 业务侧发送消息的两种命令 + +```csharp +// ① 简洁命令(推荐):枚举名即消息类型 Code +await mediator.Send(new SendNotificationCommand( + NotificationTypeEnum.LeaveApproval, + BusinessId: leaveId.ToString(), + Title: "请假审批", + Content: "张三提交了请假申请", + Priority: NotificationPriority.Important, + ForceReceivers: new List { approverId }, // 精确送达;留空 = 按订阅广播 + AssigneeIds: new List { approverId })); // 生成待办 + +// ② 底层命令:按字符串 Code,完全控制 +await mediator.Send(new CreateNotificationCommand { + NotificationTypeCode = "TeyeFound", + BusinessType = "Xdjy", + BusinessId = lsjlId.ToString(), + Title = "发现特野", + Content = "…", + ForceReceivers = new List() +}); +``` + +--- + +## 7. 可靠性设计 + +### 7.1 保障措施 + +| 风险 | 措施 | 实现位置 | +|------|------|---------| +| 消息丢失 | **先持久化再推送**:消息与接收人记录先落库,推送失败仍可拉取 | `CreateNotificationCommandHandler` | +| 重复投递 | `MessageId + UserId` 唯一约束 + 投递前判重 | 实体约束 + `DeliverAsync` | +| 推送异常 | 单接收人 try/catch 隔离,仅回写 `Failed`,不影响业务主流程 | `DeliverAsync` | +| 用户离线 | `IsOffline` 标记 + 上线后 REST 拉取兜底 | `NotificationPusher` + 前端 | +| 连接断开 | SignalR `withAutomaticReconnect()` 自动重连,重连后重新登记在线 | 前端 + Hub | +| 订阅越权 | 订阅时校验消息类型的 `RequiredPermission` | `NotificationService.SubscribeAsync` | +| 业务被消息拖累 | 消息发送失败不回滚业务(业务侧可自行选择 try/catch) | 业务调用处 | + +### 7.2 与 V1.1 的可靠性差异(如实说明) + +| 能力 | V1.1 | V2.0 | +|------|------|------| +| 失败自动重试 | ✅ `RetryCount++`,超上限标记 `Failed` | ❌ 无(无后台任务) | +| 过期撤销 | ✅ 扫描时检查 `ExpireTime` → `Revoked` | ❌ 未实现(字段保留) | +| 部分成功统计 | ✅ `PartialSuccess` | ⚠️ 逐接收人状态已记录,消息级汇总未启用 | +| 投递延迟 | 0~3 秒 | **0(同步即时)** | + +### 7.3 V2.0 新增的已知限制 + +1. **无自动重试**:推送依赖 SignalR 连接,瞬时故障只记录 `Failed`,不做补偿。补偿手段:业务侧重发、或后续增加"失败消息人工重发"接口。 +2. **在线表是进程内内存**:多实例部署下在线判定不准(见 4.6 / 9.3)。 +3. **同步推送增加请求耗时**:推送为本地内存 + Socket 写入,开销在毫秒级;接收人很多(如全员广播)时建议改为后台队列/批量推送。 +4. **推送不保证送达**:`Success` 仅代表"已写入该用户的在线连接",不代表客户端已渲染(浏览器后台标签页可能延迟处理)。 + +--- + +## 8. 技术选型说明(技术 → 用途 → 原因) + +| 技术 | 用途 | 选型理由 | +|------|------|---------| +| **SignalR** | 实时推送通道 | 免手写 WebSocket 协议;自动协商传输(WebSocket → SSE → Long Polling)、自动重连、按用户/组定向推送、与 ASP.NET Core 鉴权无缝集成 | +| **JWT** | 统一鉴权 | REST 与 Hub 共用一套 token;用户标识 `"id"` 声明贯穿两端,使 `Clients.User` 可精准命中 | +| **MediatR** | 命令/查询分发 | 业务侧只需 `Send` 一条命令即可发消息;投递逻辑集中在命令处理器,便于统一维护 | +| **FreeSql** | ORM / CodeFirst | 支持多数据库切换(SQLite/MySQL/PG)、CodeFirst 建表、`IRepository` 泛型仓储简化数据访问 | +| **SQLite** | 数据库 | 单文件、零部署,适合当前规模;切换数据库只需改连接串与 `DataType` | +| **ConcurrentDictionary** | 在线用户登记 | 读多写少、O(1) 判定、线程安全,Hub 并发回调下无锁竞争 | +| **抽象接口(依赖倒置)** | 跨层推送能力 | 接口在 Application、实现在 Api,避免应用层反向依赖表示层 | +| **Vue3 + @microsoft/signalr** | 前端实时接入 | 官方 JS 客户端;`accessTokenFactory` 自动处理 WebSocket token;组合式 API 便于共享 `unreadCount` | +| **NSwag** | 接口文档 | 自动生成 OpenAPI + Swagger UI,便于联调与生成 TS 客户端 | + +--- + +## 9. 扩展指南 + +### 9.1 新增一种消息类型 + +1. `NotificationEnums.cs` 的 `NotificationTypeEnum` 增加枚举值(枚举名 = 类型 `Code`) +2. 配置消息类型(管理端「消息类型管理」页面,或 `DbSeeder` 种子):名称、分类、`RouteTemplate`(含 `{businessId}` 占位符)、订阅权限码(可选) +3. 业务侧一行发送: + ```csharp + await mediator.Send(new SendNotificationCommand( + NotificationTypeEnum.YourNewType, + BusinessId: bizId.ToString(), + Title: "…", Content: "…", + ForceReceivers: receivers)); // 留空 = 按订阅广播 + ``` + **无需改动投递与推送代码**(投递链路对类型无感知)。 + +### 9.2 新增推送渠道(短信 / 企业微信 / 邮件) + +实现 `INotificationPusher` 的多渠道组合(装饰器或策略): + +```csharp +public class CompositePusher : INotificationPusher +{ + // 站内(SignalR)为主渠道;离线或紧急消息可扩展短信/企微 +} +``` + +或新增独立接口(如 `ISmsSender`)在 `CreateNotificationCommandHandler` 中并行调用——**投递编排集中在命令处理器**,扩展点清晰。 + +### 9.3 多实例部署改造(重要) + +当前 `OnlineUserRegistry` 为进程内内存表,多实例下在线判定失效。改造步骤: + +1. 引入 **Redis** 与 **SignalR Redis 背板**(`Microsoft.AspNetCore.SignalR.StackExchangeRedis`): + ```csharp + builder.Services.AddSignalR().AddStackExchangeRedis("redis-connection"); + ``` + 背板使 `Clients.User(...)` 可跨实例路由到用户所在实例。 +2. 在线表改用 Redis(`SET notice:online:{userId}` + 过期时间),或直接依赖背板的 `All` 通道做推送,不再自行判定在线(把 `IsOnline` 改为"始终返回 true,交由背板路由")。 +3. 消息/接收人表迁移到 PostgreSQL 或 MySQL(SQLite 不适合多实例并发写)。 + +### 9.4 恢复"后台投递 + 重试"能力 + +若业务量增长需要异步化:保留 `CreateNotificationCommandHandler` 的建消息部分,把 `DeliverAsync` 投递部分改为写入队列(本地 Channel / MQ),由消费者执行投递与重试——**接口 `INotificationPusher` 与 Hub 无需改动**。 + +--- + +## 10. 附录 + +### 10.1 关键文件索引 + +| 功能 | 文件 | +|------|------| +| 枚举定义 | `F9WebAiTest.Domain/Entities/Notice/NotificationEnums.cs` | +| 实体(5 个) | `F9WebAiTest.Domain/Entities/Notice/{NotificationType, NotificationMessage, NotificationReceiver, TodoItem, UserNotificationSubscription}.cs` | +| 消息创建命令 + 投递实现 ★ | `F9WebAiTest.Application/Services/Notice/NotificationMediatR.cs` | +| 推送抽象(接口) | `F9WebAiTest.Application/Services/Notice/INotificationPusher.cs` | +| 推送实现 | `F9WebAiTest.Api/Services/NotificationPusher.cs` | +| Hub + 在线表 + 用户标识 | `F9WebAiTest.Api/Hubs/NotificationHub.cs` | +| 订阅/消息/待办服务 | `F9WebAiTest.Application/Services/Notice/NotificationService.cs` | +| REST 控制器 | `F9WebAiTest.Api/Controllers/Notice/NotificationControllers.cs` | +| 业务变更事件 | `F9WebAiTest.Application/Services/BusinessChangedEvent.cs` + `F9WebAiTest.Api/Services/BusinessChangedEventHandler.cs` | +| 数据种子 | `F9WebAiTest.Infrastructure/Data/DbSeeder.cs` | +| 启动装配(SignalR/Hub/推送器/JWT query) | `F9WebAiTest.Api/Program.cs` | +| 前端实时客户端 | `web/src/utils/notification.js` | +| 前端页面 | `web/src/views/notification/{messages, subscribe, type}/` | +| JWT 声明(`id`) | `F9WebAiTest.Application/Services/Auth/JwtService.cs` | + +### 10.2 端到端排查清单(联调时按此顺序) + +| 现象 | 检查点 | +|------|--------| +| 前端收不到任何推送 | ① 浏览器控制器 Network → WS 是否连上 `/hubs/notification`(101 Switching Protocols)② 连接 URL 是否带 `access_token` ③ 后端是否返回 401(token 无效) | +| 消息创建了但没推送 | ① 消息 `ForceReceivers` 与订阅关系是否命中该用户 ② 该用户当时是否在线(`OnlineUserRegistry`)③ 接收人表是否有该用户记录、`SendStatus`/`IsOffline` 值 | +| 在线却没收到 | ① JWT 的 `id` 声明是否存在(`NotificationUserIdProvider` 取不到会退化为 `ConnectionId`,导致 `Clients.User(用户ID)` 无法命中)② 推送时的 userId 与连接登记的 userId 是否一致 | +| 偶发收不到 | 连接是否处于重连中(`withAutomaticReconnect` 期间 `OnDisconnectedAsync` 会移除在线登记) | + +### 10.3 参考资料 + +- Microsoft ASP.NET Core SignalR 官方文档(Hubs / IUserIdProvider / 背板 / JWT 鉴权) +- MediatR 官方文档 +- FreeSql 官方文档(CodeFirst / 连接串配置) +- IEEE 1016 软件设计描述标准 + +--- + +**文档结束**(V2.0,与 2026-09-15 代码实现一致)