feat(settings): 个人资料编辑 + 头像上传/裁切 + 自定义裁切弹窗
**新增功能:**
用户名编辑:输入框替换静态文本,白名单验证(中文/英文/数字/下划线/连字符),前端计数器(n/16),utf8对齐PG VARCHAR,XSS防控。
个性签名编辑:Textarea,128字符上限,实时计数器。
头像上传管线:校验→解码→裁切→CatmullRom缩放→WebP二分编码≤100KB→原子写入(.tmp→os.Rename)。限制5MB,128-3840px,JPEG/PNG/WebP。输出512x512 WebP。文件名 {uid}_{timestamp}.webp。清理旧头像。
自定义裁切弹窗:浅色主题,固定裁切框+图片平移/缩放(1×-3×滚轮),box-shadow遮罩,三等分网格。坐标映射pan/zoom→原图像素→subImage。
CSP修复:img-src允许data:URI(FileReader预览)。
**文件变更:**
修改: README, docs/{development,structure}.md, go.{mod,sum}, cmd/server/main.go, controller/settings, middleware/security, router, templates/settings/{index.html,css}
新增: internal/service/avatar_service.go, docs/settings.md
This commit is contained in:
@ -47,7 +47,8 @@ MetaLab 是一个正在开发中的开源技术社区平台,旨在为开发者
|
|||||||
- ✅ **安全头**——Content-Security-Policy 等安全响应头
|
- ✅ **安全头**——Content-Security-Policy 等安全响应头
|
||||||
- ✅ **管理后台**——用户管理、角色权限控制
|
- ✅ **管理后台**——用户管理、角色权限控制
|
||||||
- ✅ **软删除**——用户数据可恢复,登录时自动恢复
|
- ✅ **软删除**——用户数据可恢复,登录时自动恢复
|
||||||
- ✅ **用户设置页**——个人资料设置
|
- ✅ **用户设置页**——个人资料修改(用户名、个性签名、头像上传与裁切)
|
||||||
|
- ✅ **头像处理**——JPEG/PNG/WebP 上传,方形裁切,512×512 WebP 编码 ≤100KB
|
||||||
- ✅ **多主题支持**——可切换的前端主题系统
|
- ✅ **多主题支持**——可切换的前端主题系统
|
||||||
|
|
||||||
## 计划中
|
## 计划中
|
||||||
|
|||||||
@ -62,6 +62,7 @@ func main() {
|
|||||||
r.Static("/static", "./templates/MetaLab-2026/static")
|
r.Static("/static", "./templates/MetaLab-2026/static")
|
||||||
r.Static("/admin/static", "./templates/admin/static")
|
r.Static("/admin/static", "./templates/admin/static")
|
||||||
r.Static("/shared/static", "./templates/shared/static")
|
r.Static("/shared/static", "./templates/shared/static")
|
||||||
|
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
||||||
|
|
||||||
// 路由注册
|
// 路由注册
|
||||||
router.Setup(r, db, cfg)
|
router.Setup(r, db, cfg)
|
||||||
|
|||||||
@ -378,6 +378,37 @@ RememberMe bool `json:"remember_me"`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 14. 个人设置页与头像上传
|
||||||
|
|
||||||
|
**涉及文件**:参考 [`docs/settings.md`](settings.md) 获取完整实现文档。
|
||||||
|
|
||||||
|
**功能概要**:
|
||||||
|
|
||||||
|
| 功能 | 实现文件 |
|
||||||
|
|------|----------|
|
||||||
|
| 用户名编辑 | `auth_service.go` — `UpdateProfile()`,正则 `[\p{Han}a-zA-Z0-9_-]` 白名单 |
|
||||||
|
| 个性签名编辑 | 同上,0-128 字符 |
|
||||||
|
| 头像处理管线 | `avatar_service.go`(新文件)— 解码→裁切→CatmullRom缩放→WebP二分编码→原子写入 |
|
||||||
|
| 前端裁切弹窗 | `settings/index.html` — 固定框 + 图片平移/缩放 + 坐标映射 |
|
||||||
|
| CSS样式 | `settings.css` — 浅色主题裁切弹窗、Toast通知、输入框样式 |
|
||||||
|
| CSP放宽 | `security.go` — `img-src 'self' data:`(裁切预览用data URI) |
|
||||||
|
| 静态路由 | `main.go` — `/uploads/avatars` → `./storage/uploads/avatars` |
|
||||||
|
|
||||||
|
**设计决策**:
|
||||||
|
|
||||||
|
| 决策 | 选择 | 原因 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户名校验 | 白名单 regex | 比黑名单更安全,直接排除 `<>&"'` 等 XSS 向量 |
|
||||||
|
| 字符计数 | `utf8.RuneCountInString` | 确保与 PostgreSQL `varchar(16)` 语义一致 |
|
||||||
|
| 头像编码 | WebP + 二分查找质量 | 目标 ≤100KB,兼顾质量与体积 |
|
||||||
|
| 原子写入 | `.tmp` → `os.Rename` | 防止写入中断产生损坏文件 |
|
||||||
|
| 裁切方式 | 固定框 + 图片平移/缩放 | 框大小不变,zoom使框覆盖更小区域,真正支持精确定位 |
|
||||||
|
| GIF支持 | 不支持 | 裁切后无法保持动画,无实际用途 |
|
||||||
|
| 最小分辨率 | 128×128 | 防止马赛克图被拉升到 512×512 |
|
||||||
|
| CSP data: URI | 显式允许 img-src | 裁切预览使用 FileReader → data URI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 二、解决方案建议
|
## 二、解决方案建议
|
||||||
|
|
||||||
### 2.1 PostgreSQL 密码认证(问题一记录)
|
### 2.1 PostgreSQL 密码认证(问题一记录)
|
||||||
@ -505,7 +536,7 @@ config.yaml # 主配置(服务/数据库/JWT 含 remember_expire/BCry
|
|||||||
| ~~**P0**~~ | ~~登出功能~~ | ✅ 已完成 | `POST /api/auth/logout` → 清除三个 Cookie + 跳转首页 |
|
| ~~**P0**~~ | ~~登出功能~~ | ✅ 已完成 | `POST /api/auth/logout` → 清除三个 Cookie + 跳转首页 |
|
||||||
| **P1** | 首页完善 | 待做 | 当前仅有 Hero 区域,需添加帖子列表、话题导航等 |
|
| **P1** | 首页完善 | 待做 | 当前仅有 Hero 区域,需添加帖子列表、话题导航等 |
|
||||||
| **P1** | 帖子系统 | 待做 | CRUD + 话题分类 + Markdown 编辑 |
|
| **P1** | 帖子系统 | 待做 | CRUD + 话题分类 + Markdown 编辑 |
|
||||||
| **P1** | 用户设置页 | 待做 | `/settings` — 修改用户名/头像/密码/个人简介 |
|
| **P1** | 用户设置页 | ✅ 已完成 | `/settings/profile`、`/settings/account` — 用户名修改、个性签名、头像上传与裁切 |
|
||||||
| **P2** | 管理后台 | 待做 | `/admin` — 用户管理、内容审核、主题切换 |
|
| **P2** | 管理后台 | 待做 | `/admin` — 用户管理、内容审核、主题切换 |
|
||||||
| ~~**P2**~~ | ~~刷新令牌~~ | ✅ 已完成 | 记住我 + access/refresh 双 Token + 自动续期 + 登出 |
|
| ~~**P2**~~ | ~~刷新令牌~~ | ✅ 已完成 | 记住我 + access/refresh 双 Token + 自动续期 + 登出 |
|
||||||
| **P3** | 多主题支持 | 待做 | `MetaLab-2026` → `MetaLab-2027` 切换 |
|
| **P3** | 多主题支持 | 待做 | `MetaLab-2026` → `MetaLab-2027` 切换 |
|
||||||
|
|||||||
195
docs/settings.md
Normal file
195
docs/settings.md
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
# 个人设置功能
|
||||||
|
|
||||||
|
> 实现日期:2026-05-27
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
个人设置是用户修改公开资料和头像的功能模块,包含三个子功能:
|
||||||
|
|
||||||
|
1. **用户名编辑** — 输入框替换静态文本,白名单字符 + XSS 过滤
|
||||||
|
2. **个性签名编辑** — Textarea 控件,128 字符上限
|
||||||
|
3. **头像上传与裁切** — 完整图片处理管线,WebP 编码 ≤100KB
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/settings/profile — 个人资料(用户名、头像、个性签名)
|
||||||
|
/settings/account — 账号信息(只读:UID、邮箱、状态、注册时间)
|
||||||
|
```
|
||||||
|
|
||||||
|
左侧导航栏切换 `profile` / `account` 两个 tab,通过 `:tab` 路由参数区分。
|
||||||
|
|
||||||
|
## 用户名编辑
|
||||||
|
|
||||||
|
### 规则
|
||||||
|
|
||||||
|
| 项目 | 规格 |
|
||||||
|
|------|------|
|
||||||
|
| 允许字符 | 中文、英文大小写、数字、下划线(`_`)、连字符(`-`) |
|
||||||
|
| 前后端正则 | Go: `^[\p{Han}a-zA-Z0-9_-]+$` / JS: `^[\u4e00-\u9fffa-zA-Z0-9_-]+$` |
|
||||||
|
| 长度限制 | 1-16 字符(Unicode `utf8.RuneCountInString`) |
|
||||||
|
| XSS 防护 | 白名单排除 `<>&"'` 等 HTML 特殊字符;Go `html/template` 自动转义 |
|
||||||
|
| 去重 | 变更时检查 `ExistsByUsername`,冲突返回 409 |
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/settings/profile
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "...", "bio": "..." }
|
||||||
|
|
||||||
|
成功: { "success": true, "message": "个人资料已更新" }
|
||||||
|
冲突: 409 { "message": "该用户名已被占用" }
|
||||||
|
无效: 400 { "message": "用户名格式无效..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 个性签名编辑
|
||||||
|
|
||||||
|
| 项目 | 规格 |
|
||||||
|
|------|------|
|
||||||
|
| 控件 | `<textarea>`,`maxlength="128"` |
|
||||||
|
| 上限 | 128 字符(Unicode 语义) |
|
||||||
|
| 前端 | 实时字符计数器 `n/128` |
|
||||||
|
| XSS | Go `html/template` 自动转义,无需额外处理 |
|
||||||
|
|
||||||
|
## 头像上传与裁切
|
||||||
|
|
||||||
|
### 上传限制
|
||||||
|
|
||||||
|
| 项目 | 限制 |
|
||||||
|
|------|------|
|
||||||
|
| 格式 | JPEG、PNG、WebP(不支持 GIF) |
|
||||||
|
| 文件大小 | ≤ 5MB |
|
||||||
|
| 分辨率 | 128×128 ~ 3840×2160 |
|
||||||
|
| 接受标签 | `accept="image/jpeg,image/png,image/webp"` |
|
||||||
|
|
||||||
|
### 处理管线 (`avatar_service.go`)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 校验 Content-Type(仅 JPEG/PNG/WebP)
|
||||||
|
2. io.LimitReader + io.ReadAll,校验文件大小 ≤ 5MB
|
||||||
|
3. image.Decode() 解码,校验分辨率 [128, 3840]
|
||||||
|
4. 自定义裁切(前端传入 crop_x/crop_y/crop_size)
|
||||||
|
└─ subImage() 或 centerCrop()(后备)
|
||||||
|
5. CatmullRom 高质量缩放至 512×512
|
||||||
|
6. WebP 编码,二分查找质量参数(1-80),目标 ≤100KB
|
||||||
|
7. 原子写入:先写 .tmp 再 os.Rename 到最终路径
|
||||||
|
8. 文件名:{uid}_{timestamp}.webp
|
||||||
|
9. 清理旧头像:删除该用户所有 {uid}_*.webp
|
||||||
|
10. 更新 user.Avatar 字段 → 持久化
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键函数
|
||||||
|
|
||||||
|
| 函数 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `ProcessAvatar(userID, file, contentType, cropX, cropY, cropSize)` | 主入口,返回 avatar URL |
|
||||||
|
| `subImage(img, x, y, size)` | 自定义方形裁切 |
|
||||||
|
| `centerCrop(img)` | 默认中心裁切 |
|
||||||
|
| `encodeWebP(img, targetBytes)` | 二分查找最佳质量 |
|
||||||
|
| `cleanOldAvatars(userID)` | 删除旧文件 |
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/settings/avatar
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
字段:
|
||||||
|
avatar: (file) 图片文件
|
||||||
|
crop_x: (int) 裁切起点 X(原图坐标)
|
||||||
|
crop_y: (int) 裁切起点 Y(原图坐标)
|
||||||
|
crop_size: (int) 裁切方形边长(原图坐标)
|
||||||
|
|
||||||
|
成功: { "success": true, "data": { "url": "/uploads/avatars/1_1234567890.webp" } }
|
||||||
|
失败: 400 { "message": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端裁切弹窗
|
||||||
|
|
||||||
|
### 设计
|
||||||
|
|
||||||
|
- **浅色主题**:白色卡片 + 浅灰舞台 (`#edf0f4`),与站点统一
|
||||||
|
- **固定裁切框**:框大小不变(短边填满可见区),缩放仅作用于图片
|
||||||
|
- **透亮效果**:裁切框外 `box-shadow: 0 0 0 9999px rgba(0,0,0,.45)` 半透明遮罩
|
||||||
|
- **白色边框**:2px 实线边框标记裁切区域
|
||||||
|
- **三等分网格**:`background-image` 线性参考线
|
||||||
|
|
||||||
|
### 交互
|
||||||
|
|
||||||
|
| 操作 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 点击头像区域 | 打开文件选择器 |
|
||||||
|
| 选择文件后 | 打开裁切弹窗 |
|
||||||
|
| 拖拽图片 | 平移图片在裁切框后的位置 |
|
||||||
|
| 滚轮 | 缩放图片(1× ~ 3×,10% 步长) |
|
||||||
|
| 取消 | 关闭弹窗,清除选择 |
|
||||||
|
| 确认 | 计算裁切坐标 → POST 上传 |
|
||||||
|
|
||||||
|
### 坐标映射(JS)
|
||||||
|
|
||||||
|
```
|
||||||
|
getCropParams():
|
||||||
|
裁切框在 stage 中的固定位置 → 减去图片偏移(pan)→ 除以 zoom
|
||||||
|
→ 映射回 zoom=1 显示坐标 → 乘以 (原图宽/显示宽)
|
||||||
|
→ 返回 { x, y, size }(原图像素坐标,提交给后端 subImage)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键 JS 函数
|
||||||
|
|
||||||
|
| 函数 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `openCrop(file)` | 加载图片,确定基准显示尺寸,计算裁切框初始位置 |
|
||||||
|
| `renderCrop()` | 更新图片/裁切框/遮罩的 CSS |
|
||||||
|
| `clampPan()` | 约束图片平移范围(裁切框不露出图片外) |
|
||||||
|
| `getCropParams()` | 映射回原图像素坐标 |
|
||||||
|
| 滚轮 handler | `zoom *= 1.1` 或 `/= 1.1`,clip 到 [1, 3] |
|
||||||
|
|
||||||
|
## 前端反馈
|
||||||
|
|
||||||
|
### Toast 通知
|
||||||
|
|
||||||
|
| 位置 | 导航栏下方居中 |
|
||||||
|
|------|------|
|
||||||
|
| 成功 | 绿色 `#2ecc71` |
|
||||||
|
| 失败 | 红色 `#e74c3c` |
|
||||||
|
| 持续时间 | 5 秒自动消失 |
|
||||||
|
| 动画 | `opacity` + `translateY` 进出场 |
|
||||||
|
|
||||||
|
### 字符计数器
|
||||||
|
|
||||||
|
- 用户名输入框右侧 `n/16`
|
||||||
|
- 个性签名字段下方 `n/128`
|
||||||
|
- 实时更新(`input` 事件)
|
||||||
|
|
||||||
|
### 保存按钮
|
||||||
|
|
||||||
|
- 点击后 `disabled` + 文字变 "保存中..."
|
||||||
|
- 请求完成恢复 "保存"
|
||||||
|
- 无变更时提示 "内容未变更,无需保存"
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
| 措施 | 实现 |
|
||||||
|
|------|------|
|
||||||
|
| CSP 放宽 | `img-src 'self' data:` — 裁剪预览需要 data URI |
|
||||||
|
| 文件大小限制 | `io.LimitReader` 防止内存耗尽 |
|
||||||
|
| 原子写入 | `.tmp` → `os.Rename` 防止写入中断留下损坏文件 |
|
||||||
|
| 旧文件清理 | 每次上传删除同一用户旧头像文件 |
|
||||||
|
| XSS | 白名单 regex + Go 模板自动转义 |
|
||||||
|
| 认证 | `/api/settings/*` 路由组绑定 `authMdw.Required()` |
|
||||||
|
|
||||||
|
## 架构(接口隔离)
|
||||||
|
|
||||||
|
遵循 ISP:
|
||||||
|
|
||||||
|
```
|
||||||
|
Controller.settings_controller.go
|
||||||
|
├── profileProvider (interface: GetProfile, UpdateProfile)
|
||||||
|
└── avatarProvider (interface: ProcessAvatar)
|
||||||
|
|
||||||
|
Service.auth_service.go → implements profileProvider
|
||||||
|
Service.avatar_service.go → implements avatarProvider, depends on userAvatarStore (interface: FindByID, Update)
|
||||||
|
|
||||||
|
Repository.user_repo.go → implements userAvatarStore
|
||||||
|
```
|
||||||
@ -9,8 +9,11 @@ METAZONE.FAN/
|
|||||||
│ ├── config/ # 配置加载(结构体 + yaml/file)
|
│ ├── config/ # 配置加载(结构体 + yaml/file)
|
||||||
│ ├── router/ # 路由注册(按路由组拆文件)
|
│ ├── router/ # 路由注册(按路由组拆文件)
|
||||||
│ ├── controller/ # 控制器(瘦)— 接参数 → 调服务 → 返回
|
│ ├── controller/ # 控制器(瘦)— 接参数 → 调服务 → 返回
|
||||||
|
│ │ ├── interfaces.go # 控制器层接口定义(ISP)
|
||||||
│ │ └── admin/ # 后台子包(功能多,独立拆)
|
│ │ └── admin/ # 后台子包(功能多,独立拆)
|
||||||
│ ├── service/ # 业务逻辑(厚)— 核心逻辑、校验、编排
|
│ ├── service/ # 业务逻辑(厚)— 核心逻辑、校验、编排
|
||||||
|
│ │ ├── repository.go # Service 层仓储接口定义(ISP)
|
||||||
|
│ │ └── avatar_service.go # 头像上传处理管线
|
||||||
│ ├── repository/ # 数据访问(DAO)— 纯数据库操作
|
│ ├── repository/ # 数据访问(DAO)— 纯数据库操作
|
||||||
│ ├── model/ # 数据模型 — 纯结构体 + DTO
|
│ ├── model/ # 数据模型 — 纯结构体 + DTO
|
||||||
│ ├── middleware/ # 中间件 — 每个中间件一个文件
|
│ ├── middleware/ # 中间件 — 每个中间件一个文件
|
||||||
@ -22,13 +25,17 @@ METAZONE.FAN/
|
|||||||
│ │ ├── guidelines.html # 准则等长文本(非模板,纯内容文件)
|
│ │ ├── guidelines.html # 准则等长文本(非模板,纯内容文件)
|
||||||
│ │ ├── html/ # 页面模板
|
│ │ ├── html/ # 页面模板
|
||||||
│ │ │ ├── layout/ # 布局组件(header/nav/footer)
|
│ │ │ ├── layout/ # 布局组件(header/nav/footer)
|
||||||
│ │ │ ├── home/ post/ comment/ user/ topic/ ...
|
│ │ │ ├── home/ post/ comment/ user/ topic/ settings/
|
||||||
│ │ │ └── partials/ # 可复用组件(卡片/分页/侧栏)
|
│ │ │ └── partials/ # 可复用组件(卡片/分页/侧栏)
|
||||||
│ │ └── static/ # 静态资源(css/js/img)
|
│ │ └── static/ # 静态资源(css/js/img)
|
||||||
|
│ │ ├── css/
|
||||||
|
│ │ │ ├── common.css # 全局样式 + 导航
|
||||||
|
│ │ │ ├── home.css auth.css settings.css
|
||||||
|
│ │ └── js/
|
||||||
│ ├── MetaLab-2027/ # 未来主题(相同结构)
|
│ ├── MetaLab-2027/ # 未来主题(相同结构)
|
||||||
│ └── system/ # 系统模板(email 等,与主题无关)
|
│ └── system/ # 系统模板(email 等,与主题无关)
|
||||||
├── storage/ # 运行时数据(.gitignore)
|
├── storage/ # 运行时数据(.gitignore)
|
||||||
│ ├── uploads/avatars/ attachments/
|
│ ├── uploads/avatars/ # 用户头像(WebP)
|
||||||
│ └── logs/
|
│ └── logs/
|
||||||
└── docs/ # 项目文档
|
└── docs/ # 项目文档
|
||||||
```
|
```
|
||||||
|
|||||||
2
go.mod
2
go.mod
@ -15,6 +15,7 @@ require (
|
|||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/chai2010/webp v1.4.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
@ -51,6 +52,7 @@ require (
|
|||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/image v0.41.0 // indirect
|
||||||
golang.org/x/net v0.54.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@ -4,6 +4,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
|
|||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
|
||||||
|
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@ -117,6 +119,8 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
|||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
|
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||||
|
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
@ -15,14 +17,20 @@ type profileProvider interface {
|
|||||||
UpdateProfile(userID uint, username, bio string) error
|
UpdateProfile(userID uint, username, bio string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// avatarProvider 头像上传对 Service 层的最小依赖(ISP:1 个方法)
|
||||||
|
type avatarProvider interface {
|
||||||
|
ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
// SettingsController 个人设置控制器
|
// SettingsController 个人设置控制器
|
||||||
type SettingsController struct {
|
type SettingsController struct {
|
||||||
authService profileProvider
|
authService profileProvider
|
||||||
|
avatarService avatarProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSettingsController 构造函数
|
// NewSettingsController 构造函数
|
||||||
func NewSettingsController(authService profileProvider) *SettingsController {
|
func NewSettingsController(authService profileProvider, avatarService avatarProvider) *SettingsController {
|
||||||
return &SettingsController{authService: authService}
|
return &SettingsController{authService: authService, avatarService: avatarService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsPage 个人设置页面(需登录)
|
// SettingsPage 个人设置页面(需登录)
|
||||||
@ -82,6 +90,35 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
|||||||
common.OkMessage(c, "个人资料已更新")
|
common.OkMessage(c, "个人资料已更新")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadAvatar 上传头像(需登录,multipart/form-data)
|
||||||
|
func (sc *SettingsController) UploadAvatar(c *gin.Context) {
|
||||||
|
uid, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := c.Request.FormFile("avatar")
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// 裁切参数(可选,来自前端裁切弹窗)
|
||||||
|
cropX, _ := strconv.Atoi(c.PostForm("crop_x"))
|
||||||
|
cropY, _ := strconv.Atoi(c.PostForm("crop_y"))
|
||||||
|
cropSize, _ := strconv.Atoi(c.PostForm("crop_size"))
|
||||||
|
|
||||||
|
url, err := sc.avatarService.ProcessAvatar(uid.(uint), file, header.Header.Get("Content-Type"), cropX, cropY, cropSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{"url": url})
|
||||||
|
}
|
||||||
|
|
||||||
// handleSettingsError 统一处理 settings 接口的 service 层错误
|
// handleSettingsError 统一处理 settings 接口的 service 层错误
|
||||||
func handleSettingsError(c *gin.Context, err error) {
|
func handleSettingsError(c *gin.Context, err error) {
|
||||||
switch err {
|
switch err {
|
||||||
|
|||||||
@ -9,8 +9,9 @@ import (
|
|||||||
func SecurityHeaders() gin.HandlerFunc {
|
func SecurityHeaders() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Content-Security-Policy:仅允许本站资源 + 内联样式/脚本
|
// Content-Security-Policy:仅允许本站资源 + 内联样式/脚本
|
||||||
|
// img-src 允许 data: URI 用于头像裁切预览等场景
|
||||||
c.Header("Content-Security-Policy",
|
c.Header("Content-Security-Policy",
|
||||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:")
|
||||||
|
|
||||||
// 禁止 MIME 类型嗅探
|
// 禁止 MIME 类型嗅探
|
||||||
c.Header("X-Content-Type-Options", "nosniff")
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
|||||||
@ -27,7 +27,8 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
|||||||
authService := service.NewAuthService(userRepo, tokenSvc, cfg)
|
authService := service.NewAuthService(userRepo, tokenSvc, cfg)
|
||||||
rateLimiter := middleware.NewRateLimiter()
|
rateLimiter := middleware.NewRateLimiter()
|
||||||
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
|
authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg)
|
||||||
settingsCtrl := controller.NewSettingsController(authService)
|
avatarSvc := service.NewAvatarService(userRepo)
|
||||||
|
settingsCtrl := controller.NewSettingsController(authService, avatarSvc)
|
||||||
|
|
||||||
authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
|
authMdw := middleware.NewAuthMiddleware(cfg, userRepo)
|
||||||
|
|
||||||
@ -78,6 +79,7 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) {
|
|||||||
settingsAPI.Use(middleware.CSRF(cfg))
|
settingsAPI.Use(middleware.CSRF(cfg))
|
||||||
{
|
{
|
||||||
settingsAPI.PUT("/profile", settingsCtrl.UpdateProfile)
|
settingsAPI.PUT("/profile", settingsCtrl.UpdateProfile)
|
||||||
|
settingsAPI.POST("/avatar", settingsCtrl.UploadAvatar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 管理后台 SSR 页面(认证失败 302 跳首页) ---
|
// --- 管理后台 SSR 页面(认证失败 302 跳首页) ---
|
||||||
|
|||||||
213
internal/service/avatar_service.go
Normal file
213
internal/service/avatar_service.go
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/chai2010/webp"
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 头像处理常量
|
||||||
|
const (
|
||||||
|
avatarMaxFileSize = 5 * 1024 * 1024 // 5MB
|
||||||
|
avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升
|
||||||
|
avatarMinHeight = 128
|
||||||
|
avatarMaxWidth = 3840
|
||||||
|
avatarMaxHeight = 2160
|
||||||
|
avatarOutputSize = 512
|
||||||
|
avatarTargetKB = 100
|
||||||
|
avatarStorageDir = "storage/uploads/avatars"
|
||||||
|
)
|
||||||
|
|
||||||
|
// userAvatarStore 头像服务所需的最小仓储接口
|
||||||
|
type userAvatarStore interface {
|
||||||
|
FindByID(id uint) (*model.User, error)
|
||||||
|
Update(user *model.User) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvatarService 头像上传处理服务
|
||||||
|
type AvatarService struct {
|
||||||
|
userRepo userAvatarStore
|
||||||
|
storageDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAvatarService 构造函数
|
||||||
|
func NewAvatarService(userRepo userAvatarStore) *AvatarService {
|
||||||
|
return &AvatarService{userRepo: userRepo, storageDir: avatarStorageDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessAvatar 处理头像上传流程
|
||||||
|
// cropX, cropY, cropSize: 自定义裁切参数(原图像素坐标),全 0 则默认中心裁切
|
||||||
|
// 返回头像 URL,失败返回错误
|
||||||
|
func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) {
|
||||||
|
// 1. 检查文件类型(不支持 GIF:裁切后无法保持动画)
|
||||||
|
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
|
||||||
|
return "", fmt.Errorf("仅支持 JPEG、PNG、WebP 格式")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 读取全部字节并校验大小
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, avatarMaxFileSize+1))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("读取文件失败")
|
||||||
|
}
|
||||||
|
if len(data) > avatarMaxFileSize {
|
||||||
|
return "", fmt.Errorf("文件大小不能超过 5MB")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 解码图片
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("图片解码失败:%v", err)
|
||||||
|
}
|
||||||
|
bounds := img.Bounds()
|
||||||
|
w, h := bounds.Dx(), bounds.Dy()
|
||||||
|
if w > avatarMaxWidth || h > avatarMaxHeight {
|
||||||
|
return "", fmt.Errorf("图片分辨率不能超过 3840x2160")
|
||||||
|
}
|
||||||
|
if w < avatarMinWidth || h < avatarMinHeight {
|
||||||
|
return "", fmt.Errorf("图片分辨率不能低于 128x128")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 裁切为正方形(优先使用自定义裁切参数)
|
||||||
|
var cropped image.Image
|
||||||
|
if cropSize > 0 && cropX >= 0 && cropY >= 0 &&
|
||||||
|
cropX+cropSize <= w && cropY+cropSize <= h {
|
||||||
|
cropped = subImage(img, cropX, cropY, cropSize)
|
||||||
|
} else {
|
||||||
|
cropped = centerCrop(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 缩放至 512x512(CatmullRom 高质量)
|
||||||
|
resized := image.NewNRGBA(image.Rect(0, 0, avatarOutputSize, avatarOutputSize))
|
||||||
|
draw.CatmullRom.Scale(resized, resized.Bounds(), cropped, cropped.Bounds(), draw.Over, nil)
|
||||||
|
|
||||||
|
// 6. 编码为 WebP,目标 ≤100KB
|
||||||
|
webpData, err := encodeWebP(resized, avatarTargetKB*1024)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("图片编码失败:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 确保存储目录存在
|
||||||
|
if err := os.MkdirAll(s.storageDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("创建存储目录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 删除该用户的旧头像,仅保留最新
|
||||||
|
s.cleanOldAvatars(userID)
|
||||||
|
|
||||||
|
// 9. 原子写入:先写临时文件,再 rename 到最终路径
|
||||||
|
ts := time.Now().Unix()
|
||||||
|
filename := fmt.Sprintf("%d_%d.webp", userID, ts)
|
||||||
|
tmpPath := filepath.Join(s.storageDir, filename+".tmp")
|
||||||
|
finalPath := filepath.Join(s.storageDir, filename)
|
||||||
|
if err := os.WriteFile(tmpPath, webpData, 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("写入临时文件失败")
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("保存文件失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. 更新用户头像字段
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("用户不存在")
|
||||||
|
}
|
||||||
|
avatarURL := fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts)
|
||||||
|
user.Avatar = avatarURL
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return "", fmt.Errorf("更新头像失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return avatarURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传)
|
||||||
|
func (s *AvatarService) cleanOldAvatars(userID uint) {
|
||||||
|
prefix := strconv.FormatUint(uint64(userID), 10) + "_"
|
||||||
|
entries, err := os.ReadDir(s.storageDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ".webp") {
|
||||||
|
os.Remove(filepath.Join(s.storageDir, e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subImage 从原图中裁切指定正方形区域
|
||||||
|
func subImage(img image.Image, x, y, size int) image.Image {
|
||||||
|
cropped := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||||
|
draw.Draw(cropped, cropped.Bounds(), img, image.Point{x, y}, draw.Src)
|
||||||
|
return cropped
|
||||||
|
}
|
||||||
|
|
||||||
|
// centerCrop 中心裁切为正方形(取 min(width, height))
|
||||||
|
func centerCrop(img image.Image) image.Image {
|
||||||
|
b := img.Bounds()
|
||||||
|
w, h := b.Dx(), b.Dy()
|
||||||
|
size := w
|
||||||
|
if h < size {
|
||||||
|
size = h
|
||||||
|
}
|
||||||
|
ox := (w - size) / 2
|
||||||
|
oy := (h - size) / 2
|
||||||
|
cropped := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||||
|
draw.Draw(cropped, cropped.Bounds(), img, image.Point{ox, oy}, draw.Src)
|
||||||
|
return cropped
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeWebP 编码为 WebP,通过二分查找质量参数使文件大小接近 targetBytes
|
||||||
|
func encodeWebP(img image.Image, targetBytes int) ([]byte, error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
// 如果图片非常简单(如纯色),即使 quality=1 也可能很小
|
||||||
|
if err := webp.Encode(buf, img, &webp.Options{Quality: 80}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if buf.Len() <= targetBytes {
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二分查找合适的质量参数
|
||||||
|
low, high := 1, 80
|
||||||
|
var best []byte
|
||||||
|
for low <= high {
|
||||||
|
mid := (low + high) / 2
|
||||||
|
buf.Reset()
|
||||||
|
if err := webp.Encode(buf, img, &webp.Options{Quality: float32(mid)}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if buf.Len() <= targetBytes {
|
||||||
|
best = make([]byte, buf.Len())
|
||||||
|
copy(best, buf.Bytes())
|
||||||
|
low = mid + 1 // 尝试更高画质
|
||||||
|
} else {
|
||||||
|
high = mid - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best != nil {
|
||||||
|
return best, nil
|
||||||
|
}
|
||||||
|
// 降到底仍超限,返回最低质量
|
||||||
|
buf.Reset()
|
||||||
|
if err := webp.Encode(buf, img, &webp.Options{Quality: 1}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
@ -46,11 +46,17 @@
|
|||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">头像</span>
|
<span class="info-label">头像</span>
|
||||||
<span class="info-value">
|
<span class="info-value">
|
||||||
{{if .User.Avatar}}
|
<div class="avatar-upload-wrap" id="avatarWrap">
|
||||||
<img src="{{.User.Avatar}}" alt="avatar" class="avatar-preview">
|
{{if .User.Avatar}}
|
||||||
{{else}}
|
<img src="{{.User.Avatar}}?v={{.User.UpdatedAt.Unix}}" alt="avatar" class="avatar-preview" id="avatarPreview">
|
||||||
<span class="info-muted">未设置</span>
|
{{else}}
|
||||||
{{end}}
|
<div class="avatar-placeholder" id="avatarPreview">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="24" height="24"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<span class="avatar-hint">点击更换</span>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="avatarInput" class="avatar-input-hidden" accept="image/jpeg,image/png,image/webp">
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
@ -109,6 +115,24 @@
|
|||||||
|
|
||||||
{{template "layout/footer.html" .}}
|
{{template "layout/footer.html" .}}
|
||||||
|
|
||||||
|
<!-- 头像裁切弹窗 -->
|
||||||
|
<div class="crop-overlay" id="cropOverlay">
|
||||||
|
<div class="crop-dialog">
|
||||||
|
<div class="crop-header">调整裁切范围</div>
|
||||||
|
<div class="crop-stage" id="cropStage">
|
||||||
|
<img id="cropImageEl" draggable="false">
|
||||||
|
<div class="crop-mask" id="cropMask"></div>
|
||||||
|
<div class="crop-frame" id="cropFrame">
|
||||||
|
<div class="crop-grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="crop-footer">
|
||||||
|
<button class="crop-btn crop-btn-cancel" id="cropCancelBtn">取消</button>
|
||||||
|
<button class="crop-btn crop-btn-confirm" id="cropConfirmBtn">确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/common.js"></script>
|
<script src="/static/js/common.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
@ -214,6 +238,228 @@
|
|||||||
};
|
};
|
||||||
xhr.send(JSON.stringify({ username: username, bio: bio }));
|
xhr.send(JSON.stringify({ username: username, bio: bio }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- 头像裁切上传 ----
|
||||||
|
var avatarWrap = document.getElementById('avatarWrap');
|
||||||
|
var avatarInput = document.getElementById('avatarInput');
|
||||||
|
var avatarPreview = document.getElementById('avatarPreview');
|
||||||
|
var cropOverlay = document.getElementById('cropOverlay');
|
||||||
|
var cropStage = document.getElementById('cropStage');
|
||||||
|
var cropImageEl = document.getElementById('cropImageEl');
|
||||||
|
var cropFrame = document.getElementById('cropFrame');
|
||||||
|
var cropMask = document.getElementById('cropMask');
|
||||||
|
var cropCancelBtn = document.getElementById('cropCancelBtn');
|
||||||
|
var cropConfirmBtn = document.getElementById('cropConfirmBtn');
|
||||||
|
|
||||||
|
var pendingFile = null;
|
||||||
|
var imgNW, imgNH; // 原图像素尺寸
|
||||||
|
var imgDW, imgDH; // 图片缩放后显示尺寸(zoom=1 基准)
|
||||||
|
var zoom = 1; // 图片缩放系数 1.0 ~ 3.0
|
||||||
|
var panX = 0, panY = 0; // 图片平移偏移量
|
||||||
|
var fSize, fLeft, fTop; // 裁切框的固定屏幕像素:边长、左、顶
|
||||||
|
|
||||||
|
// 更新裁切框固定参数(图片加载时计算一次,之后不变)
|
||||||
|
function initFrame() {
|
||||||
|
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
|
||||||
|
fSize = Math.min(imgDW, imgDH); // 短边长度填满可见区域
|
||||||
|
fLeft = Math.round((sw - fSize) / 2);
|
||||||
|
fTop = Math.round((sh - fSize) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 约束 panX/panY,确保裁切框始终在图片范围内
|
||||||
|
function clampPan() {
|
||||||
|
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
|
||||||
|
var iw = imgDW * zoom, ih = imgDH * zoom;
|
||||||
|
var cx = Math.round((sw - iw) / 2); // 图片居中时的基准偏移
|
||||||
|
var cy = Math.round((sh - ih) / 2);
|
||||||
|
// 图片左边界必须在框左边之左:cx + panX <= fLeft → panX <= fLeft - cx
|
||||||
|
// 图片右边界必须在框右边之右:cx + panX + iw >= fLeft + fSize → panX >= fLeft + fSize - cx - iw
|
||||||
|
panX = Math.max(fLeft + fSize - cx - iw, Math.min(panX, fLeft - cx));
|
||||||
|
panY = Math.max(fTop + fSize - cy - ih, Math.min(panY, fTop - cy));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCrop() {
|
||||||
|
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
|
||||||
|
var iw = imgDW * zoom, ih = imgDH * zoom;
|
||||||
|
var ox = Math.round((sw - iw) / 2) + panX;
|
||||||
|
var oy = Math.round((sh - ih) / 2) + panY;
|
||||||
|
|
||||||
|
cropImageEl.style.width = iw + 'px';
|
||||||
|
cropImageEl.style.height = ih + 'px';
|
||||||
|
cropImageEl.style.left = ox + 'px';
|
||||||
|
cropImageEl.style.top = oy + 'px';
|
||||||
|
|
||||||
|
// 裁切框固定不变
|
||||||
|
cropFrame.style.left = fLeft + 'px';
|
||||||
|
cropFrame.style.top = fTop + 'px';
|
||||||
|
cropFrame.style.width = fSize + 'px';
|
||||||
|
cropFrame.style.height = fSize + 'px';
|
||||||
|
|
||||||
|
cropMask.style.left = fLeft + 'px';
|
||||||
|
cropMask.style.top = fTop + 'px';
|
||||||
|
cropMask.style.width = fSize + 'px';
|
||||||
|
cropMask.style.height = fSize + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCrop(file) {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function (e) {
|
||||||
|
cropImageEl.src = e.target.result;
|
||||||
|
cropImageEl.onload = function () {
|
||||||
|
imgNW = cropImageEl.naturalWidth;
|
||||||
|
imgNH = cropImageEl.naturalHeight;
|
||||||
|
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
|
||||||
|
var fit = Math.min(sw / imgNW, sh / imgNH);
|
||||||
|
imgDW = Math.round(imgNW * fit);
|
||||||
|
imgDH = Math.round(imgNH * fit);
|
||||||
|
zoom = 1;
|
||||||
|
panX = 0;
|
||||||
|
panY = 0;
|
||||||
|
initFrame();
|
||||||
|
renderCrop();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
cropOverlay.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCrop() {
|
||||||
|
cropOverlay.classList.remove('active');
|
||||||
|
avatarInput.value = '';
|
||||||
|
pendingFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将裁切框映射回原图像素坐标
|
||||||
|
function getCropParams() {
|
||||||
|
var sw = cropStage.clientWidth, sh = cropStage.clientHeight;
|
||||||
|
var iw = imgDW * zoom, ih = imgDH * zoom;
|
||||||
|
var ox = Math.round((sw - iw) / 2) + panX;
|
||||||
|
var oy = Math.round((sh - ih) / 2) + panY;
|
||||||
|
// 裁切框中心在 zoom=1 图片坐标中的位置
|
||||||
|
var cx = (fLeft + fSize / 2 - ox) / zoom;
|
||||||
|
var cy = (fTop + fSize / 2 - oy) / zoom;
|
||||||
|
var cs = fSize / zoom; // zoom=1 坐标中的裁切边长
|
||||||
|
var sx = imgNW / imgDW, sy = imgNH / imgDH;
|
||||||
|
return {
|
||||||
|
x: Math.round((cx - cs / 2) * sx),
|
||||||
|
y: Math.round((cy - cs / 2) * sy),
|
||||||
|
size: Math.round(cs * sx)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 图片平移(点击拖拽 stage 任意位置) ----
|
||||||
|
var panning = false, panStartX = 0, panStartY = 0, panInitX = 0, panInitY = 0;
|
||||||
|
|
||||||
|
function startPan(clientX, clientY) {
|
||||||
|
panning = true;
|
||||||
|
panStartX = clientX;
|
||||||
|
panStartY = clientY;
|
||||||
|
panInitX = panX;
|
||||||
|
panInitY = panY;
|
||||||
|
cropStage.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePan(clientX, clientY) {
|
||||||
|
panX = panInitX + (clientX - panStartX);
|
||||||
|
panY = panInitY + (clientY - panStartY);
|
||||||
|
clampPan();
|
||||||
|
renderCrop();
|
||||||
|
}
|
||||||
|
|
||||||
|
cropStage.addEventListener('mousedown', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
startPan(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
document.addEventListener('mousemove', function (e) {
|
||||||
|
if (!panning) return;
|
||||||
|
movePan(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', function () {
|
||||||
|
panning = false;
|
||||||
|
cropStage.style.cursor = 'grab';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触屏平移
|
||||||
|
cropStage.addEventListener('touchstart', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var t = e.touches[0];
|
||||||
|
startPan(t.clientX, t.clientY);
|
||||||
|
});
|
||||||
|
document.addEventListener('touchmove', function (e) {
|
||||||
|
if (!panning) return;
|
||||||
|
var t = e.touches[0];
|
||||||
|
movePan(t.clientX, t.clientY);
|
||||||
|
}, { passive: true });
|
||||||
|
document.addEventListener('touchend', function () {
|
||||||
|
panning = false;
|
||||||
|
cropStage.style.cursor = 'grab';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 滚轮缩放 ----
|
||||||
|
cropStage.addEventListener('wheel', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (panning) return;
|
||||||
|
var newZoom = Math.max(1, Math.min(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1), 3));
|
||||||
|
if (newZoom === zoom) return;
|
||||||
|
zoom = newZoom;
|
||||||
|
clampPan();
|
||||||
|
renderCrop();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
cropCancelBtn.addEventListener('click', closeCrop);
|
||||||
|
cropOverlay.addEventListener('click', function (e) {
|
||||||
|
if (e.target === cropOverlay) closeCrop();
|
||||||
|
});
|
||||||
|
|
||||||
|
cropConfirmBtn.addEventListener('click', function () {
|
||||||
|
if (!pendingFile) return;
|
||||||
|
var params = getCropParams();
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('avatar', pendingFile);
|
||||||
|
formData.append('crop_x', params.x);
|
||||||
|
formData.append('crop_y', params.y);
|
||||||
|
formData.append('crop_size', params.size);
|
||||||
|
|
||||||
|
cropConfirmBtn.disabled = true;
|
||||||
|
cropConfirmBtn.textContent = '处理中...';
|
||||||
|
showSettingsToast('上传中...');
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/api/settings/avatar', true);
|
||||||
|
xhr.onreadystatechange = function () {
|
||||||
|
if (xhr.readyState !== 4) return;
|
||||||
|
cropConfirmBtn.disabled = false;
|
||||||
|
cropConfirmBtn.textContent = '确认';
|
||||||
|
closeCrop();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = JSON.parse(xhr.responseText);
|
||||||
|
if (res.success) {
|
||||||
|
showSettingsToast('头像已更新');
|
||||||
|
if (avatarPreview.tagName === 'IMG') {
|
||||||
|
avatarPreview.src = res.data.url + '?t=' + Date.now();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showSettingsToast(res.message || '上传失败', true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showSettingsToast('上传失败', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (avatarWrap && avatarInput) {
|
||||||
|
avatarWrap.addEventListener('click', function () {
|
||||||
|
avatarInput.click();
|
||||||
|
});
|
||||||
|
avatarInput.addEventListener('change', function () {
|
||||||
|
var file = avatarInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
pendingFile = file;
|
||||||
|
openCrop(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -261,13 +261,179 @@ body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Avatar Preview ---------- */
|
/* ---------- Avatar Upload ---------- */
|
||||||
|
.avatar-upload-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-wrap:hover .avatar-hint {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-wrap:hover .avatar-preview,
|
||||||
|
.avatar-upload-wrap:hover .avatar-placeholder {
|
||||||
|
opacity: .7;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-preview {
|
.avatar-preview {
|
||||||
width: 40px;
|
width: 56px;
|
||||||
height: 40px;
|
height: 56px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 2px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
|
transition: opacity .15s ease, border-color .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: 2px dashed var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #bbb;
|
||||||
|
transition: opacity .15s ease, border-color .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-hint {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-input-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Crop Modal ---------- */
|
||||||
|
.crop-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, .4);
|
||||||
|
z-index: 2000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-dialog {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 94vw;
|
||||||
|
max-height: 94vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, .15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-header {
|
||||||
|
padding: .75rem 1.25rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-stage {
|
||||||
|
position: relative;
|
||||||
|
width: min(85vw, 480px);
|
||||||
|
height: min(85vw, 480px);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #edf0f4;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-stage img {
|
||||||
|
position: absolute;
|
||||||
|
max-width: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 裁切框外的半透明暗色遮罩 */
|
||||||
|
.crop-mask {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, .45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 裁切框白色边框(纯视觉,不可交互) */
|
||||||
|
.crop-frame {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格参考线 */
|
||||||
|
.crop-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, rgba(255, 255, 255, .3) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(255, 255, 255, .3) 1px, transparent 1px);
|
||||||
|
background-size: 33.33% 33.33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: .75rem;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-btn {
|
||||||
|
padding: .45rem 1.25rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background .15s ease, opacity .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-btn:disabled {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-btn-cancel {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-btn-cancel:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-btn-confirm {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-btn-confirm:hover {
|
||||||
|
background: #2980b9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Role Tag ---------- */
|
/* ---------- Role Tag ---------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user