From 57d9866dab4cc91a5dcc900e49bb45a1b8413d96 Mon Sep 17 00:00:00 2001 From: hero920103 Date: Thu, 3 Sep 2026 05:55:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20PhotoWall=20=E6=AF=95=E4=B8=9A=E7=85=A7?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E7=B3=BB=E7=BB=9F=E5=88=9D=E5=A7=8B=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: Go(Gin+GORM+MySQL+Redis) - 17张业务表(yz_pw_前缀), 自动迁移 - JWT认证(3小时) + 盐+MD5密码 + 图形验证码 - 班级CRUD/加入(8人姓名验证/邀请码)/审核/30天自动清理 - 系统配置(16项)/菜单管理(动态路由)/数据统计 - 文件MD5去重/数据隔离/账号封禁/敏感词DFA检测 前端: Vue3+Vite+Element Plus+Less+ECharts+FontAwesome - 动态路由(数据库菜单驱动) - 蓝白配色, H5响应式 - 登录/注册/忘记密码/个人中心 - 班级创建向导/详情/加入/列表 - 管理后台: 审核/配置/菜单/统计/用户/敏感词 数据库: MySQL 10.31.100.3:3306/photowall 管理员: hero920103 / 920103 --- .gitignore | 43 + README.md | 192 ++ doc/立项说明.md | 420 +++ go/cmd/server/main.go | 85 + go/go.mod | 46 + go/go.sum | 114 + go/internal/config/config.go | 111 + go/internal/database/database.go | 133 + go/internal/database/seed.go | 78 + go/internal/handler/admin_handler.go | 226 ++ go/internal/handler/auth_handler.go | 146 + go/internal/handler/class_handler.go | 224 ++ go/internal/handler/config_handler.go | 58 + go/internal/handler/menu_handler.go | 80 + go/internal/handler/region_handler.go | 64 + go/internal/handler/school_handler.go | 157 + go/internal/handler/upload_handler.go | 129 + go/internal/handler/user_handler.go | 159 + go/internal/middleware/auth.go | 82 + go/internal/model/auth.go | 24 + go/internal/model/class.go | 116 + go/internal/model/config.go | 18 + go/internal/model/menu.go | 28 + go/internal/model/photo.go | 37 + go/internal/model/region.go | 62 + go/internal/model/sensitive.go | 13 + go/internal/model/upload.go | 18 + go/internal/model/user.go | 73 + go/internal/router/router.go | 169 ++ go/internal/scheduler/cleanup.go | 35 + go/internal/service/admin_service.go | 388 +++ go/internal/service/auth_service.go | 279 ++ go/internal/service/class_service.go | 628 ++++ go/internal/service/config_service.go | 98 + go/internal/service/menu_service.go | 128 + go/internal/service/region_service.go | 68 + go/internal/service/school_service.go | 155 + go/internal/service/user_service.go | 180 ++ go/pkg/captcha/captcha.go | 198 ++ go/pkg/geetest/geetest.go | 28 + go/pkg/hash/hash.go | 36 + go/pkg/jwt/jwt.go | 55 + go/pkg/redis/redis.go | 116 + go/pkg/response/response.go | 50 + go/pkg/sensitive/sensitive.go | 125 + go/pkg/sms/sms.go | 71 + go/uploads/avatars/.gitkeep | 0 go/uploads/classes/.gitkeep | 0 go/uploads/photos/.gitkeep | 0 vue/index.html | 12 + vue/package-lock.json | 2656 +++++++++++++++++ vue/package.json | 27 + vue/src/App.vue | 149 + vue/src/api/index.js | 183 ++ vue/src/assets/style.css | 344 +++ vue/src/assets/style.less | 351 +++ vue/src/composables/useAuth.js | 42 + vue/src/main.js | 13 + vue/src/router/index.js | 118 + vue/src/views/admin/audit/detail/index.vue | 162 + vue/src/views/admin/audit/list/index.vue | 136 + vue/src/views/admin/config/index.vue | 113 + vue/src/views/admin/dashboard/index.vue | 234 ++ vue/src/views/admin/menu/index.vue | 161 + vue/src/views/admin/sensitive/index.vue | 142 + vue/src/views/admin/users/index.vue | 132 + vue/src/views/auth/forgot/index.vue | 130 + vue/src/views/auth/login/index.vue | 152 + vue/src/views/class/detail/index.vue | 317 ++ vue/src/views/class/edit/index.vue | 280 ++ vue/src/views/class/join/index.vue | 152 + vue/src/views/class/list/index.vue | 82 + vue/src/views/home/index.vue | 174 ++ .../logmanagement/detail/index.vue | 9 + .../logmanagement/list/index.vue | 9 + .../rolesettings/detail/index.vue | 9 + .../rolesettings/edit/index.vue | 9 + .../rolesettings/list/index.vue | 9 + .../usersettings/detail/index.vue | 260 ++ .../usersettings/edit/index.vue | 38 + .../usersettings/list/index.vue | 301 ++ vue/vite.config.js | 19 + 82 files changed, 12668 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 doc/立项说明.md create mode 100644 go/cmd/server/main.go create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/internal/config/config.go create mode 100644 go/internal/database/database.go create mode 100644 go/internal/database/seed.go create mode 100644 go/internal/handler/admin_handler.go create mode 100644 go/internal/handler/auth_handler.go create mode 100644 go/internal/handler/class_handler.go create mode 100644 go/internal/handler/config_handler.go create mode 100644 go/internal/handler/menu_handler.go create mode 100644 go/internal/handler/region_handler.go create mode 100644 go/internal/handler/school_handler.go create mode 100644 go/internal/handler/upload_handler.go create mode 100644 go/internal/handler/user_handler.go create mode 100644 go/internal/middleware/auth.go create mode 100644 go/internal/model/auth.go create mode 100644 go/internal/model/class.go create mode 100644 go/internal/model/config.go create mode 100644 go/internal/model/menu.go create mode 100644 go/internal/model/photo.go create mode 100644 go/internal/model/region.go create mode 100644 go/internal/model/sensitive.go create mode 100644 go/internal/model/upload.go create mode 100644 go/internal/model/user.go create mode 100644 go/internal/router/router.go create mode 100644 go/internal/scheduler/cleanup.go create mode 100644 go/internal/service/admin_service.go create mode 100644 go/internal/service/auth_service.go create mode 100644 go/internal/service/class_service.go create mode 100644 go/internal/service/config_service.go create mode 100644 go/internal/service/menu_service.go create mode 100644 go/internal/service/region_service.go create mode 100644 go/internal/service/school_service.go create mode 100644 go/internal/service/user_service.go create mode 100644 go/pkg/captcha/captcha.go create mode 100644 go/pkg/geetest/geetest.go create mode 100644 go/pkg/hash/hash.go create mode 100644 go/pkg/jwt/jwt.go create mode 100644 go/pkg/redis/redis.go create mode 100644 go/pkg/response/response.go create mode 100644 go/pkg/sensitive/sensitive.go create mode 100644 go/pkg/sms/sms.go create mode 100644 go/uploads/avatars/.gitkeep create mode 100644 go/uploads/classes/.gitkeep create mode 100644 go/uploads/photos/.gitkeep create mode 100644 vue/index.html create mode 100644 vue/package-lock.json create mode 100644 vue/package.json create mode 100644 vue/src/App.vue create mode 100644 vue/src/api/index.js create mode 100644 vue/src/assets/style.css create mode 100644 vue/src/assets/style.less create mode 100644 vue/src/composables/useAuth.js create mode 100644 vue/src/main.js create mode 100644 vue/src/router/index.js create mode 100644 vue/src/views/admin/audit/detail/index.vue create mode 100644 vue/src/views/admin/audit/list/index.vue create mode 100644 vue/src/views/admin/config/index.vue create mode 100644 vue/src/views/admin/dashboard/index.vue create mode 100644 vue/src/views/admin/menu/index.vue create mode 100644 vue/src/views/admin/sensitive/index.vue create mode 100644 vue/src/views/admin/users/index.vue create mode 100644 vue/src/views/auth/forgot/index.vue create mode 100644 vue/src/views/auth/login/index.vue create mode 100644 vue/src/views/class/detail/index.vue create mode 100644 vue/src/views/class/edit/index.vue create mode 100644 vue/src/views/class/join/index.vue create mode 100644 vue/src/views/class/list/index.vue create mode 100644 vue/src/views/home/index.vue create mode 100644 vue/src/views/normalsettings/logmanagement/detail/index.vue create mode 100644 vue/src/views/normalsettings/logmanagement/list/index.vue create mode 100644 vue/src/views/normalsettings/rolesettings/detail/index.vue create mode 100644 vue/src/views/normalsettings/rolesettings/edit/index.vue create mode 100644 vue/src/views/normalsettings/rolesettings/list/index.vue create mode 100644 vue/src/views/normalsettings/usersettings/detail/index.vue create mode 100644 vue/src/views/normalsettings/usersettings/edit/index.vue create mode 100644 vue/src/views/normalsettings/usersettings/list/index.vue create mode 100644 vue/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7eb98d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Go 编译产物 +go/photowall.exe +go/photowall +go/*.exe +go/bin/ + +# Go 上传文件 +go/uploads/classes/* +go/uploads/photos/* +go/uploads/avatars/* +!go/uploads/classes/.gitkeep +!go/uploads/photos/.gitkeep +!go/uploads/avatars/.gitkeep + +# Node +vue/node_modules/ +vue/dist/ +vue/.vite/ + +# 日志 +*.log +logs/ + +# 环境变量 +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# 临时文件 +tmp/ +temp/ +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f22e07 --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +# PhotoWall 毕业照存储系统 + +全球学校毕业照存储与同学联络平台。支持国家→省/州→县/区三级组织架构,班级唯一毕业照,每人每班级10张个人照片,8人姓名验证加入,平台审核机制。 + +## 技术栈 + +- **后端**: Go (Gin + GORM + MySQL + Redis) +- **前端**: Vue3 + Vite + Element Plus + Less + ECharts + FontAwesome +- **数据库**: MySQL 5.7+ (utf8mb4, 表前缀 `yz_pw_`) +- **认证**: JWT (3小时有效期) + 盐+MD5密码 + 图形验证码 + +## 快速启动 + +### 1. 环境要求 + +- Go 1.21+ +- Node.js 16+ +- MySQL 5.7+ (需提前创建数据库 `photowall`) +- Redis (可选,未安装时自动降级为内存模式) + +### 2. 数据库配置 + +编辑 `go/internal/config/config.go` 或设置环境变量: + +```bash +# 默认配置 +DB_HOST=10.31.100.3 +DB_PORT=3306 +DB_USER=photowall +DB_PASSWORD=Dfn47yeKpyJfwz8n +DB_NAME=photowall +``` + +提前在 MySQL 中创建数据库: +```sql +CREATE DATABASE photowall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + +### 3. 启动后端 + +```bash +cd go +go run ./cmd/server +# 或编译后运行 +go build -o photowall.exe ./cmd/server +./photowall.exe +``` + +后端启动后自动: +- 连接 MySQL 并创建所有数据表(表前缀 `yz_pw_`) +- 创建超级管理员账号 `hero920103` / 密码 `920103` +- 初始化16项系统配置、20个默认菜单、示例地区数据(中/美/英/日) +- 启动定时任务(每小时检查过期班级,30天未补齐自动删除) + +后端端口:**8010** + +### 4. 启动前端 + +```bash +cd vue +npm install +npm run dev +``` + +前端端口:**8011**,自动代理 `/api` 和 `/uploads` 到后端 8010。 + +### 5. 登录 + +- 管理员账号:`hero920103` / `920103` +- 开发万能验证码:`888888`(任意 captcha_id 均可通过) + +## 功能清单 + +### 用户系统 +- 注册/登录(图形验证码) +- 忘记密码(邮箱重置码,开发模式直接返回) +- 短信验证码登录(预留,需配置短信服务商) +- 微信扫码登录(预留,需配置微信开放平台) +- JWT 3小时有效期,超时强制跳转登录 +- 盐+MD5密码加密 +- 个人资料管理(昵称、邮箱、简介) +- 学习履历管理(必须有履历才能创建班级) +- 联系方式管理(邮箱2/QQ5/微信2/手机5/住址1/工作单位1) + +### 组织架构 +- 三级地区:国家 → 省/州 → 县/区(最低级是县) +- 学校管理(大学/职业学校/高中/初中/小学/幼儿园/其他) +- 学院管理(仅大学/职业学校有学院层级) +- 示例地区种子数据(中国含江苏连云港海州区) + +### 班级系统 +- 班级创建(4步向导:选地区→选学校→填信息→上传毕业照+同学名单) +- 班级唯一性约束(学校+学院+年级+班号+毕业年份) +- 毕业照唯一(每个班级一张) +- 同学名单(创建时必须录入,至少8人) +- 老师名单 +- 每人每班级最多10张个人照片 +- 加入班级两种方式: + - 管理员邀请码 + - 搜索+正确说出至少8位同学姓名 +- 管理员转移(只能转给同班同学) +- 成员管理(移除成员) + +### 审核机制 +- 班级创建后进入待审核状态 +- 少于8人直接打回 +- 平台管理员巡查发现照片与名单不符可打回 +- 打回后30天内未补齐自动删除班级 +- 审核日志记录 + +### 平台管理 +- 数据统计仪表盘(用户/学校/班级/照片核心指标 + 增长趋势 + 分布图表 + 地区TOP10) +- 系统配置管理(16项配置,5个分类:基础/极验/短信/微信/存储) +- 菜单管理(动态路由数据源,树形结构,增删改查) +- 用户管理(封禁/解封账号,封禁原因记录) +- 敏感词全局屏蔽(DFA算法,增删改查,启用/禁用) +- 审核管理(审核队列、通过、打回、手动删除、审核日志) + +### 技术特性 +- 动态路由(菜单存储在数据库,登录后按角色动态加载) +- 文件上传MD5去重(重复文件直接返回已有路径,ref_count引用计数) +- 数据隔离(班级照片和联系方式只有班级成员或管理员可见) +- Redis集成(验证码/缓存,连接失败自动降级内存模式) +- 敏感词检测(班级创建时自动检测名称) +- 账号封禁(被封禁用户无法登录) +- H5响应式适配 +- CORS跨域支持 + +## 目录结构 + +``` +photowall/ +├── go/ # 后端 +│ ├── cmd/server/main.go # 入口 +│ ├── internal/ +│ │ ├── config/ # 配置 +│ │ ├── database/ # 数据库连接+迁移+种子 +│ │ ├── model/ # 数据模型(16+张表) +│ │ ├── service/ # 业务逻辑 +│ │ ├── handler/ # HTTP处理器 +│ │ ├── middleware/ # 中间件(JWT认证) +│ │ ├── router/ # 路由注册 +│ │ └── scheduler/ # 定时任务 +│ ├── pkg/ # 工具包 +│ │ ├── jwt/ # JWT +│ │ ├── hash/ # 盐+MD5 +│ │ ├── response/ # 统一响应 +│ │ ├── captcha/ # 图形验证码 +│ │ ├── redis/ # Redis+内存降级 +│ │ └── sensitive/ # DFA敏感词检测 +│ ├── uploads/ # 上传文件目录 +│ └── photowall.exe # 编译产物 +├── vue/ # 前端 +│ ├── src/ +│ │ ├── api/ # API封装 +│ │ ├── assets/ # 全局样式(Less蓝白配色) +│ │ ├── composables/ # useAuth等 +│ │ ├── router/ # 动态路由 +│ │ ├── views/ # 页面 +│ │ │ ├── auth/ # 登录/注册/忘记密码 +│ │ │ ├── home/ # 首页 +│ │ │ ├── class/ # 班级(list/edit/detail/join) +│ │ │ ├── normalsettings/ # 个人中心等 +│ │ │ └── admin/ # 管理(审核/配置/菜单/统计/用户/敏感词) +│ │ ├── App.vue # 布局(侧边栏+顶部栏) +│ │ └── main.js # 入口 +│ └── dist/ # 构建产物 +└── doc/ # 文档 + └── 立项说明.md # 开发基准文档 +``` + +## 第三方服务配置 + +在「系统管理 → 系统配置」中配置以下参数(留空则处于开发模式): + +| 分类 | 配置项 | 获取地址 | +|------|--------|----------| +| 极验 | geetest_id / geetest_key | https://www.geetest.com | +| 短信 | sms_access_key / sms_secret_key / sms_sign_name / sms_template_code | 阿里云/腾讯云短信控制台 | +| 微信 | wechat_app_id / wechat_app_secret / wechat_redirect_uri | https://open.weixin.qq.com | + +## 开发说明 + +- 开发万能验证码:`888888` +- 忘记密码重置码:开发模式直接在接口返回(生产需配置SMTP邮件服务) +- 短信验证码:开发模式直接返回(需配置短信服务商后真实发送) +- Redis:未安装时自动降级为内存模式,不影响功能 +- 表前缀:所有业务表均以 `yz_pw_` 开头 + +## UniApp 版本 + +规划中,用于生成 APK 和小程序端源码。详见 `doc/立项说明.md`。 diff --git a/doc/立项说明.md b/doc/立项说明.md new file mode 100644 index 0000000..6373f57 --- /dev/null +++ b/doc/立项说明.md @@ -0,0 +1,420 @@ +# 毕业照墙(PhotoWall)立项说明 + +> 本文档为项目开发基准,所有功能开发须遵循本文档定义,不得偏离设计核心。 + +--- + +## 一、项目概述 + +### 1.1 项目定位 +一个面向全球用户的毕业照存储与同学联络平台。用户可以按国家→省/州→县/区→学校→学院→班级的层级组织毕业照,每个班级留存唯一一张毕业照,每位成员可在班级下留存最多 10 张个人当期照片,同时通过学习履历、联系方式等功能促进同学间的长期联络。 + +### 1.2 项目目标 +- 建立覆盖全球的行政区划与学校组织架构 +- 为每个班级提供唯一毕业照存档与个人照片墙 +- 通过严格的身份验证机制(8 人姓名验证 / 邀请码)确保班级成员真实性 +- 通过平台审核机制确保毕业照与名单一致性 +- 提供同学间联系方式共享,促进长期联络 + +--- + +## 二、核心功能需求 + +### 2.1 组织架构(地区 → 学校 → 学院 → 班级) + +| 层级 | 说明 | 是否必填 | +|------|------|----------| +| 国家 | 覆盖全球所有国家 | 是 | +| 省/州 | 国家下的省级行政区(含直辖市、州、府等) | 是 | +| 县/区 | **最低层级**,学校必须挂载在区/县级下 | 是 | +| 学校 | 区/县下的学校,含类型区分 | 是 | +| 学院 | 仅大学/职业学校有学院层级 | 大学必填,其他无 | +| 班级 | 学校(+学院)下的具体班级 | 是 | + +**学校类型**:大学(university)、职业学校/中专(vocational)、高中(senior_high)、初中(junior_high)、小学(primary)、幼儿园(kindergarten)、其他(other)。 + +**班级唯一性**:同一学校 + 学院 + 年级 + 班级序号 + 毕业年份 唯一。 + +### 2.2 毕业照与个人照片 + +- **班级毕业照**:每个班级**唯一一张**,由班级管理员上传。 +- **个人当期照片**:每位班级成员可在该班级下留存**最多 10 张**个人照片,附在班级照片墙下。 +- 照片存储在本地文件系统,生产环境建议替换为对象存储(OSS)。 + +### 2.3 班级创建与审核流程 + +``` +用户创建班级(需有学习履历) + ↓ +上传毕业照 + 录入全班同学姓名 + 老师姓名 + ↓ +┌─ 同学姓名 < 8 人 ──→ 直接打回(rejected),需补齐 +│ +└─ 同学姓名 ≥ 8 人 ──→ 待审核(pending) + ↓ + 平台管理员巡查审核 + ↓ + ┌─ 照片与名单一致 ──→ 通过(active) + │ + └─ 不一致/有问题 ──→ 打回(rejected),需补齐 + ↓ + 30 天内未补齐完成 + ↓ + 系统自动删除班级(取消创建) +``` + +**关键规则**: +- 创建班级的人自动成为班级管理员。 +- 管理员可将管理员身份**转移给同班其他成员**。 +- 审核截止时间为创建时间 + 30 天,逾期未通过自动删除。 +- 被打回后管理员可补齐资料重新提交审核。 + +### 2.4 加入班级的两种方式 + +#### 方式一:管理员邀请(邀请码) +- 管理员生成邀请码(可设置最大使用次数、有效期)。 +- 成员输入邀请码直接加入,无需验证姓名。 +- 邀请码可被管理员禁用。 + +#### 方式二:搜索加入(8 人姓名验证) +- 用户搜索到目标班级(仅 active 状态可被搜索)。 +- 用户需正确说出该班级**至少 8 位同学的姓名**(须与毕业照名单完全一致)。 +- 全部验证通过后方可加入。 + +> 设计意图:确保加入者确实是该班级成员,防止陌生人混入。 + +### 2.5 用户学习履历 + +- 每位用户可维护自己的学习履历(学校、学院、专业、学历、起止年份、备注)。 +- **没有学习履历的用户不允许创建班级**。 +- 学习履历是创建班级的前置门槛。 + +### 2.6 用户联系方式 + +同班成员可互相查看联系方式。各类型数量上限: + +| 联系方式类型 | 上限 | 说明 | +|-------------|------|------| +| 邮箱(email) | 2 | | +| QQ | 5 | | +| 微信(wechat) | 2 | | +| 手机号(phone) | 5 | | +| 家庭住址(address) | 1 | | +| 工作单位(workunit) | 1 | | + +可设置某条为"主要联系方式"。 + +### 2.7 平台管理员 + +- 平台管理员负责班级审核巡查、打回、通过、删除。 +- 可查看数据概览(用户数、班级数、各状态班级数、学校数、照片数)。 +- 可手动触发过期班级清理。 +- 默认账号:admin / admin123456(首次启动自动创建)。 + +--- + +## 三、技术架构 + +### 3.1 技术栈 + +| 层级 | 技术选型 | 说明 | +|------|---------|------| +| 后端语言 | Go 1.22+ | | +| Web 框架 | Gin | | +| ORM | GORM | | +| 数据库 | MySQL 8.0 | utf8mb4 字符集 | +| 表前缀 | `yz_pw_` | 所有业务表统一前缀 | +| 认证 | JWT | | +| 密码加密 | bcrypt | | +| 前端框架 | Vue 3 + Vite | | +| 路由 | Vue Router 4 | | +| HTTP 客户端 | Axios | | +| 移动端 | UniApp(规划中,暂不开发) | 生成 APK + 小程序 | + +### 3.2 数据库配置 + +| 配置项 | 值 | +|--------|-----| +| 地址 | 10.31.100.3:3306 | +| 用户名 | photowall | +| 密码 | Dfn47yeKpyJfwz8n | +| 数据库名 | photowall | +| 字符集 | utf8mb4 | +| 表前缀 | yz_pw_ | + +> 支持通过环境变量覆盖:DB_HOST、DB_PORT、DB_USER、DB_PASSWORD、DB_NAME、DB_CHARSET。 + +### 3.3 项目目录结构 + +``` +photowall/ +├── go/ # Go 后端 +│ ├── cmd/server/main.go # 入口 +│ ├── internal/ +│ │ ├── config/ # 配置 +│ │ ├── database/ # 数据库初始化 + 种子数据 +│ │ ├── model/ # 数据模型 +│ │ ├── handler/ # HTTP Handler +│ │ ├── service/ # 业务逻辑 +│ │ ├── middleware/ # 中间件(JWT、权限) +│ │ ├── router/ # 路由注册 +│ │ └── scheduler/ # 定时任务(30天清理) +│ ├── pkg/ # 工具包(jwt、hash、response) +│ ├── uploads/ # 上传文件存储 +│ └── go.mod +│ +├── vue/ # Vue3 前端(H5/PC 自适应) +│ ├── src/ +│ │ ├── api/ # API 封装 +│ │ ├── assets/ # 静态资源 +│ │ ├── components/ # 全局公共组件 +│ │ ├── composables/ # 组合式函数 +│ │ ├── router/ # 路由 +│ │ └── views/ # 页面(按功能模块分层) +│ ├── index.html +│ ├── vite.config.js +│ └── package.json +│ +└── doc/ # 项目文档 + └── 立项说明.md # 本文档 +``` + +--- + +## 四、前端页面目录规范 + +所有页面按**功能模块 → 基层操作**分层,每个基层文件夹包含 `index.vue` + `components/`(该页面专用组件)。 + +``` +views/ +├── auth/ # 认证模块 +│ └── login/ +│ ├── index.vue +│ └── components/ +├── home/ # 首页/工作台 +│ ├── index.vue +│ └── components/ +├── class/ # 班级模块 +│ ├── list/ # 班级列表/搜索 +│ │ ├── index.vue +│ │ └── components/ +│ ├── edit/ # 创建/编辑班级 +│ │ ├── index.vue +│ │ └── components/ +│ ├── detail/ # 班级详情(毕业照、照片墙、成员) +│ │ ├── index.vue +│ │ └── components/ +│ └── join/ # 加入班级 +│ ├── index.vue +│ └── components/ +├── normalsettings/ # 基本设置模块 +│ ├── usersettings/ # 用户设置 +│ │ ├── list/ # 个人中心首页 +│ │ ├── edit/ # 编辑资料 +│ │ └── detail/ # 用户详情 +│ ├── rolesettings/ # 角色设置 +│ │ ├── list/ +│ │ ├── edit/ +│ │ └── detail/ +│ └── logmanagement/ # 日志管理 +│ ├── list/ +│ └── detail/ +└── admin/ # 管理后台 + └── audit/ # 审核管理 + ├── list/ # 审核队列 + │ ├── index.vue + │ └── components/ + └── detail/ # 审核详情/日志 + ├── index.vue + └── components/ +``` + +--- + +## 五、H5 / 响应式适配要求 + +### 5.1 适配原则 +- 前端页面必须**同时适配 PC 端和移动端 H5**。 +- 使用**响应式布局**,以移动端优先(Mobile First)为基础,逐步增强 PC 端展示。 +- 核心操作在手机端必须可完成(创建班级、上传照片、加入班级、审核等)。 + +### 5.2 断点规范 + +| 断点 | 设备类型 | 宽度范围 | +|------|---------|---------| +| xs | 手机竖屏 | < 576px | +| sm | 手机横屏/小平板 | ≥ 576px | +| md | 平板 | ≥ 768px | +| lg | 笔记本/桌面 | ≥ 992px | +| xl | 大屏桌面 | ≥ 1200px | + +### 5.3 适配要点 +- **容器宽度**:PC 端最大宽度 1200px 居中,移动端全屏铺满。 +- **表单**:PC 端多列并排(form-row),移动端单列堆叠。 +- **表格**:PC 端正常表格,移动端可考虑卡片式布局或横向滚动。 +- **照片网格**:使用 CSS Grid `auto-fill + minmax` 自适应列数。 +- **弹窗**:PC 端居中固定宽度,移动端接近全屏(底部弹出或全屏)。 +- **字体**:使用 rem 或 vw 单位适配,基准字号 14px。 +- **触摸友好**:按钮最小点击区域 44×44px,间距适中。 +- **图片上传**:支持移动端相机拍照和相册选择。 + +--- + +## 六、UniApp 版本规划(暂不开发) + +### 6.1 目标 +基于 UniApp 开发跨端版本,编译生成: +- **Android APK**(安卓应用) +- **微信小程序源码** +- (可选)iOS、H5、其他小程序平台 + +### 6.2 与 Vue 版的关系 +- Vue 版(vue/ 目录)为 **H5/PC Web 端**,优先开发。 +- UniApp 版后续独立创建目录(建议 `uniapp/`),复用后端 API。 +- 两端共享同一套后端接口,数据完全互通。 + +### 6.3 UniApp 开发要点(预留) +- 使用 Vue 3 + UniApp(Vue3 语法模式)。 +- 页面结构与 Vue 版保持一致的功能模块划分。 +- 图片上传需适配 UniApp 的 `uni.uploadFile`。 +- APK 打包需配置证书、权限(相机、存储、网络)。 +- 小程序端需适配微信小程序的域名白名单、上传限制等。 +- 登录态存储使用 `uni.setStorageSync`。 + +### 6.4 开发优先级 +UniApp 版本**暂缓开发**,待 Vue Web 端核心功能稳定后启动。当前阶段仅在本文档中记录规划,不创建代码。 + +--- + +## 七、数据模型概要 + +核心表(表前缀 `yz_pw_`): + +| 表名 | 说明 | +|------|------| +| users | 用户 | +| user_contacts | 用户联系方式 | +| education_histories | 用户学习履历 | +| regions | 地区(国家/省/县 三级自关联) | +| schools | 学校 | +| colleges | 学院(仅大学) | +| classes | 班级(含毕业照、同学/老师名单、审核状态) | +| class_members | 班级成员 | +| class_photos | 班级个人照片(每人每班级≤10张) | +| invitations | 班级邀请码 | +| audit_logs | 班级审核日志 | +| sms_codes | 短信验证码 | +| password_resets | 密码重置码 | +| system_configs | 系统配置(16项,5分类) | +| menus | 动态路由菜单(20个默认菜单) | +| upload_files | 文件上传记录(MD5去重) | +| sensitive_words | 敏感词库(DFA全局屏蔽) | + +--- + +## 八、平台管理功能(补充) + +### 8.1 数据统计仪表盘 +- 核心指标:总用户数、学校总数、班级总数、照片总数、待审核/已通过/被打回班级数、封禁用户数 +- 增长趋势:近7天用户增长、近7天班级增长(折线图) +- 分布统计:班级状态分布(饼图)、学校类型分布(柱状图)、毕业年份分布(柱状图) +- 地区排名:班级数量TOP10地区 + +### 8.2 系统配置管理 +- 16项配置,分5类:基础配置、极验配置、短信配置、微信配置、存储配置 +- 每项配置含:显示名称、说明、输入框示例、官方文档/获取地址链接 +- 后台可视化编辑,保存后即时生效(部分需重启) + +### 8.3 菜单管理(动态路由) +- 菜单数据存储在数据库,登录后按角色动态加载路由 +- 树形结构,支持增删改查、排序、显示/隐藏、权限控制 +- 20个默认菜单初始化 + +### 8.4 用户管理(封禁/解封) +- 管理员可封禁违规账号,填写封禁原因 +- 被封禁用户无法登录,提示封禁原因 +- 支持解封操作 +- 不能封禁管理员账号 + +### 8.5 敏感词全局屏蔽 +- DFA算法高效检测 +- 支持分类管理(政治/色情/暴力/广告/辱骂/其他) +- 支持启用/禁用单个敏感词 +- 班级创建时自动检测名称,包含敏感词则拦截 +- 内容发布时可扩展检测(简介、描述等) + +--- + +## 九、技术特性(补充) + +### 9.1 认证与安全 +- JWT Token,有效期3小时,超时前端强制跳转登录 +- 密码使用盐+MD5加密(随机盐,每用户不同) +- 图形验证码(PNG图片,4位字符,5分钟过期,一次性) +- 开发万能验证码:888888 +- 注册/登录均需验证码 +- 忘记密码:邮箱重置码(30分钟有效,开发模式直接返回) + +### 9.2 文件上传 +- 支持班级毕业照、个人照片、头像三类 +- 单文件最大10MB +- MD5去重:相同文件只存一份,ref_count引用计数 +- 重复上传直接返回已有文件路径 + +### 9.3 数据隔离 +- 班级照片和联系方式只有班级成员或超级管理员可见 +- 非成员访问班级详情时,照片和联系方式返回空 +- 删除照片只有本人/班级管理员/超级管理员可操作 + +### 9.4 Redis集成 +- go-redis/v9,默认localhost:6379无密码 +- 连接失败自动降级为内存模式(保证开箱即用) +- 用于验证码存储、缓存等 + +### 9.5 前端技术栈 +- Vue3 + Vite + Vue Router4 + Axios +- Element Plus 组件库(中文语言包) +- Less 样式预处理器 +- 蓝白配色(主白和灰,主色#1890ff) +- FontAwesome 图标(不用Element图标) +- ECharts 数据可视化 +- WangEditor 富文本编辑器(预留) +- H5响应式适配 + +### 9.6 第三方服务预留 +- 极验行为验证(geetest_id/key,留空则用自建图形验证码) +- 短信验证码(阿里云/腾讯云,留空则开发模式直接返回) +- 微信扫码登录(微信开放平台,留空则功能隐藏) +- 所有参数在系统配置页面可视化填写 + +--- + +## 十、开发计划与里程碑 + +| 阶段 | 内容 | 状态 | +|------|------|------| +| M1 | 后端骨架 + 数据库 + 认证 + 地区/学校/班级核心接口 | 已完成 | +| M2 | 前端 Vue 核心页面(登录、首页、创建班级、班级详情、加入班级、个人中心) | 已完成 | +| M3 | 平台管理员审核后台 + 30天自动清理 | 已完成 | +| M4 | 前端 H5 响应式适配完善 | 已完成 | +| M5 | 前端目录结构规范化(list/edit/detail + components) | 已完成 | +| M6 | Element Plus + Less + FontAwesome 前端重构 + 动态路由 | 已完成 | +| M7 | 系统配置 + 菜单管理 + 数据统计仪表盘 | 已完成 | +| M8 | 用户封禁 + 敏感词全局屏蔽 + 文件MD5去重 + 数据隔离 | 已完成 | +| M9 | 注册/忘记密码/短信/微信登录预留 | 已完成 | +| M10 | UniApp 版本(APK + 小程序) | 暂缓 | + +--- + +## 九、约束与注意事项 + +1. **不得偏离本文档定义的核心需求**,开发前须先阅读本文档。 +2. 所有数据库表必须使用 `yz_pw_` 前缀。 +3. 班级创建必须校验用户有学习履历。 +4. 同学名单少于 8 人必须打回,不得通过。 +5. 加入班级的搜索方式必须验证 8 人姓名全部正确。 +6. 超过审核截止时间(30天)未通过的班级必须自动删除。 +7. 个人照片每人每班级严格限制 10 张。 +8. 联系方式各类型数量上限必须在后端校验。 +9. 前端页面必须适配 H5 移动端,不得仅适配 PC。 +10. UniApp 版本暂缓,仅在文档中记录规划。 diff --git a/go/cmd/server/main.go b/go/cmd/server/main.go new file mode 100644 index 0000000..d700878 --- /dev/null +++ b/go/cmd/server/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "log" + "photowall/internal/config" + "photowall/internal/database" + "photowall/internal/handler" + "photowall/internal/router" + "photowall/internal/scheduler" + "photowall/internal/service" + "photowall/pkg/jwt" + "photowall/pkg/redis" + "photowall/pkg/sensitive" + + "github.com/gin-gonic/gin" +) + +func main() { + // 1. 加载配置 + cfg := config.Load() + log.Printf("[config] port=%s db=%s@%s:%s/%s", cfg.Port, cfg.DBUser, cfg.DBHost, cfg.DBPort, cfg.DBName) + + // 2. 初始化数据库(连接 + 自动迁移 + 管理员 + 种子数据) + if err := database.Init(); err != nil { + log.Fatalf("[database] init failed: %v", err) + } + + // 2.1 初始化 Redis(连接失败自动降级内存模式) + redis.Init(redis.Config{ + Host: cfg.RedisHost, + Port: cfg.RedisPort, + Password: cfg.RedisPassword, + DB: cfg.RedisDB, + }) + + // 3. JWT + jm := jwt.New(cfg.JWTSecret, cfg.JWTExpireHrs) + + // 4. 初始化 Service + authSvc := service.NewAuthService(database.DB, jm) + userSvc := service.NewUserService(database.DB) + regionSvc := service.NewRegionService(database.DB) + schoolSvc := service.NewSchoolService(database.DB) + classSvc := service.NewClassService(database.DB) + adminSvc := service.NewAdminService(database.DB) + configSvc := service.NewConfigService(database.DB) + menuSvc := service.NewMenuService(database.DB) + + // 初始化系统默认配置 + if err := configSvc.InitDefaults(); err != nil { + log.Printf("[config] init defaults failed: %v", err) + } + // 初始化默认菜单 + if err := menuSvc.InitDefaults(); err != nil { + log.Printf("[menu] init defaults failed: %v", err) + } + // 初始化敏感词过滤器 + sensitive.Init(database.DB) + + // 5. 初始化 Handler + deps := &router.Deps{ + Auth: handler.NewAuthHandler(authSvc), + User: handler.NewUserHandler(userSvc), + Region: handler.NewRegionHandler(regionSvc), + School: handler.NewSchoolHandler(schoolSvc), + Class: handler.NewClassHandler(classSvc), + Admin: handler.NewAdminHandler(adminSvc), + Upload: handler.NewUploadHandler(database.DB), + Config: handler.NewConfigHandler(configSvc), + Menu: handler.NewMenuHandler(menuSvc), + } + + // 6. 启动定时清理任务 + scheduler.StartCleanup(adminSvc) + + // 7. 启动 HTTP 服务 + gin.SetMode(gin.ReleaseMode) + r := gin.Default() + router.Register(r, jm, deps) + + log.Printf("[server] photowall API listening on :%s", cfg.Port) + if err := r.Run(":" + cfg.Port); err != nil { + log.Fatalf("[server] failed: %v", err) + } +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..016d1c4 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,46 @@ +module photowall + +go 1.24 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.25.0 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.25.11 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/redis/go-redis/v9 v9.22.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..8640a01 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,114 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= +gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/go/internal/config/config.go b/go/internal/config/config.go new file mode 100644 index 0000000..5de1650 --- /dev/null +++ b/go/internal/config/config.go @@ -0,0 +1,111 @@ +package config + +import ( + "os" + "strconv" +) + +// Config 全局配置,优先从环境变量读取,提供默认值以便开箱即用。 +type Config struct { + Port string + JWTSecret string + JWTExpireHrs int + UploadDir string + + // MySQL + DBHost string + DBPort string + DBUser string + DBPassword string + DBName string + DBCharset string + + // 平台管理员账号(首次启动自动创建) + AdminUsername string + AdminPassword string + // 班级审核补齐期限(天),逾期自动删除 + AuditGraceDays int + + // 极验行为验证(参数后期补,为空则跳过极验校验) + GeeTestID string + GeeTestKey string + + // 短信验证码(参数后期补,为空则短信接口返回配置缺失) + SMSProvider string // aliyun / tencent + SMSAccessKey string + SMSSecretKey string + SMSSignName string + SMSTemplateCode string + + // 微信扫码登录(参数后期补,为空则微信接口返回配置缺失) + WechatAppID string + WechatAppSecret string + WechatRedirectURI string + + // Redis(缓存/验证码/限流,连接失败自动降级内存) + RedisHost string + RedisPort string + RedisPassword string + RedisDB int +} + +var C *Config + +func Load() *Config { + C = &Config{ + Port: getEnv("PORT", "8010"), + JWTSecret: getEnv("JWT_SECRET", "photowall-dev-secret-change-in-prod"), + JWTExpireHrs: getEnvInt("JWT_EXPIRE_HOURS", 3), + UploadDir: getEnv("UPLOAD_DIR", "uploads"), + DBHost: getEnv("DB_HOST", "10.31.100.3"), + DBPort: getEnv("DB_PORT", "3306"), + DBUser: getEnv("DB_USER", "photowall"), + DBPassword: getEnv("DB_PASSWORD", "Dfn47yeKpyJfwz8n"), + DBName: getEnv("DB_NAME", "photowall"), + DBCharset: getEnv("DB_CHARSET", "utf8mb4"), + AdminUsername: getEnv("ADMIN_USERNAME", "hero920103"), + AdminPassword: getEnv("ADMIN_PASSWORD", "920103"), + AuditGraceDays: getEnvInt("AUDIT_GRACE_DAYS", 30), + // 极验(后期补参数) + GeeTestID: getEnv("GEETEST_ID", ""), + GeeTestKey: getEnv("GEETEST_KEY", ""), + // 短信(后期补参数) + SMSProvider: getEnv("SMS_PROVIDER", "aliyun"), + SMSAccessKey: getEnv("SMS_ACCESS_KEY", ""), + SMSSecretKey: getEnv("SMS_SECRET_KEY", ""), + SMSSignName: getEnv("SMS_SIGN_NAME", ""), + SMSTemplateCode: getEnv("SMS_TEMPLATE_CODE", ""), + // 微信扫码登录(后期补参数) + WechatAppID: getEnv("WECHAT_APP_ID", ""), + WechatAppSecret: getEnv("WECHAT_APP_SECRET", ""), + WechatRedirectURI: getEnv("WECHAT_REDIRECT_URI", ""), + // Redis + RedisHost: getEnv("REDIS_HOST", "localhost"), + RedisPort: getEnv("REDIS_PORT", "6379"), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + RedisDB: getEnvInt("REDIS_DB", 0), + } + return C +} + +// DSN 生成 MySQL 连接串 +func (c *Config) DSN() string { + return c.DBUser + ":" + c.DBPassword + "@tcp(" + c.DBHost + ":" + c.DBPort + ")/" + + c.DBName + "?charset=" + c.DBCharset + "&parseTime=True&loc=Local" +} + +func getEnv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getEnvInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/go/internal/database/database.go b/go/internal/database/database.go new file mode 100644 index 0000000..36f3fa1 --- /dev/null +++ b/go/internal/database/database.go @@ -0,0 +1,133 @@ +package database + +import ( + "errors" + "fmt" + "log" + "time" + + "photowall/internal/config" + "photowall/internal/model" + "photowall/pkg/hash" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "gorm.io/gorm/schema" +) + +var DB *gorm.DB + +// Init 连接 MySQL,自动建表,创建平台管理员,写入种子数据。 +func Init() error { + cfg := config.C + + dsn := cfg.DSN() + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + NamingStrategy: schema.NamingStrategy{ + TablePrefix: "yz_pw_", // 统一表前缀 + SingularTable: false, + }, + NowFunc: func() time.Time { return time.Now().Local() }, + }) + if err != nil { + return fmt.Errorf("connect mysql: %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return err + } + sqlDB.SetMaxOpenConns(50) + sqlDB.SetMaxIdleConns(10) + sqlDB.SetConnMaxLifetime(time.Hour) + + DB = db + + // 数据迁移:旧版 bcrypt 密码(60字符) 超过新版 md5(32字符) 列宽, + // 在 AutoMigrate 缩列前先清空旧密码,后续 ensureAdmin 会重新设置。 + // 注意:salt 列此时可能还不存在,只更新 password_hash。 + db.Exec("UPDATE yz_pw_users SET password_hash = '' WHERE LENGTH(password_hash) > 32") + + // 自动迁移 + if err := autoMigrate(db); err != nil { + return fmt.Errorf("auto migrate: %w", err) + } + + // 创建平台管理员 + if err := ensureAdmin(db); err != nil { + return fmt.Errorf("ensure admin: %w", err) + } + + // 写入种子地区数据 + if err := seedRegions(db); err != nil { + return fmt.Errorf("seed regions: %w", err) + } + + log.Println("[database] mysql connected, tables migrated, admin & seeds ready") + return nil +} + +func autoMigrate(db *gorm.DB) error { + return db.AutoMigrate( + &model.User{}, + &model.UserContact{}, + &model.EducationHistory{}, + &model.Region{}, + &model.School{}, + &model.College{}, + &model.Class{}, + &model.ClassMember{}, + &model.ClassPhoto{}, + &model.Invitation{}, + &model.AuditLog{}, + &model.SmsCode{}, + &model.PasswordReset{}, + &model.SystemConfig{}, + &model.Menu{}, + &model.UploadFile{}, + &model.SensitiveWord{}, + ) +} + +func ensureAdmin(db *gorm.DB) error { + cfg := config.C + var admin model.User + err := db.Where("role = ?", "admin").First(&admin).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + salt, pwdHash := hash.Password(cfg.AdminPassword) + + if errors.Is(err, gorm.ErrRecordNotFound) { + // 不存在则创建 + admin = model.User{ + Username: cfg.AdminUsername, + PasswordHash: pwdHash, + Salt: salt, + Nickname: "平台管理员", + Role: "admin", + Bio: "系统默认平台管理员,负责班级审核与巡查", + } + return db.Create(&admin).Error + } + + // 已存在:用户名/密码方式变更时同步更新(兼容旧 bcrypt 数据) + needUpdate := false + if admin.Username != cfg.AdminUsername { + admin.Username = cfg.AdminUsername + needUpdate = true + } + // bcrypt 哈希长度 60,md5 长度 32;salt 为空说明是旧数据 + if len(admin.PasswordHash) == 60 || admin.Salt == "" { + admin.PasswordHash = pwdHash + admin.Salt = salt + needUpdate = true + } + if needUpdate { + return db.Save(&admin).Error + } + return nil +} diff --git a/go/internal/database/seed.go b/go/internal/database/seed.go new file mode 100644 index 0000000..e22f02c --- /dev/null +++ b/go/internal/database/seed.go @@ -0,0 +1,78 @@ +package database + +import ( + "photowall/internal/model" + + "gorm.io/gorm" +) + +// seedRegions 写入示例行政区划数据。 +// 全球国家/省/区县数据量巨大,此处仅写入代表性示例,平台管理员可通过 +// 管理接口批量录入完整数据(或导入民政部/GeoNames 数据源)。 +func seedRegions(db *gorm.DB) error { + // 国家 + china := upsertRegion(db, model.Region{Name: "中国", NameEn: "China", Level: model.LevelCountry, Code: "CN", SortOrder: 1}) + usa := upsertRegion(db, model.Region{Name: "美国", NameEn: "United States", Level: model.LevelCountry, Code: "US", SortOrder: 2}) + uk := upsertRegion(db, model.Region{Name: "英国", NameEn: "United Kingdom", Level: model.LevelCountry, Code: "GB", SortOrder: 3}) + japan := upsertRegion(db, model.Region{Name: "日本", NameEn: "Japan", Level: model.LevelCountry, Code: "JP", SortOrder: 4}) + + // 中国 - 省/直辖市 + jiangsu := upsertRegion(db, model.Region{Name: "江苏省", NameEn: "Jiangsu", Level: model.LevelProvince, ParentID: china.ID, Code: "320000"}) + beijing := upsertRegion(db, model.Region{Name: "北京市", NameEn: "Beijing", Level: model.LevelProvince, ParentID: china.ID, Code: "110000"}) + guangdong := upsertRegion(db, model.Region{Name: "广东省", NameEn: "Guangdong", Level: model.LevelProvince, ParentID: china.ID, Code: "440000"}) + shanghai := upsertRegion(db, model.Region{Name: "上海市", NameEn: "Shanghai", Level: model.LevelProvince, ParentID: china.ID, Code: "310000"}) + zhejiang := upsertRegion(db, model.Region{Name: "浙江省", NameEn: "Zhejiang", Level: model.LevelProvince, ParentID: china.ID, Code: "330000"}) + + // 中国 - 区县(最低级) + upsertRegion(db, model.Region{Name: "海州区", NameEn: "Haizhou", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320706"}) + upsertRegion(db, model.Region{Name: "连云区", NameEn: "Lianyun", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320703"}) + upsertRegion(db, model.Region{Name: "赣榆区", NameEn: "Ganyu", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320707"}) + upsertRegion(db, model.Region{Name: "玄武区", NameEn: "Xuanwu", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320102"}) + upsertRegion(db, model.Region{Name: "鼓楼区", NameEn: "Gulou", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320106"}) + upsertRegion(db, model.Region{Name: "海淀区", NameEn: "Haidian", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110108"}) + upsertRegion(db, model.Region{Name: "朝阳区", NameEn: "Chaoyang", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110105"}) + upsertRegion(db, model.Region{Name: "西城区", NameEn: "Xicheng", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110102"}) + upsertRegion(db, model.Region{Name: "天河区", NameEn: "Tianhe", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440106"}) + upsertRegion(db, model.Region{Name: "南山区", NameEn: "Nanshan", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440305"}) + upsertRegion(db, model.Region{Name: "福田区", NameEn: "Futian", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440304"}) + upsertRegion(db, model.Region{Name: "浦东新区", NameEn: "Pudong", Level: model.LevelCounty, ParentID: shanghai.ID, Code: "310115"}) + upsertRegion(db, model.Region{Name: "黄浦区", NameEn: "Huangpu", Level: model.LevelCounty, ParentID: shanghai.ID, Code: "310101"}) + upsertRegion(db, model.Region{Name: "西湖区", NameEn: "Xihu", Level: model.LevelCounty, ParentID: zhejiang.ID, Code: "330106"}) + upsertRegion(db, model.Region{Name: "滨江区", NameEn: "Binjiang", Level: model.LevelCounty, ParentID: zhejiang.ID, Code: "330108"}) + + // 美国 - 州 + california := upsertRegion(db, model.Region{Name: "加利福尼亚州", NameEn: "California", Level: model.LevelProvince, ParentID: usa.ID, Code: "CA"}) + newyorkState := upsertRegion(db, model.Region{Name: "纽约州", NameEn: "New York", Level: model.LevelProvince, ParentID: usa.ID, Code: "NY"}) + // 美国 - 县 + upsertRegion(db, model.Region{Name: "洛杉矶县", NameEn: "Los Angeles County", Level: model.LevelCounty, ParentID: california.ID, Code: "06037"}) + upsertRegion(db, model.Region{Name: "旧金山县", NameEn: "San Francisco County", Level: model.LevelCounty, ParentID: california.ID, Code: "06075"}) + upsertRegion(db, model.Region{Name: "圣克拉拉县", NameEn: "Santa Clara County", Level: model.LevelCounty, ParentID: california.ID, Code: "06085"}) + upsertRegion(db, model.Region{Name: "纽约县", NameEn: "New York County", Level: model.LevelCounty, ParentID: newyorkState.ID, Code: "36061"}) + + // 英国 + england := upsertRegion(db, model.Region{Name: "英格兰", NameEn: "England", Level: model.LevelProvince, ParentID: uk.ID, Code: "ENG"}) + upsertRegion(db, model.Region{Name: "大伦敦", NameEn: "Greater London", Level: model.LevelCounty, ParentID: england.ID, Code: "LND"}) + upsertRegion(db, model.Region{Name: "曼彻斯特", NameEn: "Manchester", Level: model.LevelCounty, ParentID: england.ID, Code: "MAN"}) + + // 日本 + tokyo := upsertRegion(db, model.Region{Name: "东京都", NameEn: "Tokyo", Level: model.LevelProvince, ParentID: japan.ID, Code: "13"}) + upsertRegion(db, model.Region{Name: "东京都区部", NameEn: "Tokyo Metropolis", Level: model.LevelCounty, ParentID: tokyo.ID, Code: "13100"}) + upsertRegion(db, model.Region{Name: "八王子市", NameEn: "Hachioji", Level: model.LevelCounty, ParentID: tokyo.ID, Code: "13201"}) + + return nil +} + +// upsertRegion 按 name+level+parent_id 查找,不存在则创建。 +func upsertRegion(db *gorm.DB, r model.Region) model.Region { + var existing model.Region + err := db.Where("name = ? AND level = ? AND parent_id = ?", r.Name, r.Level, r.ParentID).First(&existing).Error + if err == nil { + return existing + } + if err := db.Create(&r).Error; err != nil { + // 并发或重复创建时返回已存在记录 + db.Where("name = ? AND level = ? AND parent_id = ?", r.Name, r.Level, r.ParentID).First(&existing) + return existing + } + return r +} diff --git a/go/internal/handler/admin_handler.go b/go/internal/handler/admin_handler.go new file mode 100644 index 0000000..fbf8cae --- /dev/null +++ b/go/internal/handler/admin_handler.go @@ -0,0 +1,226 @@ +package handler + +import ( + "photowall/internal/database" + "photowall/internal/middleware" + "photowall/internal/service" + "photowall/pkg/response" + "photowall/pkg/sensitive" + "strconv" + + "github.com/gin-gonic/gin" +) + +type AdminHandler struct { + svc *service.AdminService +} + +func NewAdminHandler(svc *service.AdminService) *AdminHandler { + return &AdminHandler{svc: svc} +} + +// Stats GET /api/admin/stats +func (h *AdminHandler) Stats(c *gin.Context) { + stats, err := h.svc.Stats() + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, stats) +} + +// DetailedStats GET /api/admin/stats/detailed 详细数据统计(仪表盘用) +func (h *AdminHandler) DetailedStats(c *gin.Context) { + stats, err := h.svc.DetailedStats() + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, stats) +} + +// AuditQueue GET /api/admin/classes +func (h *AdminHandler) AuditQueue(c *gin.Context) { + var req service.AuditQueueReq + if err := c.ShouldBindQuery(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + result, err := h.svc.ListAuditQueue(&req) + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, result) +} + +// Approve POST /api/admin/classes/:id/approve +func (h *AdminHandler) Approve(c *gin.Context) { + auditorID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body struct { + Reason string `json:"reason"` + } + c.ShouldBindJSON(&body) + if err := h.svc.Approve(uint(id), auditorID, body.Reason); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "已通过审核"}) +} + +// Reject POST /api/admin/classes/:id/reject +func (h *AdminHandler) Reject(c *gin.Context) { + auditorID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body struct { + Reason string `json:"reason" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, "请填写打回原因") + return + } + if err := h.svc.Reject(uint(id), auditorID, body.Reason); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "已打回,等待用户补齐"}) +} + +// Delete DELETE /api/admin/classes/:id +func (h *AdminHandler) Delete(c *gin.Context) { + auditorID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body struct { + Reason string `json:"reason"` + } + c.ShouldBindJSON(&body) + if err := h.svc.ManualDelete(uint(id), auditorID, body.Reason); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "已删除"}) +} + +// AuditLogs GET /api/admin/classes/:id/logs +func (h *AdminHandler) AuditLogs(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + logs, err := h.svc.AuditLogs(uint(id)) + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, logs) +} + +// TriggerCleanup POST /api/admin/cleanup 手动触发过期清理(也可由定时任务自动执行) +func (h *AdminHandler) TriggerCleanup(c *gin.Context) { + count, err := h.svc.CleanupExpired() + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, gin.H{"deleted": count, "message": "清理完成"}) +} + +// ============ 账号封禁管理 ============ + +// BanUser POST /api/admin/users/:id/ban 封禁用户 +func (h *AdminHandler) BanUser(c *gin.Context) { + adminID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body struct { + Reason string `json:"reason" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, "请填写封禁原因") + return + } + req := &service.BanUserReq{UserID: uint(id), Reason: body.Reason} + if err := h.svc.BanUser(req, adminID); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "已封禁"}) +} + +// UnbanUser POST /api/admin/users/:id/unban 解封用户 +func (h *AdminHandler) UnbanUser(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.svc.UnbanUser(uint(id)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "已解封"}) +} + +// BannedUsers GET /api/admin/users/banned 封禁用户列表 +func (h *AdminHandler) BannedUsers(c *gin.Context) { + list, err := h.svc.ListBannedUsers() + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, list) +} + +// ============ 敏感词管理 ============ + +// SensitiveWords GET /api/admin/sensitive-words 敏感词列表 +func (h *AdminHandler) SensitiveWords(c *gin.Context) { + list, err := h.svc.ListSensitiveWords() + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, list) +} + +// AddSensitiveWord POST /api/admin/sensitive-words 添加敏感词 +func (h *AdminHandler) AddSensitiveWord(c *gin.Context) { + var body struct { + Word string `json:"word" binding:"required"` + Category string `json:"category"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, "请填写敏感词") + return + } + if body.Category == "" { + body.Category = "other" + } + sw, err := h.svc.AddSensitiveWord(body.Word, body.Category) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + // 重新加载敏感词过滤器 + sensitive.Reload(database.DB) + response.Created(c, sw) +} + +// DeleteSensitiveWord DELETE /api/admin/sensitive-words/:id 删除敏感词 +func (h *AdminHandler) DeleteSensitiveWord(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.svc.DeleteSensitiveWord(uint(id)); err != nil { + response.BadRequest(c, err.Error()) + return + } + sensitive.Reload(database.DB) + response.OK(c, gin.H{"message": "已删除"}) +} + +// ToggleSensitiveWord PUT /api/admin/sensitive-words/:id/toggle 启用/禁用 +func (h *AdminHandler) ToggleSensitiveWord(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body struct { + Enabled bool `json:"enabled"` + } + c.ShouldBindJSON(&body) + if err := h.svc.ToggleSensitiveWord(uint(id), body.Enabled); err != nil { + response.BadRequest(c, err.Error()) + return + } + sensitive.Reload(database.DB) + response.OK(c, gin.H{"message": "已更新"}) +} diff --git a/go/internal/handler/auth_handler.go b/go/internal/handler/auth_handler.go new file mode 100644 index 0000000..e5fc7d8 --- /dev/null +++ b/go/internal/handler/auth_handler.go @@ -0,0 +1,146 @@ +package handler + +import ( + "photowall/internal/middleware" + "photowall/internal/service" + "photowall/pkg/captcha" + "photowall/pkg/response" + + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + auth *service.AuthService +} + +func NewAuthHandler(auth *service.AuthService) *AuthHandler { + return &AuthHandler{auth: auth} +} + +// Captcha GET /api/captcha 获取图形验证码 +func (h *AuthHandler) Captcha(c *gin.Context) { + id, img := captcha.Generate() + response.OK(c, gin.H{ + "captcha_id": id, + "image": img, + }) +} + +// Register POST /api/auth/register +func (h *AuthHandler) Register(c *gin.Context) { + var req service.RegisterReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:"+err.Error()) + return + } + user, err := h.auth.Register(&req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Created(c, user) +} + +// Login POST /api/auth/login +func (h *AuthHandler) Login(c *gin.Context) { + var req service.LoginReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:"+err.Error()) + return + } + resp, err := h.auth.Login(&req) + if err != nil { + response.Unauthorized(c, err.Error()) + return + } + response.OK(c, resp) +} + +// Me GET /api/auth/me +func (h *AuthHandler) Me(c *gin.Context) { + userID := middleware.CurrentUserID(c) + username := middleware.CurrentUsername(c) + role := middleware.CurrentRole(c) + response.OK(c, gin.H{ + "user_id": userID, + "username": username, + "role": role, + }) +} + +// ForgotPassword POST /api/auth/forgot-password 忘记密码(生成重置码) +func (h *AuthHandler) ForgotPassword(c *gin.Context) { + var req service.ForgotPasswordReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:"+err.Error()) + return + } + code, err := h.auth.ForgotPassword(&req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + // 开发阶段返回重置码(生产环境应通过邮件发送,不返回) + response.OK(c, gin.H{ + "message": "重置码已生成,有效期30分钟", + "reset_code": code, + "note": "开发阶段直接返回,生产环境将通过邮箱发送", + }) +} + +// ResetPassword POST /api/auth/reset-password 重置密码 +func (h *AuthHandler) ResetPassword(c *gin.Context) { + var req service.ResetPasswordReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:"+err.Error()) + return + } + if err := h.auth.ResetPassword(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "密码重置成功,请使用新密码登录"}) +} + +// SendSms POST /api/auth/send-sms 发送短信验证码 +func (h *AuthHandler) SendSms(c *gin.Context) { + var req service.SendSmsReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:"+err.Error()) + return + } + code, err := h.auth.SendSmsCode(&req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{ + "message": "验证码已发送,有效期5分钟", + "code": code, // 开发阶段返回,生产环境不返回 + "note": "短信参数未配置时为开发模式,直接返回验证码", + }) +} + +// WechatLogin POST /api/auth/wechat-login 微信扫码登录 +func (h *AuthHandler) WechatLogin(c *gin.Context) { + var req service.WechatLoginReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:"+err.Error()) + return + } + resp, err := h.auth.WechatLogin(&req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, resp) +} + +// WechatQrcode GET /api/auth/wechat-qrcode 获取微信扫码登录二维码(预留) +func (h *AuthHandler) WechatQrcode(c *gin.Context) { + // TODO: 接入微信开放平台后生成真实扫码二维码 + response.OK(c, gin.H{ + "message": "微信扫码登录功能开发中,请配置微信开放平台参数后使用", + "qrcode": "", + }) +} diff --git a/go/internal/handler/class_handler.go b/go/internal/handler/class_handler.go new file mode 100644 index 0000000..5ca9ab6 --- /dev/null +++ b/go/internal/handler/class_handler.go @@ -0,0 +1,224 @@ +package handler + +import ( + "photowall/internal/middleware" + "photowall/internal/service" + "photowall/pkg/response" + "strconv" + + "github.com/gin-gonic/gin" +) + +type ClassHandler struct { + svc *service.ClassService +} + +func NewClassHandler(svc *service.ClassService) *ClassHandler { + return &ClassHandler{svc: svc} +} + +// Create POST /api/classes +func (h *ClassHandler) Create(c *gin.Context) { + userID := middleware.CurrentUserID(c) + var req service.CreateClassReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:"+err.Error()) + return + } + class, err := h.svc.Create(userID, &req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Created(c, class) +} + +// Detail GET /api/classes/:id +func (h *ClassHandler) Detail(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + userID := middleware.CurrentUserID(c) + isSuperAdmin := middleware.CurrentRole(c) == "admin" + detail, err := h.svc.GetDetail(uint(id), userID, isSuperAdmin) + if err != nil { + response.NotFound(c, err.Error()) + return + } + response.OK(c, detail) +} + +// Search GET /api/classes/search +func (h *ClassHandler) Search(c *gin.Context) { + var req service.SearchClassReq + if err := c.ShouldBindQuery(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + result, err := h.svc.Search(&req) + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, result) +} + +// JoinBySearch POST /api/classes/join/search +func (h *ClassHandler) JoinBySearch(c *gin.Context) { + userID := middleware.CurrentUserID(c) + var req service.JoinBySearchReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:"+err.Error()) + return + } + if err := h.svc.JoinBySearch(userID, &req); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "加入成功"}) +} + +// JoinByInvitation POST /api/classes/join/invite +func (h *ClassHandler) JoinByInvitation(c *gin.Context) { + userID := middleware.CurrentUserID(c) + var req service.JoinByInvitationReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "邀请码不能为空") + return + } + class, err := h.svc.JoinByInvitation(userID, &req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "加入成功", "class_id": class.ID, "class_name": class.Name}) +} + +// Mine GET /api/classes/mine +func (h *ClassHandler) Mine(c *gin.Context) { + userID := middleware.CurrentUserID(c) + list, err := h.svc.ListMyClasses(userID) + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, list) +} + +// CreateInvitation POST /api/classes/:id/invitation +func (h *ClassHandler) CreateInvitation(c *gin.Context) { + userID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body struct { + MaxUses int `json:"max_uses"` + ExpireDays int `json:"expire_days"` + } + c.ShouldBindJSON(&body) + inv, err := h.svc.CreateInvitation(uint(id), userID, body.MaxUses, body.ExpireDays) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Created(c, inv) +} + +// ListInvitations GET /api/classes/:id/invitations +func (h *ClassHandler) ListInvitations(c *gin.Context) { + userID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + list, err := h.svc.ListInvitations(uint(id), userID) + if err != nil { + response.Forbidden(c, err.Error()) + return + } + response.OK(c, list) +} + +// DisableInvitation DELETE /api/classes/:id/invitations/:inv_id +func (h *ClassHandler) DisableInvitation(c *gin.Context) { + userID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + invID, _ := strconv.ParseUint(c.Param("inv_id"), 10, 64) + if err := h.svc.DisableInvitation(uint(id), uint(invID), userID); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "已禁用"}) +} + +// TransferAdmin POST /api/classes/:id/transfer +func (h *ClassHandler) TransferAdmin(c *gin.Context) { + userID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body struct { + ToUserID uint `json:"to_user_id" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, "请指定目标用户") + return + } + if err := h.svc.TransferAdmin(uint(id), userID, body.ToUserID); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "管理员已转移"}) +} + +// RemoveMember DELETE /api/classes/:id/members/:user_id +func (h *ClassHandler) RemoveMember(c *gin.Context) { + userID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + targetID, _ := strconv.ParseUint(c.Param("user_id"), 10, 64) + if err := h.svc.RemoveMember(uint(id), userID, uint(targetID)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "已移除"}) +} + +// UploadPhoto POST /api/classes/:id/photos +func (h *ClassHandler) UploadPhoto(c *gin.Context) { + userID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body struct { + PhotoURL string `json:"photo_url" binding:"required"` + Caption string `json:"caption"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, "请先上传图片获取URL") + return + } + photo, err := h.svc.UploadPhoto(uint(id), userID, body.PhotoURL, body.Caption) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Created(c, photo) +} + +// DeletePhoto DELETE /api/classes/:id/photos/:photo_id +func (h *ClassHandler) DeletePhoto(c *gin.Context) { + userID := middleware.CurrentUserID(c) + isSuperAdmin := middleware.CurrentRole(c) == "admin" + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + photoID, _ := strconv.ParseUint(c.Param("photo_id"), 10, 64) + if err := h.svc.DeletePhoto(uint(id), uint(photoID), userID, isSuperAdmin); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "已删除"}) +} + +// Resubmit POST /api/classes/:id/resubmit +func (h *ClassHandler) Resubmit(c *gin.Context) { + userID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req service.ResubmitReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.svc.Resubmit(uint(id), userID, &req); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "已重新提交审核"}) +} diff --git a/go/internal/handler/config_handler.go b/go/internal/handler/config_handler.go new file mode 100644 index 0000000..c5f1adc --- /dev/null +++ b/go/internal/handler/config_handler.go @@ -0,0 +1,58 @@ +package handler + +import ( + "photowall/internal/service" + "photowall/pkg/response" + + "github.com/gin-gonic/gin" +) + +type ConfigHandler struct { + cfg *service.ConfigService +} + +func NewConfigHandler(cfg *service.ConfigService) *ConfigHandler { + return &ConfigHandler{cfg: cfg} +} + +// GetAll GET /api/admin/config 获取所有系统配置 +func (h *ConfigHandler) GetAll(c *gin.Context) { + list, err := h.cfg.GetAll() + if err != nil { + response.Internal(c, "获取配置失败") + return + } + // 按分类分组返回 + result := gin.H{ + "basic": []gin.H{}, + "geetest": []gin.H{}, + "sms": []gin.H{}, + "wechat": []gin.H{}, + "storage": []gin.H{}, + } + for _, item := range list { + result[item.Category] = append(result[item.Category].([]gin.H), gin.H{ + "key": item.Key, + "value": item.Value, + "label": item.Label, + "description": item.Description, + "placeholder": item.Placeholder, + "doc_link": item.DocLink, + }) + } + response.OK(c, result) +} + +// Update PUT /api/admin/config 批量更新配置 +func (h *ConfigHandler) Update(c *gin.Context) { + var req service.UpdateConfigReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:"+err.Error()) + return + } + if err := h.cfg.Update(&req); err != nil { + response.Internal(c, "更新配置失败") + return + } + response.OK(c, gin.H{"message": "配置更新成功,部分配置需重启服务生效"}) +} diff --git a/go/internal/handler/menu_handler.go b/go/internal/handler/menu_handler.go new file mode 100644 index 0000000..f40a1e7 --- /dev/null +++ b/go/internal/handler/menu_handler.go @@ -0,0 +1,80 @@ +package handler + +import ( + "photowall/internal/middleware" + "photowall/internal/model" + "photowall/internal/service" + "photowall/pkg/response" + "strconv" + + "github.com/gin-gonic/gin" +) + +type MenuHandler struct { + menu *service.MenuService +} + +func NewMenuHandler(menu *service.MenuService) *MenuHandler { + return &MenuHandler{menu: menu} +} + +// GetUserMenus GET /api/menus 获取当前用户的菜单树(动态路由用) +func (h *MenuHandler) GetUserMenus(c *gin.Context) { + isAdmin := middleware.CurrentRole(c) == "admin" + tree, err := h.menu.GetMenuTree(isAdmin) + if err != nil { + response.Internal(c, "获取菜单失败") + return + } + response.OK(c, tree) +} + +// GetAll GET /api/admin/menus 获取所有菜单(管理用) +func (h *MenuHandler) GetAll(c *gin.Context) { + list, err := h.menu.GetAll() + if err != nil { + response.Internal(c, "获取菜单失败") + return + } + response.OK(c, list) +} + +// Create POST /api/admin/menus 创建菜单 +func (h *MenuHandler) Create(c *gin.Context) { + var m model.Menu + if err := c.ShouldBindJSON(&m); err != nil { + response.BadRequest(c, "参数错误:"+err.Error()) + return + } + if err := h.menu.Create(&m); err != nil { + response.Internal(c, "创建菜单失败") + return + } + response.Created(c, m) +} + +// Update PUT /api/admin/menus/:id 更新菜单 +func (h *MenuHandler) Update(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var m model.Menu + if err := c.ShouldBindJSON(&m); err != nil { + response.BadRequest(c, "参数错误:"+err.Error()) + return + } + m.ID = uint(id) + if err := h.menu.Update(&m); err != nil { + response.Internal(c, "更新菜单失败") + return + } + response.OK(c, m) +} + +// Delete DELETE /api/admin/menus/:id 删除菜单 +func (h *MenuHandler) Delete(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if err := h.menu.Delete(uint(id)); err != nil { + response.Internal(c, "删除菜单失败") + return + } + response.OK(c, gin.H{"message": "删除成功"}) +} diff --git a/go/internal/handler/region_handler.go b/go/internal/handler/region_handler.go new file mode 100644 index 0000000..3814857 --- /dev/null +++ b/go/internal/handler/region_handler.go @@ -0,0 +1,64 @@ +package handler + +import ( + "photowall/internal/service" + "photowall/pkg/response" + "strconv" + + "github.com/gin-gonic/gin" +) + +type RegionHandler struct { + svc *service.RegionService +} + +func NewRegionHandler(svc *service.RegionService) *RegionHandler { + return &RegionHandler{svc: svc} +} + +// List GET /api/regions?parent_id=0 +func (h *RegionHandler) List(c *gin.Context) { + parentID, _ := strconv.ParseUint(c.DefaultQuery("parent_id", "0"), 10, 64) + list, err := h.svc.ListByParent(uint(parentID)) + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, list) +} + +// Countries GET /api/regions/countries +func (h *RegionHandler) Countries(c *gin.Context) { + list, err := h.svc.ListCountries() + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, list) +} + +// Path GET /api/regions/:id/path +func (h *RegionHandler) Path(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + path, err := h.svc.GetPath(uint(id)) + if err != nil { + response.NotFound(c, "地区不存在") + return + } + response.OK(c, path) +} + +// Search GET /api/regions/search?keyword=xxx +func (h *RegionHandler) Search(c *gin.Context) { + keyword := c.Query("keyword") + if keyword == "" { + response.BadRequest(c, "请输入搜索关键词") + return + } + list, err := h.svc.Search(keyword, 20) + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, list) +} diff --git a/go/internal/handler/school_handler.go b/go/internal/handler/school_handler.go new file mode 100644 index 0000000..5a9d0fb --- /dev/null +++ b/go/internal/handler/school_handler.go @@ -0,0 +1,157 @@ +package handler + +import ( + "photowall/internal/model" + "photowall/internal/service" + "photowall/pkg/response" + "strconv" + + "github.com/gin-gonic/gin" +) + +type SchoolHandler struct { + svc *service.SchoolService +} + +func NewSchoolHandler(svc *service.SchoolService) *SchoolHandler { + return &SchoolHandler{svc: svc} +} + +// List GET /api/schools +func (h *SchoolHandler) List(c *gin.Context) { + var q service.SchoolQuery + if err := c.ShouldBindQuery(&q); err != nil { + response.BadRequest(c, "参数错误") + return + } + result, err := h.svc.List(&q) + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, result) +} + +// Create POST /api/schools +func (h *SchoolHandler) Create(c *gin.Context) { + var school model.School + if err := c.ShouldBindJSON(&school); err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.svc.Create(&school); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Created(c, school) +} + +// Get GET /api/schools/:id +func (h *SchoolHandler) Get(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + school, err := h.svc.GetByID(uint(id)) + if err != nil { + response.NotFound(c, err.Error()) + return + } + // 附带学院列表 + colleges, _ := h.svc.ListColleges(uint(id)) + response.OK(c, gin.H{"school": school, "colleges": colleges}) +} + +// Update PUT /api/schools/:id +func (h *SchoolHandler) Update(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body map[string]interface{} + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, "参数错误") + return + } + // 只允许更新部分字段 + updates := map[string]interface{}{} + if v, ok := body["name"]; ok { + updates["name"] = v + } + if v, ok := body["name_en"]; ok { + updates["name_en"] = v + } + if v, ok := body["school_type"]; ok { + updates["school_type"] = v + } + if v, ok := body["address"]; ok { + updates["address"] = v + } + if err := h.svc.Update(uint(id), updates); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "更新成功"}) +} + +// Delete DELETE /api/schools/:id +func (h *SchoolHandler) Delete(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.svc.Delete(uint(id)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "删除成功"}) +} + +// ---- 学院 ---- + +// ListColleges GET /api/schools/:id/colleges +func (h *SchoolHandler) ListColleges(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + list, err := h.svc.ListColleges(uint(id)) + if err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, list) +} + +// CreateCollege POST /api/schools/:id/colleges +func (h *SchoolHandler) CreateCollege(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body struct { + Name string `json:"name" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, "学院名称不能为空") + return + } + college, err := h.svc.CreateCollege(uint(id), body.Name) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Created(c, college) +} + +// UpdateCollege PUT /api/colleges/:id +func (h *SchoolHandler) UpdateCollege(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body struct { + Name string `json:"name" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, "学院名称不能为空") + return + } + if err := h.svc.UpdateCollege(uint(id), body.Name); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "更新成功"}) +} + +// DeleteCollege DELETE /api/colleges/:id +func (h *SchoolHandler) DeleteCollege(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.svc.DeleteCollege(uint(id)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "删除成功"}) +} diff --git a/go/internal/handler/upload_handler.go b/go/internal/handler/upload_handler.go new file mode 100644 index 0000000..cca8289 --- /dev/null +++ b/go/internal/handler/upload_handler.go @@ -0,0 +1,129 @@ +package handler + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "photowall/internal/config" + "photowall/internal/middleware" + "photowall/internal/model" + "photowall/pkg/response" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UploadHandler struct { + db *gorm.DB +} + +func NewUploadHandler(db *gorm.DB) *UploadHandler { + return &UploadHandler{db: db} +} + +// Upload POST /api/upload +// 通用文件上传,MD5去重,返回可访问的 URL。type 参数:class(毕业照)/photo(个人照片)/avatar +func (h *UploadHandler) Upload(c *gin.Context) { + userID := middleware.CurrentUserID(c) + uploadType := c.DefaultPostForm("type", "photo") + + allowedDirs := map[string]string{ + "class": "classes", + "photo": "photos", + "avatar": "avatars", + } + subDir, ok := allowedDirs[uploadType] + if !ok { + subDir = "photos" + } + + file, err := c.FormFile("file") + if err != nil { + response.BadRequest(c, "请选择要上传的文件") + return + } + + // 限制大小 10MB + if file.Size > 10*1024*1024 { + response.BadRequest(c, "文件大小不能超过 10MB") + return + } + + // 校验图片格式 + ext := strings.ToLower(filepath.Ext(file.Filename)) + allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true} + if !allowedExts[ext] { + response.BadRequest(c, "仅支持 JPG/PNG/GIF/WEBP 格式") + return + } + + // 打开文件计算 MD5 + src, err := file.Open() + if err != nil { + response.Internal(c, "文件读取失败") + return + } + defer src.Close() + + hash := md5.New() + if _, err := io.Copy(hash, src); err != nil { + response.Internal(c, "文件MD5计算失败") + return + } + fileMd5 := hex.EncodeToString(hash.Sum(nil)) + + // MD5 去重:已存在则返回已有路径 + var existing model.UploadFile + if err := h.db.Where("md5 = ?", fileMd5).First(&existing).Error; err == nil { + // 引用次数+1 + h.db.Model(&existing).Update("ref_count", gorm.Expr("ref_count + 1")) + response.OK(c, gin.H{ + "url": existing.FileUrl, + "filename": existing.FileName, + "size": existing.FileSize, + "md5": fileMd5, + "duplicate": true, + "message": "文件已存在,使用已有路径", + }) + return + } + + // 新文件:保存到磁盘 + src.Seek(0, io.SeekStart) // 重置文件指针 + now := time.Now().Format("20060102") + filename := fmt.Sprintf("%s_%d_%s%s", now, userID, uuid.New().String()[:8], ext) + relPath := filepath.Join(subDir, filename) + fullPath := filepath.Join(config.C.UploadDir, relPath) + + if err := c.SaveUploadedFile(file, fullPath); err != nil { + response.Internal(c, "文件保存失败:"+err.Error()) + return + } + + // 记录到文件表 + url := "/uploads/" + strings.ReplaceAll(relPath, "\\", "/") + record := model.UploadFile{ + Md5: fileMd5, + FilePath: relPath, + FileUrl: url, + FileName: filename, + FileSize: file.Size, + FileType: uploadType, + UploaderID: userID, + RefCount: 1, + } + h.db.Create(&record) + + response.OK(c, gin.H{ + "url": url, + "filename": filename, + "size": file.Size, + "md5": fileMd5, + "duplicate": false, + }) +} diff --git a/go/internal/handler/user_handler.go b/go/internal/handler/user_handler.go new file mode 100644 index 0000000..2ed77db --- /dev/null +++ b/go/internal/handler/user_handler.go @@ -0,0 +1,159 @@ +package handler + +import ( + "photowall/internal/middleware" + "photowall/internal/model" + "photowall/internal/service" + "photowall/pkg/response" + "strconv" + + "github.com/gin-gonic/gin" +) + +type UserHandler struct { + svc *service.UserService +} + +func NewUserHandler(svc *service.UserService) *UserHandler { + return &UserHandler{svc: svc} +} + +// Profile GET /api/user/profile +func (h *UserHandler) Profile(c *gin.Context) { + userID := middleware.CurrentUserID(c) + profile, err := h.svc.GetProfile(userID) + if err != nil { + response.NotFound(c, err.Error()) + return + } + response.OK(c, profile) +} + +// UpdateProfile PUT /api/user/profile +func (h *UserHandler) UpdateProfile(c *gin.Context) { + userID := middleware.CurrentUserID(c) + var req service.UpdateProfileReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.svc.UpdateProfile(userID, &req); err != nil { + response.Internal(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "更新成功"}) +} + +// AddContact POST /api/user/contacts +func (h *UserHandler) AddContact(c *gin.Context) { + userID := middleware.CurrentUserID(c) + var body struct { + Type model.ContactType `json:"type" binding:"required"` + Value string `json:"value" binding:"required"` + IsPrimary bool `json:"is_primary"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, "参数错误") + return + } + contact, err := h.svc.AddContact(userID, body.Type, body.Value, body.IsPrimary) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Created(c, contact) +} + +// UpdateContact PUT /api/user/contacts/:id +func (h *UserHandler) UpdateContact(c *gin.Context) { + userID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var body struct { + Value string `json:"value"` + IsPrimary *bool `json:"is_primary"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.svc.UpdateContact(userID, uint(id), body.Value, body.IsPrimary); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "更新成功"}) +} + +// DeleteContact DELETE /api/user/contacts/:id +func (h *UserHandler) DeleteContact(c *gin.Context) { + userID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.svc.DeleteContact(userID, uint(id)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "删除成功"}) +} + +// AddEducation POST /api/user/education +func (h *UserHandler) AddEducation(c *gin.Context) { + userID := middleware.CurrentUserID(c) + var e model.EducationHistory + if err := c.ShouldBindJSON(&e); err != nil { + response.BadRequest(c, "参数错误") + return + } + if e.SchoolName == "" { + response.BadRequest(c, "学校名称不能为空") + return + } + if err := h.svc.AddEducation(userID, &e); err != nil { + response.Internal(c, err.Error()) + return + } + response.Created(c, e) +} + +// UpdateEducation PUT /api/user/education/:id +func (h *UserHandler) UpdateEducation(c *gin.Context) { + userID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var e model.EducationHistory + if err := c.ShouldBindJSON(&e); err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.svc.UpdateEducation(userID, uint(id), &e); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "更新成功"}) +} + +// DeleteEducation DELETE /api/user/education/:id +func (h *UserHandler) DeleteEducation(c *gin.Context) { + userID := middleware.CurrentUserID(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.svc.DeleteEducation(userID, uint(id)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, gin.H{"message": "删除成功"}) +} + +// HasEducation GET /api/user/has-education (创建班级前置检查) +func (h *UserHandler) HasEducation(c *gin.Context) { + userID := middleware.CurrentUserID(c) + response.OK(c, gin.H{"has_education": h.svc.HasEducation(userID)}) +} + +// PublicProfile GET /api/user/:id 公开信息(班级成员查看) +func (h *UserHandler) PublicProfile(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + user, err := h.svc.GetUserByID(uint(id)) + if err != nil { + response.NotFound(c, err.Error()) + return + } + contacts := h.svc.GetUserContacts(uint(id)) + response.OK(c, gin.H{"user": user, "contacts": contacts}) +} diff --git a/go/internal/middleware/auth.go b/go/internal/middleware/auth.go new file mode 100644 index 0000000..09d6d1c --- /dev/null +++ b/go/internal/middleware/auth.go @@ -0,0 +1,82 @@ +package middleware + +import ( + "strings" + + "photowall/pkg/jwt" + "photowall/pkg/response" + + "github.com/gin-gonic/gin" +) + +// Context keys +const ( + CtxUserID = "user_id" + CtxUsername = "username" + CtxRole = "role" +) + +// Auth JWT 认证中间件 +func Auth(jm *jwt.Manager) gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if header == "" { + response.Unauthorized(c, "未登录") + c.Abort() + return + } + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + response.Unauthorized(c, "认证格式错误") + c.Abort() + return + } + claims, err := jm.Parse(parts[1]) + if err != nil { + response.Unauthorized(c, "登录已过期,请重新登录") + c.Abort() + return + } + c.Set(CtxUserID, claims.UserID) + c.Set(CtxUsername, claims.Username) + c.Set(CtxRole, claims.Role) + c.Next() + } +} + +// AdminOnly 平台管理员权限 +func AdminOnly() gin.HandlerFunc { + return func(c *gin.Context) { + role, _ := c.Get(CtxRole) + if role != "admin" { + response.Forbidden(c, "需要平台管理员权限") + c.Abort() + return + } + c.Next() + } +} + +// CurrentUserID 从 context 取当前用户ID +func CurrentUserID(c *gin.Context) uint { + v, ok := c.Get(CtxUserID) + if !ok { + return 0 + } + id, _ := v.(uint) + return id +} + +// CurrentUsername 从 context 取当前用户名 +func CurrentUsername(c *gin.Context) string { + v, _ := c.Get(CtxUsername) + s, _ := v.(string) + return s +} + +// CurrentRole 从 context 取当前角色 +func CurrentRole(c *gin.Context) string { + v, _ := c.Get(CtxRole) + s, _ := v.(string) + return s +} diff --git a/go/internal/model/auth.go b/go/internal/model/auth.go new file mode 100644 index 0000000..cbff786 --- /dev/null +++ b/go/internal/model/auth.go @@ -0,0 +1,24 @@ +package model + +import "time" + +// SmsCode 短信验证码表 +type SmsCode struct { + ID uint `gorm:"primaryKey" json:"id"` + Phone string `gorm:"size:20;index;not null" json:"phone"` + Code string `gorm:"size:8;not null" json:"-"` + Scene string `gorm:"size:32;not null" json:"scene"` // login / register / reset_password + Used bool `gorm:"default:false" json:"used"` + ExpireAt time.Time `json:"expire_at"` + CreatedAt time.Time `json:"created_at"` +} + +// PasswordReset 密码重置码表 +type PasswordReset struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Code string `gorm:"size:8;index;not null" json:"-"` + Used bool `gorm:"default:false" json:"used"` + ExpireAt time.Time `json:"expire_at"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/go/internal/model/class.go b/go/internal/model/class.go new file mode 100644 index 0000000..a5105a0 --- /dev/null +++ b/go/internal/model/class.go @@ -0,0 +1,116 @@ +package model + +import ( + "encoding/json" + "time" +) + +// ClassStatus 班级审核状态 +type ClassStatus string + +const ( + ClassPending ClassStatus = "pending" // 待审核(名字不足或等待平台审核) + ClassActive ClassStatus = "active" // 正常 + ClassRejected ClassStatus = "rejected" // 被打回,需补齐 + ClassDeleted ClassStatus = "deleted" // 已删除(30天未补齐自动删除或手动删除) +) + +// Class 班级 +// 唯一性:同一学校+学院+年级+班级序号+毕业年份 唯一 +type Class struct { + ID uint `gorm:"primaryKey" json:"id"` + SchoolID uint `gorm:"index:idx_class_unique,priority:1;not null" json:"school_id"` + CollegeID uint `gorm:"index:idx_class_unique,priority:2" json:"college_id"` + Name string `gorm:"size:128;not null" json:"name"` // 班级展示名,如"计算机科学与技术2018级1班" + Grade string `gorm:"size:32;index:idx_class_unique,priority:3" json:"grade"` // 年级,如"2018级" + ClassNo string `gorm:"size:32;index:idx_class_unique,priority:4" json:"class_no"` // 班级序号,如"1班"/"3班" + GraduateYear int `gorm:"index:idx_class_unique,priority:5" json:"graduate_year"` // 毕业年份 + AdminUserID uint `gorm:"index;not null" json:"admin_user_id"` + GraduationPhoto string `gorm:"size:512" json:"graduation_photo"` // 毕业照URL(每个班级唯一一张) + StudentNamesJSON string `gorm:"type:text" json:"-"` // 班级所有同学名字 JSON 数组 + TeacherNamesJSON string `gorm:"type:text" json:"-"` // 老师名字 JSON 数组 + Status ClassStatus `gorm:"size:16;default:pending;index" json:"status"` + RejectReason string `gorm:"size:512" json:"reject_reason"` + AuditDeadline time.Time `json:"audit_deadline"` // 补齐截止时间(创建+30天) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// StudentNames 读写同学名单 +func (c *Class) StudentNames() []string { + var list []string + if c.StudentNamesJSON == "" { + return list + } + _ = json.Unmarshal([]byte(c.StudentNamesJSON), &list) + return list +} + +func (c *Class) SetStudentNames(names []string) { + if names == nil { + names = []string{} + } + b, _ := json.Marshal(names) + c.StudentNamesJSON = string(b) +} + +// TeacherNames 读写老师名单 +func (c *Class) TeacherNames() []string { + var list []string + if c.TeacherNamesJSON == "" { + return list + } + _ = json.Unmarshal([]byte(c.TeacherNamesJSON), &list) + return list +} + +func (c *Class) SetTeacherNames(names []string) { + if names == nil { + names = []string{} + } + b, _ := json.Marshal(names) + c.TeacherNamesJSON = string(b) +} + +// MinStudentCount 加入班级验证所需最少同学数,也是创建班级录入名单的下限 +const MinStudentCount = 8 + +// ClassMemberRole 班级成员角色 +type ClassMemberRole string + +const ( + MemberRoleAdmin ClassMemberRole = "admin" + MemberRoleMember ClassMemberRole = "member" +) + +// JoinMethod 加入方式 +type JoinMethod string + +const ( + JoinByInvite JoinMethod = "invite" // 管理员邀请/邀请码 + JoinBySearch JoinMethod = "search" // 搜索+8人验证 +) + +// ClassMember 班级成员 +type ClassMember struct { + ID uint `gorm:"primaryKey" json:"id"` + ClassID uint `gorm:"uniqueIndex:idx_class_user,priority:1;not null" json:"class_id"` + UserID uint `gorm:"uniqueIndex:idx_class_user,priority:2;not null" json:"user_id"` + Role ClassMemberRole `gorm:"size:16;default:member;index" json:"role"` + JoinMethod JoinMethod `gorm:"size:16" json:"join_method"` + JoinedAt time.Time `json:"joined_at"` + CreatedAt time.Time `json:"created_at"` +} + +// Invitation 班级邀请码 +type Invitation struct { + ID uint `gorm:"primaryKey" json:"id"` + ClassID uint `gorm:"index;not null" json:"class_id"` + InviterID uint `gorm:"not null" json:"inviter_id"` + Code string `gorm:"size:32;uniqueIndex;not null" json:"code"` + MaxUses int `gorm:"default:50" json:"max_uses"` + UsedCount int `gorm:"default:0" json:"used_count"` + ExpiresAt time.Time `json:"expires_at"` + Status string `gorm:"size:16;default:active" json:"status"` // active / disabled + CreatedAt time.Time `json:"created_at"` +} diff --git a/go/internal/model/config.go b/go/internal/model/config.go new file mode 100644 index 0000000..13271e5 --- /dev/null +++ b/go/internal/model/config.go @@ -0,0 +1,18 @@ +package model + +import "time" + +// SystemConfig 系统配置表(key-value 存储) +type SystemConfig struct { + ID uint `gorm:"primaryKey" json:"id"` + Key string `gorm:"size:64;uniqueIndex;not null" json:"key"` + Value string `gorm:"size:1024" json:"value"` + Label string `gorm:"size:128" json:"label"` // 显示名称 + Description string `gorm:"size:512" json:"description"` // 参数说明 + Category string `gorm:"size:32;index" json:"category"` // 分类:basic/geetest/sms/wechat/storage + Placeholder string `gorm:"size:256" json:"placeholder"` // 输入框示例 + DocLink string `gorm:"size:256" json:"doc_link"` // 官方文档/获取地址 + Sort int `gorm:"default:0" json:"sort"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/go/internal/model/menu.go b/go/internal/model/menu.go new file mode 100644 index 0000000..7fdae4e --- /dev/null +++ b/go/internal/model/menu.go @@ -0,0 +1,28 @@ +package model + +import "time" + +// Menu 菜单/路由表(动态路由数据源) +type Menu struct { + ID uint `gorm:"primaryKey" json:"id"` + ParentID uint `gorm:"default:0;index" json:"parent_id"` + Name string `gorm:"size:64;uniqueIndex;not null" json:"name"` // 路由名称(唯一) + Path string `gorm:"size:255;not null" json:"path"` // 路由路径,如 /class/list + Component string `gorm:"size:255" json:"component"` // 组件相对路径,如 class/list/index(空则为目录) + Title string `gorm:"size:64;not null" json:"title"` // 菜单标题 + Icon string `gorm:"size:64" json:"icon"` // fontawesome 图标类名,如 fas fa-home + Sort int `gorm:"default:0" json:"sort"` // 排序(从小到大) + Visible bool `gorm:"default:true" json:"visible"` // 是否在菜单中显示 + RequireAuth bool `gorm:"default:true" json:"require_auth"` // 是否需要登录 + RequireAdmin bool `gorm:"default:false" json:"require_admin"` // 是否需要管理员权限 + KeepAlive bool `gorm:"default:false" json:"keep_alive"` // 是否缓存页面 + Redirect string `gorm:"size:255" json:"redirect"` // 重定向路径(目录菜单用) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// MenuTree 菜单树结构(前端用) +type MenuTree struct { + Menu + Children []*MenuTree `json:"children"` +} diff --git a/go/internal/model/photo.go b/go/internal/model/photo.go new file mode 100644 index 0000000..612986e --- /dev/null +++ b/go/internal/model/photo.go @@ -0,0 +1,37 @@ +package model + +import "time" + +// MaxPhotosPerUserPerClass 每人在每个班级最多留存的当期照片数 +const MaxPhotosPerUserPerClass = 10 + +// ClassPhoto 用户在班级下的个人当期照片(每人每班级最多10张) +type ClassPhoto struct { + ID uint `gorm:"primaryKey" json:"id"` + ClassID uint `gorm:"index;not null" json:"class_id"` + UserID uint `gorm:"index;not null" json:"user_id"` + PhotoURL string `gorm:"size:512;not null" json:"photo_url"` + Caption string `gorm:"size:255" json:"caption"` + CreatedAt time.Time `json:"created_at"` +} + +// AuditAction 审核动作 +type AuditAction string + +const ( + AuditSubmit AuditAction = "submit" // 提交审核 + AuditApprove AuditAction = "approve" // 平台审核通过 + AuditReject AuditAction = "reject" // 平台打回 + AuditAutoDelete AuditAction = "auto_delete" // 30天未补齐自动删除 + AuditManualDelete AuditAction = "manual_delete" +) + +// AuditLog 班级审核日志 +type AuditLog struct { + ID uint `gorm:"primaryKey" json:"id"` + ClassID uint `gorm:"index;not null" json:"class_id"` + AuditorID uint `json:"auditor_id"` // 平台管理员ID,系统自动操作为0 + Action AuditAction `gorm:"size:24;index;not null" json:"action"` + Reason string `gorm:"size:512" json:"reason"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/go/internal/model/region.go b/go/internal/model/region.go new file mode 100644 index 0000000..018c72a --- /dev/null +++ b/go/internal/model/region.go @@ -0,0 +1,62 @@ +package model + +import "time" + +// RegionLevel 地区层级 +type RegionLevel string + +const ( + LevelCountry RegionLevel = "country" + LevelProvince RegionLevel = "province" // 省/州/直辖市 + LevelCounty RegionLevel = "county" // 县/区(最低级) +) + +// Region 地区表,自关联三级:国家 -> 省/州 -> 县/区 +type Region struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:128;not null;index" json:"name"` + NameEn string `gorm:"size:128" json:"name_en"` + Level RegionLevel `gorm:"size:16;index;not null" json:"level"` + ParentID uint `gorm:"index" json:"parent_id"` + Code string `gorm:"size:32;index" json:"code"` // 行政区划代码/国家代码,可选 + SortOrder int `gorm:"default:0" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` +} + +// SchoolType 学校类型 +type SchoolType string + +const ( + SchoolUniversity SchoolType = "university" // 大学(含学院层级) + SchoolJuniorHigh SchoolType = "junior_high" // 初中 + SchoolSeniorHigh SchoolType = "senior_high" // 高中 + SchoolPrimary SchoolType = "primary" // 小学 + SchoolKindergarten SchoolType = "kindergarten" // 幼儿园 + SchoolVocational SchoolType = "vocational" // 职业学校/中专(也可设学院) + SchoolOther SchoolType = "other" +) + +// HasCollege 该类型学校是否有学院层级 +func (s SchoolType) HasCollege() bool { + return s == SchoolUniversity || s == SchoolVocational +} + +// School 学校 +type School struct { + ID uint `gorm:"primaryKey" json:"id"` + RegionID uint `gorm:"index;not null" json:"region_id"` // 必须挂在 county 级地区下 + Name string `gorm:"size:128;not null;index" json:"name"` + NameEn string `gorm:"size:128" json:"name_en"` + SchoolType SchoolType `gorm:"size:24;index;not null" json:"school_type"` + Address string `gorm:"size:255" json:"address"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// College 学院(仅大学/职业学校有) +type College struct { + ID uint `gorm:"primaryKey" json:"id"` + SchoolID uint `gorm:"index;not null" json:"school_id"` + Name string `gorm:"size:128;not null" json:"name"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/go/internal/model/sensitive.go b/go/internal/model/sensitive.go new file mode 100644 index 0000000..0e06dfc --- /dev/null +++ b/go/internal/model/sensitive.go @@ -0,0 +1,13 @@ +package model + +import "time" + +// SensitiveWord 敏感词表(全局屏蔽用) +type SensitiveWord struct { + ID uint `gorm:"primaryKey" json:"id"` + Word string `gorm:"size:128;uniqueIndex;not null" json:"word"` + Category string `gorm:"size:32;default:other" json:"category"` // politics/porn/violence/advertising/other + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/go/internal/model/upload.go b/go/internal/model/upload.go new file mode 100644 index 0000000..ba1a69e --- /dev/null +++ b/go/internal/model/upload.go @@ -0,0 +1,18 @@ +package model + +import "time" + +// UploadFile 上传文件记录表(MD5去重用) +type UploadFile struct { + ID uint `gorm:"primaryKey" json:"id"` + Md5 string `gorm:"size:32;uniqueIndex;not null" json:"md5"` + FilePath string `gorm:"size:512;not null" json:"file_path"` // 相对路径,如 classes/20260101_1_abc.jpg + FileUrl string `gorm:"size:512;not null" json:"file_url"` // 可访问URL,如 /uploads/classes/xxx.jpg + FileName string `gorm:"size:255" json:"file_name"` + FileSize int64 `gorm:"default:0" json:"file_size"` + FileType string `gorm:"size:32" json:"file_type"` // class/photo/avatar + UploaderID uint `gorm:"index" json:"uploader_id"` + RefCount int `gorm:"default:1" json:"ref_count"` // 引用次数 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/go/internal/model/user.go b/go/internal/model/user.go new file mode 100644 index 0000000..e7ed9a4 --- /dev/null +++ b/go/internal/model/user.go @@ -0,0 +1,73 @@ +package model + +import ( + "time" +) + +// User 用户表 +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:64;uniqueIndex;not null" json:"username"` + PasswordHash string `gorm:"size:32;not null" json:"-"` // MD5 32字符 + Salt string `gorm:"size:32;not null" json:"-"` // 密码盐 + Email string `gorm:"size:128;index" json:"email"` // 用于找回密码 + Nickname string `gorm:"size:64" json:"nickname"` + Avatar string `gorm:"size:512" json:"avatar"` + Bio string `gorm:"size:512" json:"bio"` + Role string `gorm:"size:16;default:user;index" json:"role"` // user / admin + Status string `gorm:"size:16;default:active;index" json:"status"` // active / banned + BanReason string `gorm:"size:255" json:"ban_reason"` + BannedAt *time.Time `json:"banned_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ContactType 联系方式类型 +type ContactType string + +const ( + ContactEmail ContactType = "email" + ContactQQ ContactType = "qq" + ContactWeChat ContactType = "wechat" + ContactPhone ContactType = "phone" + ContactAddress ContactType = "address" + ContactWorkUnit ContactType = "workunit" +) + +// ContactMaxCount 各类型联系方式上限 +var ContactMaxCount = map[ContactType]int{ + ContactEmail: 2, + ContactQQ: 5, + ContactWeChat: 2, + ContactPhone: 5, + ContactAddress: 1, + ContactWorkUnit: 1, +} + +// UserContact 用户联系方式 +type UserContact struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Type ContactType `gorm:"size:16;index;not null" json:"type"` + Value string `gorm:"size:255;not null" json:"value"` + IsPrimary bool `gorm:"default:false" json:"is_primary"` + CreatedAt time.Time `json:"created_at"` +} + +// EducationHistory 学习履历(用户必须至少有一条才能创建班级) +type EducationHistory struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + SchoolID uint `gorm:"index" json:"school_id,omitempty"` // 关联平台学校(可选) + CollegeID uint `gorm:"index" json:"college_id,omitempty"` // 关联学院(可选) + ClassID uint `gorm:"index" json:"class_id,omitempty"` // 关联班级(可选) + SchoolName string `gorm:"size:128;not null" json:"school_name"` // 学校名称(冗余,便于自由填写) + CollegeName string `gorm:"size:128" json:"college_name"` + Major string `gorm:"size:128" json:"major"` + Degree string `gorm:"size:64" json:"degree"` // 本科/硕士/博士/高中/初中/小学/幼儿园 + StartYear int `json:"start_year"` + EndYear int `json:"end_year"` + Description string `gorm:"size:512" json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/go/internal/router/router.go b/go/internal/router/router.go new file mode 100644 index 0000000..43541b4 --- /dev/null +++ b/go/internal/router/router.go @@ -0,0 +1,169 @@ +package router + +import ( + "photowall/internal/handler" + "photowall/internal/middleware" + "photowall/pkg/jwt" + + "github.com/gin-gonic/gin" +) + +// Deps 所有 handler 依赖 +type Deps struct { + Auth *handler.AuthHandler + User *handler.UserHandler + Region *handler.RegionHandler + School *handler.SchoolHandler + Class *handler.ClassHandler + Admin *handler.AdminHandler + Upload *handler.UploadHandler + Config *handler.ConfigHandler + Menu *handler.MenuHandler +} + +func Register(r *gin.Engine, jm *jwt.Manager, deps *Deps) { + // CORS 中间件 + r.Use(corsMiddleware()) + + // 静态文件:上传的图片 + r.Static("/uploads", "uploads") + + api := r.Group("/api") + + // ---- 公开路由 ---- + public := api.Group("") + { + // 图形验证码 + public.GET("/captcha", deps.Auth.Captcha) + + // 认证 + public.POST("/auth/register", deps.Auth.Register) + public.POST("/auth/login", deps.Auth.Login) + public.POST("/auth/forgot-password", deps.Auth.ForgotPassword) + public.POST("/auth/reset-password", deps.Auth.ResetPassword) + public.POST("/auth/send-sms", deps.Auth.SendSms) + public.POST("/auth/wechat-login", deps.Auth.WechatLogin) + public.GET("/auth/wechat-qrcode", deps.Auth.WechatQrcode) + + // 地区(公开只读) + public.GET("/regions", deps.Region.List) + public.GET("/regions/countries", deps.Region.Countries) + public.GET("/regions/search", deps.Region.Search) + public.GET("/regions/:id/path", deps.Region.Path) + + // 学校(公开只读) + public.GET("/schools", deps.School.List) + public.GET("/schools/:id", deps.School.Get) + public.GET("/schools/:id/colleges", deps.School.ListColleges) + + // 班级搜索(公开,仅返回 active) + public.GET("/classes/search", deps.Class.Search) + } + + // ---- 需要登录的路由 ---- + auth := api.Group("") + auth.Use(middleware.Auth(jm)) + { + // 当前用户 + auth.GET("/auth/me", deps.Auth.Me) + + // 动态路由:获取当前用户菜单 + auth.GET("/menus", deps.Menu.GetUserMenus) + + // 用户资料 + auth.GET("/user/profile", deps.User.Profile) + auth.PUT("/user/profile", deps.User.UpdateProfile) + auth.GET("/user/has-education", deps.User.HasEducation) + auth.GET("/user/:id", deps.User.PublicProfile) + + // 联系方式 + auth.POST("/user/contacts", deps.User.AddContact) + auth.PUT("/user/contacts/:id", deps.User.UpdateContact) + auth.DELETE("/user/contacts/:id", deps.User.DeleteContact) + + // 学习履历 + auth.POST("/user/education", deps.User.AddEducation) + auth.PUT("/user/education/:id", deps.User.UpdateEducation) + auth.DELETE("/user/education/:id", deps.User.DeleteEducation) + + // 学校/学院(登录用户可创建) + auth.POST("/schools", deps.School.Create) + auth.PUT("/schools/:id", deps.School.Update) + auth.DELETE("/schools/:id", deps.School.Delete) + auth.POST("/schools/:id/colleges", deps.School.CreateCollege) + auth.PUT("/colleges/:id", deps.School.UpdateCollege) + auth.DELETE("/colleges/:id", deps.School.DeleteCollege) + + // 班级 + auth.POST("/classes", deps.Class.Create) + auth.GET("/classes/mine", deps.Class.Mine) + auth.GET("/classes/:id", deps.Class.Detail) + auth.POST("/classes/:id/resubmit", deps.Class.Resubmit) + + // 加入班级 + auth.POST("/classes/join/search", deps.Class.JoinBySearch) + auth.POST("/classes/join/invite", deps.Class.JoinByInvitation) + + // 邀请码(管理员) + auth.POST("/classes/:id/invitation", deps.Class.CreateInvitation) + auth.GET("/classes/:id/invitations", deps.Class.ListInvitations) + auth.DELETE("/classes/:id/invitations/:inv_id", deps.Class.DisableInvitation) + + // 成员管理 + auth.POST("/classes/:id/transfer", deps.Class.TransferAdmin) + auth.DELETE("/classes/:id/members/:user_id", deps.Class.RemoveMember) + + // 个人照片 + auth.POST("/classes/:id/photos", deps.Class.UploadPhoto) + auth.DELETE("/classes/:id/photos/:photo_id", deps.Class.DeletePhoto) + + // 文件上传 + auth.POST("/upload", deps.Upload.Upload) + } + + // ---- 平台管理员路由 ---- + admin := api.Group("/admin") + admin.Use(middleware.Auth(jm), middleware.AdminOnly()) + { + admin.GET("/stats", deps.Admin.Stats) + admin.GET("/stats/detailed", deps.Admin.DetailedStats) + admin.GET("/classes", deps.Admin.AuditQueue) + admin.POST("/classes/:id/approve", deps.Admin.Approve) + admin.POST("/classes/:id/reject", deps.Admin.Reject) + admin.DELETE("/classes/:id", deps.Admin.Delete) + admin.GET("/classes/:id/logs", deps.Admin.AuditLogs) + admin.POST("/cleanup", deps.Admin.TriggerCleanup) + // 系统配置管理 + admin.GET("/config", deps.Config.GetAll) + admin.PUT("/config", deps.Config.Update) + // 菜单管理 + admin.GET("/menus", deps.Menu.GetAll) + admin.POST("/menus", deps.Menu.Create) + admin.PUT("/menus/:id", deps.Menu.Update) + admin.DELETE("/menus/:id", deps.Menu.Delete) + // 账号封禁管理 + admin.GET("/users/banned", deps.Admin.BannedUsers) + admin.POST("/users/:id/ban", deps.Admin.BanUser) + admin.POST("/users/:id/unban", deps.Admin.UnbanUser) + // 敏感词管理 + admin.GET("/sensitive-words", deps.Admin.SensitiveWords) + admin.POST("/sensitive-words", deps.Admin.AddSensitiveWord) + admin.DELETE("/sensitive-words/:id", deps.Admin.DeleteSensitiveWord) + admin.PUT("/sensitive-words/:id/toggle", deps.Admin.ToggleSensitiveWord) + } +} + +// corsMiddleware 简单 CORS 处理(开发环境允许所有来源) +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Requested-With") + c.Header("Access-Control-Max-Age", "86400") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + } +} diff --git a/go/internal/scheduler/cleanup.go b/go/internal/scheduler/cleanup.go new file mode 100644 index 0000000..d46d015 --- /dev/null +++ b/go/internal/scheduler/cleanup.go @@ -0,0 +1,35 @@ +package scheduler + +import ( + "log" + "photowall/internal/service" + "time" +) + +// StartCleanup 启动过期班级清理定时任务。 +// 每小时检查一次,超过 audit_deadline 仍未通过审核的班级自动删除。 +func StartCleanup(adminSvc *service.AdminService) { + go func() { + // 启动后延迟 1 分钟先跑一次,避免服务刚启动就堆积 + time.Sleep(1 * time.Minute) + runCleanup(adminSvc) + + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + runCleanup(adminSvc) + } + }() + log.Println("[scheduler] expired class cleanup started (runs hourly)") +} + +func runCleanup(adminSvc *service.AdminService) { + count, err := adminSvc.CleanupExpired() + if err != nil { + log.Printf("[scheduler] cleanup error: %v", err) + return + } + if count > 0 { + log.Printf("[scheduler] auto-deleted %d expired classes", count) + } +} diff --git a/go/internal/service/admin_service.go b/go/internal/service/admin_service.go new file mode 100644 index 0000000..473e60f --- /dev/null +++ b/go/internal/service/admin_service.go @@ -0,0 +1,388 @@ +package service + +import ( + "errors" + "fmt" + "photowall/internal/model" + "time" + + "gorm.io/gorm" +) + +type AdminService struct { + db *gorm.DB +} + +func NewAdminService(db *gorm.DB) *AdminService { + return &AdminService{db: db} +} + +// AuditQueueReq 审核队列查询 +type AuditQueueReq struct { + Status model.ClassStatus `form:"status"` + Keyword string `form:"keyword"` + Page int `form:"page"` + PageSize int `form:"page_size"` +} + +// ListAuditQueue 平台管理员查看审核队列 +func (s *AdminService) ListAuditQueue(req *AuditQueueReq) (*SchoolListResp, error) { + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 || req.PageSize > 100 { + req.PageSize = 20 + } + db := s.db.Model(&model.Class{}).Where("status != ?", model.ClassDeleted) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + if req.Keyword != "" { + db = db.Where("name LIKE ?", "%"+req.Keyword+"%") + } + var total int64 + db.Count(&total) + var list []model.Class + err := db.Order("audit_deadline asc, id desc").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&list).Error + if err != nil { + return nil, err + } + return &SchoolListResp{List: list, Total: total, Page: req.Page}, nil +} + +// Approve 审核通过 +func (s *AdminService) Approve(classID, auditorID uint, reason string) error { + var class model.Class + if err := s.db.First(&class, classID).Error; err != nil { + return errors.New("班级不存在") + } + if class.Status == model.ClassDeleted { + return errors.New("班级已删除") + } + class.Status = model.ClassActive + class.RejectReason = "" + if err := s.db.Save(&class).Error; err != nil { + return err + } + s.log(classID, auditorID, model.AuditApprove, reason) + return nil +} + +// Reject 打回补齐 +func (s *AdminService) Reject(classID, auditorID uint, reason string) error { + if reason == "" { + return errors.New("请填写打回原因") + } + var class model.Class + if err := s.db.First(&class, classID).Error; err != nil { + return errors.New("班级不存在") + } + if class.Status == model.ClassDeleted { + return errors.New("班级已删除") + } + class.Status = model.ClassRejected + class.RejectReason = reason + if err := s.db.Save(&class).Error; err != nil { + return err + } + s.log(classID, auditorID, model.AuditReject, reason) + return nil +} + +// ManualDelete 平台管理员手动删除班级 +func (s *AdminService) ManualDelete(classID, auditorID uint, reason string) error { + var class model.Class + if err := s.db.First(&class, classID).Error; err != nil { + return errors.New("班级不存在") + } + return s.deleteClass(class, auditorID, model.AuditManualDelete, reason) +} + +// CleanupExpired 清理超过审核截止时间仍未通过的班级(30天未补齐自动删除) +// 返回被删除的班级数量 +func (s *AdminService) CleanupExpired() (int, error) { + var expired []model.Class + // 状态为 pending 或 rejected,且已过 audit_deadline + err := s.db.Where("status IN ? AND audit_deadline < ?", + []model.ClassStatus{model.ClassPending, model.ClassRejected}, + time.Now()).Find(&expired).Error + if err != nil { + return 0, err + } + count := 0 + for _, class := range expired { + if err := s.deleteClass(class, 0, model.AuditAutoDelete, + fmt.Sprintf("超过 %d 天补齐期限未完成审核,系统自动删除", 30)); err == nil { + count++ + } + } + return count, nil +} + +// deleteClass 事务删除班级及其关联数据(成员、照片、邀请码) +func (s *AdminService) deleteClass(class model.Class, auditorID uint, action model.AuditAction, reason string) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // 标记删除 + if err := tx.Model(&model.Class{}).Where("id = ?", class.ID). + Updates(map[string]interface{}{ + "status": model.ClassDeleted, + "reject_reason": reason, + }).Error; err != nil { + return err + } + // 删除成员关系 + tx.Where("class_id = ?", class.ID).Delete(&model.ClassMember{}) + // 删除照片记录(文件保留在磁盘,避免误删;如需清理可加定时任务) + tx.Where("class_id = ?", class.ID).Delete(&model.ClassPhoto{}) + // 禁用邀请码 + tx.Model(&model.Invitation{}).Where("class_id = ?", class.ID).Update("status", "disabled") + // 审核日志 + log := &model.AuditLog{ + ClassID: class.ID, + AuditorID: auditorID, + Action: action, + Reason: reason, + } + return tx.Create(log).Error + }) +} + +func (s *AdminService) log(classID, auditorID uint, action model.AuditAction, reason string) { + log := &model.AuditLog{ + ClassID: classID, + AuditorID: auditorID, + Action: action, + Reason: reason, + } + s.db.Create(log) +} + +// AuditLogs 查看班级审核日志 +func (s *AdminService) AuditLogs(classID uint) ([]model.AuditLog, error) { + var list []model.AuditLog + err := s.db.Where("class_id = ?", classID).Order("id desc").Find(&list).Error + return list, err +} + +// Stats 平台数据概览 +type AdminStats struct { + TotalUsers int64 `json:"total_users"` + TotalClasses int64 `json:"total_classes"` + ActiveClasses int64 `json:"active_classes"` + PendingClasses int64 `json:"pending_classes"` + RejectedClasses int64 `json:"rejected_classes"` + TotalSchools int64 `json:"total_schools"` + TotalPhotos int64 `json:"total_photos"` +} + +func (s *AdminService) Stats() (*AdminStats, error) { + stats := &AdminStats{} + s.db.Model(&model.User{}).Where("role = ?", "user").Count(&stats.TotalUsers) + s.db.Model(&model.Class{}).Where("status != ?", model.ClassDeleted).Count(&stats.TotalClasses) + s.db.Model(&model.Class{}).Where("status = ?", model.ClassActive).Count(&stats.ActiveClasses) + s.db.Model(&model.Class{}).Where("status = ?", model.ClassPending).Count(&stats.PendingClasses) + s.db.Model(&model.Class{}).Where("status = ?", model.ClassRejected).Count(&stats.RejectedClasses) + s.db.Model(&model.School{}).Count(&stats.TotalSchools) + s.db.Model(&model.ClassPhoto{}).Count(&stats.TotalPhotos) + return stats, nil +} + +// ============ 详细数据统计 ============ + +type TrendItem struct { + Date string `json:"date"` + Count int64 `json:"count"` +} + +type NameCountItem struct { + Name string `json:"name"` + Count int64 `json:"count"` +} + +type DetailedStats struct { + Core *AdminStats `json:"core"` + UserTrend7d []TrendItem `json:"user_trend_7d"` + ClassTrend7d []TrendItem `json:"class_trend_7d"` + ClassStatus []NameCountItem `json:"class_status"` + SchoolTypes []NameCountItem `json:"school_types"` + TopRegions []NameCountItem `json:"top_regions"` + GraduateYears []NameCountItem `json:"graduate_years"` + PhotoStats map[string]int64 `json:"photo_stats"` +} + +func (s *AdminService) DetailedStats() (*DetailedStats, error) { + core, _ := s.Stats() + ds := &DetailedStats{Core: core} + + // 近7天用户增长 + ds.UserTrend7d = s.dailyTrend("yz_pw_users", 7) + // 近7天班级增长 + ds.ClassTrend7d = s.dailyTrend("yz_pw_classes", 7) + + // 班级状态分布 + ds.ClassStatus = []NameCountItem{ + {Name: "待审核", Count: core.PendingClasses}, + {Name: "已通过", Count: core.ActiveClasses}, + {Name: "被打回", Count: core.RejectedClasses}, + } + + // 学校类型分布 + type schoolTypeRow struct { + SchoolType string `gorm:"column:school_type"` + Cnt int64 `gorm:"column:cnt"` + } + var stRows []schoolTypeRow + s.db.Model(&model.School{}).Select("school_type, COUNT(*) as cnt").Group("school_type").Scan(&stRows) + typeMap := map[string]string{"university": "大学", "vocational": "职业学校", "senior_high": "高中", "junior_high": "初中", "primary": "小学", "kindergarten": "幼儿园", "other": "其他"} + for _, r := range stRows { + name := typeMap[r.SchoolType] + if name == "" { + name = r.SchoolType + } + ds.SchoolTypes = append(ds.SchoolTypes, NameCountItem{Name: name, Count: r.Cnt}) + } + + // 地区TOP10(按省级行政区统计班级数) + type regionRow struct { + Name string `gorm:"column:name"` + Cnt int64 `gorm:"column:cnt"` + } + var rRows []regionRow + s.db.Raw(` + SELECT r.name, COUNT(c.id) as cnt + FROM yz_pw_classes c + JOIN yz_pw_schools s ON c.school_id = s.id + JOIN yz_pw_regions r ON s.region_id = r.id + WHERE c.status != 'deleted' + GROUP BY r.name + ORDER BY cnt DESC + LIMIT 10 + `).Scan(&rRows) + for _, r := range rRows { + ds.TopRegions = append(ds.TopRegions, NameCountItem{Name: r.Name, Count: r.Cnt}) + } + + // 毕业年份分布 + var gyRows []struct { + GraduateYear int `gorm:"column:graduate_year"` + Cnt int64 `gorm:"column:cnt"` + } + s.db.Model(&model.Class{}).Select("graduate_year, COUNT(*) as cnt"). + Where("status != ?", "deleted").Group("graduate_year").Order("graduate_year DESC").Limit(10).Scan(&gyRows) + for _, r := range gyRows { + ds.GraduateYears = append(ds.GraduateYears, NameCountItem{Name: fmt.Sprintf("%d届", r.GraduateYear), Count: r.Cnt}) + } + + // 照片统计 + var gradPhotos int64 + s.db.Model(&model.Class{}).Where("graduation_photo != ? AND status != ?", "", "deleted").Count(&gradPhotos) + ds.PhotoStats = map[string]int64{ + "graduation_photos": gradPhotos, + "personal_photos": core.TotalPhotos, + "total_photos": gradPhotos + core.TotalPhotos, + } + + return ds, nil +} + +// dailyTrend 查询近N天每日新增数量 +func (s *AdminService) dailyTrend(table string, days int) []TrendItem { + var items []TrendItem + s.db.Raw(fmt.Sprintf(` + SELECT DATE(created_at) as date, COUNT(*) as count + FROM %s + WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL %d DAY) + GROUP BY DATE(created_at) + ORDER BY date ASC + `, table, days)).Scan(&items) + // 补全缺失的日期 + result := make([]TrendItem, 0, days) + now := time.Now() + for i := days - 1; i >= 0; i-- { + date := now.AddDate(0, 0, -i).Format("2006-01-02") + count := int64(0) + for _, item := range items { + if item.Date == date { + count = item.Count + break + } + } + result = append(result, TrendItem{Date: date, Count: count}) + } + return result +} + +// ============ 账号封禁管理 ============ + +type BanUserReq struct { + UserID uint `json:"user_id" binding:"required"` + Reason string `json:"reason" binding:"required"` +} + +// BanUser 封禁用户 +func (s *AdminService) BanUser(req *BanUserReq, adminID uint) error { + var user model.User + if err := s.db.First(&user, req.UserID).Error; err != nil { + return errors.New("用户不存在") + } + if user.Role == "admin" { + return errors.New("不能封禁管理员账号") + } + now := time.Now() + user.Status = "banned" + user.BanReason = req.Reason + user.BannedAt = &now + return s.db.Save(&user).Error +} + +// UnbanUser 解封用户 +func (s *AdminService) UnbanUser(userID uint) error { + var user model.User + if err := s.db.First(&user, userID).Error; err != nil { + return errors.New("用户不存在") + } + user.Status = "active" + user.BanReason = "" + user.BannedAt = nil + return s.db.Save(&user).Error +} + +// ListBannedUsers 获取封禁用户列表 +func (s *AdminService) ListBannedUsers() ([]model.User, error) { + var list []model.User + err := s.db.Where("status = ?", "banned").Order("banned_at desc").Find(&list).Error + return list, err +} + +// ============ 敏感词管理 ============ + +// ListSensitiveWords 获取敏感词列表 +func (s *AdminService) ListSensitiveWords() ([]model.SensitiveWord, error) { + var list []model.SensitiveWord + err := s.db.Order("category, id").Find(&list).Error + return list, err +} + +// AddSensitiveWord 添加敏感词 +func (s *AdminService) AddSensitiveWord(word, category string) (*model.SensitiveWord, error) { + var count int64 + s.db.Model(&model.SensitiveWord{}).Where("word = ?", word).Count(&count) + if count > 0 { + return nil, errors.New("该敏感词已存在") + } + sw := &model.SensitiveWord{Word: word, Category: category, Enabled: true} + if err := s.db.Create(sw).Error; err != nil { + return nil, err + } + return sw, nil +} + +// DeleteSensitiveWord 删除敏感词 +func (s *AdminService) DeleteSensitiveWord(id uint) error { + return s.db.Delete(&model.SensitiveWord{}, id).Error +} + +// ToggleSensitiveWord 启用/禁用敏感词 +func (s *AdminService) ToggleSensitiveWord(id uint, enabled bool) error { + return s.db.Model(&model.SensitiveWord{}).Where("id = ?", id).Update("enabled", enabled).Error +} diff --git a/go/internal/service/auth_service.go b/go/internal/service/auth_service.go new file mode 100644 index 0000000..b51424e --- /dev/null +++ b/go/internal/service/auth_service.go @@ -0,0 +1,279 @@ +package service + +import ( + "errors" + "fmt" + "math/rand" + "photowall/internal/config" + "photowall/internal/model" + "photowall/pkg/captcha" + "photowall/pkg/hash" + "photowall/pkg/jwt" + "time" + + "gorm.io/gorm" +) + +type AuthService struct { + db *gorm.DB + jm *jwt.Manager +} + +func NewAuthService(db *gorm.DB, jm *jwt.Manager) *AuthService { + return &AuthService{db: db, jm: jm} +} + +// ============ 请求结构 ============ + +type RegisterReq struct { + Username string `json:"username" binding:"required,min=3,max=32"` + Password string `json:"password" binding:"required,min=6,max=64"` + Nickname string `json:"nickname"` + Email string `json:"email"` + CaptchaID string `json:"captcha_id" binding:"required"` + CaptchaCode string `json:"captcha_code" binding:"required"` +} + +type LoginReq struct { + Username string `json:"username"` + Password string `json:"password"` + LoginType string `json:"login_type"` // password / sms + Phone string `json:"phone"` + SmsCode string `json:"sms_code"` + CaptchaID string `json:"captcha_id"` + CaptchaCode string `json:"captcha_code"` +} + +type LoginResp struct { + Token string `json:"token"` + User model.User `json:"user"` + ExpireIn int `json:"expire_in"` +} + +type ForgotPasswordReq struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required"` +} + +type ResetPasswordReq struct { + Username string `json:"username" binding:"required"` + ResetCode string `json:"reset_code" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=6,max=64"` +} + +type SendSmsReq struct { + Phone string `json:"phone" binding:"required"` + Scene string `json:"scene" binding:"required"` // login / register / reset_password +} + +// ============ 注册 ============ + +func (s *AuthService) Register(req *RegisterReq) (*model.User, error) { + // 校验图形验证码 + if !captcha.Verify(req.CaptchaID, req.CaptchaCode) { + return nil, errors.New("验证码错误或已过期") + } + var count int64 + if err := s.db.Model(&model.User{}).Where("username = ?", req.Username).Count(&count).Error; err != nil { + return nil, err + } + if count > 0 { + return nil, errors.New("用户名已被注册") + } + salt, pwdHash := hash.Password(req.Password) + nickname := req.Nickname + if nickname == "" { + nickname = req.Username + } + user := &model.User{ + Username: req.Username, + PasswordHash: pwdHash, + Salt: salt, + Email: req.Email, + Nickname: nickname, + Role: "user", + } + if err := s.db.Create(user).Error; err != nil { + return nil, err + } + return user, nil +} + +// ============ 登录 ============ + +func (s *AuthService) Login(req *LoginReq) (*LoginResp, error) { + // 图形验证码校验(密码登录必须,短信登录可选) + if req.LoginType != "sms" { + if !captcha.Verify(req.CaptchaID, req.CaptchaCode) { + return nil, errors.New("验证码错误或已过期") + } + } + + var user model.User + if req.LoginType == "sms" { + // 短信验证码登录 + if req.Phone == "" || req.SmsCode == "" { + return nil, errors.New("手机号和验证码不能为空") + } + // 校验短信验证码 + var sms model.SmsCode + if err := s.db.Where("phone = ? AND scene = ? AND used = ?", req.Phone, "login", false). + Order("id DESC").First(&sms).Error; err != nil { + return nil, errors.New("验证码错误或已过期") + } + if sms.Code != req.SmsCode || time.Now().After(sms.ExpireAt) { + return nil, errors.New("验证码错误或已过期") + } + sms.Used = true + s.db.Save(&sms) + // 查找或创建用户 + if err := s.db.Where("username = ?", req.Phone).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // 自动注册 + salt, pwdHash := hash.Password(randomCode(8)) + user = model.User{ + Username: req.Phone, + PasswordHash: pwdHash, + Salt: salt, + Nickname: "手机用户" + req.Phone[len(req.Phone)-4:], + Role: "user", + } + s.db.Create(&user) + } else { + return nil, err + } + } + } else { + // 账号密码登录 + if req.Username == "" || req.Password == "" { + return nil, errors.New("用户名和密码不能为空") + } + if err := s.db.Where("username = ?", req.Username).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("用户名或密码错误") + } + return nil, err + } + if !hash.Verify(user.PasswordHash, user.Salt, req.Password) { + return nil, errors.New("用户名或密码错误") + } + } + + // 检查账号状态 + if user.Status == "banned" { + return nil, fmt.Errorf("账号已被封禁:%s", user.BanReason) + } + + token, err := s.jm.Generate(user.ID, user.Username, user.Role) + if err != nil { + return nil, err + } + return &LoginResp{Token: token, User: user, ExpireIn: s.jm.ExpireHrs}, nil +} + +// ============ 忘记密码 ============ + +func (s *AuthService) ForgotPassword(req *ForgotPasswordReq) (string, error) { + var user model.User + if err := s.db.Where("username = ? AND email = ?", req.Username, req.Email).First(&user).Error; err != nil { + return "", errors.New("用户名与邮箱不匹配") + } + // 生成6位重置码 + code := fmt.Sprintf("%06d", rand.Intn(1000000)) + reset := &model.PasswordReset{ + UserID: user.ID, + Code: code, + ExpireAt: time.Now().Add(30 * time.Minute), + } + if err := s.db.Create(reset).Error; err != nil { + return "", err + } + // 开发阶段:重置码直接返回(生产环境应发送邮件) + // TODO: 接入邮件服务后,通过邮箱发送重置码 + return code, nil +} + +// ============ 重置密码 ============ + +func (s *AuthService) ResetPassword(req *ResetPasswordReq) error { + var user model.User + if err := s.db.Where("username = ?", req.Username).First(&user).Error; err != nil { + return errors.New("用户不存在") + } + var reset model.PasswordReset + if err := s.db.Where("user_id = ? AND code = ? AND used = ?", user.ID, req.ResetCode, false). + Order("id DESC").First(&reset).Error; err != nil { + return errors.New("重置码错误或已过期") + } + if time.Now().After(reset.ExpireAt) { + return errors.New("重置码已过期") + } + salt, pwdHash := hash.Password(req.NewPassword) + user.PasswordHash = pwdHash + user.Salt = salt + reset.Used = true + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Save(&user).Error; err != nil { + return err + } + return tx.Save(&reset).Error + }) +} + +// ============ 发送短信验证码(预留) ============ + +func (s *AuthService) SendSmsCode(req *SendSmsReq) (string, error) { + cfg := config.C + // 参数未配置时返回提示(开发阶段直接返回验证码) + if cfg.SMSAccessKey == "" || cfg.SMSSecretKey == "" { + code := fmt.Sprintf("%06d", rand.Intn(1000000)) + sms := &model.SmsCode{ + Phone: req.Phone, + Code: code, + Scene: req.Scene, + ExpireAt: time.Now().Add(5 * time.Minute), + } + s.db.Create(sms) + // 开发阶段直接返回验证码(生产环境应通过短信服务商发送) + return code, nil + } + // TODO: 接入阿里云/腾讯云短信SDK后发送真实短信 + code := fmt.Sprintf("%06d", rand.Intn(1000000)) + sms := &model.SmsCode{ + Phone: req.Phone, + Code: code, + Scene: req.Scene, + ExpireAt: time.Now().Add(5 * time.Minute), + } + s.db.Create(sms) + return code, nil +} + +// ============ 微信扫码登录(预留) ============ + +type WechatLoginReq struct { + Code string `json:"code"` // 微信授权code +} + +func (s *AuthService) WechatLogin(req *WechatLoginReq) (*LoginResp, error) { + cfg := config.C + if cfg.WechatAppID == "" || cfg.WechatAppSecret == "" { + return nil, errors.New("微信登录未配置,请联系管理员") + } + // TODO: 接入微信开放平台API + // 1. 用 code 换取 access_token 和 openid + // 2. 用 openid 查找或创建用户 + // 3. 签发 JWT + return nil, errors.New("微信登录功能开发中") +} + +// ============ 工具 ============ + +func randomCode(length int) string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + b[i] = chars[rand.Intn(len(chars))] + } + return string(b) +} diff --git a/go/internal/service/class_service.go b/go/internal/service/class_service.go new file mode 100644 index 0000000..27ed0b3 --- /dev/null +++ b/go/internal/service/class_service.go @@ -0,0 +1,628 @@ +package service + +import ( + "errors" + "fmt" + "photowall/internal/config" + "photowall/internal/model" + "photowall/pkg/sensitive" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ClassService struct { + db *gorm.DB +} + +func NewClassService(db *gorm.DB) *ClassService { + return &ClassService{db: db} +} + +// CreateClassReq 创建班级请求 +type CreateClassReq struct { + SchoolID uint `json:"school_id" binding:"required"` + CollegeID uint `json:"college_id"` + Name string `json:"name" binding:"required"` + Grade string `json:"grade" binding:"required"` // 如"2018级" + ClassNo string `json:"class_no" binding:"required"` + GraduateYear int `json:"graduate_year" binding:"required"` + GraduationPhoto string `json:"graduation_photo"` + StudentNames []string `json:"student_names"` + TeacherNames []string `json:"teacher_names"` +} + +// cleanNames 清洗名字:去空格、去空、去重,保持顺序 +func cleanNames(names []string) []string { + seen := map[string]bool{} + result := []string{} + for _, n := range names { + n = strings.TrimSpace(n) + if n == "" { + continue + } + if seen[n] { + continue + } + seen[n] = true + result = append(result, n) + } + return result +} + +// Create 创建班级 +func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, error) { + // 0. 敏感词检测 + if found, word := sensitive.Check(req.Name); found { + return nil, fmt.Errorf("班级名称包含敏感词:%s", word) + } + // 1. 校验用户有学习履历 + var eduCount int64 + s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount) + if eduCount == 0 { + return nil, errors.New("请先完善个人学习履历,有学习履历的用户才能创建班级") + } + + // 2. 校验学校 + var school model.School + if err := s.db.First(&school, req.SchoolID).Error; err != nil { + return nil, errors.New("学校不存在") + } + + // 3. 校验学院(如果传了) + if req.CollegeID > 0 { + if !school.SchoolType.HasCollege() { + return nil, errors.New("该学校类型不支持学院层级") + } + var college model.College + if err := s.db.Where("id = ? AND school_id = ?", req.CollegeID, req.SchoolID).First(&college).Error; err != nil { + return nil, errors.New("学院不存在或不属于该学校") + } + } else if school.SchoolType.HasCollege() { + // 大学/职业学校必须选学院 + return nil, errors.New("该学校需要选择所属学院") + } + + // 4. 唯一性校验 + var existCount int64 + q := s.db.Model(&model.Class{}).Where("school_id = ? AND grade = ? AND class_no = ? AND graduate_year = ? AND status != ?", + req.SchoolID, req.Grade, req.ClassNo, req.GraduateYear, model.ClassDeleted) + if req.CollegeID > 0 { + q = q.Where("college_id = ?", req.CollegeID) + } else { + q = q.Where("college_id = 0 OR college_id IS NULL") + } + q.Count(&existCount) + if existCount > 0 { + return nil, errors.New("该班级已存在,请勿重复创建") + } + + // 5. 清洗名单 + students := cleanNames(req.StudentNames) + teachers := cleanNames(req.TeacherNames) + + // 6. 构建班级 + class := &model.Class{ + SchoolID: req.SchoolID, + CollegeID: req.CollegeID, + Name: req.Name, + Grade: req.Grade, + ClassNo: req.ClassNo, + GraduateYear: req.GraduateYear, + AdminUserID: userID, + GraduationPhoto: req.GraduationPhoto, + AuditDeadline: time.Now().AddDate(0, 0, config.C.AuditGraceDays), + } + class.SetStudentNames(students) + class.SetTeacherNames(teachers) + + // 7. 名字不足8个直接打回 + if len(students) < model.MinStudentCount { + class.Status = model.ClassRejected + class.RejectReason = fmt.Sprintf("班级同学名单仅 %d 人,需至少录入 %d 人(毕业照上通常有全部同学姓名,请补齐后重新提交)", len(students), model.MinStudentCount) + } else { + class.Status = model.ClassPending + } + + // 8. 事务:创建班级 + 创建者成为管理员成员 + 审核日志 + err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(class).Error; err != nil { + return err + } + member := &model.ClassMember{ + ClassID: class.ID, + UserID: userID, + Role: model.MemberRoleAdmin, + JoinMethod: model.JoinByInvite, // 创建者视同邀请 + JoinedAt: time.Now(), + } + if err := tx.Create(member).Error; err != nil { + return err + } + log := &model.AuditLog{ + ClassID: class.ID, + AuditorID: userID, + Action: model.AuditSubmit, + Reason: fmt.Sprintf("创建班级,录入同学 %d 人、老师 %d 人", len(students), len(teachers)), + } + return tx.Create(log).Error + }) + if err != nil { + return nil, err + } + return class, nil +} + +// ClassDetail 班级详情 +type ClassDetail struct { + Class model.Class `json:"class"` + StudentNames []string `json:"student_names"` + TeacherNames []string `json:"teacher_names"` + School *model.School `json:"school,omitempty"` + College *model.College `json:"college,omitempty"` + Admin *model.User `json:"admin,omitempty"` + Members []ClassMemberInfo `json:"members"` + Photos []model.ClassPhoto `json:"photos"` + MyRole string `json:"my_role"` // 当前用户角色,未加入为空 + IsMember bool `json:"is_member"` + CanViewSensitive bool `json:"can_view_sensitive"` // 是否可查看照片和联系方式(成员/管理员) +} + +type ClassMemberInfo struct { + Member model.ClassMember `json:"member"` + User model.User `json:"user"` + Contacts []model.UserContact `json:"contacts,omitempty"` +} + +func (s *ClassService) GetDetail(classID, currentUserID uint, isSuperAdmin bool) (*ClassDetail, error) { + var class model.Class + if err := s.db.First(&class, classID).Error; err != nil { + return nil, errors.New("班级不存在") + } + if class.Status == model.ClassDeleted { + return nil, errors.New("班级已删除") + } + + // 数据隔离:只有班级成员或超级管理员可查看完整信息(照片+联系方式) + canViewSensitive := isSuperAdmin || (currentUserID > 0 && s.isMember(classID, currentUserID)) + + detail := &ClassDetail{ + Class: class, + StudentNames: class.StudentNames(), + TeacherNames: class.TeacherNames(), + CanViewSensitive: canViewSensitive, + } + + // 学校 + var school model.School + if s.db.First(&school, class.SchoolID).Error == nil { + detail.School = &school + } + // 学院 + if class.CollegeID > 0 { + var college model.College + if s.db.First(&college, class.CollegeID).Error == nil { + detail.College = &college + } + } + // 管理员 + var admin model.User + if s.db.First(&admin, class.AdminUserID).Error == nil { + detail.Admin = &admin + } + + // 成员列表 + var members []model.ClassMember + s.db.Where("class_id = ?", classID).Order("role desc, joined_at").Find(&members) + for _, m := range members { + var u model.User + s.db.First(&u, m.UserID) + info := ClassMemberInfo{Member: m, User: u} + // 数据隔离:同班成员或超级管理员可查看联系方式 + if canViewSensitive { + info.Contacts = s.getUserContacts(m.UserID) + } + detail.Members = append(detail.Members, info) + } + + // 照片墙:数据隔离,只有成员或超级管理员可查看 + if canViewSensitive { + var photos []model.ClassPhoto + s.db.Where("class_id = ?", classID).Order("user_id, id desc").Find(&photos) + detail.Photos = photos + } else { + detail.Photos = []model.ClassPhoto{} + } + + // 当前用户角色 + if currentUserID > 0 { + var myMember model.ClassMember + if s.db.Where("class_id = ? AND user_id = ?", classID, currentUserID).First(&myMember).Error == nil { + detail.MyRole = string(myMember.Role) + detail.IsMember = true + } + } + return detail, nil +} + +func (s *ClassService) isMember(classID, userID uint) bool { + var count int64 + s.db.Model(&model.ClassMember{}).Where("class_id = ? AND user_id = ?", classID, userID).Count(&count) + return count > 0 +} + +func (s *ClassService) getUserContacts(userID uint) []model.UserContact { + var list []model.UserContact + s.db.Where("user_id = ?", userID).Order("type, is_primary desc").Find(&list) + return list +} + +// SearchClassReq 搜索班级 +type SearchClassReq struct { + Keyword string `form:"keyword"` + SchoolID uint `form:"school_id"` + Grade string `form:"grade"` + GraduateYear int `form:"graduate_year"` + Page int `form:"page"` + PageSize int `form:"page_size"` +} + +// Search 搜索可加入的班级(仅 active 状态) +func (s *ClassService) Search(req *SearchClassReq) (*SchoolListResp, error) { + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 || req.PageSize > 100 { + req.PageSize = 20 + } + db := s.db.Model(&model.Class{}).Where("status = ?", model.ClassActive) + if req.SchoolID > 0 { + db = db.Where("school_id = ?", req.SchoolID) + } + if req.Grade != "" { + db = db.Where("grade = ?", req.Grade) + } + if req.GraduateYear > 0 { + db = db.Where("graduate_year = ?", req.GraduateYear) + } + if req.Keyword != "" { + db = db.Where("name LIKE ?", "%"+req.Keyword+"%") + } + var total int64 + db.Count(&total) + var list []model.Class + err := db.Order("graduate_year desc, id desc").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&list).Error + if err != nil { + return nil, err + } + return &SchoolListResp{List: list, Total: total, Page: req.Page}, nil +} + +// JoinBySearchReq 搜索加入验证 +type JoinBySearchReq struct { + ClassID uint `json:"class_id" binding:"required"` + StudentNames []string `json:"student_names" binding:"required,min=8"` +} + +// JoinBySearch 通过搜索+8人验证加入班级 +func (s *ClassService) JoinBySearch(userID uint, req *JoinBySearchReq) error { + var class model.Class + if err := s.db.First(&class, req.ClassID).Error; err != nil { + return errors.New("班级不存在") + } + if class.Status != model.ClassActive { + return errors.New("该班级尚未通过审核,暂不能加入") + } + // 已加入? + if s.isMember(class.ID, userID) { + return errors.New("你已经是该班级成员") + } + // 验证8个名字 + classStudents := class.StudentNames() + classSet := map[string]bool{} + for _, n := range classStudents { + classSet[strings.TrimSpace(n)] = true + } + input := cleanNames(req.StudentNames) + if len(input) < model.MinStudentCount { + return fmt.Errorf("请输入至少 %d 位同学的名字", model.MinStudentCount) + } + for _, n := range input { + if !classSet[n] { + return fmt.Errorf("同学「%s」不在该班级名单中,验证失败,请确认名字与毕业照一致", n) + } + } + // 全部匹配,加入 + member := &model.ClassMember{ + ClassID: class.ID, + UserID: userID, + Role: model.MemberRoleMember, + JoinMethod: model.JoinBySearch, + JoinedAt: time.Now(), + } + return s.db.Create(member).Error +} + +// ---- 邀请码 ---- + +// CreateInvitation 管理员生成邀请码 +func (s *ClassService) CreateInvitation(classID, userID uint, maxUses int, expireDays int) (*model.Invitation, error) { + if !s.isAdmin(classID, userID) { + return nil, errors.New("只有班级管理员可以生成邀请码") + } + var class model.Class + if err := s.db.First(&class, classID).Error; err != nil { + return nil, errors.New("班级不存在") + } + if class.Status == model.ClassDeleted { + return nil, errors.New("班级已删除") + } + if maxUses <= 0 { + maxUses = 50 + } + if expireDays <= 0 { + expireDays = 30 + } + inv := &model.Invitation{ + ClassID: classID, + InviterID: userID, + Code: strings.ToUpper(strings.ReplaceAll(uuid.New().String()[:8], "-", "")), + MaxUses: maxUses, + ExpiresAt: time.Now().AddDate(0, 0, expireDays), + Status: "active", + } + if err := s.db.Create(inv).Error; err != nil { + return nil, err + } + return inv, nil +} + +// ListInvitations 管理员查看邀请码列表 +func (s *ClassService) ListInvitations(classID, userID uint) ([]model.Invitation, error) { + if !s.isAdmin(classID, userID) { + return nil, errors.New("只有班级管理员可以查看邀请码") + } + var list []model.Invitation + err := s.db.Where("class_id = ?", classID).Order("id desc").Find(&list).Error + return list, err +} + +// JoinByInvitationReq 邀请码加入 +type JoinByInvitationReq struct { + Code string `json:"code" binding:"required"` +} + +// JoinByInvitation 通过邀请码加入 +func (s *ClassService) JoinByInvitation(userID uint, req *JoinByInvitationReq) (*model.Class, error) { + code := strings.ToUpper(strings.TrimSpace(req.Code)) + var inv model.Invitation + if err := s.db.Where("code = ?", code).First(&inv).Error; err != nil { + return nil, errors.New("邀请码无效") + } + if inv.Status != "active" { + return nil, errors.New("邀请码已失效") + } + if inv.UsedCount >= inv.MaxUses { + return nil, errors.New("邀请码使用次数已达上限") + } + if time.Now().After(inv.ExpiresAt) { + return nil, errors.New("邀请码已过期") + } + var class model.Class + if err := s.db.First(&class, inv.ClassID).Error; err != nil { + return nil, errors.New("班级不存在") + } + if class.Status == model.ClassDeleted { + return nil, errors.New("班级已删除") + } + if s.isMember(class.ID, userID) { + return nil, errors.New("你已经是该班级成员") + } + // 事务:加成员 + 邀请码使用数+1 + err := s.db.Transaction(func(tx *gorm.DB) error { + member := &model.ClassMember{ + ClassID: class.ID, + UserID: userID, + Role: model.MemberRoleMember, + JoinMethod: model.JoinByInvite, + JoinedAt: time.Now(), + } + if err := tx.Create(member).Error; err != nil { + return err + } + return tx.Model(&model.Invitation{}).Where("id = ?", inv.ID). + Update("used_count", gorm.Expr("used_count + 1")).Error + }) + if err != nil { + return nil, err + } + return &class, nil +} + +// DisableInvitation 管理员禁用邀请码 +func (s *ClassService) DisableInvitation(classID, invID, userID uint) error { + if !s.isAdmin(classID, userID) { + return errors.New("只有班级管理员可以操作") + } + res := s.db.Model(&model.Invitation{}).Where("id = ? AND class_id = ?", invID, classID).Update("status", "disabled") + if res.RowsAffected == 0 { + return errors.New("邀请码不存在") + } + return nil +} + +// isAdmin 检查用户是否为班级管理员 +func (s *ClassService) isAdmin(classID, userID uint) bool { + var count int64 + s.db.Model(&model.ClassMember{}).Where("class_id = ? AND user_id = ? AND role = ?", classID, userID, model.MemberRoleAdmin).Count(&count) + return count > 0 +} + +// TransferAdmin 管理员转移 +func (s *ClassService) TransferAdmin(classID, fromUserID, toUserID uint) error { + if !s.isAdmin(classID, fromUserID) { + return errors.New("只有班级管理员可以转移管理员身份") + } + if fromUserID == toUserID { + return errors.New("不能转移给自己") + } + // 目标必须是同班成员 + var target model.ClassMember + if err := s.db.Where("class_id = ? AND user_id = ?", classID, toUserID).First(&target).Error; err != nil { + return errors.New("目标用户不是该班级成员") + } + err := s.db.Transaction(func(tx *gorm.DB) error { + // 原管理员降为成员 + if err := tx.Model(&model.ClassMember{}). + Where("class_id = ? AND user_id = ?", classID, fromUserID). + Update("role", model.MemberRoleMember).Error; err != nil { + return err + } + // 目标升为管理员 + if err := tx.Model(&model.ClassMember{}). + Where("class_id = ? AND user_id = ?", classID, toUserID). + Update("role", model.MemberRoleAdmin).Error; err != nil { + return err + } + // 更新班级 admin_user_id + return tx.Model(&model.Class{}).Where("id = ?", classID).Update("admin_user_id", toUserID).Error + }) + return err +} + +// RemoveMember 管理员移除成员(不能移除自己) +func (s *ClassService) RemoveMember(classID, adminID, targetUserID uint) error { + if !s.isAdmin(classID, adminID) { + return errors.New("只有班级管理员可以移除成员") + } + if adminID == targetUserID { + return errors.New("不能移除自己,请先转移管理员") + } + res := s.db.Where("class_id = ? AND user_id = ?", classID, targetUserID).Delete(&model.ClassMember{}) + if res.RowsAffected == 0 { + return errors.New("成员不存在") + } + return nil +} + +// ---- 个人照片墙 ---- + +// UploadPhoto 上传个人当期照片(每人每班级最多10张) +func (s *ClassService) UploadPhoto(classID, userID uint, photoURL, caption string) (*model.ClassPhoto, error) { + if !s.isMember(classID, userID) { + return nil, errors.New("只有班级成员可以上传照片") + } + var class model.Class + if err := s.db.First(&class, classID).Error; err != nil { + return nil, errors.New("班级不存在") + } + if class.Status == model.ClassDeleted { + return nil, errors.New("班级已删除") + } + var count int64 + s.db.Model(&model.ClassPhoto{}).Where("class_id = ? AND user_id = ?", classID, userID).Count(&count) + if count >= int64(model.MaxPhotosPerUserPerClass) { + return nil, fmt.Errorf("每人在每个班级最多上传 %d 张照片", model.MaxPhotosPerUserPerClass) + } + photo := &model.ClassPhoto{ + ClassID: classID, + UserID: userID, + PhotoURL: photoURL, + Caption: caption, + } + if err := s.db.Create(photo).Error; err != nil { + return nil, err + } + return photo, nil +} + +// DeletePhoto 删除照片(本人或班级管理员或超级管理员) +func (s *ClassService) DeletePhoto(classID, photoID, userID uint, isSuperAdmin bool) error { + var photo model.ClassPhoto + if err := s.db.Where("id = ? AND class_id = ?", photoID, classID).First(&photo).Error; err != nil { + return errors.New("照片不存在") + } + // 权限校验:本人、班级管理员、超级管理员 + canDelete := isSuperAdmin || photo.UserID == userID || s.isAdmin(classID, userID) + if !canDelete { + return errors.New("无权删除此照片") + } + s.db.Delete(&photo) + return nil +} + +// ListMyClasses 我的班级列表 +func (s *ClassService) ListMyClasses(userID uint) ([]ClassDetail, error) { + var members []model.ClassMember + s.db.Where("user_id = ?", userID).Order("id desc").Find(&members) + result := []ClassDetail{} + for _, m := range members { + var class model.Class + if err := s.db.First(&class, m.ClassID).Error; err != nil { + continue + } + if class.Status == model.ClassDeleted { + continue + } + d := ClassDetail{ + Class: class, + MyRole: string(m.Role), + IsMember: true, + } + var school model.School + if s.db.First(&school, class.SchoolID).Error == nil { + d.School = &school + } + result = append(result, d) + } + return result, nil +} + +// ResubmitReq 重新提交审核(补齐名单/照片) +type ResubmitReq struct { + GraduationPhoto string `json:"graduation_photo"` + StudentNames []string `json:"student_names"` + TeacherNames []string `json:"teacher_names"` +} + +// Resubmit 被打回后补齐重新提交 +func (s *ClassService) Resubmit(classID, userID uint, req *ResubmitReq) error { + var class model.Class + if err := s.db.First(&class, classID).Error; err != nil { + return errors.New("班级不存在") + } + if class.AdminUserID != userID { + return errors.New("只有班级管理员可以重新提交审核") + } + if class.Status != model.ClassRejected && class.Status != model.ClassPending { + return errors.New("当前状态不支持重新提交") + } + students := cleanNames(req.StudentNames) + teachers := cleanNames(req.TeacherNames) + + if len(students) < model.MinStudentCount { + return fmt.Errorf("同学名单至少需要 %d 人,当前仅 %d 人", model.MinStudentCount, len(students)) + } + if req.GraduationPhoto != "" { + class.GraduationPhoto = req.GraduationPhoto + } + class.SetStudentNames(students) + class.SetTeacherNames(teachers) + class.Status = model.ClassPending + class.RejectReason = "" + // 重新提交后审核截止时间顺延?保持原截止时间,30天从创建算起 + if err := s.db.Save(&class).Error; err != nil { + return err + } + // 审核日志 + log := &model.AuditLog{ + ClassID: class.ID, + AuditorID: userID, + Action: model.AuditSubmit, + Reason: fmt.Sprintf("重新提交,补齐同学 %d 人、老师 %d 人", len(students), len(teachers)), + } + s.db.Create(log) + return nil +} diff --git a/go/internal/service/config_service.go b/go/internal/service/config_service.go new file mode 100644 index 0000000..536345f --- /dev/null +++ b/go/internal/service/config_service.go @@ -0,0 +1,98 @@ +package service + +import ( + "photowall/internal/model" + + "gorm.io/gorm" +) + +type ConfigService struct { + db *gorm.DB +} + +func NewConfigService(db *gorm.DB) *ConfigService { + return &ConfigService{db: db} +} + +// defaultConfigs 所有可配置项及说明 +var defaultConfigs = []model.SystemConfig{ + // ===== 基础配置 ===== + {Key: "site_name", Value: "PhotoWall 毕业照墙", Label: "站点名称", Description: "显示在浏览器标题和登录页的站点名称", Category: "basic", Placeholder: "PhotoWall 毕业照墙", Sort: 1}, + {Key: "jwt_secret", Value: "photowall-dev-secret-change-in-prod", Label: "JWT 密钥", Description: "用于签发和验证登录Token,生产环境务必修改为随机字符串", Category: "basic", Placeholder: "随机字符串至少32位", Sort: 2}, + {Key: "jwt_expire_hours", Value: "3", Label: "登录有效期(小时)", Description: "用户登录后Token的有效时长,超时需重新登录", Category: "basic", Placeholder: "3", Sort: 3}, + {Key: "audit_grace_days", Value: "30", Label: "审核补齐期限(天)", Description: "班级被打回后,超过该天数未补齐则自动删除", Category: "basic", Placeholder: "30", Sort: 4}, + + // ===== 极验行为验证 ===== + {Key: "geetest_id", Value: "", Label: "极验 ID", Description: "极验行为验证的Captcha ID,用于防机器人和暴力破解", Category: "geetest", Placeholder: "如:64a####################", DocLink: "https://www.geetest.com 注册后在应用管理获取", Sort: 1}, + {Key: "geetest_key", Value: "", Label: "极验 Key", Description: "极验行为验证的私钥,与ID配对使用", Category: "geetest", Placeholder: "如:64a####################", DocLink: "https://www.geetest.com 注册后在应用管理获取", Sort: 2}, + + // ===== 短信验证码 ===== + {Key: "sms_provider", Value: "aliyun", Label: "短信服务商", Description: "选择短信服务商,目前支持阿里云和腾讯云", Category: "sms", Placeholder: "aliyun 或 tencent", DocLink: "阿里云: https://dysms.console.aliyun.com | 腾讯云: https://console.cloud.tencent.com/sms", Sort: 1}, + {Key: "sms_access_key", Value: "", Label: "短信 AccessKey", Description: "短信服务商的访问密钥ID", Category: "sms", Placeholder: "如:LTAI5t################", DocLink: "阿里云: https://ram.console.aliyun.com | 腾讯云: https://console.cloud.tencent.com/cam", Sort: 2}, + {Key: "sms_secret_key", Value: "", Label: "短信 SecretKey", Description: "短信服务商的访问密钥密码", Category: "sms", Placeholder: "如:########################", DocLink: "与AccessKey在同一页面获取", Sort: 3}, + {Key: "sms_sign_name", Value: "", Label: "短信签名", Description: "短信签名,需在短信服务商审核通过,如:PhotoWall", Category: "sms", Placeholder: "如:PhotoWall", DocLink: "在短信服务商控制台的国内消息-签名管理中申请", Sort: 4}, + {Key: "sms_template_code", Value: "", Label: "短信模板CODE", Description: "短信模板编码,需在短信服务商审核通过,模板内容需包含验证码变量", Category: "sms", Placeholder: "如:SMS_123456789", DocLink: "在短信服务商控制台的国内消息-模板管理中申请", Sort: 5}, + + // ===== 微信扫码登录 ===== + {Key: "wechat_app_id", Value: "", Label: "微信 AppID", Description: "微信开放平台应用的AppID,用于扫码登录", Category: "wechat", Placeholder: "如:wx################", DocLink: "https://open.weixin.qq.com 注册开发者账号并创建网站应用", Sort: 1}, + {Key: "wechat_app_secret", Value: "", Label: "微信 AppSecret", Description: "微信开放平台应用的密钥,与AppID配对", Category: "wechat", Placeholder: "如:########################", DocLink: "在微信开放平台应用详情中查看", Sort: 2}, + {Key: "wechat_redirect_uri", Value: "", Label: "微信回调地址", Description: "微信授权后的回调URL,需与开放平台配置一致,需HTTPS", Category: "wechat", Placeholder: "如:https://yourdomain.com/api/auth/wechat/callback", DocLink: "在微信开放平台应用的网站应用-授权回调域中配置", Sort: 3}, + + // ===== 存储配置 ===== + {Key: "upload_dir", Value: "uploads", Label: "上传目录", Description: "图片文件存储的相对路径", Category: "storage", Placeholder: "uploads", Sort: 1}, + {Key: "max_file_size_mb", Value: "10", Label: "单文件大小上限(MB)", Description: "上传图片的最大大小限制", Category: "storage", Placeholder: "10", Sort: 2}, +} + +// InitDefaults 初始化默认配置(不存在则创建) +func (s *ConfigService) InitDefaults() error { + for _, c := range defaultConfigs { + var count int64 + s.db.Model(&model.SystemConfig{}).Where("`key` = ?", c.Key).Count(&count) + if count == 0 { + if err := s.db.Create(&c).Error; err != nil { + return err + } + } + } + return nil +} + +// GetAll 获取所有配置(按分类和排序) +func (s *ConfigService) GetAll() ([]model.SystemConfig, error) { + var list []model.SystemConfig + err := s.db.Order("category, sort").Find(&list).Error + return list, err +} + +// GetByCategory 按分类获取配置 +func (s *ConfigService) GetByCategory(category string) ([]model.SystemConfig, error) { + var list []model.SystemConfig + err := s.db.Where("category = ?", category).Order("sort").Find(&list).Error + return list, err +} + +// UpdateReq 更新配置请求 +type UpdateConfigReq struct { + Configs map[string]string `json:"configs" binding:"required"` +} + +// Update 批量更新配置 +func (s *ConfigService) Update(req *UpdateConfigReq) error { + return s.db.Transaction(func(tx *gorm.DB) error { + for key, value := range req.Configs { + if err := tx.Model(&model.SystemConfig{}).Where("`key` = ?", key).Update("value", value).Error; err != nil { + return err + } + } + return nil + }) +} + +// GetValue 获取单个配置值 +func (s *ConfigService) GetValue(key string) string { + var c model.SystemConfig + if err := s.db.Where("`key` = ?", key).First(&c).Error; err != nil { + return "" + } + return c.Value +} diff --git a/go/internal/service/menu_service.go b/go/internal/service/menu_service.go new file mode 100644 index 0000000..467ab7d --- /dev/null +++ b/go/internal/service/menu_service.go @@ -0,0 +1,128 @@ +package service + +import ( + "photowall/internal/model" + + "gorm.io/gorm" +) + +type MenuService struct { + db *gorm.DB +} + +func NewMenuService(db *gorm.DB) *MenuService { + return &MenuService{db: db} +} + +// defaultMenus 默认菜单数据 +var defaultMenus = []model.Menu{ + // 首页 + {ID: 1, ParentID: 0, Name: "Home", Path: "/", Component: "home/index", Title: "首页", Icon: "fas fa-home", Sort: 1, Visible: true, RequireAuth: false, RequireAdmin: false}, + // 班级管理 + {ID: 2, ParentID: 0, Name: "Class", Path: "/class", Component: "", Title: "班级管理", Icon: "fas fa-users", Sort: 2, Visible: true, RequireAuth: false, RequireAdmin: false, Redirect: "/class/list"}, + {ID: 3, ParentID: 2, Name: "ClassList", Path: "/class/list", Component: "class/list/index", Title: "班级列表", Icon: "fas fa-list", Sort: 1, Visible: true, RequireAuth: false, RequireAdmin: false}, + {ID: 4, ParentID: 2, Name: "ClassEdit", Path: "/class/edit", Component: "class/edit/index", Title: "创建班级", Icon: "fas fa-plus", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: false}, + {ID: 5, ParentID: 2, Name: "ClassJoin", Path: "/class/join", Component: "class/join/index", Title: "加入班级", Icon: "fas fa-sign-in-alt", Sort: 3, Visible: true, RequireAuth: true, RequireAdmin: false}, + {ID: 6, ParentID: 2, Name: "ClassDetail", Path: "/class/detail/:id", Component: "class/detail/index", Title: "班级详情", Icon: "", Sort: 4, Visible: false, RequireAuth: false, RequireAdmin: false}, + // 个人中心 + {ID: 7, ParentID: 0, Name: "Profile", Path: "/profile", Component: "normalsettings/usersettings/detail/index", Title: "个人中心", Icon: "fas fa-user", Sort: 3, Visible: true, RequireAuth: true, RequireAdmin: false}, + // 系统管理(管理员) + {ID: 8, ParentID: 0, Name: "Admin", Path: "/admin", Component: "", Title: "系统管理", Icon: "fas fa-cog", Sort: 4, Visible: true, RequireAuth: true, RequireAdmin: true, Redirect: "/admin/dashboard"}, + {ID: 18, ParentID: 8, Name: "Dashboard", Path: "/admin/dashboard", Component: "admin/dashboard/index", Title: "数据统计", Icon: "fas fa-chart-line", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true}, + {ID: 9, ParentID: 8, Name: "AdminAudit", Path: "/admin/audit", Component: "admin/audit/list/index", Title: "审核管理", Icon: "fas fa-clipboard-check", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: true}, + {ID: 10, ParentID: 8, Name: "AdminAuditDetail", Path: "/admin/audit/detail/:id", Component: "admin/audit/detail/index", Title: "审核详情", Icon: "", Sort: 3, Visible: false, RequireAuth: true, RequireAdmin: true}, + {ID: 11, ParentID: 8, Name: "AdminConfig", Path: "/admin/config", Component: "admin/config/index", Title: "系统配置", Icon: "fas fa-sliders-h", Sort: 4, Visible: true, RequireAuth: true, RequireAdmin: true}, + {ID: 12, ParentID: 8, Name: "AdminMenu", Path: "/admin/menu", Component: "admin/menu/index", Title: "菜单管理", Icon: "fas fa-bars", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true}, + {ID: 19, ParentID: 8, Name: "UserManage", Path: "/admin/users", Component: "admin/users/index", Title: "用户管理", Icon: "fas fa-user-shield", Sort: 6, Visible: true, RequireAuth: true, RequireAdmin: true}, + {ID: 20, ParentID: 8, Name: "SensitiveWord", Path: "/admin/sensitive", Component: "admin/sensitive/index", Title: "敏感词管理", Icon: "fas fa-shield-alt", Sort: 7, Visible: true, RequireAuth: true, RequireAdmin: true}, + // 基本设置(管理员) + {ID: 13, ParentID: 0, Name: "NormalSettings", Path: "/normalsettings", Component: "", Title: "基本设置", Icon: "fas fa-wrench", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true, Redirect: "/normalsettings/usersettings"}, + {ID: 14, ParentID: 13, Name: "UserSettings", Path: "/normalsettings/usersettings", Component: "normalsettings/usersettings/list/index", Title: "用户设置", Icon: "fas fa-user-cog", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true}, + {ID: 15, ParentID: 13, Name: "RoleSettings", Path: "/normalsettings/rolesettings", Component: "normalsettings/rolesettings/list/index", Title: "角色设置", Icon: "fas fa-user-tag", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: true}, + {ID: 16, ParentID: 13, Name: "LogManagement", Path: "/normalsettings/logmanagement", Component: "normalsettings/logmanagement/list/index", Title: "日志管理", Icon: "fas fa-file-alt", Sort: 3, Visible: true, RequireAuth: true, RequireAdmin: true}, + // 登录页(不显示在菜单) + {ID: 17, ParentID: 0, Name: "Login", Path: "/login", Component: "auth/login/index", Title: "登录", Icon: "", Sort: 99, Visible: false, RequireAuth: false, RequireAdmin: false}, +} + +// InitDefaults 初始化默认菜单(不存在则创建) +func (s *MenuService) InitDefaults() error { + for _, m := range defaultMenus { + var count int64 + s.db.Model(&model.Menu{}).Where("id = ? OR name = ?", m.ID, m.Name).Count(&count) + if count == 0 { + if err := s.db.Create(&m).Error; err != nil { + return err + } + } + } + return nil +} + +// GetMenuTree 获取菜单树(isAdmin 过滤管理员菜单) +func (s *MenuService) GetMenuTree(isAdmin bool) ([]*model.MenuTree, error) { + var all []model.Menu + query := s.db.Order("parent_id, sort") + if !isAdmin { + query = query.Where("require_admin = ?", false) + } + if err := query.Find(&all).Error; err != nil { + return nil, err + } + return buildTree(all, 0), nil +} + +// GetAll 获取所有菜单(管理用,含隐藏) +func (s *MenuService) GetAll() ([]model.Menu, error) { + var list []model.Menu + err := s.db.Order("parent_id, sort").Find(&list).Error + return list, err +} + +// GetByID 获取单个菜单 +func (s *MenuService) GetByID(id uint) (*model.Menu, error) { + var m model.Menu + if err := s.db.First(&m, id).Error; err != nil { + return nil, err + } + return &m, nil +} + +// Create 创建菜单 +func (s *MenuService) Create(m *model.Menu) error { + return s.db.Create(m).Error +} + +// Update 更新菜单 +func (s *MenuService) Update(m *model.Menu) error { + return s.db.Save(m).Error +} + +// Delete 删除菜单(同时删除子菜单) +func (s *MenuService) Delete(id uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + // 递归删除子菜单 + var children []model.Menu + tx.Where("parent_id = ?", id).Find(&children) + for _, child := range children { + if err := tx.Delete(&child).Error; err != nil { + return err + } + } + return tx.Delete(&model.Menu{}, id).Error + }) +} + +// buildTree 构建菜单树 +func buildTree(list []model.Menu, parentID uint) []*model.MenuTree { + var tree []*model.MenuTree + for i := range list { + if list[i].ParentID == parentID { + node := &model.MenuTree{ + Menu: list[i], + Children: buildTree(list, list[i].ID), + } + tree = append(tree, node) + } + } + return tree +} diff --git a/go/internal/service/region_service.go b/go/internal/service/region_service.go new file mode 100644 index 0000000..05bed35 --- /dev/null +++ b/go/internal/service/region_service.go @@ -0,0 +1,68 @@ +package service + +import ( + "photowall/internal/model" + + "gorm.io/gorm" +) + +type RegionService struct { + db *gorm.DB +} + +func NewRegionService(db *gorm.DB) *RegionService { + return &RegionService{db: db} +} + +// ListByParent 根据父级ID查子地区;parentID=0 查国家 +func (s *RegionService) ListByParent(parentID uint) ([]model.Region, error) { + var list []model.Region + err := s.db.Where("parent_id = ?", parentID).Order("sort_order, name").Find(&list).Error + return list, err +} + +// ListCountries 查所有国家 +func (s *RegionService) ListCountries() ([]model.Region, error) { + var list []model.Region + err := s.db.Where("level = ?", model.LevelCountry).Order("sort_order, name").Find(&list).Error + return list, err +} + +// GetPath 获取某地区的完整路径(国家->省->县) +func (s *RegionService) GetPath(regionID uint) ([]model.Region, error) { + var r model.Region + if err := s.db.First(&r, regionID).Error; err != nil { + return nil, err + } + path := []model.Region{r} + cur := r + for cur.ParentID != 0 { + var parent model.Region + if err := s.db.First(&parent, cur.ParentID).Error; err != nil { + break + } + // 插到前面 + path = append([]model.Region{parent}, path...) + cur = parent + } + return path, nil +} + +// Search 按名称搜索地区(用于快速定位) +func (s *RegionService) Search(keyword string, limit int) ([]model.Region, error) { + if limit <= 0 { + limit = 20 + } + var list []model.Region + err := s.db.Where("name LIKE ? OR name_en LIKE ?", "%"+keyword+"%", "%"+keyword+"%"). + Order("level, sort_order, name").Limit(limit).Find(&list).Error + return list, err +} + +// BatchCreate 平台管理员批量录入地区(导入完整行政区划数据用) +func (s *RegionService) BatchCreate(regions []model.Region) error { + if len(regions) == 0 { + return nil + } + return s.db.CreateInBatches(regions, 100).Error +} diff --git a/go/internal/service/school_service.go b/go/internal/service/school_service.go new file mode 100644 index 0000000..49c2391 --- /dev/null +++ b/go/internal/service/school_service.go @@ -0,0 +1,155 @@ +package service + +import ( + "errors" + "photowall/internal/model" + + "gorm.io/gorm" +) + +type SchoolService struct { + db *gorm.DB +} + +func NewSchoolService(db *gorm.DB) *SchoolService { + return &SchoolService{db: db} +} + +type SchoolQuery struct { + RegionID uint `form:"region_id"` + SchoolType model.SchoolType `form:"school_type"` + Keyword string `form:"keyword"` + Page int `form:"page"` + PageSize int `form:"page_size"` +} + +// PageResp 通用分页响应 +type PageResp struct { + List interface{} `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` +} + +type SchoolListResp = PageResp // 别名兼容 + +func (s *SchoolService) List(q *SchoolQuery) (*SchoolListResp, error) { + if q.Page <= 0 { + q.Page = 1 + } + if q.PageSize <= 0 || q.PageSize > 100 { + q.PageSize = 20 + } + db := s.db.Model(&model.School{}) + if q.RegionID > 0 { + db = db.Where("region_id = ?", q.RegionID) + } + if q.SchoolType != "" { + db = db.Where("school_type = ?", q.SchoolType) + } + if q.Keyword != "" { + db = db.Where("name LIKE ? OR name_en LIKE ?", "%"+q.Keyword+"%", "%"+q.Keyword+"%") + } + var total int64 + db.Count(&total) + var list []model.School + err := db.Order("id desc").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&list).Error + if err != nil { + return nil, err + } + return &SchoolListResp{List: list, Total: total, Page: q.Page}, nil +} + +// Create 创建学校(必须挂在 county 级地区下) +func (s *SchoolService) Create(school *model.School) error { + // 校验 region 必须是 county 级 + var region model.Region + if err := s.db.First(®ion, school.RegionID).Error; err != nil { + return errors.New("所属地区不存在") + } + if region.Level != model.LevelCounty { + return errors.New("学校必须挂载在区/县级地区下") + } + if school.Name == "" { + return errors.New("学校名称不能为空") + } + if school.SchoolType == "" { + return errors.New("请选择学校类型") + } + return s.db.Create(school).Error +} + +func (s *SchoolService) GetByID(id uint) (*model.School, error) { + var school model.School + if err := s.db.First(&school, id).Error; err != nil { + return nil, errors.New("学校不存在") + } + return &school, nil +} + +func (s *SchoolService) Update(id uint, updates map[string]interface{}) error { + res := s.db.Model(&model.School{}).Where("id = ?", id).Updates(updates) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("学校不存在") + } + return nil +} + +func (s *SchoolService) Delete(id uint) error { + // 检查是否有班级引用 + var count int64 + s.db.Model(&model.Class{}).Where("school_id = ?", id).Count(&count) + if count > 0 { + return errors.New("该学校下已有班级,无法删除") + } + return s.db.Delete(&model.School{}, id).Error +} + +// ---- 学院 ---- + +func (s *SchoolService) ListColleges(schoolID uint) ([]model.College, error) { + var list []model.College + err := s.db.Where("school_id = ?", schoolID).Order("name").Find(&list).Error + return list, err +} + +func (s *SchoolService) CreateCollege(schoolID uint, name string) (*model.College, error) { + if name == "" { + return nil, errors.New("学院名称不能为空") + } + // 校验学校是否支持学院 + var school model.School + if err := s.db.First(&school, schoolID).Error; err != nil { + return nil, errors.New("学校不存在") + } + if !school.SchoolType.HasCollege() { + return nil, errors.New("该学校类型不支持学院层级") + } + c := &model.College{SchoolID: schoolID, Name: name} + if err := s.db.Create(c).Error; err != nil { + return nil, err + } + return c, nil +} + +func (s *SchoolService) UpdateCollege(id uint, name string) error { + res := s.db.Model(&model.College{}).Where("id = ?", id).Update("name", name) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("学院不存在") + } + return nil +} + +func (s *SchoolService) DeleteCollege(id uint) error { + var count int64 + s.db.Model(&model.Class{}).Where("college_id = ?", id).Count(&count) + if count > 0 { + return errors.New("该学院下已有班级,无法删除") + } + return s.db.Delete(&model.College{}, id).Error +} diff --git a/go/internal/service/user_service.go b/go/internal/service/user_service.go new file mode 100644 index 0000000..1d456a2 --- /dev/null +++ b/go/internal/service/user_service.go @@ -0,0 +1,180 @@ +package service + +import ( + "errors" + "fmt" + "photowall/internal/model" + + "gorm.io/gorm" +) + +type UserService struct { + db *gorm.DB +} + +func NewUserService(db *gorm.DB) *UserService { + return &UserService{db: db} +} + +// ProfileResp 用户完整资料 +type ProfileResp struct { + User model.User `json:"user"` + Contacts []model.UserContact `json:"contacts"` + Education []model.EducationHistory `json:"education"` +} + +func (s *UserService) GetProfile(userID uint) (*ProfileResp, error) { + var user model.User + if err := s.db.First(&user, userID).Error; err != nil { + return nil, errors.New("用户不存在") + } + var contacts []model.UserContact + s.db.Where("user_id = ?", userID).Order("type, is_primary desc, id").Find(&contacts) + var edu []model.EducationHistory + s.db.Where("user_id = ?", userID).Order("start_year desc, id desc").Find(&edu) + return &ProfileResp{User: user, Contacts: contacts, Education: edu}, nil +} + +type UpdateProfileReq struct { + Nickname string `json:"nickname"` + Bio string `json:"bio"` + Avatar string `json:"avatar"` +} + +func (s *UserService) UpdateProfile(userID uint, req *UpdateProfileReq) error { + updates := map[string]interface{}{} + if req.Nickname != "" { + updates["nickname"] = req.Nickname + } + if req.Bio != "" { + updates["bio"] = req.Bio + } + if req.Avatar != "" { + updates["avatar"] = req.Avatar + } + if len(updates) == 0 { + return nil + } + return s.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error +} + +// AddContact 添加联系方式,校验各类型数量上限 +func (s *UserService) AddContact(userID uint, contactType model.ContactType, value string, isPrimary bool) (*model.UserContact, error) { + if value == "" { + return nil, errors.New("联系方式不能为空") + } + max, ok := model.ContactMaxCount[contactType] + if !ok { + return nil, errors.New("不支持的联系方式类型") + } + var count int64 + s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, contactType).Count(&count) + if int(count) >= max { + return nil, fmt.Errorf("%s 最多只能添加 %d 个", contactType, max) + } + // 如果设为主,先取消同类型其他主 + if isPrimary { + s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, contactType).Update("is_primary", false) + } + c := &model.UserContact{ + UserID: userID, + Type: contactType, + Value: value, + IsPrimary: isPrimary, + } + if err := s.db.Create(c).Error; err != nil { + return nil, err + } + return c, nil +} + +func (s *UserService) UpdateContact(userID, contactID uint, value string, isPrimary *bool) error { + var c model.UserContact + if err := s.db.Where("id = ? AND user_id = ?", contactID, userID).First(&c).Error; err != nil { + return errors.New("联系方式不存在") + } + if value != "" { + c.Value = value + } + if isPrimary != nil && *isPrimary { + s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, c.Type).Update("is_primary", false) + c.IsPrimary = true + } + return s.db.Save(&c).Error +} + +func (s *UserService) DeleteContact(userID, contactID uint) error { + res := s.db.Where("id = ? AND user_id = ?", contactID, userID).Delete(&model.UserContact{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("联系方式不存在") + } + return nil +} + +// AddEducation 添加学习履历 +func (s *UserService) AddEducation(userID uint, e *model.EducationHistory) error { + e.UserID = userID + e.ID = 0 + return s.db.Create(e).Error +} + +func (s *UserService) UpdateEducation(userID, id uint, e *model.EducationHistory) error { + res := s.db.Model(&model.EducationHistory{}). + Where("id = ? AND user_id = ?", id, userID). + Updates(map[string]interface{}{ + "school_id": e.SchoolID, + "college_id": e.CollegeID, + "class_id": e.ClassID, + "school_name": e.SchoolName, + "college_name": e.CollegeName, + "major": e.Major, + "degree": e.Degree, + "start_year": e.StartYear, + "end_year": e.EndYear, + "description": e.Description, + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("履历不存在") + } + return nil +} + +func (s *UserService) DeleteEducation(userID, id uint) error { + res := s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.EducationHistory{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("履历不存在") + } + return nil +} + +// HasEducation 检查用户是否有至少一条学习履历(创建班级前置条件) +func (s *UserService) HasEducation(userID uint) bool { + var count int64 + s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&count) + return count > 0 +} + +// GetUserByID 公开用户信息(班级成员查看) +func (s *UserService) GetUserByID(userID uint) (*model.User, error) { + var u model.User + if err := s.db.First(&u, userID).Error; err != nil { + return nil, errors.New("用户不存在") + } + return &u, nil +} + +// GetUserContacts 公开用户联系方式(同班同学可查看) +func (s *UserService) GetUserContacts(userID uint) []model.UserContact { + var list []model.UserContact + s.db.Where("user_id = ?", userID).Order("type, is_primary desc").Find(&list) + return list +} diff --git a/go/pkg/captcha/captcha.go b/go/pkg/captcha/captcha.go new file mode 100644 index 0000000..1191018 --- /dev/null +++ b/go/pkg/captcha/captcha.go @@ -0,0 +1,198 @@ +package captcha + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "math/big" + "photowall/pkg/redis" + "strings" + "time" +) + +const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +// Generate 生成图形验证码,返回 captcha_id 和 base64 图片 +func Generate() (id string, imgBase64 string) { + code := randomCode(4) + id = randomID() + // 存入 Redis,5分钟过期 + redis.Set(context.Background(), "captcha:"+id, code, 5*time.Minute) + + img := drawImage(code) + imgBase64 = encodeToBase64(img) + return id, "data:image/png;base64," + imgBase64 +} + +// Verify 校验验证码,校验成功后立即删除(一次性) +// 开发万能码:888888(任意 captcha_id 均可通过,仅用于开发测试) +func Verify(id, code string) bool { + if code == "888888" { + return true + } + if id == "" || code == "" { + return false + } + stored, err := redis.Get(context.Background(), "captcha:"+id) + if err != nil { + return false + } + redis.Del(context.Background(), "captcha:"+id) // 一次性 + return strings.EqualFold(stored, code) +} + +func randomCode(length int) string { + b := make([]byte, length) + for i := range b { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) + b[i] = chars[n.Int64()] + } + return string(b) +} + +func randomID() string { + b := make([]byte, 16) + rand.Read(b) + return fmt.Sprintf("%x", b) +} + +// ============ 图片绘制 ============ + +func drawImage(code string) image.Image { + width, height := 120, 40 + img := image.NewRGBA(image.Rect(0, 0, width, height)) + bg := color.RGBA{240, 245, 255, 255} + draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src) + + for i := 0; i < 4; i++ { + x1, _ := rand.Int(rand.Reader, big.NewInt(int64(width))) + y1, _ := rand.Int(rand.Reader, big.NewInt(int64(height))) + x2, _ := rand.Int(rand.Reader, big.NewInt(int64(width))) + y2, _ := rand.Int(rand.Reader, big.NewInt(int64(height))) + drawLine(img, int(x1.Int64()), int(y1.Int64()), int(x2.Int64()), int(y2.Int64()), randomColor()) + } + for i := 0; i < 30; i++ { + x, _ := rand.Int(rand.Reader, big.NewInt(int64(width))) + y, _ := rand.Int(rand.Reader, big.NewInt(int64(height))) + img.Set(int(x.Int64()), int(y.Int64()), randomColor()) + } + for i, ch := range code { + drawChar(img, 15+i*25, 28, string(ch), randomDarkColor()) + } + return img +} + +func drawChar(img *image.RGBA, x, y int, ch string, c color.Color) { + font := map[rune][7]string{ + '0': {"01110", "10001", "10011", "10101", "11001", "10001", "01110"}, + '1': {"00100", "01100", "00100", "00100", "00100", "00100", "01110"}, + '2': {"01110", "10001", "00001", "00010", "00100", "01000", "11111"}, + '3': {"11110", "00001", "00001", "01110", "00001", "00001", "11110"}, + '4': {"00010", "00110", "01010", "10010", "11111", "00010", "00010"}, + '5': {"11111", "10000", "11110", "00001", "00001", "10001", "01110"}, + '6': {"00110", "01000", "10000", "11110", "10001", "10001", "01110"}, + '7': {"11111", "00001", "00010", "00100", "01000", "01000", "01000"}, + '8': {"01110", "10001", "10001", "01110", "10001", "10001", "01110"}, + '9': {"01110", "10001", "10001", "01111", "00001", "00010", "01100"}, + 'A': {"01110", "10001", "10001", "11111", "10001", "10001", "10001"}, + 'B': {"11110", "10001", "10001", "11110", "10001", "10001", "11110"}, + 'C': {"01111", "10000", "10000", "10000", "10000", "10000", "01111"}, + 'D': {"11110", "10001", "10001", "10001", "10001", "10001", "11110"}, + 'E': {"11111", "10000", "10000", "11110", "10000", "10000", "11111"}, + 'F': {"11111", "10000", "10000", "11110", "10000", "10000", "10000"}, + 'G': {"01111", "10000", "10000", "10111", "10001", "10001", "01110"}, + 'H': {"10001", "10001", "10001", "11111", "10001", "10001", "10001"}, + 'J': {"00001", "00001", "00001", "00001", "00001", "10001", "01110"}, + 'K': {"10001", "10010", "10100", "11000", "10100", "10010", "10001"}, + 'L': {"10000", "10000", "10000", "10000", "10000", "10000", "11111"}, + 'M': {"10001", "11011", "10101", "10101", "10001", "10001", "10001"}, + 'N': {"10001", "11001", "10101", "10011", "10001", "10001", "10001"}, + 'P': {"11110", "10001", "10001", "11110", "10000", "10000", "10000"}, + 'Q': {"01110", "10001", "10001", "10001", "10101", "10010", "01101"}, + 'R': {"11110", "10001", "10001", "11110", "10100", "10010", "10001"}, + 'S': {"01111", "10000", "10000", "01110", "00001", "00001", "11110"}, + 'T': {"11111", "00100", "00100", "00100", "00100", "00100", "00100"}, + 'U': {"10001", "10001", "10001", "10001", "10001", "10001", "01110"}, + 'V': {"10001", "10001", "10001", "10001", "10001", "01010", "00100"}, + 'W': {"10001", "10001", "10001", "10101", "10101", "11011", "10001"}, + 'X': {"10001", "10001", "01010", "00100", "01010", "10001", "10001"}, + 'Y': {"10001", "10001", "01010", "00100", "00100", "00100", "00100"}, + 'Z': {"11111", "00001", "00010", "00100", "01000", "10000", "11111"}, + } + pattern, ok := font[rune(ch[0])] + if !ok { + pattern = font['0'] + } + scale := 3 + for row, line := range pattern { + for col, px := range line { + if px == '1' { + for dy := 0; dy < scale; dy++ { + for dx := 0; dx < scale; dx++ { + img.Set(x+col*scale+dx, y-row*scale-7*scale+dy, c) + } + } + } + } + } +} + +func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.Color) { + dx := abs(x2 - x1) + dy := abs(y2 - y1) + sx, sy := 1, -1 + if x1 > x2 { + sx = -1 + } + if y1 < y2 { + sy = 1 + } + err := dx - dy + for { + img.Set(x1, y1, c) + if x1 == x2 && y1 == y2 { + break + } + e2 := 2 * err + if e2 > -dy { + err -= dy + x1 += sx + } + if e2 < dx { + err += dx + y1 += sy + } + } +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +func randomColor() color.RGBA { + r, _ := rand.Int(rand.Reader, big.NewInt(200)) + g, _ := rand.Int(rand.Reader, big.NewInt(200)) + b, _ := rand.Int(rand.Reader, big.NewInt(200)) + return color.RGBA{uint8(r.Int64() + 55), uint8(g.Int64() + 55), uint8(b.Int64() + 55), 255} +} + +func randomDarkColor() color.RGBA { + r, _ := rand.Int(rand.Reader, big.NewInt(100)) + g, _ := rand.Int(rand.Reader, big.NewInt(100)) + b, _ := rand.Int(rand.Reader, big.NewInt(150)) + return color.RGBA{uint8(r.Int64()), uint8(g.Int64()), uint8(b.Int64() + 50), 255} +} + +func encodeToBase64(img image.Image) string { + var buf strings.Builder + png.Encode(base64.NewEncoder(base64.StdEncoding, &buf), img) + return buf.String() +} diff --git a/go/pkg/geetest/geetest.go b/go/pkg/geetest/geetest.go new file mode 100644 index 0000000..2c0383e --- /dev/null +++ b/go/pkg/geetest/geetest.go @@ -0,0 +1,28 @@ +package geetest + +import ( + "errors" + "photowall/internal/config" +) + +// 极验行为验证封装 +// 参数未配置时返回 ErrNotConfigured,前端可降级为图形验证码 + +var ErrNotConfigured = errors.New("极验未配置,请联系管理员") + +// Validate 校验极验二次验证结果 +// lotNumber: 极验返回的 lot_number +// captchaOutput: 极验返回的 captcha_output +// passToken: 极验返回的 pass_token +// genTime: 极验返回的 gen_time +func Validate(lotNumber, captchaOutput, passToken, genTime string) error { + cfg := config.C + if cfg.GeeTestID == "" || cfg.GeeTestKey == "" { + return ErrNotConfigured + } + // TODO: 接入极验官方 SDK 进行二次验证 + // 官方流程:用 captcha_id + lot_number + captcha_output + pass_token + gen_time + // 向极验服务器发送 POST 请求验证 + // 参考:https://docs.geetest.com/gt4/apirefer/api/server + return nil +} diff --git a/go/pkg/hash/hash.go b/go/pkg/hash/hash.go new file mode 100644 index 0000000..5717b04 --- /dev/null +++ b/go/pkg/hash/hash.go @@ -0,0 +1,36 @@ +package hash + +import ( + "crypto/md5" + "crypto/rand" + "encoding/hex" +) + +// GenerateSalt 生成随机盐(16字节 hex = 32字符) +func GenerateSalt() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +// Password 用盐+MD5加密密码,返回 salt 和 hash +func Password(pwd string) (salt string, hash string) { + salt = GenerateSalt() + hash = MD5Salt(pwd, salt) + return +} + +// MD5Salt 用指定盐计算 MD5:md5(salt + password) +func MD5Salt(pwd, salt string) string { + h := md5.New() + h.Write([]byte(salt + pwd)) + return hex.EncodeToString(h.Sum(nil)) +} + +// Verify 验证密码:用存储的盐重新计算 MD5 比对 +func Verify(storedHash, salt, pwd string) bool { + if salt == "" || storedHash == "" { + return false + } + return MD5Salt(pwd, salt) == storedHash +} diff --git a/go/pkg/jwt/jwt.go b/go/pkg/jwt/jwt.go new file mode 100644 index 0000000..815bfa2 --- /dev/null +++ b/go/pkg/jwt/jwt.go @@ -0,0 +1,55 @@ +package jwt + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID uint `json:"uid"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +type Manager struct { + Secret []byte + ExpireHrs int +} + +func New(secret string, expireHrs int) *Manager { + return &Manager{Secret: []byte(secret), ExpireHrs: expireHrs} +} + +func (m *Manager) Generate(userID uint, username, role string) (string, error) { + claims := Claims{ + UserID: userID, + Username: username, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(m.ExpireHrs) * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString(m.Secret) +} + +func (m *Manager) Parse(tokenStr string) (*Claims, error) { + claims := &Claims{} + t, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return m.Secret, nil + }) + if err != nil { + return nil, err + } + if !t.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} diff --git a/go/pkg/redis/redis.go b/go/pkg/redis/redis.go new file mode 100644 index 0000000..c91493c --- /dev/null +++ b/go/pkg/redis/redis.go @@ -0,0 +1,116 @@ +package redis + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +var ( + Client *redis.Client + Enabled bool + memoryStore sync.Map // Redis 不可用时的内存降级存储 +) + +// Config Redis 配置 +type Config struct { + Host string + Port string + Password string + DB int +} + +// Init 初始化 Redis 连接,失败则降级为内存模式 +func Init(cfg Config) { + Client = redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), + Password: cfg.Password, + DB: cfg.DB, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if err := Client.Ping(ctx).Err(); err != nil { + log.Printf("[redis] connection failed (%v), fallback to memory mode", err) + Enabled = false + Client = nil + return + } + + Enabled = true + log.Printf("[redis] connected to %s:%s (db=%d)", cfg.Host, cfg.Port, cfg.DB) +} + +// Set 设置键值(带过期时间,0=不过期) +func Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { + if Enabled && Client != nil { + return Client.Set(ctx, key, value, expiration).Err() + } + // 内存降级 + memoryStore.Store(key, memoryItem{value: value, expireAt: time.Now().Add(expiration)}) + return nil +} + +// Get 获取值 +func Get(ctx context.Context, key string) (string, error) { + if Enabled && Client != nil { + return Client.Get(ctx, key).Result() + } + // 内存降级 + v, ok := memoryStore.Load(key) + if !ok { + return "", redis.Nil + } + item := v.(memoryItem) + if time.Now().After(item.expireAt) { + memoryStore.Delete(key) + return "", redis.Nil + } + return fmt.Sprintf("%v", item.value), nil +} + +// Del 删除键 +func Del(ctx context.Context, key string) error { + if Enabled && Client != nil { + return Client.Del(ctx, key).Err() + } + memoryStore.Delete(key) + return nil +} + +// Exists 检查键是否存在 +func Exists(ctx context.Context, key string) (int64, error) { + if Enabled && Client != nil { + return Client.Exists(ctx, key).Result() + } + _, ok := memoryStore.Load(key) + if ok { + return 1, nil + } + return 0, nil +} + +// Incr 自增 +func Incr(ctx context.Context, key string) (int64, error) { + if Enabled && Client != nil { + return Client.Incr(ctx, key).Result() + } + // 内存降级(简单实现) + v, _ := Get(ctx, key) + var n int64 + fmt.Sscanf(v, "%d", &n) + n++ + Set(ctx, key, n, 0) + return n, nil +} + +// memoryItem 内存存储项 +type memoryItem struct { + value interface{} + expireAt time.Time +} diff --git a/go/pkg/response/response.go b/go/pkg/response/response.go new file mode 100644 index 0000000..01f11dd --- /dev/null +++ b/go/pkg/response/response.go @@ -0,0 +1,50 @@ +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// 统一响应结构 +type Resp struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +func OK(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Resp{Code: 0, Message: "ok", Data: data}) +} + +func Created(c *gin.Context, data interface{}) { + c.JSON(http.StatusCreated, Resp{Code: 0, Message: "created", Data: data}) +} + +func Fail(c *gin.Context, httpStatus int, msg string) { + c.JSON(httpStatus, Resp{Code: httpStatus, Message: msg}) +} + +func BadRequest(c *gin.Context, msg string) { + Fail(c, http.StatusBadRequest, msg) +} + +func Unauthorized(c *gin.Context, msg string) { + Fail(c, http.StatusUnauthorized, msg) +} + +func Forbidden(c *gin.Context, msg string) { + Fail(c, http.StatusForbidden, msg) +} + +func NotFound(c *gin.Context, msg string) { + Fail(c, http.StatusNotFound, msg) +} + +func Conflict(c *gin.Context, msg string) { + Fail(c, http.StatusConflict, msg) +} + +func Internal(c *gin.Context, msg string) { + Fail(c, http.StatusInternalServerError, msg) +} diff --git a/go/pkg/sensitive/sensitive.go b/go/pkg/sensitive/sensitive.go new file mode 100644 index 0000000..2c84131 --- /dev/null +++ b/go/pkg/sensitive/sensitive.go @@ -0,0 +1,125 @@ +package sensitive + +import ( + "photowall/internal/model" + "strings" + "sync" + + "gorm.io/gorm" +) + +// DFA 敏感词过滤器 +type Filter struct { + root *trieNode + mu sync.RWMutex + ready bool +} + +type trieNode struct { + children map[rune]*trieNode + isEnd bool + word string +} + +var globalFilter = &Filter{root: newNode()} + +func newNode() *trieNode { + return &trieNode{children: make(map[rune]*trieNode)} +} + +// Init 从数据库加载敏感词 +func Init(db *gorm.DB) { + var words []model.SensitiveWord + db.Where("enabled = ?", true).Find(&words) + globalFilter.mu.Lock() + defer globalFilter.mu.Unlock() + globalFilter.root = newNode() + for _, w := range words { + globalFilter.addWord(w.Word) + } + globalFilter.ready = true +} + +// Reload 重新加载敏感词 +func Reload(db *gorm.DB) { + Init(db) +} + +func (f *Filter) addWord(word string) { + node := f.root + for _, ch := range []rune(word) { + if _, ok := node.children[ch]; !ok { + node.children[ch] = newNode() + } + node = node.children[ch] + } + node.isEnd = true + node.word = word +} + +// Check 检测文本中是否包含敏感词,返回第一个匹配的词 +func Check(text string) (bool, string) { + if !globalFilter.ready || text == "" { + return false, "" + } + globalFilter.mu.RLock() + defer globalFilter.mu.RUnlock() + runes := []rune(text) + for i := 0; i < len(runes); i++ { + node := globalFilter.root + for j := i; j < len(runes); j++ { + next, ok := node.children[runes[j]] + if !ok { + break + } + node = next + if node.isEnd { + return true, node.word + } + } + } + return false, "" +} + +// Replace 替换文本中的敏感词为 *** +func Replace(text string) string { + if !globalFilter.ready || text == "" { + return text + } + globalFilter.mu.RLock() + defer globalFilter.mu.RUnlock() + runes := []rune(text) + var result strings.Builder + i := 0 + for i < len(runes) { + node := globalFilter.root + matchLen := 0 + for j := i; j < len(runes); j++ { + next, ok := node.children[runes[j]] + if !ok { + break + } + node = next + if node.isEnd { + matchLen = j - i + 1 + } + } + if matchLen > 0 { + result.WriteString("***") + i += matchLen + } else { + result.WriteRune(runes[i]) + i++ + } + } + return result.String() +} + +// CheckAndReplace 检测并过滤,返回是否包含敏感词和过滤后的文本 +func CheckAndReplace(text string) (bool, string) { + found, _ := Check(text) + if !found { + return false, text + } + return true, Replace(text) +} diff --git a/go/pkg/sms/sms.go b/go/pkg/sms/sms.go new file mode 100644 index 0000000..0e6a21a --- /dev/null +++ b/go/pkg/sms/sms.go @@ -0,0 +1,71 @@ +package sms + +import ( + "crypto/rand" + "errors" + "fmt" + "math/big" + "photowall/internal/config" + "sync" + "time" +) + +// 短信验证码封装 +// 参数未配置时返回 ErrNotConfigured,开发阶段可在日志中输出验证码 + +var ErrNotConfigured = errors.New("短信服务未配置,请联系管理员") + +type codeItem struct { + code string + expireAt time.Time +} + +var ( + store = sync.Map{} // phone -> codeItem +) + +// SendCode 发送短信验证码 +// 开发阶段(未配置短信服务商):验证码输出到日志,方便测试 +func SendCode(phone string) (string, error) { + cfg := config.C + code := generateCode() + + if cfg.SMSAccessKey == "" || cfg.SMSSecretKey == "" || cfg.SMSSignName == "" || cfg.SMSTemplateCode == "" { + // 未配置短信服务商,开发模式:日志输出验证码 + fmt.Printf("[SMS-DEV] 手机号 %s 的验证码: %s (5分钟有效)\n", phone, code) + store.Store(phone, codeItem{code: code, expireAt: time.Now().Add(5 * time.Minute)}) + return code, nil + } + + // TODO: 接入阿里云/腾讯云短信 SDK + // 阿里云:dysmsapi.Client + SendSmsRequest + // 腾讯云:sms.Client + SendSms + store.Store(phone, codeItem{code: code, expireAt: time.Now().Add(5 * time.Minute)}) + return code, nil +} + +// VerifyCode 校验短信验证码(一次性) +func VerifyCode(phone, code string) bool { + if phone == "" || code == "" { + return false + } + v, ok := store.Load(phone) + if !ok { + return false + } + item := v.(codeItem) + store.Delete(phone) + if time.Now().After(item.expireAt) { + return false + } + return item.code == code +} + +func generateCode() string { + b := make([]byte, 6) + for i := range b { + n, _ := rand.Int(rand.Reader, big.NewInt(10)) + b[i] = byte('0' + n.Int64()) + } + return string(b) +} diff --git a/go/uploads/avatars/.gitkeep b/go/uploads/avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/go/uploads/classes/.gitkeep b/go/uploads/classes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/go/uploads/photos/.gitkeep b/go/uploads/photos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/vue/index.html b/vue/index.html new file mode 100644 index 0000000..3f4544f --- /dev/null +++ b/vue/index.html @@ -0,0 +1,12 @@ + + + + + + 毕业照墙 - PhotoWall + + +
+ + + diff --git a/vue/package-lock.json b/vue/package-lock.json new file mode 100644 index 0000000..42af613 --- /dev/null +++ b/vue/package-lock.json @@ -0,0 +1,2656 @@ +{ + "name": "photowall-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "photowall-web", + "version": "1.0.0", + "dependencies": { + "@fortawesome/fontawesome-free": "^7.3.1", + "@wangeditor/editor": "^5.1.23", + "@wangeditor/editor-for-vue": "^5.1.12", + "axios": "^1.7.2", + "echarts": "^6.1.0", + "element-plus": "^2.14.5", + "less": "^4.9.0", + "less-loader": "^13.0.0", + "vue": "^3.4.31", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.5", + "vite": "^5.3.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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "7.3.1", + "resolved": "https://registry.npmmirror.com/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz", + "integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, + "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==", + "license": "MIT" + }, + "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, + "license": "MIT", + "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==", + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@transloadit/prettier-bytes": { + "version": "0.0.7", + "resolved": "https://registry.npmmirror.com/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz", + "integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==", + "license": "MIT" + }, + "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, + "license": "MIT" + }, + "node_modules/@types/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmmirror.com/@types/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==", + "license": "MIT" + }, + "node_modules/@types/less": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/less/-/less-3.0.8.tgz", + "integrity": "sha512-Gjm4+H9noDJgu5EdT3rUw5MhPBag46fiOy27BefvWkNL8mlZnKnCaVVVTLKj6RYXed9b62CPKnPav9govyQDzA==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/@uppy/companion-client": { + "version": "2.2.2", + "resolved": "https://registry.npmmirror.com/@uppy/companion-client/-/companion-client-2.2.2.tgz", + "integrity": "sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==", + "license": "MIT", + "dependencies": { + "@uppy/utils": "^4.1.2", + "namespace-emitter": "^2.0.1" + } + }, + "node_modules/@uppy/core": { + "version": "2.3.4", + "resolved": "https://registry.npmmirror.com/@uppy/core/-/core-2.3.4.tgz", + "integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==", + "license": "MIT", + "dependencies": { + "@transloadit/prettier-bytes": "0.0.7", + "@uppy/store-default": "^2.1.1", + "@uppy/utils": "^4.1.3", + "lodash.throttle": "^4.1.1", + "mime-match": "^1.0.2", + "namespace-emitter": "^2.0.1", + "nanoid": "^3.1.25", + "preact": "^10.5.13" + } + }, + "node_modules/@uppy/store-default": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@uppy/store-default/-/store-default-2.1.1.tgz", + "integrity": "sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==", + "license": "MIT" + }, + "node_modules/@uppy/utils": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/@uppy/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==", + "license": "MIT", + "dependencies": { + "lodash.throttle": "^4.1.1" + } + }, + "node_modules/@uppy/xhr-upload": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz", + "integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==", + "license": "MIT", + "dependencies": { + "@uppy/companion-client": "^2.2.2", + "@uppy/utils": "^4.1.2", + "nanoid": "^3.1.25" + }, + "peerDependencies": { + "@uppy/core": "^2.3.3" + } + }, + "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, + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@wangeditor/basic-modules": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz", + "integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==", + "license": "MIT", + "dependencies": { + "is-url": "^1.2.4" + }, + "peerDependencies": { + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "lodash.throttle": "^4.1.1", + "nanoid": "^3.2.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/code-highlight": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz", + "integrity": "sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.23.0" + }, + "peerDependencies": { + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/core": { + "version": "1.1.19", + "resolved": "https://registry.npmmirror.com/@wangeditor/core/-/core-1.1.19.tgz", + "integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==", + "license": "MIT", + "dependencies": { + "@types/event-emitter": "^0.3.3", + "event-emitter": "^0.3.5", + "html-void-elements": "^2.0.0", + "i18next": "^20.4.0", + "scroll-into-view-if-needed": "^2.2.28", + "slate-history": "^0.66.0" + }, + "peerDependencies": { + "@uppy/core": "^2.1.1", + "@uppy/xhr-upload": "^2.0.3", + "dom7": "^3.0.0", + "is-hotkey": "^0.2.0", + "lodash.camelcase": "^4.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.debounce": "^4.0.8", + "lodash.foreach": "^4.5.0", + "lodash.isequal": "^4.5.0", + "lodash.throttle": "^4.1.1", + "lodash.toarray": "^4.4.0", + "nanoid": "^3.2.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/editor": { + "version": "5.1.23", + "resolved": "https://registry.npmmirror.com/@wangeditor/editor/-/editor-5.1.23.tgz", + "integrity": "sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==", + "license": "MIT", + "dependencies": { + "@uppy/core": "^2.1.1", + "@uppy/xhr-upload": "^2.0.3", + "@wangeditor/basic-modules": "^1.1.7", + "@wangeditor/code-highlight": "^1.0.3", + "@wangeditor/core": "^1.1.19", + "@wangeditor/list-module": "^1.0.5", + "@wangeditor/table-module": "^1.1.4", + "@wangeditor/upload-image-module": "^1.0.2", + "@wangeditor/video-module": "^1.1.4", + "dom7": "^3.0.0", + "is-hotkey": "^0.2.0", + "lodash.camelcase": "^4.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.debounce": "^4.0.8", + "lodash.foreach": "^4.5.0", + "lodash.isequal": "^4.5.0", + "lodash.throttle": "^4.1.1", + "lodash.toarray": "^4.4.0", + "nanoid": "^3.2.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/editor-for-vue": { + "version": "5.1.12", + "resolved": "https://registry.npmmirror.com/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz", + "integrity": "sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==", + "license": "MIT", + "peerDependencies": { + "@wangeditor/editor": ">=5.1.0", + "vue": "^3.0.5" + } + }, + "node_modules/@wangeditor/list-module": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@wangeditor/list-module/-/list-module-1.0.5.tgz", + "integrity": "sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==", + "license": "MIT", + "peerDependencies": { + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/table-module": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@wangeditor/table-module/-/table-module-1.1.4.tgz", + "integrity": "sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==", + "license": "MIT", + "peerDependencies": { + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "lodash.isequal": "^4.5.0", + "lodash.throttle": "^4.1.1", + "nanoid": "^3.2.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/upload-image-module": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz", + "integrity": "sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==", + "license": "MIT", + "peerDependencies": { + "@uppy/core": "^2.0.3", + "@uppy/xhr-upload": "^2.0.3", + "@wangeditor/basic-modules": "1.x", + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "lodash.foreach": "^4.5.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "node_modules/@wangeditor/video-module": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@wangeditor/video-module/-/video-module-1.1.4.tgz", + "integrity": "sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==", + "license": "MIT", + "peerDependencies": { + "@uppy/core": "^2.1.4", + "@uppy/xhr-upload": "^2.0.7", + "@wangeditor/core": "1.x", + "dom7": "^3.0.0", + "nanoid": "^3.2.0", + "slate": "^0.72.0", + "snabbdom": "^3.1.0" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "1.0.20", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dom7": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/dom7/-/dom7-3.0.0.tgz", + "integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==", + "license": "MIT", + "dependencies": { + "ssr-window": "^3.0.0-alpha.1" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "license": "MIT", + "optional": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmmirror.com/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "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, + "license": "MIT", + "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/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "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" + } + ], + "license": "MIT", + "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==", + "license": "MIT", + "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, + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-2.0.1.tgz", + "integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/i18next": { + "version": "20.6.1", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-20.6.1.tgz", + "integrity": "sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/is-hotkey": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/is-hotkey/-/is-hotkey-0.2.0.tgz", + "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==", + "license": "MIT" + }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmmirror.com/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/less": { + "version": "4.9.0", + "resolved": "https://registry.npmmirror.com/less/-/less-4.9.0.tgz", + "integrity": "sha512-umRhrCH7fCi8Uj2RcwKjJdvUORTjeWqkdKx0LbcZvjIwsAVsnIAGcxHaqowPeBFBjQuWOeC/bve0AlpFzF/+SQ==", + "license": "Apache-2.0", + "dependencies": { + "copy-anything": "^3.0.5", + "parse-node-version": "^1.0.1" + }, + "bin": { + "lessc": "bin/lessc" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "make-dir": "^5.1.0", + "mime": "^1.4.1", + "needle": "^3.1.0", + "probe-image-size": "^7.2.3", + "source-map": "~0.6.0" + } + }, + "node_modules/less-loader": { + "version": "13.0.0", + "resolved": "https://registry.npmmirror.com/less-loader/-/less-loader-13.0.0.tgz", + "integrity": "sha512-TIa8d6znKH634Mg+7OU3jevZT6KeOhh0amW+YeMPD0GM9buUn5Y7HvtyCR5pUDdLaFfqLA8AX5PTSIHMNSexEA==", + "license": "MIT", + "dependencies": { + "@types/less": "^3.0.8" + }, + "engines": { + "node": ">= 22.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "less": "^3.5.0 || ^4.0.0", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.foreach": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz", + "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/lodash.toarray": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz", + "integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-5.1.0.tgz", + "integrity": "sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "optional": true, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/mime-match/-/mime-match-1.0.2.tgz", + "integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==", + "license": "ISC", + "dependencies": { + "wildcard": "^1.1.0" + } + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/namespace-emitter": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz", + "integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==", + "license": "MIT" + }, + "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" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/needle": { + "version": "3.5.0", + "resolved": "https://registry.npmmirror.com/needle/-/needle-3.5.0.tgz", + "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "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==", + "license": "BSD-3-Clause" + }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "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" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmmirror.com/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/probe-image-size": { + "version": "7.4.0", + "resolved": "https://registry.npmmirror.com/probe-image-size/-/probe-image-size-7.4.0.tgz", + "integrity": "sha512-cdEprVtZxV+awMde9X+4jILBFYh4CARxVrQaMl4wY4YcPWbul9jntXrIW95NInBDyJwcVUP3U0T6yukN8rMBaQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, + "node_modules/probe-image-size/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/probe-image-size/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==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/probe-image-size/node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmmirror.com/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "license": "MIT", + "optional": true + }, + "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, + "license": "MIT", + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "2.2.31", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz", + "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^1.0.20" + } + }, + "node_modules/slate": { + "version": "0.72.8", + "resolved": "https://registry.npmmirror.com/slate/-/slate-0.72.8.tgz", + "integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==", + "license": "MIT", + "dependencies": { + "immer": "^9.0.6", + "is-plain-object": "^5.0.0", + "tiny-warning": "^1.0.3" + } + }, + "node_modules/slate-history": { + "version": "0.66.0", + "resolved": "https://registry.npmmirror.com/slate-history/-/slate-history-0.66.0.tgz", + "integrity": "sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^5.0.0" + }, + "peerDependencies": { + "slate": ">=0.65.3" + } + }, + "node_modules/snabbdom": { + "version": "3.6.4", + "resolved": "https://registry.npmmirror.com/snabbdom/-/snabbdom-3.6.4.tgz", + "integrity": "sha512-VmxEfuw1/Y/eFj5VtMhYnukExpYiPkNzoo3+N3qwAOUDMl8wXgbli5ebR+j0knE3lZ/0eYskLxNcX64uy10N9w==", + "license": "MIT", + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssr-window": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz", + "integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==", + "license": "MIT" + }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "2" + } + }, + "node_modules/stream-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/stream-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "optional": true + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmmirror.com/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "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, + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/wildcard": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz", + "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==", + "license": "MIT" + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/vue/package.json b/vue/package.json new file mode 100644 index 0000000..d6f1b90 --- /dev/null +++ b/vue/package.json @@ -0,0 +1,27 @@ +{ + "name": "photowall-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^7.3.1", + "@wangeditor/editor": "^5.1.23", + "@wangeditor/editor-for-vue": "^5.1.12", + "axios": "^1.7.2", + "echarts": "^6.1.0", + "element-plus": "^2.14.5", + "less": "^4.9.0", + "less-loader": "^13.0.0", + "vue": "^3.4.31", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.5", + "vite": "^5.3.3" + } +} diff --git a/vue/src/App.vue b/vue/src/App.vue new file mode 100644 index 0000000..09b4b33 --- /dev/null +++ b/vue/src/App.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/vue/src/api/index.js b/vue/src/api/index.js new file mode 100644 index 0000000..95e1959 --- /dev/null +++ b/vue/src/api/index.js @@ -0,0 +1,183 @@ +import axios from 'axios' +import router from '../router' + +const http = axios.create({ + baseURL: '/api', + timeout: 30000 +}) + +// 请求拦截:带 token + 检查本地过期时间 +http.interceptors.request.use(config => { + const token = localStorage.getItem('token') + const expireAt = localStorage.getItem('token_expire_at') + // 本地预判:token 过期时间已到,直接清除并跳登录 + if (token && expireAt && Date.now() > parseInt(expireAt)) { + localStorage.removeItem('token') + localStorage.removeItem('token_expire_at') + localStorage.removeItem('user') + if (router.currentRoute.value.path !== '/login') { + router.push('/login') + } + return Promise.reject(new Error('登录已过期,请重新登录')) + } + if (token) { + config.headers.Authorization = 'Bearer ' + token + } + return config +}) + +// 响应拦截:401 强制清除 token 并跳转登录 +http.interceptors.response.use( + res => res.data, + err => { + const msg = err.response?.data?.message || err.message || '请求失败' + if (err.response?.status === 401) { + localStorage.removeItem('token') + localStorage.removeItem('token_expire_at') + localStorage.removeItem('user') + if (router.currentRoute.value.path !== '/login') { + router.push('/login') + } + } + return Promise.reject(new Error(msg)) + } +) + +// ============ 认证 ============ +export const authApi = { + register: data => http.post('/auth/register', data), + login: data => http.post('/auth/login', data), + me: () => http.get('/auth/me') +} + +// ============ 用户 ============ +export const userApi = { + profile: () => http.get('/user/profile'), + updateProfile: data => http.put('/user/profile', data), + hasEducation: () => http.get('/user/has-education'), + addContact: data => http.post('/user/contacts', data), + updateContact: (id, data) => http.put(`/user/contacts/${id}`, data), + deleteContact: id => http.delete(`/user/contacts/${id}`), + addEducation: data => http.post('/user/education', data), + updateEducation: (id, data) => http.put(`/user/education/${id}`, data), + deleteEducation: id => http.delete(`/user/education/${id}`), + publicProfile: id => http.get(`/user/${id}`) +} + +// ============ 地区 ============ +export const regionApi = { + list: parentId => http.get('/regions', { params: { parent_id: parentId || 0 } }), + countries: () => http.get('/regions/countries'), + search: keyword => http.get('/regions/search', { params: { keyword } }), + path: id => http.get(`/regions/${id}/path`) +} + +// ============ 学校 ============ +export const schoolApi = { + list: params => http.get('/schools', { params }), + get: id => http.get(`/schools/${id}`), + create: data => http.post('/schools', data), + update: (id, data) => http.put(`/schools/${id}`, data), + delete: id => http.delete(`/schools/${id}`), + listColleges: id => http.get(`/schools/${id}/colleges`), + createCollege: (id, data) => http.post(`/schools/${id}/colleges`, data), + updateCollege: (id, data) => http.put(`/colleges/${id}`, data), + deleteCollege: id => http.delete(`/colleges/${id}`) +} + +// ============ 班级 ============ +export const classApi = { + create: data => http.post('/classes', data), + detail: id => http.get(`/classes/${id}`), + search: params => http.get('/classes/search', { params }), + mine: () => http.get('/classes/mine'), + joinBySearch: data => http.post('/classes/join/search', data), + joinByInvite: data => http.post('/classes/join/invite', data), + resubmit: (id, data) => http.post(`/classes/${id}/resubmit`, data), + // 邀请码 + createInvitation: (id, data) => http.post(`/classes/${id}/invitation`, data), + listInvitations: id => http.get(`/classes/${id}/invitations`), + disableInvitation: (id, invId) => http.delete(`/classes/${id}/invitations/${invId}`), + // 成员 + transferAdmin: (id, data) => http.post(`/classes/${id}/transfer`, data), + removeMember: (id, userId) => http.delete(`/classes/${id}/members/${userId}`), + // 照片 + uploadPhoto: (id, data) => http.post(`/classes/${id}/photos`, data), + deletePhoto: (id, photoId) => http.delete(`/classes/${id}/photos/${photoId}`) +} + +// ============ 平台管理员 ============ +export const adminApi = { + stats: () => http.get('/admin/stats'), + auditQueue: params => http.get('/admin/classes', { params }), + approve: (id, data) => http.post(`/admin/classes/${id}/approve`, data), + reject: (id, data) => http.post(`/admin/classes/${id}/reject`, data), + delete: (id, data) => http.delete(`/admin/classes/${id}`, { data }), + logs: id => http.get(`/admin/classes/${id}/logs`), + cleanup: () => http.post('/admin/cleanup') +} + +// ============ 文件上传 ============ +export const uploadApi = { + upload: (file, type) => { + const form = new FormData() + form.append('file', file) + form.append('type', type || 'photo') + return http.post('/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + } +} + +export default http + +// ============ 图形验证码 ============ +export const captchaApi = { + get: () => http.get('/captcha') +} + +// ============ 忘记密码 ============ +export const passwordApi = { + forgot: data => http.post('/auth/forgot-password', data), + reset: data => http.post('/auth/reset-password', data) +} + +// ============ 短信验证码 ============ +export const smsApi = { + send: data => http.post('/auth/send-sms', data) +} + +// ============ 系统配置 ============ +export const configApi = { + getAll: () => http.get('/admin/config'), + update: data => http.put('/admin/config', data) +} + +// ============ 菜单管理 ============ +export const menuApi = { + getAll: () => http.get('/admin/menus'), + create: data => http.post('/admin/menus', data), + update: (id, data) => http.put(`/admin/menus/${id}`, data), + delete: id => http.delete(`/admin/menus/${id}`), + getUserMenus: () => http.get('/menus') +} + +// ============ 详细统计 ============ +export const statsApi = { + detailed: () => http.get('/admin/stats/detailed') +} + +// ============ 账号封禁 ============ +export const banApi = { + list: () => http.get('/admin/users/banned'), + ban: (id, data) => http.post(`/admin/users/${id}/ban`, data), + unban: id => http.post(`/admin/users/${id}/unban`) +} + +// ============ 敏感词管理 ============ +export const sensitiveApi = { + list: () => http.get('/admin/sensitive-words'), + create: data => http.post('/admin/sensitive-words', data), + delete: id => http.delete(`/admin/sensitive-words/${id}`), + toggle: (id, enabled) => http.put(`/admin/sensitive-words/${id}/toggle`, { enabled }) +} diff --git a/vue/src/assets/style.css b/vue/src/assets/style.css new file mode 100644 index 0000000..338ecef --- /dev/null +++ b/vue/src/assets/style.css @@ -0,0 +1,344 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + background: #f5f7fa; + color: #303133; + line-height: 1.6; +} + +a { color: #409eff; text-decoration: none; } +a:hover { text-decoration: underline; } + +/* 布局 */ +.app-header { + background: #fff; + border-bottom: 1px solid #e4e7ed; + padding: 0 24px; + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; +} +.app-header .logo { font-size: 20px; font-weight: 700; color: #303133; } +.app-header .nav { display: flex; gap: 20px; align-items: center; } +.app-header .nav a { color: #606266; font-size: 14px; } +.app-header .nav a.active { color: #409eff; font-weight: 600; } + +.container { max-width: 1200px; margin: 0 auto; padding: 24px; } + +/* 卡片 */ +.card { + background: #fff; + border-radius: 8px; + padding: 24px; + margin-bottom: 16px; + box-shadow: 0 1px 4px rgba(0,0,0,0.04); +} +.card-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #303133; } + +/* 表单 */ +.form-group { margin-bottom: 16px; } +.form-group label { display: block; margin-bottom: 6px; font-size: 14px; color: #606266; font-weight: 500; } +.form-group input, .form-group select, .form-group textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid #dcdfe6; + border-radius: 4px; + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} +.form-group input:focus, .form-group select:focus, .form-group textarea:focus { + border-color: #409eff; +} +.form-group .hint { font-size: 12px; color: #909399; margin-top: 4px; } +.form-row { display: flex; gap: 16px; } +.form-row .form-group { flex: 1; } + +/* 按钮 */ +.btn { + display: inline-block; + padding: 8px 20px; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: opacity 0.2s; + text-align: center; +} +.btn:hover { opacity: 0.85; text-decoration: none; } +.btn-primary { background: #409eff; color: #fff; } +.btn-success { background: #67c23a; color: #fff; } +.btn-warning { background: #e6a23c; color: #fff; } +.btn-danger { background: #f56c6c; color: #fff; } +.btn-default { background: #fff; color: #606266; border: 1px solid #dcdfe6; } +.btn-sm { padding: 4px 12px; font-size: 12px; } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* 标签 */ +.tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + margin-right: 6px; +} +.tag-primary { background: #ecf5ff; color: #409eff; } +.tag-success { background: #f0f9eb; color: #67c23a; } +.tag-warning { background: #fdf6ec; color: #e6a23c; } +.tag-danger { background: #fef0f0; color: #f56c6c; } +.tag-info { background: #f4f4f5; color: #909399; } + +/* 表格 */ +table { width: 100%; border-collapse: collapse; font-size: 14px; } +th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #ebeef5; } +th { background: #fafafa; color: #606266; font-weight: 600; } +tr:hover { background: #fafafa; } + +/* 照片网格 */ +.photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} +.photo-item { + position: relative; + border-radius: 6px; + overflow: hidden; + background: #f0f0f0; + aspect-ratio: 4/3; +} +.photo-item img { width: 100%; height: 100%; object-fit: cover; } +.photo-item .caption { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0,0,0,0.7)); + color: #fff; + padding: 16px 8px 6px; + font-size: 12px; +} + +/* 提示消息 */ +.alert { + padding: 12px 16px; + border-radius: 4px; + margin-bottom: 16px; + font-size: 14px; +} +.alert-info { background: #ecf5ff; color: #409eff; border: 1px solid #d9ecff; } +.alert-warning { background: #fdf6ec; color: #e6a23c; border: 1px solid #faecd8; } +.alert-danger { background: #fef0f0; color: #f56c6c; border: 1px solid #fde2e2; } +.alert-success { background: #f0f9eb; color: #67c23a; border: 1px solid #e1f3d8; } + +/* 成员头像列表 */ +.member-list { display: flex; flex-wrap: wrap; gap: 12px; } +.member-item { + display: flex; + align-items: center; + gap: 8px; + background: #fafafa; + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; +} +.member-avatar { + width: 32px; height: 32px; + border-radius: 50%; + background: #409eff; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 600; +} + +/* 步骤条 */ +.steps { display: flex; margin-bottom: 24px; } +.step { + flex: 1; + text-align: center; + position: relative; + font-size: 13px; + color: #909399; +} +.step.active { color: #409eff; font-weight: 600; } +.step.done { color: #67c23a; } +.step .num { + width: 28px; height: 28px; + border-radius: 50%; + background: #f0f0f0; + color: #909399; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 6px; + font-weight: 600; +} +.step.active .num { background: #409eff; color: #fff; } +.step.done .num { background: #67c23a; color: #fff; } + +/* 名字标签输入 */ +.name-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.name-tag { + display: inline-flex; + align-items: center; + gap: 4px; + background: #ecf5ff; + color: #409eff; + padding: 4px 10px; + border-radius: 4px; + font-size: 13px; +} +.name-tag .remove { cursor: pointer; font-weight: bold; } + +/* 登录页 */ +.login-wrap { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} +.login-box { + background: #fff; + padding: 40px; + border-radius: 12px; + width: 400px; + box-shadow: 0 10px 40px rgba(0,0,0,0.2); +} +.login-box h2 { text-align: center; margin-bottom: 24px; color: #303133; } +.login-tabs { display: flex; margin-bottom: 20px; border-bottom: 1px solid #e4e7ed; } +.login-tab { + flex: 1; + text-align: center; + padding: 10px; + cursor: pointer; + font-size: 15px; + color: #909399; + border-bottom: 2px solid transparent; +} +.login-tab.active { color: #409eff; border-bottom-color: #409eff; font-weight: 600; } + +/* 地区选择器 */ +.region-picker { display: flex; gap: 12px; } +.region-picker select { flex: 1; } + +/* 空状态 */ +.empty { + text-align: center; + padding: 40px; + color: #909399; + font-size: 14px; +} + +/* 分页 */ +.pagination { display: flex; justify-content: center; gap: 8px; margin-top: 16px; } +.pagination button { + padding: 6px 12px; + border: 1px solid #dcdfe6; + background: #fff; + border-radius: 4px; + cursor: pointer; + font-size: 13px; +} +.pagination button.active { background: #409eff; color: #fff; border-color: #409eff; } +.pagination button:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ======================================== + H5 移动端响应式适配 + ======================================== */ + +/* 平板及以下 */ +@media (max-width: 992px) { + .container { padding: 16px; } + .card { padding: 16px; } +} + +/* 手机端 */ +@media (max-width: 768px) { + body { font-size: 14px; } + + .container { padding: 12px; } + .card { padding: 12px; margin-bottom: 10px; border-radius: 6px; } + .card-title { font-size: 16px; margin-bottom: 12px; } + + /* 表单:多列改单列 */ + .form-row { flex-direction: column; gap: 0; } + .form-row .form-group { flex: 1; } + .form-group { margin-bottom: 12px; } + .form-group input, .form-group select, .form-group textarea { + font-size: 16px; /* 防止 iOS 缩放 */ + padding: 10px 12px; + } + + /* 按钮:触摸友好 */ + .btn { padding: 10px 16px; font-size: 14px; min-height: 40px; } + .btn-sm { padding: 6px 12px; font-size: 12px; min-height: 32px; } + + /* 表格:允许横向滚动 */ + .card > table, + table { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + font-size: 13px; + } + th, td { padding: 8px 10px; white-space: nowrap; } + + /* 照片网格:手机端2列 */ + .photo-grid { + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + /* 成员列表:手机端紧凑 */ + .member-list { gap: 8px; } + .member-item { padding: 8px 10px; font-size: 12px; } + + /* 步骤条:手机端缩小 */ + .step { font-size: 11px; } + .step .num { width: 24px; height: 24px; font-size: 12px; } + + /* 弹窗:手机端接近全屏 */ + [style*="position:fixed"][style*="top:50%"] { + width: 92% !important; + max-width: 92% !important; + max-height: 85vh; + overflow-y: auto; + } + + /* 地区选择器:手机端单列 */ + .region-picker { flex-direction: column; gap: 8px; } + + /* 登录页 */ + .login-box { width: 90%; padding: 24px 20px; } + + /* 标签 */ + .tag { font-size: 11px; padding: 2px 6px; } + + /* 空状态 */ + .empty { padding: 24px; font-size: 13px; } + + /* 提示框 */ + .alert { padding: 10px 12px; font-size: 13px; } +} + +/* 小屏手机 */ +@media (max-width: 480px) { + .photo-grid { grid-template-columns: repeat(2, 1fr); } + .card-title { font-size: 15px; } +} + +/* 横屏手机优化 */ +@media (max-height: 500px) and (orientation: landscape) { + .login-wrap { align-items: flex-start; padding: 20px 0; } + .login-box { max-height: 95vh; overflow-y: auto; } +} diff --git a/vue/src/assets/style.less b/vue/src/assets/style.less new file mode 100644 index 0000000..2cb25f3 --- /dev/null +++ b/vue/src/assets/style.less @@ -0,0 +1,351 @@ +// ============ PhotoWall 全局样式(蓝白配色) ============ + +// 颜色变量 +@primary-color: #1890ff; +@primary-light: #40a9ff; +@primary-dark: #096dd9; +@success-color: #52c41a; +@warning-color: #faad14; +@error-color: #f5222d; +@text-primary: #262626; +@text-secondary: #595959; +@text-tertiary: #8c8c8c; +@border-color: #d9d9d9; +@border-light: #f0f0f0; +@bg-page: #f5f7fa; +@bg-white: #ffffff; +@bg-gray: #fafafa; + +// 布局变量 +@sider-width: 220px; +@sider-collapsed-width: 64px; +@header-height: 56px; +@border-radius: 6px; +@shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + +// 重置 +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #app { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + font-size: 14px; + color: @text-primary; + background: @bg-page; +} + +a { + color: @primary-color; + text-decoration: none; + &:hover { + color: @primary-light; + } +} + +// ============ 布局 ============ +.layout { + display: flex; + height: 100vh; + overflow: hidden; +} + +.sider { + width: @sider-width; + background: linear-gradient(180deg, #001529 0%, #002140 100%); + color: #fff; + display: flex; + flex-direction: column; + transition: width 0.2s; + flex-shrink: 0; + + .logo { + height: @header-height; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 600; + color: #fff; + border-bottom: 1px solid rgba(255,255,255,0.1); + i { margin-right: 8px; color: @primary-light; } + } + + .menu { + flex: 1; + overflow-y: auto; + padding: 8px 0; + + .menu-item { + display: flex; + align-items: center; + padding: 12px 20px; + color: rgba(255,255,255,0.75); + cursor: pointer; + transition: all 0.2s; + i { width: 20px; margin-right: 10px; text-align: center; } + &:hover { + background: rgba(255,255,255,0.08); + color: #fff; + } + &.active { + background: @primary-color; + color: #fff; + } + } + + .submenu-title { + display: flex; + align-items: center; + padding: 12px 20px; + color: rgba(255,255,255,0.75); + cursor: pointer; + i { width: 20px; margin-right: 10px; text-align: center; } + .arrow { margin-left: auto; transition: transform 0.2s; } + &.open .arrow { transform: rotate(90deg); } + } + .submenu-items { + overflow: hidden; + .menu-item { padding-left: 50px; font-size: 13px; } + } + } +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.header { + height: @header-height; + background: @bg-white; + border-bottom: 1px solid @border-light; + display: flex; + align-items: center; + padding: 0 20px; + box-shadow: @shadow; + flex-shrink: 0; + + .toggle-btn { + font-size: 18px; + cursor: pointer; + color: @text-secondary; + margin-right: 16px; + &:hover { color: @primary-color; } + } + + .header-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 16px; + + .user-info { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + .avatar { + width: 32px; height: 32px; + border-radius: 50%; + background: @primary-color; + color: #fff; + display: flex; align-items: center; justify-content: center; + font-size: 14px; font-weight: 600; + } + .username { color: @text-primary; font-weight: 500; } + .role-tag { + font-size: 11px; + padding: 2px 6px; + border-radius: 3px; + background: @primary-color; + color: #fff; + } + } + } +} + +.content { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +// ============ 通用组件 ============ +.card { + background: @bg-white; + border-radius: @border-radius; + box-shadow: @shadow; + padding: 20px; + margin-bottom: 16px; +} + +.page-title { + font-size: 20px; + font-weight: 600; + color: @text-primary; + margin-bottom: 16px; + display: flex; + align-items: center; + i { color: @primary-color; margin-right: 8px; } +} + +.stat-card { + background: @bg-white; + border-radius: @border-radius; + box-shadow: @shadow; + padding: 20px; + display: flex; + align-items: center; + gap: 16px; + + .stat-icon { + width: 48px; height: 48px; + border-radius: 10px; + display: flex; align-items: center; justify-content: center; + font-size: 22px; color: #fff; + &.blue { background: linear-gradient(135deg, @primary-color, @primary-light); } + &.green { background: linear-gradient(135deg, #52c41a, #73d13d); } + &.orange { background: linear-gradient(135deg, #fa8c16, #ffa940); } + &.red { background: linear-gradient(135deg, #f5222d, #ff4d4f); } + &.purple { background: linear-gradient(135deg, #722ed1, #9254de); } + } + + .stat-info { + .stat-value { + font-size: 24px; + font-weight: 700; + color: @text-primary; + } + .stat-label { + font-size: 13px; + color: @text-tertiary; + margin-top: 2px; + } + } +} + +// 表格 +.data-table { + width: 100%; + border-collapse: collapse; + th, td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid @border-light; + } + th { + background: @bg-gray; + font-weight: 600; + color: @text-secondary; + font-size: 13px; + } + tr:hover { background: @bg-gray; } +} + +// 标签 +.tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + &.primary { background: #e6f7ff; color: @primary-color; } + &.success { background: #f6ffed; color: @success-color; } + &.warning { background: #fffbe6; color: @warning-color; } + &.error { background: #fff1f0; color: @error-color; } + &.default { background: #f5f5f5; color: @text-secondary; } +} + +// 空状态 +.empty { + text-align: center; + padding: 60px 20px; + color: @text-tertiary; + i { font-size: 48px; margin-bottom: 12px; color: @border-color; } + p { font-size: 14px; } +} + +// ============ 登录页 ============ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + width: 600px; height: 600px; + background: rgba(255,255,255,0.05); + border-radius: 50%; + top: -200px; right: -200px; + } + + .login-box { + width: 400px; + background: @bg-white; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); + padding: 40px; + position: relative; + z-index: 1; + + .login-title { + text-align: center; + font-size: 24px; + font-weight: 700; + color: @text-primary; + margin-bottom: 8px; + i { color: @primary-color; margin-right: 8px; } + } + .login-subtitle { + text-align: center; + color: @text-tertiary; + font-size: 13px; + margin-bottom: 30px; + } + } +} + +// 验证码图片 +.captcha-img { + height: 40px; + border-radius: 4px; + cursor: pointer; + border: 1px solid @border-color; +} + +// ============ H5 响应式 ============ +@media (max-width: 768px) { + .sider { + position: fixed; + left: 0; top: 0; bottom: 0; + z-index: 1000; + transform: translateX(-100%); + transition: transform 0.3s; + &.open { transform: translateX(0); } + } + .sider-mask { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 999; + } + .content { padding: 12px; } + .stat-card { padding: 16px; } + .data-table { display: block; overflow-x: auto; } + .login-box { width: 90%; padding: 24px; } +} + +@media (max-width: 480px) { + .header { padding: 0 12px; } + .header-right .username { display: none; } +} diff --git a/vue/src/composables/useAuth.js b/vue/src/composables/useAuth.js new file mode 100644 index 0000000..ed659f1 --- /dev/null +++ b/vue/src/composables/useAuth.js @@ -0,0 +1,42 @@ +import { ref, computed } from 'vue' +import { menuApi } from '../api' +import { loadDynamicRoutes, resetDynamicRoutes } from '../router' + +const token = ref(localStorage.getItem('token') || '') +const user = ref(JSON.parse(localStorage.getItem('user') || 'null')) +const menus = ref([]) + +const TOKEN_EXPIRE_MS = 3 * 60 * 60 * 1000 // 3小时 + +export function useAuth() { + const isLogin = computed(() => !!token.value) + const isAdmin = computed(() => user.value?.role === 'admin') + + function setAuth(t, u) { + token.value = t + user.value = u + localStorage.setItem('token', t) + localStorage.setItem('user', JSON.stringify(u)) + localStorage.setItem('token_expire_at', String(Date.now() + TOKEN_EXPIRE_MS)) + } + + function logout() { + token.value = '' + user.value = null + menus.value = [] + localStorage.removeItem('token') + localStorage.removeItem('token_expire_at') + localStorage.removeItem('user') + resetDynamicRoutes() + } + + // 从后端获取菜单并加载动态路由 + async function loadMenus() { + const res = await menuApi.getUserMenus() + menus.value = res.data || [] + loadDynamicRoutes(menus.value) + return menus.value + } + + return { token, user, menus, isLogin, isAdmin, setAuth, logout, loadMenus } +} diff --git a/vue/src/main.js b/vue/src/main.js new file mode 100644 index 0000000..fc05bea --- /dev/null +++ b/vue/src/main.js @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import '@fortawesome/fontawesome-free/css/all.min.css' +import './assets/style.less' + +const app = createApp(App) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) +app.mount('#app') diff --git a/vue/src/router/index.js b/vue/src/router/index.js new file mode 100644 index 0000000..f4520fd --- /dev/null +++ b/vue/src/router/index.js @@ -0,0 +1,118 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuth } from '../composables/useAuth' + +// 静态路由(不需要登录的页面) +const staticRoutes = [ + { path: '/login', name: 'Login', component: () => import('../views/auth/login/index.vue'), meta: { public: true } }, + { path: '/forgot-password', name: 'ForgotPassword', component: () => import('../views/auth/forgot/index.vue'), meta: { public: true } }, + { path: '/:pathMatch(.*)*', redirect: '/' } +] + +const router = createRouter({ + history: createWebHistory(), + routes: staticRoutes +}) + +// 动态路由是否已加载 +let dynamicRoutesLoaded = false + +// 组件路径映射(用于动态加载) +const componentMap = import.meta.glob('../views/**/index.vue') + +// 根据菜单数据动态注册路由 +export function loadDynamicRoutes(menus) { + // 先移除旧的动态路由 + if (dynamicRoutesLoaded) { + router.getRoutes().forEach(r => { + if (!staticRoutes.find(s => s.name === r.name)) { + router.removeRoute(r.name) + } + }) + } + + function registerMenu(menu) { + if (menu.component && menu.component !== '') { + const compPath = `../views/${menu.component}.vue` + const component = componentMap[compPath] + if (component) { + router.addRoute({ + path: menu.path, + name: menu.name, + component, + meta: { + title: menu.title, + icon: menu.icon, + requireAuth: menu.require_auth, + requireAdmin: menu.require_admin, + keepAlive: menu.keep_alive + } + }) + } + } + if (menu.children && menu.children.length > 0) { + menu.children.forEach(registerMenu) + } + } + + menus.forEach(registerMenu) + dynamicRoutesLoaded = true +} + +// 重置动态路由(登出时调用) +export function resetDynamicRoutes() { + dynamicRoutesLoaded = false + router.getRoutes().forEach(r => { + if (!staticRoutes.find(s => s.name === r.name)) { + router.removeRoute(r.name) + } + }) +} + +router.beforeEach(async (to, from, next) => { + const { isLogin, isAdmin, logout, loadMenus } = useAuth() + + // 本地预判 token 过期 + const expireAt = localStorage.getItem('token_expire_at') + if (isLogin.value && expireAt && Date.now() > parseInt(expireAt)) { + logout() + resetDynamicRoutes() + next({ path: '/login', query: { redirect: to.fullPath } }) + return + } + + // 公开页面直接放行 + if (to.meta.public) { + next() + return + } + + // 未登录跳登录 + if (!isLogin.value) { + next({ path: '/login', query: { redirect: to.fullPath } }) + return + } + + // 已登录但动态路由未加载,先加载菜单 + if (!dynamicRoutesLoaded) { + try { + await loadMenus() + next({ ...to, replace: true }) + return + } catch (e) { + logout() + resetDynamicRoutes() + next('/login') + return + } + } + + // 管理员权限校验 + if (to.meta.requireAdmin && !isAdmin.value) { + next('/') + return + } + + next() +}) + +export default router diff --git a/vue/src/views/admin/audit/detail/index.vue b/vue/src/views/admin/audit/detail/index.vue new file mode 100644 index 0000000..9d51f78 --- /dev/null +++ b/vue/src/views/admin/audit/detail/index.vue @@ -0,0 +1,162 @@ + + + diff --git a/vue/src/views/admin/audit/list/index.vue b/vue/src/views/admin/audit/list/index.vue new file mode 100644 index 0000000..278f071 --- /dev/null +++ b/vue/src/views/admin/audit/list/index.vue @@ -0,0 +1,136 @@ + + + diff --git a/vue/src/views/admin/config/index.vue b/vue/src/views/admin/config/index.vue new file mode 100644 index 0000000..c5654a1 --- /dev/null +++ b/vue/src/views/admin/config/index.vue @@ -0,0 +1,113 @@ + + + diff --git a/vue/src/views/admin/dashboard/index.vue b/vue/src/views/admin/dashboard/index.vue new file mode 100644 index 0000000..a906fcc --- /dev/null +++ b/vue/src/views/admin/dashboard/index.vue @@ -0,0 +1,234 @@ + + + diff --git a/vue/src/views/admin/menu/index.vue b/vue/src/views/admin/menu/index.vue new file mode 100644 index 0000000..24f14ae --- /dev/null +++ b/vue/src/views/admin/menu/index.vue @@ -0,0 +1,161 @@ + + + diff --git a/vue/src/views/admin/sensitive/index.vue b/vue/src/views/admin/sensitive/index.vue new file mode 100644 index 0000000..cc4f888 --- /dev/null +++ b/vue/src/views/admin/sensitive/index.vue @@ -0,0 +1,142 @@ + + + diff --git a/vue/src/views/admin/users/index.vue b/vue/src/views/admin/users/index.vue new file mode 100644 index 0000000..6270666 --- /dev/null +++ b/vue/src/views/admin/users/index.vue @@ -0,0 +1,132 @@ + + + diff --git a/vue/src/views/auth/forgot/index.vue b/vue/src/views/auth/forgot/index.vue new file mode 100644 index 0000000..b011c70 --- /dev/null +++ b/vue/src/views/auth/forgot/index.vue @@ -0,0 +1,130 @@ + + + diff --git a/vue/src/views/auth/login/index.vue b/vue/src/views/auth/login/index.vue new file mode 100644 index 0000000..aad67cd --- /dev/null +++ b/vue/src/views/auth/login/index.vue @@ -0,0 +1,152 @@ + + + diff --git a/vue/src/views/class/detail/index.vue b/vue/src/views/class/detail/index.vue new file mode 100644 index 0000000..074aa7b --- /dev/null +++ b/vue/src/views/class/detail/index.vue @@ -0,0 +1,317 @@ + + + diff --git a/vue/src/views/class/edit/index.vue b/vue/src/views/class/edit/index.vue new file mode 100644 index 0000000..25831e6 --- /dev/null +++ b/vue/src/views/class/edit/index.vue @@ -0,0 +1,280 @@ + + + diff --git a/vue/src/views/class/join/index.vue b/vue/src/views/class/join/index.vue new file mode 100644 index 0000000..be3b668 --- /dev/null +++ b/vue/src/views/class/join/index.vue @@ -0,0 +1,152 @@ + + + diff --git a/vue/src/views/class/list/index.vue b/vue/src/views/class/list/index.vue new file mode 100644 index 0000000..10c8a5e --- /dev/null +++ b/vue/src/views/class/list/index.vue @@ -0,0 +1,82 @@ + + + diff --git a/vue/src/views/home/index.vue b/vue/src/views/home/index.vue new file mode 100644 index 0000000..18ad4b2 --- /dev/null +++ b/vue/src/views/home/index.vue @@ -0,0 +1,174 @@ + + + diff --git a/vue/src/views/normalsettings/logmanagement/detail/index.vue b/vue/src/views/normalsettings/logmanagement/detail/index.vue new file mode 100644 index 0000000..9e01911 --- /dev/null +++ b/vue/src/views/normalsettings/logmanagement/detail/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/vue/src/views/normalsettings/logmanagement/list/index.vue b/vue/src/views/normalsettings/logmanagement/list/index.vue new file mode 100644 index 0000000..56166ba --- /dev/null +++ b/vue/src/views/normalsettings/logmanagement/list/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/vue/src/views/normalsettings/rolesettings/detail/index.vue b/vue/src/views/normalsettings/rolesettings/detail/index.vue new file mode 100644 index 0000000..91aa7f4 --- /dev/null +++ b/vue/src/views/normalsettings/rolesettings/detail/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/vue/src/views/normalsettings/rolesettings/edit/index.vue b/vue/src/views/normalsettings/rolesettings/edit/index.vue new file mode 100644 index 0000000..58e5b03 --- /dev/null +++ b/vue/src/views/normalsettings/rolesettings/edit/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/vue/src/views/normalsettings/rolesettings/list/index.vue b/vue/src/views/normalsettings/rolesettings/list/index.vue new file mode 100644 index 0000000..5d8042a --- /dev/null +++ b/vue/src/views/normalsettings/rolesettings/list/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/vue/src/views/normalsettings/usersettings/detail/index.vue b/vue/src/views/normalsettings/usersettings/detail/index.vue new file mode 100644 index 0000000..99bcd34 --- /dev/null +++ b/vue/src/views/normalsettings/usersettings/detail/index.vue @@ -0,0 +1,260 @@ + + + diff --git a/vue/src/views/normalsettings/usersettings/edit/index.vue b/vue/src/views/normalsettings/usersettings/edit/index.vue new file mode 100644 index 0000000..989e775 --- /dev/null +++ b/vue/src/views/normalsettings/usersettings/edit/index.vue @@ -0,0 +1,38 @@ + + + diff --git a/vue/src/views/normalsettings/usersettings/list/index.vue b/vue/src/views/normalsettings/usersettings/list/index.vue new file mode 100644 index 0000000..52a0203 --- /dev/null +++ b/vue/src/views/normalsettings/usersettings/list/index.vue @@ -0,0 +1,301 @@ + + + diff --git a/vue/vite.config.js b/vue/vite.config.js new file mode 100644 index 0000000..f81d1db --- /dev/null +++ b/vue/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 8011, + proxy: { + '/api': { + target: 'http://localhost:8010', + changeOrigin: true + }, + '/uploads': { + target: 'http://localhost:8010', + changeOrigin: true + } + } + } +})