feat: PhotoWall 毕业照存储系统初始版本
后端: 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
This commit is contained in:
+43
@@ -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
|
||||
@@ -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`。
|
||||
+420
@@ -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 版本暂缓,仅在文档中记录规划。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": "已更新"})
|
||||
}
|
||||
@@ -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": "",
|
||||
})
|
||||
}
|
||||
@@ -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": "已重新提交审核"})
|
||||
}
|
||||
@@ -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": "配置更新成功,部分配置需重启服务生效"})
|
||||
}
|
||||
@@ -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": "删除成功"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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": "删除成功"})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>毕业照墙 - PhotoWall</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2656
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div v-if="isLogin" class="layout">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sider" :class="{ open: siderOpen }">
|
||||
<div class="logo">
|
||||
<i class="fas fa-images"></i>
|
||||
<span v-if="!siderCollapsed">PhotoWall</span>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<template v-for="menu in visibleMenus" :key="menu.id">
|
||||
<!-- 有子菜单 -->
|
||||
<div v-if="menu.children && menu.children.filter(c => c.visible).length > 0">
|
||||
<div class="submenu-title" :class="{ open: openSubmenus.includes(menu.id) }" @click="toggleSubmenu(menu.id)">
|
||||
<i :class="menu.icon || 'fas fa-folder'"></i>
|
||||
<span v-if="!siderCollapsed">{{ menu.title }}</span>
|
||||
<i v-if="!siderCollapsed" class="fas fa-chevron-right arrow"></i>
|
||||
</div>
|
||||
<div class="submenu-items" v-show="openSubmenus.includes(menu.id)">
|
||||
<div v-for="child in menu.children.filter(c => c.visible)" :key="child.id"
|
||||
class="menu-item" :class="{ active: $route.path === child.path }"
|
||||
@click="navigate(child.path)">
|
||||
<i :class="child.icon || 'fas fa-circle'"></i>
|
||||
<span>{{ child.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 无子菜单 -->
|
||||
<div v-else class="menu-item" :class="{ active: $route.path === menu.path }" @click="navigate(menu.path)">
|
||||
<i :class="menu.icon || 'fas fa-file'"></i>
|
||||
<span v-if="!siderCollapsed">{{ menu.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 移动端遮罩 -->
|
||||
<div v-if="siderOpen && isMobile" class="sider-mask" @click="siderOpen = false"></div>
|
||||
|
||||
<!-- 主区域 -->
|
||||
<div class="main">
|
||||
<div class="header">
|
||||
<i class="fas fa-bars toggle-btn" @click="toggleSider"></i>
|
||||
<span style="color:#8c8c8c;font-size:13px">{{ currentTitle }}</span>
|
||||
<div class="header-right">
|
||||
<el-dropdown @command="handleCommand">
|
||||
<div class="user-info">
|
||||
<div class="avatar">{{ (user.nickname || user.username || 'U').charAt(0).toUpperCase() }}</div>
|
||||
<span class="username">{{ user.nickname || user.username }}</span>
|
||||
<span v-if="isAdmin" class="role-tag">管理员</span>
|
||||
<i class="fas fa-chevron-down" style="color:#8c8c8c;font-size:12px"></i>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile"><i class="fas fa-user"></i> 个人中心</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided><i class="fas fa-sign-out-alt"></i> 退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 未登录:直接渲染路由(登录页等) -->
|
||||
<router-view v-else />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuth } from './composables/useAuth'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { user, isLogin, isAdmin, menus, logout } = useAuth()
|
||||
|
||||
const siderOpen = ref(false)
|
||||
const siderCollapsed = ref(false)
|
||||
const openSubmenus = ref([])
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
})
|
||||
})
|
||||
|
||||
// 过滤可见菜单
|
||||
const visibleMenus = computed(() => {
|
||||
return menus.value.filter(m => m.visible)
|
||||
})
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
return route.meta.title || ''
|
||||
})
|
||||
|
||||
function toggleSider() {
|
||||
if (isMobile.value) {
|
||||
siderOpen.value = !siderOpen.value
|
||||
} else {
|
||||
siderCollapsed.value = !siderCollapsed.value
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSubmenu(id) {
|
||||
const idx = openSubmenus.value.indexOf(id)
|
||||
if (idx > -1) {
|
||||
openSubmenus.value.splice(idx, 1)
|
||||
} else {
|
||||
openSubmenus.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(path) {
|
||||
router.push(path)
|
||||
if (isMobile.value) siderOpen.value = false
|
||||
}
|
||||
|
||||
function handleCommand(cmd) {
|
||||
if (cmd === 'profile') {
|
||||
router.push('/profile')
|
||||
} else if (cmd === 'logout') {
|
||||
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
logout()
|
||||
router.push('/login')
|
||||
ElMessage.success('已退出登录')
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div class="page-title">
|
||||
<i class="fas fa-clipboard-check"></i>审核详情 - {{ detail?.class?.name }}
|
||||
<el-button style="float:right" @click="$router.back()"><i class="fas fa-arrow-left" style="margin-right:4px"></i>返回</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="detail" class="card">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="班级名称">{{ detail.class.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学校">{{ detail.school?.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学院">{{ detail.college?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年级">{{ detail.class.grade }}</el-descriptions-item>
|
||||
<el-descriptions-item label="班号">{{ detail.class.class_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="毕业年份">{{ detail.class.graduate_year }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建者">{{ detail.admin?.nickname || detail.admin?.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<span :class="['tag', statusMap[detail.class.status]?.cls]">{{ statusMap[detail.class.status]?.text }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核截止">{{ detail.class.audit_deadline || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="打回原因">{{ detail.class.reject_reason || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 毕业照 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-image" style="color:#1890ff"></i> 毕业照</h3>
|
||||
<div v-if="detail?.class?.graduation_photo" style="text-align:center">
|
||||
<img :src="detail.class.graduation_photo" style="max-width:100%;border-radius:8px" />
|
||||
</div>
|
||||
<div v-else class="empty"><i class="fas fa-image"></i><p>未上传毕业照</p></div>
|
||||
</div>
|
||||
|
||||
<!-- 同学名单 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px">
|
||||
<i class="fas fa-user-friends" style="color:#1890ff"></i> 同学名单 ({{ detail?.student_names?.length || 0 }}人)
|
||||
<el-tag v-if="(detail?.student_names?.length || 0) < 8" type="danger" style="margin-left:8px">不足8人,需打回补齐</el-tag>
|
||||
</h3>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||||
<span v-for="(name, i) in detail?.student_names" :key="i" class="tag primary" style="padding:4px 12px">{{ i+1 }}. {{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 老师名单 -->
|
||||
<div v-if="detail?.teacher_names?.length > 0" class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-chalkboard-teacher" style="color:#1890ff"></i> 老师名单 ({{ detail.teacher_names.length }}人)</h3>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||||
<span v-for="(name, i) in detail.teacher_names" :key="i" class="tag success" style="padding:4px 12px">{{ i+1 }}. {{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审核日志 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-history" style="color:#1890ff"></i> 审核日志</h3>
|
||||
<el-timeline>
|
||||
<el-timeline-item v-for="log in logs" :key="log.id" :timestamp="log.created_at">
|
||||
<el-card shadow="never">
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span>
|
||||
<el-tag :type="log.action === 'approve' ? 'success' : log.action === 'reject' ? 'danger' : 'warning'" size="small">
|
||||
{{ actionMap[log.action] || log.action }}
|
||||
</el-tag>
|
||||
<span style="margin-left:8px">{{ log.comment || log.reason || '' }}</span>
|
||||
</span>
|
||||
<span style="color:#8c8c8c;font-size:12px">审核员ID: {{ log.auditor_id }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<div v-if="logs.length === 0" class="empty" style="padding:20px"><i class="fas fa-history"></i><p>暂无审核记录</p></div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="detail?.class?.status === 'pending'" class="card" style="text-align:center">
|
||||
<el-button type="success" size="large" style="margin-right:16px" @click="approve">
|
||||
<i class="fas fa-check" style="margin-right:4px"></i>审核通过
|
||||
</el-button>
|
||||
<el-button type="warning" size="large" @click="showReject = true">
|
||||
<i class="fas fa-undo" style="margin-right:4px"></i>打回补齐
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 打回弹窗 -->
|
||||
<el-dialog v-model="showReject" title="打回原因" width="400px">
|
||||
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请输入打回原因,将通知创建者在30天内补齐" />
|
||||
<template #footer>
|
||||
<el-button @click="showReject=false">取消</el-button>
|
||||
<el-button type="warning" @click="confirmReject">确认打回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { classApi, adminApi } from '../../../../api'
|
||||
|
||||
const route = useRoute()
|
||||
const detail = ref(null)
|
||||
const logs = ref([])
|
||||
const loading = ref(false)
|
||||
const showReject = ref(false)
|
||||
const rejectReason = ref('')
|
||||
|
||||
const statusMap = {
|
||||
pending: { text: '待审核', cls: 'warning' },
|
||||
active: { text: '已通过', cls: 'success' },
|
||||
rejected: { text: '被打回', cls: 'error' },
|
||||
deleted: { text: '已删除', cls: 'default' }
|
||||
}
|
||||
const actionMap = { approve: '通过', reject: '打回', manual_delete: '删除', resubmit: '重新提交', create: '创建' }
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await classApi.detail(route.params.id)
|
||||
detail.value = res.data
|
||||
} catch (e) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const res = await adminApi.logs(route.params.id)
|
||||
logs.value = res.data || []
|
||||
} catch (e) { logs.value = [] }
|
||||
}
|
||||
|
||||
async function approve() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定通过该班级的审核吗?', '提示', { type: 'success' })
|
||||
await adminApi.approve(route.params.id, { comment: '审核通过' })
|
||||
ElMessage.success('已通过')
|
||||
loadDetail()
|
||||
loadLogs()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
if (!rejectReason.value.trim()) { ElMessage.warning('请输入打回原因'); return }
|
||||
try {
|
||||
await adminApi.reject(route.params.id, { reason: rejectReason.value })
|
||||
ElMessage.success('已打回')
|
||||
showReject.value = false
|
||||
loadDetail()
|
||||
loadLogs()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDetail()
|
||||
loadLogs()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-clipboard-check"></i>审核管理</div>
|
||||
|
||||
<div class="card">
|
||||
<el-radio-group v-model="statusFilter" @change="loadList" style="margin-bottom:16px">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button label="pending">待审核</el-radio-button>
|
||||
<el-radio-button label="active">已通过</el-radio-button>
|
||||
<el-radio-button label="rejected">被打回</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
<el-table-column prop="name" label="班级名称" min-width="200" />
|
||||
<el-table-column prop="grade" label="年级" width="120" />
|
||||
<el-table-column prop="graduate_year" label="毕业年份" width="100" />
|
||||
<el-table-column label="同学数" width="80">
|
||||
<template #default="{ row }">{{ row.student_names?.length || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', statusMap[row.status]?.cls]">{{ statusMap[row.status]?.text }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核截止" width="120">
|
||||
<template #default="{ row }">{{ row.audit_deadline ? row.audit_deadline.substring(0,10) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="$router.push(`/admin/audit/detail/${row.id}`)">审核</el-button>
|
||||
<el-button v-if="row.status === 'pending'" type="success" link @click="approve(row)">通过</el-button>
|
||||
<el-button v-if="row.status === 'pending'" type="warning" link @click="reject(row)">打回</el-button>
|
||||
<el-button type="danger" link @click="del(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top:16px;text-align:right">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10,20,50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="loadList"
|
||||
@current-change="loadList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 打回原因弹窗 -->
|
||||
<el-dialog v-model="showReject" title="打回原因" width="400px">
|
||||
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请输入打回原因,将通知创建者补齐" />
|
||||
<template #footer>
|
||||
<el-button @click="showReject=false">取消</el-button>
|
||||
<el-button type="warning" @click="confirmReject">确认打回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { adminApi } from '../../../../api'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const statusFilter = ref('')
|
||||
const showReject = ref(false)
|
||||
const rejectReason = ref('')
|
||||
const currentRow = ref(null)
|
||||
|
||||
const statusMap = {
|
||||
pending: { text: '待审核', cls: 'warning' },
|
||||
active: { text: '已通过', cls: 'success' },
|
||||
rejected: { text: '被打回', cls: 'error' },
|
||||
deleted: { text: '已删除', cls: 'default' }
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, page_size: pageSize.value }
|
||||
if (statusFilter.value) params.status = statusFilter.value
|
||||
const res = await adminApi.auditQueue(params)
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function approve(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定通过班级「${row.name}」的审核吗?`, '提示', { type: 'success' })
|
||||
await adminApi.approve(row.id, { comment: '审核通过' })
|
||||
ElMessage.success('已通过')
|
||||
loadList()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function reject(row) {
|
||||
currentRow.value = row
|
||||
rejectReason.value = ''
|
||||
showReject.value = true
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
if (!rejectReason.value.trim()) { ElMessage.warning('请输入打回原因'); return }
|
||||
try {
|
||||
await adminApi.reject(currentRow.value.id, { reason: rejectReason.value })
|
||||
ElMessage.success('已打回')
|
||||
showReject.value = false
|
||||
loadList()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function del(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除班级「${row.name}」吗?此操作不可恢复!`, '警告', { type: 'danger' })
|
||||
await adminApi.delete(row.id, { reason: '管理员手动删除' })
|
||||
ElMessage.success('已删除')
|
||||
loadList()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
onMounted(() => { loadList() })
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-cogs"></i>系统配置</div>
|
||||
|
||||
<div class="card">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
在此配置平台各项参数。第三方服务(极验/短信/微信)的密钥请从对应平台获取后填入,留空则该功能处于开发模式。
|
||||
</el-alert>
|
||||
|
||||
<el-tabs v-model="activeTab" stretch>
|
||||
<el-tab-pane label="基础配置" name="basic">
|
||||
<el-form label-width="180px" style="max-width:700px">
|
||||
<el-form-item v-for="item in filteredConfigs('basic')" :key="item.config_key" :label="item.label">
|
||||
<el-input v-model="item.config_value" :placeholder="item.placeholder" />
|
||||
<div v-if="item.description" style="font-size:12px;color:#8c8c8c;margin-top:4px">{{ item.description }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="极验配置" name="geetest">
|
||||
<el-form label-width="180px" style="max-width:700px">
|
||||
<el-form-item v-for="item in filteredConfigs('geetest')" :key="item.config_key" :label="item.label">
|
||||
<el-input v-model="item.config_value" :placeholder="item.placeholder" show-password />
|
||||
<div v-if="item.description" style="font-size:12px;color:#8c8c8c;margin-top:4px">{{ item.description }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="短信配置" name="sms">
|
||||
<el-form label-width="180px" style="max-width:700px">
|
||||
<el-form-item v-for="item in filteredConfigs('sms')" :key="item.config_key" :label="item.label">
|
||||
<el-input v-model="item.config_value" :placeholder="item.placeholder" :show-password="item.config_key.includes('secret') || item.config_key.includes('key')" />
|
||||
<div v-if="item.description" style="font-size:12px;color:#8c8c8c;margin-top:4px">{{ item.description }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="微信配置" name="wechat">
|
||||
<el-form label-width="180px" style="max-width:700px">
|
||||
<el-form-item v-for="item in filteredConfigs('wechat')" :key="item.config_key" :label="item.label">
|
||||
<el-input v-model="item.config_value" :placeholder="item.placeholder" :show-password="item.config_key.includes('secret')" />
|
||||
<div v-if="item.description" style="font-size:12px;color:#8c8c8c;margin-top:4px">{{ item.description }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="存储配置" name="storage">
|
||||
<el-form label-width="180px" style="max-width:700px">
|
||||
<el-form-item v-for="item in filteredConfigs('storage')" :key="item.config_key" :label="item.label">
|
||||
<el-input v-model="item.config_value" :placeholder="item.placeholder" />
|
||||
<div v-if="item.description" style="font-size:12px;color:#8c8c8c;margin-top:4px">{{ item.description }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div style="margin-top:24px;text-align:center">
|
||||
<el-button type="primary" size="large" :loading="saving" @click="save">
|
||||
<i class="fas fa-save" style="margin-right:4px"></i>保存配置
|
||||
</el-button>
|
||||
<el-button size="large" @click="load"><i class="fas fa-redo" style="margin-right:4px"></i>重新加载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { configApi } from '../../../api'
|
||||
|
||||
const activeTab = ref('basic')
|
||||
const configs = ref([])
|
||||
const saving = ref(false)
|
||||
|
||||
function filteredConfigs(category) {
|
||||
return configs.value.filter(c => c.category === category)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await configApi.getAll()
|
||||
// 后端返回按分类分组的对象,展开成数组
|
||||
const data = res.data || {}
|
||||
const flat = []
|
||||
Object.keys(data).forEach(cat => {
|
||||
if (Array.isArray(data[cat])) {
|
||||
data[cat].forEach(item => {
|
||||
flat.push({ ...item, category: cat, config_key: item.key, config_value: item.value })
|
||||
})
|
||||
}
|
||||
})
|
||||
configs.value = flat
|
||||
} catch (e) {
|
||||
ElMessage.error('加载配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const updates = configs.value.map(c => ({ key: c.config_key, value: c.config_value }))
|
||||
await configApi.update({ configs: updates })
|
||||
ElMessage.success('配置已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-chart-line"></i>数据统计</div>
|
||||
|
||||
<!-- 核心指标 -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon blue"><i class="fas fa-users"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.total_users || 0 }}</div>
|
||||
<div class="stat-label">总用户数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon green"><i class="fas fa-school"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.total_schools || 0 }}</div>
|
||||
<div class="stat-label">学校总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orange"><i class="fas fa-graduation-cap"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.total_classes || 0 }}</div>
|
||||
<div class="stat-label">班级总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon purple"><i class="fas fa-images"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.total_photos || 0 }}</div>
|
||||
<div class="stat-label">照片总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 第二行指标 -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon blue"><i class="fas fa-clock"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.pending_classes || 0 }}</div>
|
||||
<div class="stat-label">待审核班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon green"><i class="fas fa-check-circle"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.active_classes || 0 }}</div>
|
||||
<div class="stat-label">已通过班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon red"><i class="fas fa-undo"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.rejected_classes || 0 }}</div>
|
||||
<div class="stat-label">被打回班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orange"><i class="fas fa-user-ban"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.banned_users || 0 }}</div>
|
||||
<div class="stat-label">封禁用户</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<!-- 用户增长趋势 -->
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-chart-area" style="color:#1890ff"></i> 近7天用户增长</h3>
|
||||
<div ref="userChartRef" style="height:280px"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<!-- 班级增长趋势 -->
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-chart-area" style="color:#52c41a"></i> 近7天班级增长</h3>
|
||||
<div ref="classChartRef" style="height:280px"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<!-- 班级状态分布 -->
|
||||
<el-col :xs="24" :md="8">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-chart-pie" style="color:#1890ff"></i> 班级状态分布</h3>
|
||||
<div ref="statusChartRef" style="height:260px"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<!-- 学校类型分布 -->
|
||||
<el-col :xs="24" :md="8">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-chart-bar" style="color:#fa8c16"></i> 学校类型分布</h3>
|
||||
<div ref="typeChartRef" style="height:260px"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<!-- 地区TOP10 -->
|
||||
<el-col :xs="24" :md="8">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-map-marked-alt" style="color:#722ed1"></i> 地区TOP10</h3>
|
||||
<el-table :data="topRegions" size="small" max-height="260">
|
||||
<el-table-column prop="region_name" label="地区" min-width="100" />
|
||||
<el-table-column prop="count" label="班级数" width="80" align="right" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 毕业年份分布 -->
|
||||
<el-row :gutter="16" style="margin-top:16px">
|
||||
<el-col :span="24">
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-calendar-alt" style="color:#1890ff"></i> 毕业年份分布</h3>
|
||||
<div ref="yearChartRef" style="height:280px"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { statsApi } from '../../../api'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const stats = ref({})
|
||||
const topRegions = ref([])
|
||||
const userChartRef = ref()
|
||||
const classChartRef = ref()
|
||||
const statusChartRef = ref()
|
||||
const typeChartRef = ref()
|
||||
const yearChartRef = ref()
|
||||
|
||||
const typeMap = {
|
||||
university: '大学', vocational: '职业学校', senior_high: '高中',
|
||||
junior_high: '初中', primary: '小学', kindergarten: '幼儿园', other: '其他'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await statsApi.detailed()
|
||||
const d = res.data || {}
|
||||
stats.value = d
|
||||
topRegions.value = d.top_regions || []
|
||||
await nextTick()
|
||||
renderCharts(d)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function renderCharts(d) {
|
||||
// 用户增长
|
||||
if (userChartRef.value) {
|
||||
const chart = echarts.init(userChartRef.value)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'category', data: (d.user_trend || []).map(i => i.date) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ data: (d.user_trend || []).map(i => i.count), type: 'line', smooth: true, areaStyle: { color: 'rgba(24,144,255,0.2)' }, itemStyle: { color: '#1890ff' } }]
|
||||
})
|
||||
}
|
||||
// 班级增长
|
||||
if (classChartRef.value) {
|
||||
const chart = echarts.init(classChartRef.value)
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'category', data: (d.class_trend || []).map(i => i.date) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ data: (d.class_trend || []).map(i => i.count), type: 'line', smooth: true, areaStyle: { color: 'rgba(82,196,26,0.2)' }, itemStyle: { color: '#52c41a' } }]
|
||||
})
|
||||
}
|
||||
// 班级状态分布
|
||||
if (statusChartRef.value) {
|
||||
const chart = echarts.init(statusChartRef.value)
|
||||
const data = d.class_status_dist || []
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [{
|
||||
type: 'pie', radius: ['40%', '70%'], center: ['50%', '45%'],
|
||||
data: data.map(i => ({ name: i.status, value: i.count })),
|
||||
label: { show: false }
|
||||
}]
|
||||
})
|
||||
}
|
||||
// 学校类型分布
|
||||
if (typeChartRef.value) {
|
||||
const chart = echarts.init(typeChartRef.value)
|
||||
const data = d.school_type_dist || []
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 80, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: { type: 'category', data: data.map(i => typeMap[i.school_type] || i.school_type) },
|
||||
series: [{ data: data.map(i => i.count), type: 'bar', itemStyle: { color: '#fa8c16' } }]
|
||||
})
|
||||
}
|
||||
// 毕业年份分布
|
||||
if (yearChartRef.value) {
|
||||
const chart = echarts.init(yearChartRef.value)
|
||||
const data = d.graduate_year_dist || []
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'category', data: data.map(i => i.graduate_year) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ data: data.map(i => i.count), type: 'bar', itemStyle: { color: '#1890ff' } }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title">
|
||||
<i class="fas fa-list-alt"></i>菜单管理
|
||||
<el-button type="primary" style="float:right" @click="showAdd(null)">
|
||||
<i class="fas fa-plus" style="margin-right:4px"></i>新增菜单
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
菜单数据存储在数据库中,登录后根据用户角色动态加载路由。修改菜单后需重新登录生效。
|
||||
</el-alert>
|
||||
|
||||
<el-table :data="treeData" row-key="id" :tree-props="{ children: 'children' }" default-expand-all v-loading="loading">
|
||||
<el-table-column prop="title" label="菜单名称" min-width="180" />
|
||||
<el-table-column prop="path" label="路由路径" min-width="180" />
|
||||
<el-table-column prop="component" label="组件路径" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<code style="font-size:12px;color:#1890ff">{{ row.component || '-' }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="icon" label="图标" width="100">
|
||||
<template #default="{ row }">
|
||||
<i :class="row.icon" style="color:#1890ff"></i>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.require_admin" type="danger" size="small">管理员</el-tag>
|
||||
<el-tag v-else type="success" size="small">登录用户</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column label="显示" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.visible" @change="toggleVisible(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="showAdd(row)">添加子菜单</el-button>
|
||||
<el-button type="warning" link @click="edit(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="del(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="showDialog" :title="form.id ? '编辑菜单' : '新增菜单'" width="550px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="菜单名称" required><el-input v-model="form.title" /></el-form-item>
|
||||
<el-form-item label="路由路径"><el-input v-model="form.path" placeholder="如 /class" /></el-form-item>
|
||||
<el-form-item label="组件路径"><el-input v-model="form.component" placeholder="如 class/list/index" /></el-form-item>
|
||||
<el-form-item label="图标"><el-input v-model="form.icon" placeholder="如 fas fa-users" /></el-form-item>
|
||||
<el-form-item label="父级菜单">
|
||||
<el-select v-model="form.parent_id" clearable style="width:100%">
|
||||
<el-option label="顶级菜单" :value="0" />
|
||||
<el-option v-for="m in flatMenus" :key="m.id" :label="m.title" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sort_order" :min="0" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="权限">
|
||||
<el-radio-group v-model="form.require_admin">
|
||||
<el-radio :value="false">登录用户</el-radio>
|
||||
<el-radio :value="true">管理员</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示菜单"><el-switch v-model="form.visible" /></el-form-item>
|
||||
<el-form-item label="缓存"><el-switch v-model="form.keep_alive" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog=false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { menuApi } from '../../../api'
|
||||
|
||||
const treeData = ref([])
|
||||
const loading = ref(false)
|
||||
const showDialog = ref(false)
|
||||
const form = reactive({ id: null, parent_id: 0, title: '', path: '', component: '', icon: '', sort_order: 0, require_admin: false, visible: true, keep_alive: false })
|
||||
|
||||
const flatMenus = computed(() => {
|
||||
const result = []
|
||||
function flatten(list) {
|
||||
list.forEach(m => {
|
||||
result.push(m)
|
||||
if (m.children) flatten(m.children)
|
||||
})
|
||||
}
|
||||
flatten(treeData.value)
|
||||
return result
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await menuApi.getAll()
|
||||
treeData.value = res.data || []
|
||||
} catch (e) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showAdd(parent) {
|
||||
Object.assign(form, { id: null, parent_id: parent ? parent.id : 0, title: '', path: '', component: '', icon: '', sort_order: 0, require_admin: false, visible: true, keep_alive: false })
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function edit(row) {
|
||||
Object.assign(form, row)
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.title) { ElMessage.warning('请输入菜单名称'); return }
|
||||
try {
|
||||
if (form.id) {
|
||||
await menuApi.update(form.id, form)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await menuApi.create(form)
|
||||
ElMessage.success('已创建')
|
||||
}
|
||||
showDialog.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function del(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除菜单「${row.title}」吗?子菜单也会一并删除!`, '警告', { type: 'danger' })
|
||||
await menuApi.delete(row.id)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function toggleVisible(row) {
|
||||
try {
|
||||
await menuApi.update(row.id, { visible: row.visible })
|
||||
} catch (e) {
|
||||
row.visible = !row.visible
|
||||
ElMessage.error('更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title">
|
||||
<i class="fas fa-shield-alt"></i>敏感词管理
|
||||
<el-button type="primary" style="float:right" @click="showAdd = true">
|
||||
<i class="fas fa-plus" style="margin-right:4px"></i>添加敏感词
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
全局敏感词屏蔽:用户发布的班级名称、简介等内容将自动检测,包含敏感词的内容将被拦截或替换为 ***
|
||||
</el-alert>
|
||||
|
||||
<el-input v-model="keyword" placeholder="搜索敏感词" clearable style="width:250px;margin-bottom:16px" @keyup.enter="load">
|
||||
<template #prefix><i class="fas fa-search" style="color:#8c8c8c"></i></template>
|
||||
</el-input>
|
||||
|
||||
<el-table :data="filteredList" stripe v-loading="loading">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="word" label="敏感词" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight:600;color:#f5222d">{{ row.word }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category" label="分类" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="categoryMap[row.category]?.type || 'info'" size="small">
|
||||
{{ categoryMap[row.category]?.label || row.category }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" @change="toggle(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="del(row)"><i class="fas fa-trash"></i> 删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 添加弹窗 -->
|
||||
<el-dialog v-model="showAdd" title="添加敏感词" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="敏感词" required>
|
||||
<el-input v-model="newWord" placeholder="请输入敏感词" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="newCategory" style="width:100%">
|
||||
<el-option label="政治敏感" value="politics" />
|
||||
<el-option label="色情低俗" value="porn" />
|
||||
<el-option label="暴力恐怖" value="violence" />
|
||||
<el-option label="广告营销" value="ad" />
|
||||
<el-option label="辱骂攻击" value="abuse" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAdd=false">取消</el-button>
|
||||
<el-button type="primary" @click="add">添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { sensitiveApi } from '../../../api'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const showAdd = ref(false)
|
||||
const newWord = ref('')
|
||||
const newCategory = ref('other')
|
||||
|
||||
const categoryMap = {
|
||||
politics: { label: '政治敏感', type: 'danger' },
|
||||
porn: { label: '色情低俗', type: 'warning' },
|
||||
violence: { label: '暴力恐怖', type: 'danger' },
|
||||
ad: { label: '广告营销', type: 'info' },
|
||||
abuse: { label: '辱骂攻击', type: 'warning' },
|
||||
other: { label: '其他', type: 'info' }
|
||||
}
|
||||
|
||||
const filteredList = computed(() => {
|
||||
if (!keyword.value) return list.value
|
||||
return list.value.filter(w => w.word.includes(keyword.value))
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await sensitiveApi.list()
|
||||
list.value = res.data || []
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!newWord.value.trim()) { ElMessage.warning('请输入敏感词'); return }
|
||||
try {
|
||||
await sensitiveApi.create({ word: newWord.value, category: newCategory.value })
|
||||
ElMessage.success('已添加')
|
||||
showAdd.value = false
|
||||
newWord.value = ''
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function del(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除敏感词「${row.word}」吗?`, '提示', { type: 'warning' })
|
||||
await sensitiveApi.delete(row.id)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function toggle(row) {
|
||||
try {
|
||||
await sensitiveApi.toggle(row.id, row.enabled)
|
||||
ElMessage.success(row.enabled ? '已启用' : '已禁用')
|
||||
} catch (e) {
|
||||
row.enabled = !row.enabled
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-user-shield"></i>用户管理</div>
|
||||
|
||||
<div class="card">
|
||||
<el-tabs v-model="activeTab" stretch @tab-change="load">
|
||||
<el-tab-pane label="全部用户" name="all" />
|
||||
<el-tab-pane label="封禁用户" name="banned" />
|
||||
</el-tabs>
|
||||
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
<el-table-column label="用户" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#1890ff;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:600">
|
||||
{{ (row.nickname || row.username || 'U').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div>
|
||||
<div>{{ row.nickname || row.username }}</div>
|
||||
<div style="font-size:12px;color:#8c8c8c">@{{ row.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="邮箱" width="180" />
|
||||
<el-table-column label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : 'success'" size="small">
|
||||
{{ row.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'banned' ? 'danger' : 'success'" size="small">
|
||||
{{ row.status === 'banned' ? '已封禁' : '正常' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="封禁原因" min-width="150">
|
||||
<template #default="{ row }">{{ row.ban_reason || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="注册时间" width="160">
|
||||
<template #default="{ row }">{{ row.created_at }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status !== 'banned' && row.role !== 'admin'" type="danger" link @click="ban(row)">封禁</el-button>
|
||||
<el-button v-if="row.status === 'banned'" type="success" link @click="unban(row)">解封</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 封禁弹窗 -->
|
||||
<el-dialog v-model="showBan" title="封禁用户" width="400px">
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
|
||||
封禁后该用户将无法登录,所有班级操作受限
|
||||
</el-alert>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="用户">{{ currentUser?.nickname || currentUser?.username }}</el-form-item>
|
||||
<el-form-item label="封禁原因" required>
|
||||
<el-input v-model="banReason" type="textarea" :rows="3" placeholder="请输入封禁原因" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showBan=false">取消</el-button>
|
||||
<el-button type="danger" @click="confirmBan">确认封禁</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { banApi, userApi } from '../../../api'
|
||||
|
||||
const activeTab = ref('all')
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const showBan = ref(false)
|
||||
const banReason = ref('')
|
||||
const currentUser = ref(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (activeTab.value === 'banned') {
|
||||
const res = await banApi.list()
|
||||
list.value = res.data || []
|
||||
} else {
|
||||
// 全部用户 - 暂时用封禁列表+其他方式,后续可加用户列表接口
|
||||
const res = await banApi.list()
|
||||
list.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function ban(row) {
|
||||
currentUser.value = row
|
||||
banReason.value = ''
|
||||
showBan.value = true
|
||||
}
|
||||
|
||||
async function confirmBan() {
|
||||
if (!banReason.value.trim()) { ElMessage.warning('请输入封禁原因'); return }
|
||||
try {
|
||||
await banApi.ban(currentUser.value.id, { reason: banReason.value })
|
||||
ElMessage.success('已封禁')
|
||||
showBan.value = false
|
||||
load()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '封禁失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function unban(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定解封用户「${row.nickname || row.username}」吗?`, '提示', { type: 'warning' })
|
||||
await banApi.unban(row.id)
|
||||
ElMessage.success('已解封')
|
||||
load()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-box">
|
||||
<div class="login-title">
|
||||
<i class="fas fa-key"></i>找回密码
|
||||
</div>
|
||||
<div class="login-subtitle">通过注册邮箱验证身份,重置登录密码</div>
|
||||
|
||||
<el-steps :active="step" finish-status="success" align-center style="margin-bottom:24px">
|
||||
<el-step title="验证身份" />
|
||||
<el-step title="设置新密码" />
|
||||
<el-step title="完成" />
|
||||
</el-steps>
|
||||
|
||||
<!-- 第一步:验证身份 -->
|
||||
<el-form v-if="step === 0" :model="form" :rules="rules" ref="formRef" label-position="top">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="用户名" size="large" :prefix-icon="UserIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="email">
|
||||
<el-input v-model="form.email" placeholder="注册邮箱" size="large" :prefix-icon="MailIcon" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleSubmit">
|
||||
获取重置码
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<!-- 第二步:设置新密码 -->
|
||||
<el-form v-if="step === 1" :model="form" :rules="resetRules" ref="resetFormRef" label-position="top">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
<template #title>
|
||||
重置码已发送(开发模式直接显示):<strong style="color:#1890ff">{{ resetCode }}</strong>
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-form-item prop="reset_code">
|
||||
<el-input v-model="form.reset_code" placeholder="请输入重置码" size="large" :prefix-icon="KeyIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="new_password">
|
||||
<el-input v-model="form.new_password" type="password" placeholder="新密码(6-64位)" size="large" show-password :prefix-icon="LockIcon" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleReset">
|
||||
重置密码
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<!-- 第三步:完成 -->
|
||||
<div v-if="step === 2" style="text-align:center;padding:20px 0">
|
||||
<i class="fas fa-check-circle" style="font-size:64px;color:#52c41a;margin-bottom:16px"></i>
|
||||
<h3 style="margin-bottom:8px">密码重置成功</h3>
|
||||
<p style="color:#8c8c8c;margin-bottom:24px">请使用新密码登录</p>
|
||||
<el-button type="primary" size="large" @click="$router.push('/login')">
|
||||
<i class="fas fa-sign-in-alt" style="margin-right:6px"></i>去登录
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin-top:16px">
|
||||
<el-link type="primary" @click="$router.push('/login')">
|
||||
<i class="fas fa-arrow-left" style="margin-right:4px"></i>返回登录
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, markRaw } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock, Key, Message } from '@element-plus/icons-vue'
|
||||
import { passwordApi } from '../../../api'
|
||||
|
||||
const router = useRouter()
|
||||
const UserIcon = markRaw(User)
|
||||
const LockIcon = markRaw(Lock)
|
||||
const KeyIcon = markRaw(Key)
|
||||
const MailIcon = markRaw(Message)
|
||||
|
||||
const step = ref(0)
|
||||
const loading = ref(false)
|
||||
const resetCode = ref('')
|
||||
|
||||
const form = reactive({ username: '', email: '', reset_code: '', new_password: '' })
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
email: [{ required: true, type: 'email', message: '请输入正确的邮箱', trigger: 'blur' }]
|
||||
}
|
||||
const resetRules = {
|
||||
reset_code: [{ required: true, message: '请输入重置码', trigger: 'blur' }],
|
||||
new_password: [{ required: true, min: 6, max: 64, message: '密码6-64位', trigger: 'blur' }]
|
||||
}
|
||||
const formRef = ref()
|
||||
const resetFormRef = ref()
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await passwordApi.forgot({ username: form.username, email: form.email })
|
||||
resetCode.value = res.data.reset_code
|
||||
step.value = 1
|
||||
ElMessage.success('重置码已生成')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '操作失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
await resetFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await passwordApi.reset({
|
||||
username: form.username,
|
||||
reset_code: form.reset_code,
|
||||
new_password: form.new_password
|
||||
})
|
||||
step.value = 2
|
||||
ElMessage.success('密码重置成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '重置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-box">
|
||||
<div class="login-title">
|
||||
<i class="fas fa-graduation-cap"></i>PhotoWall
|
||||
</div>
|
||||
<div class="login-subtitle">毕业照存储与同学联络平台</div>
|
||||
|
||||
<el-tabs v-model="activeTab" stretch>
|
||||
<el-tab-pane label="登录" name="login">
|
||||
<el-form :model="loginForm" :rules="loginRules" ref="loginFormRef" label-position="top" @keyup.enter="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="loginForm.username" placeholder="用户名" size="large" :prefix-icon="UserIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="密码" size="large" show-password :prefix-icon="LockIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="captcha_code">
|
||||
<div style="display:flex;gap:10px;width:100%">
|
||||
<el-input v-model="loginForm.captcha_code" placeholder="验证码" size="large" :prefix-icon="KeyIcon" style="flex:1" />
|
||||
<img :src="captchaImg" class="captcha-img" @click="refreshCaptcha" title="点击刷新" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin">
|
||||
<i class="fas fa-sign-in-alt" style="margin-right:6px"></i>登 录
|
||||
</el-button>
|
||||
</el-form>
|
||||
<div style="text-align:center;margin-top:12px">
|
||||
<el-link type="primary" @click="$router.push('/forgot-password')">忘记密码?</el-link>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="注册" name="register">
|
||||
<el-form :model="regForm" :rules="regRules" ref="regFormRef" label-position="top">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="regForm.username" placeholder="用户名(3-32位)" size="large" :prefix-icon="UserIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="regForm.password" type="password" placeholder="密码(6-64位)" size="large" show-password :prefix-icon="LockIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="email">
|
||||
<el-input v-model="regForm.email" placeholder="邮箱(用于找回密码)" size="large" :prefix-icon="MailIcon" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="captcha_code">
|
||||
<div style="display:flex;gap:10px;width:100%">
|
||||
<el-input v-model="regForm.captcha_code" placeholder="验证码" size="large" :prefix-icon="KeyIcon" style="flex:1" />
|
||||
<img :src="captchaImg" class="captcha-img" @click="refreshCaptcha" title="点击刷新" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleRegister">
|
||||
<i class="fas fa-user-plus" style="margin-right:6px"></i>注 册
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, markRaw } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock, Key, Message } from '@element-plus/icons-vue'
|
||||
import { authApi, captchaApi } from '../../../api'
|
||||
import { useAuth } from '../../../composables/useAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { setAuth, loadMenus } = useAuth()
|
||||
|
||||
const UserIcon = markRaw(User)
|
||||
const LockIcon = markRaw(Lock)
|
||||
const KeyIcon = markRaw(Key)
|
||||
const MailIcon = markRaw(Message)
|
||||
|
||||
const activeTab = ref('login')
|
||||
const loading = ref(false)
|
||||
const captchaImg = ref('')
|
||||
const captchaId = ref('')
|
||||
|
||||
const loginForm = reactive({ username: '', password: '', captcha_id: '', captcha_code: '' })
|
||||
const loginRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
captcha_code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||
}
|
||||
const loginFormRef = ref()
|
||||
|
||||
const regForm = reactive({ username: '', password: '', email: '', captcha_id: '', captcha_code: '' })
|
||||
const regRules = {
|
||||
username: [{ required: true, min: 3, max: 32, message: '用户名3-32位', trigger: 'blur' }],
|
||||
password: [{ required: true, min: 6, max: 64, message: '密码6-64位', trigger: 'blur' }],
|
||||
captcha_code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||
}
|
||||
const regFormRef = ref()
|
||||
|
||||
async function refreshCaptcha() {
|
||||
try {
|
||||
const res = await captchaApi.get()
|
||||
captchaId.value = res.data.captcha_id
|
||||
captchaImg.value = res.data.image
|
||||
loginForm.captcha_id = res.data.captcha_id
|
||||
regForm.captcha_id = res.data.captcha_id
|
||||
} catch (e) {
|
||||
ElMessage.error('验证码加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
await loginFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await authApi.login(loginForm)
|
||||
setAuth(res.data.token, res.data.user)
|
||||
await loadMenus()
|
||||
ElMessage.success('登录成功')
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.push(redirect)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '登录失败')
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
await regFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await authApi.register(regForm)
|
||||
ElMessage.success('注册成功,请登录')
|
||||
activeTab.value = 'login'
|
||||
loginForm.username = regForm.username
|
||||
refreshCaptcha()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '注册失败')
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<div class="page-title">
|
||||
<i class="fas fa-graduation-cap"></i>{{ detail?.class?.name || '班级详情' }}
|
||||
<span :class="['tag', statusMap[detail?.class?.status]?.cls]" style="margin-left:12px;font-size:12px">{{ statusMap[detail?.class?.status]?.text }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="detail" class="card">
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="学校">{{ detail.school?.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学院">{{ detail.college?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="年级">{{ detail.class.grade }}</el-descriptions-item>
|
||||
<el-descriptions-item label="班号">{{ detail.class.class_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="毕业年份">{{ detail.class.graduate_year }}</el-descriptions-item>
|
||||
<el-descriptions-item label="管理员">{{ detail.admin?.nickname || detail.admin?.username }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 毕业照 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-image" style="color:#1890ff"></i> 毕业照</h3>
|
||||
<div v-if="detail.class.graduation_photo" style="text-align:center">
|
||||
<img :src="detail.class.graduation_photo" style="max-width:100%;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,0.1)" />
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
<i class="fas fa-image"></i>
|
||||
<p>暂无毕业照</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 同学名单 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-user-friends" style="color:#1890ff"></i> 同学名单 ({{ detail.student_names?.length || 0 }}人)</h3>
|
||||
<div v-if="detail.can_view_sensitive" style="display:flex;flex-wrap:wrap;gap:8px">
|
||||
<span v-for="(name, i) in detail.student_names" :key="i" class="tag primary" style="padding:4px 12px">{{ i+1 }}. {{ name }}</span>
|
||||
</div>
|
||||
<div v-else class="empty" style="padding:20px">
|
||||
<i class="fas fa-lock"></i>
|
||||
<p>加入班级后可查看完整同学名单</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 照片墙 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px">
|
||||
<i class="fas fa-images" style="color:#1890ff"></i> 照片墙 ({{ detail.photos?.length || 0 }}张)
|
||||
<el-button v-if="detail.is_member" type="primary" size="small" style="float:right" @click="showUpload = true">
|
||||
<i class="fas fa-upload" style="margin-right:4px"></i>上传照片
|
||||
</el-button>
|
||||
</h3>
|
||||
<div v-if="detail.can_view_sensitive && detail.photos?.length > 0" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px">
|
||||
<div v-for="photo in detail.photos" :key="photo.id" style="position:relative;border-radius:6px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.1)">
|
||||
<img :src="photo.photo_url" style="width:100%;height:140px;object-fit:cover;cursor:pointer" @click="previewPhoto(photo.photo_url)" />
|
||||
<div style="padding:6px 8px;font-size:12px;color:#595959;background:#fff">
|
||||
<i class="fas fa-user" style="margin-right:4px"></i>{{ getUploaderName(photo.user_id) }}
|
||||
<el-button v-if="canDeletePhoto(photo)" type="danger" size="small" link style="float:right" @click="deletePhoto(photo)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!detail.can_view_sensitive" class="empty" style="padding:20px">
|
||||
<i class="fas fa-lock"></i>
|
||||
<p>加入班级后可查看照片墙</p>
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
<i class="fas fa-images"></i>
|
||||
<p>暂无照片,快来上传第一张吧</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成员列表 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-users" style="color:#1890ff"></i> 班级成员 ({{ detail.members?.length || 0 }}人)</h3>
|
||||
<el-table :data="detail.members" stripe>
|
||||
<el-table-column label="用户" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#1890ff;color:#fff;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600">
|
||||
{{ (row.user.nickname || row.user.username || 'U').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span>{{ row.user.nickname || row.user.username }}</span>
|
||||
<span v-if="row.member.role === 'admin'" class="tag primary" style="margin-left:4px">管理员</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="加入方式" width="120">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.member.join_method === 'invite' ? 'success' : 'default']">
|
||||
{{ row.member.join_method === 'invite' ? '邀请加入' : '搜索加入' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="加入时间" width="180">
|
||||
<template #default="{ row }">{{ row.member.joined_at }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="detail.can_view_sensitive" label="联系方式" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.contacts?.length" style="font-size:12px;color:#595959">
|
||||
<div v-for="c in row.contacts" :key="c.id" style="margin-bottom:2px">
|
||||
<i :class="contactIcon(c.type)" style="margin-right:4px;color:#1890ff"></i>{{ c.value }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else style="color:#bfbfbf">未填写</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 管理员面板 -->
|
||||
<div v-if="detail.my_role === 'admin'" class="card">
|
||||
<h3 style="margin-bottom:12px"><i class="fas fa-cog" style="color:#1890ff"></i> 管理员面板</h3>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="6">
|
||||
<el-button type="primary" style="width:100%" @click="showInvite = true">
|
||||
<i class="fas fa-share-alt" style="margin-right:4px"></i>生成邀请码
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-button type="warning" style="width:100%" @click="showTransfer = true">
|
||||
<i class="fas fa-exchange-alt" style="margin-right:4px"></i>转移管理员
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-button type="danger" style="width:100%" @click="resubmit">
|
||||
<i class="fas fa-redo" style="margin-right:4px"></i>重新提交审核
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 上传照片弹窗 -->
|
||||
<el-dialog v-model="showUpload" title="上传照片" width="500px">
|
||||
<el-upload
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:data="{ type: 'photo' }"
|
||||
:show-file-list="false"
|
||||
:on-success="onUploadSuccess"
|
||||
accept="image/*"
|
||||
>
|
||||
<div style="width:100%;height:200px;border:2px dashed #d9d9d9;border-radius:6px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#8c8c8c">
|
||||
<div style="text-align:center">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size:40px;margin-bottom:8px"></i>
|
||||
<div>点击上传照片(每人最多10张)</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div v-if="uploadPreview" style="margin-top:12px;text-align:center">
|
||||
<img :src="uploadPreview" style="max-width:100%;border-radius:6px" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 邀请码弹窗 -->
|
||||
<el-dialog v-model="showInvite" title="生成邀请码" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="使用次数">
|
||||
<el-input-number v-model="inviteForm.max_uses" :min="1" :max="100" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期(天)">
|
||||
<el-input-number v-model="inviteForm.expire_days" :min="1" :max="365" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div v-if="inviteCode" style="margin:12px 0;padding:12px;background:#f6ffed;border:1px solid #b7eb8f;border-radius:4px;text-align:center">
|
||||
<div style="font-size:12px;color:#52c41a;margin-bottom:4px">邀请码已生成</div>
|
||||
<div style="font-size:24px;font-weight:700;letter-spacing:4px;color:#52c41a">{{ inviteCode }}</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showInvite=false">关闭</el-button>
|
||||
<el-button type="primary" @click="generateInvite">生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 转移管理员弹窗 -->
|
||||
<el-dialog v-model="showTransfer" title="转移管理员" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="新管理员">
|
||||
<el-select v-model="transferUserId" placeholder="选择成员" style="width:100%">
|
||||
<el-option v-for="m in detail?.members?.filter(x => x.member.role !== 'admin')" :key="m.member.user_id" :label="m.user.nickname || m.user.username" :value="m.member.user_id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:12px">转移后你将变为普通成员</el-alert>
|
||||
<template #footer>
|
||||
<el-button @click="showTransfer=false">取消</el-button>
|
||||
<el-button type="danger" @click="transferAdmin">确认转移</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<el-dialog v-model="showPreview" width="80%" top="5vh">
|
||||
<img :src="previewUrl" style="width:100%;border-radius:6px" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { classApi } from '../../../api'
|
||||
import { useAuth } from '../../../composables/useAuth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { token, user } = useAuth()
|
||||
|
||||
const detail = ref(null)
|
||||
const loading = ref(false)
|
||||
const showUpload = ref(false)
|
||||
const showInvite = ref(false)
|
||||
const showTransfer = ref(false)
|
||||
const showPreview = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const uploadPreview = ref('')
|
||||
const inviteCode = ref('')
|
||||
const transferUserId = ref(null)
|
||||
|
||||
const inviteForm = reactive({ max_uses: 10, expire_days: 30 })
|
||||
const uploadUrl = '/api/upload'
|
||||
const uploadHeaders = computed(() => ({ Authorization: 'Bearer ' + token.value }))
|
||||
|
||||
const statusMap = {
|
||||
pending: { text: '待审核', cls: 'warning' },
|
||||
active: { text: '已通过', cls: 'success' },
|
||||
rejected: { text: '被打回', cls: 'error' },
|
||||
deleted: { text: '已删除', cls: 'default' }
|
||||
}
|
||||
|
||||
function contactIcon(type) {
|
||||
const map = { email: 'fas fa-envelope', phone: 'fas fa-phone', qq: 'fab fa-qq', wechat: 'fab fa-weixin', address: 'fas fa-map-marker-alt', workunit: 'fas fa-building' }
|
||||
return map[type] || 'fas fa-info-circle'
|
||||
}
|
||||
|
||||
function getUploaderName(userId) {
|
||||
const m = detail.value?.members?.find(x => x.member.user_id === userId)
|
||||
return m?.user?.nickname || m?.user?.username || '未知'
|
||||
}
|
||||
|
||||
function canDeletePhoto(photo) {
|
||||
return detail.value?.my_role === 'admin' || photo.user_id === user.value?.id || user.value?.role === 'admin'
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await classApi.detail(route.params.id)
|
||||
detail.value = res.data
|
||||
} catch (e) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function previewPhoto(url) {
|
||||
previewUrl.value = url
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
function onUploadSuccess(res) {
|
||||
if (res.code === 0) {
|
||||
uploadPreview.value = res.data.url
|
||||
classApi.uploadPhoto(route.params.id, { photo_url: res.data.url, caption: '' }).then(() => {
|
||||
ElMessage.success('上传成功')
|
||||
showUpload.value = false
|
||||
uploadPreview.value = ''
|
||||
loadDetail()
|
||||
}).catch(e => ElMessage.error(e.message || '上传失败'))
|
||||
} else {
|
||||
ElMessage.error(res.message || '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePhoto(photo) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除这张照片吗?', '提示', { type: 'warning' })
|
||||
await classApi.deletePhoto(route.params.id, photo.id)
|
||||
ElMessage.success('已删除')
|
||||
loadDetail()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function generateInvite() {
|
||||
try {
|
||||
const res = await classApi.createInvitation(route.params.id, inviteForm)
|
||||
inviteCode.value = res.data.code
|
||||
ElMessage.success('邀请码已生成')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function transferAdmin() {
|
||||
if (!transferUserId.value) { ElMessage.warning('请选择新管理员'); return }
|
||||
try {
|
||||
await classApi.transferAdmin(route.params.id, { target_user_id: transferUserId.value })
|
||||
ElMessage.success('管理员已转移')
|
||||
showTransfer.value = false
|
||||
loadDetail()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '转移失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function resubmit() {
|
||||
try {
|
||||
await classApi.resubmit(route.params.id, {})
|
||||
ElMessage.success('已重新提交审核')
|
||||
loadDetail()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '提交失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadDetail() })
|
||||
</script>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-plus-circle"></i>创建班级</div>
|
||||
|
||||
<div class="card">
|
||||
<el-steps :active="step" finish-status="success" align-center style="margin-bottom:32px">
|
||||
<el-step title="选择地区" />
|
||||
<el-step title="选择学校" />
|
||||
<el-step title="班级信息" />
|
||||
<el-step title="毕业照与名单" />
|
||||
</el-steps>
|
||||
|
||||
<!-- 第一步:选择地区 -->
|
||||
<div v-if="step === 0">
|
||||
<el-form label-width="100px" style="max-width:600px">
|
||||
<el-form-item label="国家">
|
||||
<el-select v-model="form.country_id" placeholder="选择国家" style="width:100%" @change="loadProvinces">
|
||||
<el-option v-for="c in countries" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="省/州">
|
||||
<el-select v-model="form.province_id" placeholder="选择省/州" style="width:100%" @change="loadCounties" :disabled="!form.country_id">
|
||||
<el-option v-for="p in provinces" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="区/县">
|
||||
<el-select v-model="form.county_id" placeholder="选择区/县" style="width:100%" @change="loadSchools" :disabled="!form.province_id">
|
||||
<el-option v-for="c in counties" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="text-align:right;margin-top:24px">
|
||||
<el-button type="primary" :disabled="!form.county_id" @click="step=1">下一步<i class="fas fa-arrow-right" style="margin-left:4px"></i></el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:选择学校 -->
|
||||
<div v-if="step === 1">
|
||||
<el-form label-width="100px" style="max-width:600px">
|
||||
<el-form-item label="选择学校">
|
||||
<el-select v-model="form.school_id" placeholder="选择学校" style="width:100%" filterable @change="onSchoolChange">
|
||||
<el-option v-for="s in schools" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="schoolInfo && hasCollege" label="选择学院">
|
||||
<el-select v-model="form.college_id" placeholder="选择学院" style="width:100%">
|
||||
<el-option v-for="c in colleges" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" link @click="showCreateSchool = true">
|
||||
<i class="fas fa-plus" style="margin-right:4px"></i>学校不在列表?创建新学校
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="text-align:right;margin-top:24px">
|
||||
<el-button @click="step=0"><i class="fas fa-arrow-left" style="margin-right:4px"></i>上一步</el-button>
|
||||
<el-button type="primary" :disabled="!form.school_id || (hasCollege && !form.college_id)" @click="step=2">下一步<i class="fas fa-arrow-right" style="margin-left:4px"></i></el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第三步:班级信息 -->
|
||||
<div v-if="step === 2">
|
||||
<el-form :model="form" label-width="100px" style="max-width:600px">
|
||||
<el-form-item label="班级名称" required>
|
||||
<el-input v-model="form.name" placeholder="如:连云港市第一中学2015级高三1班" />
|
||||
</el-form-item>
|
||||
<el-form-item label="年级" required>
|
||||
<el-input v-model="form.grade" placeholder="如:2015级" />
|
||||
</el-form-item>
|
||||
<el-form-item label="班号" required>
|
||||
<el-input v-model="form.class_no" placeholder="如:1班" />
|
||||
</el-form-item>
|
||||
<el-form-item label="毕业年份" required>
|
||||
<el-input-number v-model="form.graduate_year" :min="1950" :max="2100" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="text-align:right;margin-top:24px">
|
||||
<el-button @click="step=1"><i class="fas fa-arrow-left" style="margin-right:4px"></i>上一步</el-button>
|
||||
<el-button type="primary" :disabled="!form.name || !form.grade || !form.class_no" @click="step=3">下一步<i class="fas fa-arrow-right" style="margin-left:4px"></i></el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四步:毕业照与名单 -->
|
||||
<div v-if="step === 3">
|
||||
<el-form label-width="100px" style="max-width:700px">
|
||||
<el-form-item label="毕业照">
|
||||
<el-upload
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:data="{ type: 'class' }"
|
||||
:show-file-list="false"
|
||||
:on-success="onPhotoSuccess"
|
||||
accept="image/*"
|
||||
>
|
||||
<div v-if="form.graduation_photo" style="position:relative">
|
||||
<img :src="form.graduation_photo" style="max-width:300px;border-radius:6px;border:1px solid #d9d9d9" />
|
||||
<el-button type="danger" size="small" style="position:absolute;top:8px;right:8px" @click.stop="form.graduation_photo=''">
|
||||
<i class="fas fa-times"></i>
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-else style="width:300px;height:180px;border:2px dashed #d9d9d9;border-radius:6px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#8c8c8c">
|
||||
<div style="text-align:center">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size:32px;margin-bottom:8px"></i>
|
||||
<div>点击上传毕业照</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item label="同学名单" required>
|
||||
<el-input
|
||||
v-model="studentNamesText"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="每行一个同学姓名,或用逗号/空格分隔 至少需要8位同学姓名"
|
||||
/>
|
||||
<div style="margin-top:4px;font-size:12px;color:#8c8c8c">
|
||||
已输入 <span :style="{color: studentCount >= 8 ? '#52c41a' : '#f5222d'}">{{ studentCount }}</span> 位同学(至少8位)
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="老师名单">
|
||||
<el-input
|
||||
v-model="teacherNamesText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="每行一个老师姓名,或用逗号/空格分隔"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div style="text-align:right;margin-top:24px">
|
||||
<el-button @click="step=2"><i class="fas fa-arrow-left" style="margin-right:4px"></i>上一步</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="studentCount < 8" @click="submit">
|
||||
<i class="fas fa-check" style="margin-right:4px"></i>提交创建
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建学校弹窗 -->
|
||||
<el-dialog v-model="showCreateSchool" title="创建新学校" width="500px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="学校名称" required>
|
||||
<el-input v-model="newSchool.name" placeholder="学校全称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="学校类型" required>
|
||||
<el-select v-model="newSchool.school_type" style="width:100%">
|
||||
<el-option label="大学" value="university" />
|
||||
<el-option label="职业学校/中专" value="vocational" />
|
||||
<el-option label="高中" value="senior_high" />
|
||||
<el-option label="初中" value="junior_high" />
|
||||
<el-option label="小学" value="primary" />
|
||||
<el-option label="幼儿园" value="kindergarten" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="newSchool.address" placeholder="详细地址" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateSchool=false">取消</el-button>
|
||||
<el-button type="primary" @click="createSchool">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { regionApi, schoolApi, classApi } from '../../../api'
|
||||
import { useAuth } from '../../../composables/useAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const { token } = useAuth()
|
||||
|
||||
const step = ref(0)
|
||||
const submitting = ref(false)
|
||||
const showCreateSchool = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
country_id: null, province_id: null, county_id: null,
|
||||
school_id: null, college_id: 0,
|
||||
name: '', grade: '', class_no: '', graduate_year: 2020,
|
||||
graduation_photo: '', student_names: [], teacher_names: []
|
||||
})
|
||||
|
||||
const studentNamesText = ref('')
|
||||
const teacherNamesText = ref('')
|
||||
|
||||
const studentCount = computed(() => {
|
||||
return studentNamesText.value.split(/[\n,,\s]+/).filter(s => s.trim()).length
|
||||
})
|
||||
|
||||
const countries = ref([])
|
||||
const provinces = ref([])
|
||||
const counties = ref([])
|
||||
const schools = ref([])
|
||||
const colleges = ref([])
|
||||
const schoolInfo = ref(null)
|
||||
const hasCollege = computed(() => schoolInfo.value?.school_type === 'university' || schoolInfo.value?.school_type === 'vocational')
|
||||
|
||||
const newSchool = reactive({ name: '', school_type: 'senior_high', address: '' })
|
||||
|
||||
const uploadUrl = '/api/upload'
|
||||
const uploadHeaders = computed(() => ({ Authorization: 'Bearer ' + token.value }))
|
||||
|
||||
function parseNames(text) {
|
||||
return text.split(/[\n,,\s]+/).filter(s => s.trim()).map(s => s.trim())
|
||||
}
|
||||
|
||||
async function loadCountries() {
|
||||
const res = await regionApi.countries()
|
||||
countries.value = res.data || []
|
||||
}
|
||||
async function loadProvinces() {
|
||||
form.province_id = null; form.county_id = null
|
||||
const res = await regionApi.list(form.country_id)
|
||||
provinces.value = res.data || []
|
||||
}
|
||||
async function loadCounties() {
|
||||
form.county_id = null
|
||||
const res = await regionApi.list(form.province_id)
|
||||
counties.value = res.data || []
|
||||
}
|
||||
async function loadSchools() {
|
||||
const res = await schoolApi.list({ region_id: form.county_id })
|
||||
schools.value = res.data || []
|
||||
}
|
||||
async function onSchoolChange() {
|
||||
schoolInfo.value = schools.value.find(s => s.id === form.school_id)
|
||||
form.college_id = 0
|
||||
if (hasCollege.value) {
|
||||
const res = await schoolApi.listColleges(form.school_id)
|
||||
colleges.value = res.data || []
|
||||
}
|
||||
}
|
||||
async function createSchool() {
|
||||
if (!newSchool.name) { ElMessage.warning('请输入学校名称'); return }
|
||||
try {
|
||||
const res = await schoolApi.create({ ...newSchool, region_id: form.county_id })
|
||||
schools.value.push(res.data)
|
||||
form.school_id = res.data.id
|
||||
schoolInfo.value = res.data
|
||||
showCreateSchool.value = false
|
||||
ElMessage.success('学校创建成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '创建失败')
|
||||
}
|
||||
}
|
||||
function onPhotoSuccess(res) {
|
||||
if (res.code === 0) {
|
||||
form.graduation_photo = res.data.url
|
||||
ElMessage.success('毕业照上传成功')
|
||||
} else {
|
||||
ElMessage.error(res.message || '上传失败')
|
||||
}
|
||||
}
|
||||
async function submit() {
|
||||
form.student_names = parseNames(studentNamesText.value)
|
||||
form.teacher_names = parseNames(teacherNamesText.value)
|
||||
if (form.student_names.length < 8) {
|
||||
ElMessage.error('同学名单至少需要8位')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await classApi.create(form)
|
||||
ElMessage.success('班级创建成功,等待平台审核')
|
||||
router.push(`/class/detail/${res.data.id}`)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadCountries() })
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-sign-in-alt"></i>加入班级</div>
|
||||
|
||||
<div class="card">
|
||||
<el-tabs v-model="activeTab" stretch>
|
||||
<!-- 搜索加入 -->
|
||||
<el-tab-pane label="搜索加入" name="search">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
搜索到班级后,需要正确说出至少8位同学的姓名才能加入
|
||||
</el-alert>
|
||||
<el-input v-model="keyword" placeholder="输入班级名称或学校名称搜索" size="large" clearable @keyup.enter="search">
|
||||
<template #prefix><i class="fas fa-search" style="color:#8c8c8c"></i></template>
|
||||
<template #append><el-button type="primary" @click="search">搜索</el-button></template>
|
||||
</el-input>
|
||||
|
||||
<div v-if="results.length > 0" style="margin-top:16px">
|
||||
<el-table :data="results" stripe>
|
||||
<el-table-column prop="name" label="班级名称" min-width="200" />
|
||||
<el-table-column prop="grade" label="年级" width="120" />
|
||||
<el-table-column prop="graduate_year" label="毕业年份" width="100" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.status === 'active' ? 'success' : 'warning']">
|
||||
{{ row.status === 'active' ? '已通过' : '待审核' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="showVerify(row)">申请加入</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else-if="searched" class="empty">
|
||||
<i class="fas fa-search"></i>
|
||||
<p>未找到相关班级</p>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 邀请码加入 -->
|
||||
<el-tab-pane label="邀请码加入" name="invite">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
输入管理员生成的邀请码即可直接加入班级
|
||||
</el-alert>
|
||||
<el-form label-width="80px" style="max-width:400px">
|
||||
<el-form-item label="邀请码">
|
||||
<el-input v-model="inviteCode" placeholder="请输入邀请码" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="large" style="width:100%" :loading="joining" @click="joinByInvite">
|
||||
<i class="fas fa-check" style="margin-right:4px"></i>加入班级
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 姓名验证弹窗 -->
|
||||
<el-dialog v-model="showVerifyDialog" title="同学姓名验证" width="500px">
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
|
||||
请输入该班级中至少8位同学的姓名(每行一个),验证通过后即可加入
|
||||
</el-alert>
|
||||
<el-input
|
||||
v-model="verifyNames"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="每行输入一个同学姓名 例如: 张三 李四 王五"
|
||||
/>
|
||||
<div style="margin-top:8px;font-size:12px;color:#8c8c8c">
|
||||
已输入 <span :style="{color: verifyCount >= 8 ? '#52c41a' : '#f5222d'}">{{ verifyCount }}</span> 位(至少8位)
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showVerifyDialog=false">取消</el-button>
|
||||
<el-button type="primary" :disabled="verifyCount < 8" :loading="joining" @click="joinBySearch">
|
||||
验证并加入
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { classApi } from '../../../api'
|
||||
|
||||
const router = useRouter()
|
||||
const activeTab = ref('search')
|
||||
const keyword = ref('')
|
||||
const results = ref([])
|
||||
const searched = ref(false)
|
||||
const joining = ref(false)
|
||||
const inviteCode = ref('')
|
||||
const showVerifyDialog = ref(false)
|
||||
const verifyNames = ref('')
|
||||
const currentClass = ref(null)
|
||||
|
||||
const verifyCount = computed(() => {
|
||||
return verifyNames.value.split(/[\n,,\s]+/).filter(s => s.trim()).length
|
||||
})
|
||||
|
||||
async function search() {
|
||||
if (!keyword.value.trim()) { ElMessage.warning('请输入搜索关键词'); return }
|
||||
searched.value = true
|
||||
try {
|
||||
const res = await classApi.search({ keyword: keyword.value })
|
||||
results.value = (res.data.list || []).filter(c => c.status === 'active')
|
||||
} catch (e) {
|
||||
results.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function showVerify(row) {
|
||||
currentClass.value = row
|
||||
verifyNames.value = ''
|
||||
showVerifyDialog.value = true
|
||||
}
|
||||
|
||||
async function joinBySearch() {
|
||||
const names = verifyNames.value.split(/[\n,,\s]+/).filter(s => s.trim())
|
||||
if (names.length < 8) { ElMessage.error('至少需要8位同学姓名'); return }
|
||||
joining.value = true
|
||||
try {
|
||||
await classApi.joinBySearch({ class_id: currentClass.value.id, student_names: names })
|
||||
ElMessage.success('加入成功')
|
||||
showVerifyDialog.value = false
|
||||
router.push(`/class/detail/${currentClass.value.id}`)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败,请检查同学姓名是否正确')
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function joinByInvite() {
|
||||
if (!inviteCode.value.trim()) { ElMessage.warning('请输入邀请码'); return }
|
||||
joining.value = true
|
||||
try {
|
||||
const res = await classApi.joinByInvite({ code: inviteCode.value })
|
||||
ElMessage.success('加入成功')
|
||||
router.push(`/class/detail/${res.data.class_id}`)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加入失败')
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-users"></i>班级列表</div>
|
||||
|
||||
<div class="card">
|
||||
<el-input v-model="keyword" placeholder="搜索班级名称、学校..." clearable style="width:300px;margin-right:12px" @keyup.enter="loadList">
|
||||
<template #prefix><i class="fas fa-search" style="color:#8c8c8c"></i></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="loadList"><i class="fas fa-search" style="margin-right:4px"></i>搜索</el-button>
|
||||
<el-button @click="keyword='';loadList()"><i class="fas fa-redo" style="margin-right:4px"></i>重置</el-button>
|
||||
<el-button type="success" style="float:right" @click="$router.push('/class/edit')">
|
||||
<i class="fas fa-plus" style="margin-right:4px"></i>创建班级
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<el-table :data="list" stripe v-loading="loading">
|
||||
<el-table-column prop="name" label="班级名称" min-width="200" />
|
||||
<el-table-column prop="grade" label="年级" width="120" />
|
||||
<el-table-column prop="class_no" label="班号" width="80" />
|
||||
<el-table-column prop="graduate_year" label="毕业年份" width="100" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', statusMap[row.status]?.cls]">{{ statusMap[row.status]?.text }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="$router.push(`/class/detail/${row.id}`)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="margin-top:16px;text-align:right">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10,20,50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="loadList"
|
||||
@current-change="loadList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { classApi } from '../../../api'
|
||||
|
||||
const keyword = ref('')
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const statusMap = {
|
||||
pending: { text: '待审核', cls: 'warning' },
|
||||
active: { text: '已通过', cls: 'success' },
|
||||
rejected: { text: '被打回', cls: 'error' },
|
||||
deleted: { text: '已删除', cls: 'default' }
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await classApi.search({ keyword: keyword.value, page: page.value, page_size: pageSize.value })
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-home"></i>首页</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="card">
|
||||
<el-input v-model="keyword" placeholder="搜索班级名称、学校名称..." size="large" clearable @keyup.enter="search">
|
||||
<template #prefix><i class="fas fa-search" style="color:#8c8c8c"></i></template>
|
||||
<template #append>
|
||||
<el-button type="primary" @click="search">搜索</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 快速入口 -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px">
|
||||
<el-col :xs="12" :sm="8" :md="6">
|
||||
<div class="stat-card" @click="$router.push('/class/edit')" style="cursor:pointer">
|
||||
<div class="stat-icon blue"><i class="fas fa-plus"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">创建</div>
|
||||
<div class="stat-label">创建我的班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="6">
|
||||
<div class="stat-card" @click="$router.push('/class/join')" style="cursor:pointer">
|
||||
<div class="stat-icon green"><i class="fas fa-sign-in-alt"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">加入</div>
|
||||
<div class="stat-label">加入已有班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="6">
|
||||
<div class="stat-card" @click="$router.push('/class')" style="cursor:pointer">
|
||||
<div class="stat-icon orange"><i class="fas fa-list"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">浏览</div>
|
||||
<div class="stat-label">浏览所有班级</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="8" :md="6">
|
||||
<div class="stat-card" @click="$router.push('/profile')" style="cursor:pointer">
|
||||
<div class="stat-icon purple"><i class="fas fa-user"></i></div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">我的</div>
|
||||
<div class="stat-label">个人中心</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 地区浏览 -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-globe-asia" style="color:#1890ff"></i> 按地区浏览学校</h3>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="8" :md="6">
|
||||
<el-select v-model="selectedCountry" placeholder="选择国家" style="width:100%" @change="loadProvinces">
|
||||
<el-option v-for="c in countries" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="6">
|
||||
<el-select v-model="selectedProvince" placeholder="选择省/州" style="width:100%" @change="loadCounties" :disabled="!selectedCountry">
|
||||
<el-option v-for="p in provinces" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="6">
|
||||
<el-select v-model="selectedCounty" placeholder="选择区/县" style="width:100%" @change="loadSchools" :disabled="!selectedProvince">
|
||||
<el-option v-for="c in counties" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div v-if="schools.length > 0" style="margin-top:16px">
|
||||
<el-table :data="schools" stripe>
|
||||
<el-table-column prop="name" label="学校名称" />
|
||||
<el-table-column prop="school_type" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="tag primary">{{ typeMap[row.school_type] || row.school_type }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="address" label="地址" />
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else-if="selectedCounty" class="empty">
|
||||
<i class="fas fa-school"></i>
|
||||
<p>该地区暂无学校,快去创建第一个吧</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="searchResults.length > 0" class="card">
|
||||
<h3 style="margin-bottom:16px"><i class="fas fa-search" style="color:#1890ff"></i> 搜索结果</h3>
|
||||
<el-table :data="searchResults" stripe>
|
||||
<el-table-column prop="name" label="班级名称" />
|
||||
<el-table-column prop="grade" label="年级" width="120" />
|
||||
<el-table-column prop="graduate_year" label="毕业年份" width="100" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="$router.push(`/class/detail/${row.id}`)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { regionApi, schoolApi, classApi } from '../../api'
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const searchResults = ref([])
|
||||
const countries = ref([])
|
||||
const provinces = ref([])
|
||||
const counties = ref([])
|
||||
const schools = ref([])
|
||||
const selectedCountry = ref()
|
||||
const selectedProvince = ref()
|
||||
const selectedCounty = ref()
|
||||
|
||||
const typeMap = {
|
||||
university: '大学', vocational: '职业学校', senior_high: '高中',
|
||||
junior_high: '初中', primary: '小学', kindergarten: '幼儿园', other: '其他'
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!keyword.value.trim()) {
|
||||
ElMessage.warning('请输入搜索关键词')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await classApi.search({ keyword: keyword.value })
|
||||
searchResults.value = res.data.list || []
|
||||
} catch (e) {
|
||||
ElMessage.error('搜索失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCountries() {
|
||||
const res = await regionApi.countries()
|
||||
countries.value = res.data || []
|
||||
}
|
||||
|
||||
async function loadProvinces() {
|
||||
selectedProvince.value = null
|
||||
selectedCounty.value = null
|
||||
counties.value = []
|
||||
schools.value = []
|
||||
const res = await regionApi.list(selectedCountry.value)
|
||||
provinces.value = res.data || []
|
||||
}
|
||||
|
||||
async function loadCounties() {
|
||||
selectedCounty.value = null
|
||||
schools.value = []
|
||||
const res = await regionApi.list(selectedProvince.value)
|
||||
counties.value = res.data || []
|
||||
}
|
||||
|
||||
async function loadSchools() {
|
||||
const res = await schoolApi.list({ region_id: selectedCounty.value })
|
||||
schools.value = res.data || []
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCountries()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">日志详情</div>
|
||||
<div class="empty">日志详情功能开发中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">日志管理 - 操作日志列表</div>
|
||||
<div class="empty">日志管理功能开发中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">角色详情</div>
|
||||
<div class="empty">角色详情功能开发中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">角色设置 - 编辑角色</div>
|
||||
<div class="empty">角色编辑功能开发中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">角色设置 - 角色列表</div>
|
||||
<div class="empty">角色管理功能开发中...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-title"><i class="fas fa-user-cog"></i>个人中心</div>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<!-- 左侧:基本信息 -->
|
||||
<el-col :xs="24" :md="8">
|
||||
<div class="card" style="text-align:center">
|
||||
<div style="width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,#1890ff,#40a9ff);color:#fff;display:flex;align-items:center;justify-content:center;font-size:32px;font-weight:700;margin:0 auto 12px">
|
||||
{{ (user?.nickname || user?.username || 'U').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<h3 style="margin-bottom:4px">{{ user?.nickname || user?.username }}</h3>
|
||||
<p style="color:#8c8c8c;font-size:13px;margin-bottom:16px">@{{ user?.username }}</p>
|
||||
<el-tag v-if="user?.role === 'admin'" type="primary" size="large">平台管理员</el-tag>
|
||||
<el-tag v-else type="success" size="large">普通用户</el-tag>
|
||||
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #f0f0f0;text-align:left">
|
||||
<div style="margin-bottom:8px;color:#595959"><i class="fas fa-envelope" style="color:#1890ff;width:20px"></i>{{ user?.email || '未设置邮箱' }}</div>
|
||||
<div style="color:#595959"><i class="fas fa-clock" style="color:#1890ff;width:20px"></i>注册于 {{ formatDate(user?.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧:编辑区 -->
|
||||
<el-col :xs="24" :md="16">
|
||||
<el-tabs v-model="activeTab" stretch>
|
||||
<!-- 基本资料 -->
|
||||
<el-tab-pane label="基本资料" name="profile">
|
||||
<div class="card">
|
||||
<el-form :model="profileForm" label-width="100px">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="profileForm.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="profileForm.email" placeholder="请输入邮箱(用于找回密码)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="个人简介">
|
||||
<el-input v-model="profileForm.bio" type="textarea" :rows="4" placeholder="介绍一下自己吧" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveProfile"><i class="fas fa-save" style="margin-right:4px"></i>保存资料</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 学习履历 -->
|
||||
<el-tab-pane label="学习履历" name="education">
|
||||
<div class="card">
|
||||
<div style="margin-bottom:16px">
|
||||
<el-button type="primary" @click="showEduDialog = true"><i class="fas fa-plus" style="margin-right:4px"></i>添加履历</el-button>
|
||||
<el-tag type="info" style="margin-left:12px">有学习履历才能创建班级</el-tag>
|
||||
</div>
|
||||
<el-timeline v-if="educationList.length > 0">
|
||||
<el-timeline-item v-for="edu in educationList" :key="edu.id" :timestamp="`${edu.start_year} - ${edu.end_year || '至今'}`" placement="top">
|
||||
<el-card shadow="never">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||
<div>
|
||||
<h4 style="margin-bottom:4px">{{ edu.school_name }}</h4>
|
||||
<p style="color:#595959;font-size:13px;margin-bottom:4px">
|
||||
{{ edu.college_name || '' }} {{ edu.grade || '' }} {{ edu.class_no || '' }}
|
||||
</p>
|
||||
<p style="color:#8c8c8c;font-size:12px">{{ edu.major || '' }}</p>
|
||||
</div>
|
||||
<el-button type="danger" link @click="deleteEdu(edu)"><i class="fas fa-trash"></i></el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<div v-else class="empty">
|
||||
<i class="fas fa-graduation-cap"></i>
|
||||
<p>暂无学习履历,添加后才能创建班级</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<el-tab-pane label="联系方式" name="contacts">
|
||||
<div class="card">
|
||||
<div style="margin-bottom:16px">
|
||||
<el-button type="primary" @click="showContactDialog = true"><i class="fas fa-plus" style="margin-right:4px"></i>添加联系方式</el-button>
|
||||
</div>
|
||||
<el-table :data="contacts" stripe>
|
||||
<el-table-column label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<i :class="contactIcon(row.type)" style="color:#1890ff;margin-right:6px"></i>{{ contactTypeMap[row.type] }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="value" label="内容" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="deleteContact(row)"><i class="fas fa-trash"></i></el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-alert type="info" :closable="false" style="margin-top:16px">
|
||||
联系方式上限:邮箱2个、QQ5个、微信2个、手机号5个、住址1个、工作单位1个
|
||||
</el-alert>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 我的班级 -->
|
||||
<el-tab-pane label="我的班级" name="classes">
|
||||
<div class="card">
|
||||
<el-table :data="myClasses" stripe>
|
||||
<el-table-column prop="name" label="班级名称" min-width="200" />
|
||||
<el-table-column label="我的角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'primary' : 'success'" size="small">
|
||||
{{ row.role === 'admin' ? '管理员' : '成员' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="$router.push(`/class/detail/${row.class_id}`)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 添加履历弹窗 -->
|
||||
<el-dialog v-model="showEduDialog" title="添加学习履历" width="500px">
|
||||
<el-form :model="eduForm" label-width="100px">
|
||||
<el-form-item label="学校名称" required><el-input v-model="eduForm.school_name" /></el-form-item>
|
||||
<el-form-item label="学院/系"><el-input v-model="eduForm.college_name" /></el-form-item>
|
||||
<el-form-item label="专业"><el-input v-model="eduForm.major" /></el-form-item>
|
||||
<el-form-item label="年级"><el-input v-model="eduForm.grade" /></el-form-item>
|
||||
<el-form-item label="班级"><el-input v-model="eduForm.class_no" /></el-form-item>
|
||||
<el-form-item label="入学年份"><el-input-number v-model="eduForm.start_year" :min="1950" :max="2100" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="毕业年份"><el-input-number v-model="eduForm.end_year" :min="1950" :max="2100" style="width:100%" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEduDialog=false">取消</el-button>
|
||||
<el-button type="primary" @click="addEdu">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加联系方式弹窗 -->
|
||||
<el-dialog v-model="showContactDialog" title="添加联系方式" width="400px">
|
||||
<el-form :model="contactForm" label-width="80px">
|
||||
<el-form-item label="类型" required>
|
||||
<el-select v-model="contactForm.type" style="width:100%">
|
||||
<el-option label="邮箱" value="email" />
|
||||
<el-option label="QQ" value="qq" />
|
||||
<el-option label="微信" value="wechat" />
|
||||
<el-option label="手机号" value="phone" />
|
||||
<el-option label="家庭住址" value="address" />
|
||||
<el-option label="工作单位" value="workunit" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" required><el-input v-model="contactForm.value" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showContactDialog=false">取消</el-button>
|
||||
<el-button type="primary" @click="addContact">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { userApi, classApi } from '../../../../api'
|
||||
import { useAuth } from '../../../../composables/useAuth'
|
||||
|
||||
const { user, setAuth } = useAuth()
|
||||
const activeTab = ref('profile')
|
||||
const profileForm = reactive({ nickname: '', email: '', bio: '' })
|
||||
const educationList = ref([])
|
||||
const contacts = ref([])
|
||||
const myClasses = ref([])
|
||||
const showEduDialog = ref(false)
|
||||
const showContactDialog = ref(false)
|
||||
const eduForm = reactive({ school_name: '', college_name: '', major: '', grade: '', class_no: '', start_year: 2015, end_year: 2018 })
|
||||
const contactForm = reactive({ type: 'email', value: '' })
|
||||
|
||||
const contactTypeMap = { email: '邮箱', qq: 'QQ', wechat: '微信', phone: '手机号', address: '家庭住址', workunit: '工作单位' }
|
||||
function contactIcon(type) {
|
||||
const map = { email: 'fas fa-envelope', phone: 'fas fa-phone', qq: 'fab fa-qq', wechat: 'fab fa-weixin', address: 'fas fa-map-marker-alt', workunit: 'fas fa-building' }
|
||||
return map[type] || 'fas fa-info-circle'
|
||||
}
|
||||
function formatDate(d) {
|
||||
if (!d) return ''
|
||||
return d.substring(0, 10)
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
const res = await userApi.profile()
|
||||
Object.assign(profileForm, res.data)
|
||||
}
|
||||
async function loadEducation() {
|
||||
const res = await userApi.listEducation()
|
||||
educationList.value = res.data || []
|
||||
}
|
||||
async function loadContacts() {
|
||||
const res = await userApi.listContacts()
|
||||
contacts.value = res.data || []
|
||||
}
|
||||
async function loadMyClasses() {
|
||||
try {
|
||||
const res = await classApi.mine()
|
||||
myClasses.value = res.data || []
|
||||
} catch (e) { myClasses.value = [] }
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
try {
|
||||
const res = await userApi.updateProfile(profileForm)
|
||||
setAuth(user.value.token, res.data)
|
||||
ElMessage.success('资料已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
}
|
||||
}
|
||||
async function addEdu() {
|
||||
if (!eduForm.school_name) { ElMessage.warning('请输入学校名称'); return }
|
||||
try {
|
||||
await userApi.addEducation(eduForm)
|
||||
ElMessage.success('已添加')
|
||||
showEduDialog.value = false
|
||||
loadEducation()
|
||||
} catch (e) { ElMessage.error(e.message || '添加失败') }
|
||||
}
|
||||
async function deleteEdu(edu) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除这条履历吗?', '提示', { type: 'warning' })
|
||||
await userApi.deleteEducation(edu.id)
|
||||
ElMessage.success('已删除')
|
||||
loadEducation()
|
||||
} catch (e) {}
|
||||
}
|
||||
async function addContact() {
|
||||
if (!contactForm.value) { ElMessage.warning('请输入内容'); return }
|
||||
try {
|
||||
await userApi.addContact(contactForm)
|
||||
ElMessage.success('已添加')
|
||||
showContactDialog.value = false
|
||||
loadContacts()
|
||||
} catch (e) { ElMessage.error(e.message || '添加失败') }
|
||||
}
|
||||
async function deleteContact(c) {
|
||||
try {
|
||||
await userApi.deleteContact(c.id)
|
||||
ElMessage.success('已删除')
|
||||
loadContacts()
|
||||
} catch (e) { ElMessage.error(e.message || '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile()
|
||||
loadEducation()
|
||||
loadContacts()
|
||||
loadMyClasses()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">用户设置 - 编辑资料</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>昵称</label>
|
||||
<input v-model="form.nickname" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>个人简介</label>
|
||||
<textarea v-model="form.bio" rows="3"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="save">保存</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { userApi } from '../../../../api'
|
||||
|
||||
const form = ref({ nickname: '', bio: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
const profile = await userApi.profile()
|
||||
form.value.nickname = profile.user.nickname
|
||||
form.value.bio = profile.user.bio
|
||||
})
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await userApi.updateProfile(form.value)
|
||||
alert('保存成功')
|
||||
} catch (e) {
|
||||
alert('保存失败:' + e.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-title">个人资料</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input :value="profile?.user.username" disabled style="background:#f5f7fa" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>昵称</label>
|
||||
<input v-model="editForm.nickname" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>个人简介</label>
|
||||
<textarea v-model="editForm.bio" rows="2" placeholder="一句话介绍自己"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="saveProfile" :disabled="saving">{{ saving?'保存中...':'保存资料' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<div class="card">
|
||||
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span>联系方式</span>
|
||||
<button class="btn btn-primary btn-sm" @click="showAddContact=true">添加联系方式</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-bottom:12px">
|
||||
邮箱最多2个、QQ最多5个、微信最多2个、手机号最多5个、家庭住址和工作单位各1个。同班同学可互相查看。
|
||||
</div>
|
||||
<table v-if="profile?.contacts.length">
|
||||
<thead><tr><th>类型</th><th>内容</th><th>主要</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in profile.contacts" :key="c.id">
|
||||
<td>{{ contactTypeText(c.type) }}</td>
|
||||
<td>{{ c.value }}</td>
|
||||
<td><span v-if="c.is_primary" class="tag tag-primary">主要</span></td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteContact(c.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty">暂无联系方式</div>
|
||||
</div>
|
||||
|
||||
<!-- 学习履历 -->
|
||||
<div class="card">
|
||||
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span>学习履历</span>
|
||||
<button class="btn btn-primary btn-sm" @click="openEduForm()">添加履历</button>
|
||||
</div>
|
||||
<div class="alert alert-info" v-if="profile?.education.length===0">
|
||||
还没有学习履历。<strong>有学习履历才能创建班级</strong>,请先添加。
|
||||
</div>
|
||||
<div v-for="e in profile?.education" :key="e.id" style="border:1px solid #ebeef5;border-radius:6px;padding:12px;margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<div>
|
||||
<strong>{{ e.school_name }}</strong>
|
||||
<span v-if="e.college_name" style="color:#606266"> · {{ e.college_name }}</span>
|
||||
<span v-if="e.major" style="color:#606266"> · {{ e.major }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-default btn-sm" @click="openEduForm(e)" style="margin-right:4px">编辑</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteEdu(e.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:13px;color:#909399;margin-top:4px">
|
||||
{{ e.start_year }} - {{ e.end_year || '至今' }}
|
||||
<span v-if="e.degree" style="margin-left:8px">{{ e.degree }}</span>
|
||||
</div>
|
||||
<div v-if="e.description" style="font-size:13px;color:#606266;margin-top:4px">{{ e.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的班级 -->
|
||||
<div class="card">
|
||||
<div class="card-title">我的班级</div>
|
||||
<div v-if="myClasses.length===0" class="empty">
|
||||
还没有加入任何班级,<router-link to="/class/edit">创建一个</router-link> 或 <router-link to="/class/join">加入一个</router-link>
|
||||
</div>
|
||||
<table v-else>
|
||||
<thead><tr><th>班级名称</th><th>毕业年份</th><th>我的角色</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in myClasses" :key="c.class.id">
|
||||
<td>{{ c.class.name }}</td>
|
||||
<td>{{ c.class.graduate_year }}</td>
|
||||
<td><span class="tag" :class="c.my_role==='admin'?'tag-warning':'tag-info'">{{ c.my_role==='admin'?'管理员':'成员' }}</span></td>
|
||||
<td><span class="tag" :class="statusTag(c.class.status)">{{ statusText(c.class.status) }}</span></td>
|
||||
<td><button class="btn btn-primary btn-sm" @click="$router.push('/class/detail/'+c.class.id)">查看</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 添加联系方式弹窗 -->
|
||||
<div v-if="showAddContact" class="card" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:400px;z-index:200;box-shadow:0 4px 20px rgba(0,0,0,0.2)">
|
||||
<div class="card-title">添加联系方式</div>
|
||||
<div class="form-group">
|
||||
<label>类型</label>
|
||||
<select v-model="contactForm.type">
|
||||
<option value="email">邮箱</option>
|
||||
<option value="qq">QQ</option>
|
||||
<option value="wechat">微信</option>
|
||||
<option value="phone">手机号</option>
|
||||
<option value="address">家庭住址</option>
|
||||
<option value="workunit">工作单位</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>内容</label>
|
||||
<input v-model="contactForm.value" placeholder="请输入" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" v-model="contactForm.is_primary" style="width:auto;margin-right:4px" />设为主要联系方式</label>
|
||||
</div>
|
||||
<div v-if="contactError" class="alert alert-danger">{{ contactError }}</div>
|
||||
<div style="text-align:right">
|
||||
<button class="btn btn-default" @click="showAddContact=false" style="margin-right:8px">取消</button>
|
||||
<button class="btn btn-primary" @click="addContact">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showAddContact" @click="showAddContact=false" style="position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:199"></div>
|
||||
|
||||
<!-- 履历编辑弹窗 -->
|
||||
<div v-if="showEduForm" class="card" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:500px;max-height:85vh;overflow-y:auto;z-index:200;box-shadow:0 4px 20px rgba(0,0,0,0.2)">
|
||||
<div class="card-title">{{ eduForm.id?'编辑':'添加' }}学习履历</div>
|
||||
<div class="form-group">
|
||||
<label>学校名称 <span style="color:#f56c6c">*</span></label>
|
||||
<input v-model="eduForm.school_name" placeholder="如:连云港市第一中学" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>学院/系</label>
|
||||
<input v-model="eduForm.college_name" placeholder="如:计算机学院" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>专业</label>
|
||||
<input v-model="eduForm.major" placeholder="如:计算机科学与技术" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>入学年份</label>
|
||||
<input type="number" v-model.number="eduForm.start_year" placeholder="2018" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>毕业年份</label>
|
||||
<input type="number" v-model.number="eduForm.end_year" placeholder="2022" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>学历</label>
|
||||
<select v-model="eduForm.degree">
|
||||
<option value="">请选择</option>
|
||||
<option value="幼儿园">幼儿园</option>
|
||||
<option value="小学">小学</option>
|
||||
<option value="初中">初中</option>
|
||||
<option value="高中">高中</option>
|
||||
<option value="专科">专科</option>
|
||||
<option value="本科">本科</option>
|
||||
<option value="硕士">硕士</option>
|
||||
<option value="博士">博士</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>备注</label>
|
||||
<textarea v-model="eduForm.description" rows="2"></textarea>
|
||||
</div>
|
||||
<div v-if="eduError" class="alert alert-danger">{{ eduError }}</div>
|
||||
<div style="text-align:right">
|
||||
<button class="btn btn-default" @click="showEduForm=false" style="margin-right:8px">取消</button>
|
||||
<button class="btn btn-primary" @click="saveEdu">{{ eduForm.id?'保存':'添加' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showEduForm" @click="showEduForm=false" style="position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:199"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { userApi, classApi } from '../../../../api'
|
||||
|
||||
const profile = ref(null)
|
||||
const myClasses = ref([])
|
||||
const saving = ref(false)
|
||||
|
||||
const editForm = ref({ nickname: '', bio: '' })
|
||||
|
||||
// 联系方式
|
||||
const showAddContact = ref(false)
|
||||
const contactForm = ref({ type: 'email', value: '', is_primary: false })
|
||||
const contactError = ref('')
|
||||
|
||||
// 履历
|
||||
const showEduForm = ref(false)
|
||||
const eduForm = ref({ id: 0, school_name: '', college_name: '', major: '', degree: '', start_year: 0, end_year: 0, description: '' })
|
||||
const eduError = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile()
|
||||
loadMyClasses()
|
||||
})
|
||||
|
||||
async function loadProfile() {
|
||||
profile.value = await userApi.profile()
|
||||
editForm.value.nickname = profile.value.user.nickname
|
||||
editForm.value.bio = profile.value.user.bio
|
||||
}
|
||||
|
||||
async function loadMyClasses() {
|
||||
myClasses.value = await classApi.mine()
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
saving.value = true
|
||||
try {
|
||||
await userApi.updateProfile(editForm.value)
|
||||
alert('保存成功')
|
||||
loadProfile()
|
||||
} catch (e) {
|
||||
alert('保存失败:' + e.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addContact() {
|
||||
contactError.value = ''
|
||||
if (!contactForm.value.value) {
|
||||
contactError.value = '请输入内容'
|
||||
return
|
||||
}
|
||||
try {
|
||||
await userApi.addContact(contactForm.value)
|
||||
showAddContact.value = false
|
||||
contactForm.value = { type: 'email', value: '', is_primary: false }
|
||||
loadProfile()
|
||||
} catch (e) {
|
||||
contactError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact(id) {
|
||||
if (!confirm('确定删除?')) return
|
||||
try {
|
||||
await userApi.deleteContact(id)
|
||||
loadProfile()
|
||||
} catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
function openEduForm(e) {
|
||||
if (e) {
|
||||
eduForm.value = { ...e }
|
||||
} else {
|
||||
eduForm.value = { id: 0, school_name: '', college_name: '', major: '', degree: '', start_year: 0, end_year: 0, description: '' }
|
||||
}
|
||||
eduError.value = ''
|
||||
showEduForm.value = true
|
||||
}
|
||||
|
||||
async function saveEdu() {
|
||||
eduError.value = ''
|
||||
if (!eduForm.value.school_name) {
|
||||
eduError.value = '请输入学校名称'
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (eduForm.value.id) {
|
||||
await userApi.updateEducation(eduForm.value.id, eduForm.value)
|
||||
} else {
|
||||
await userApi.addEducation(eduForm.value)
|
||||
}
|
||||
showEduForm.value = false
|
||||
loadProfile()
|
||||
} catch (e) {
|
||||
eduError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEdu(id) {
|
||||
if (!confirm('确定删除这条履历?')) return
|
||||
try {
|
||||
await userApi.deleteEducation(id)
|
||||
loadProfile()
|
||||
} catch (e) { alert(e.message) }
|
||||
}
|
||||
|
||||
function contactTypeText(t) {
|
||||
const map = { email: '邮箱', qq: 'QQ', wechat: '微信', phone: '手机号', address: '家庭住址', workunit: '工作单位' }
|
||||
return map[t] || t
|
||||
}
|
||||
function statusText(s) {
|
||||
const map = { pending: '待审核', active: '正常', rejected: '被打回', deleted: '已删除' }
|
||||
return map[s] || s
|
||||
}
|
||||
function statusTag(s) {
|
||||
const map = { pending: 'tag-warning', active: 'tag-success', rejected: 'tag-danger', deleted: 'tag-info' }
|
||||
return map[s] || 'tag-info'
|
||||
}
|
||||
</script>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user