4 Commits
24 changed files with 1259 additions and 464 deletions
+210 -358
View File
@@ -2,9 +2,9 @@
> **读者**:前端开发者。本文档覆盖 API 的全部端点:请求参数、响应结构、可能出现的错误与已知坑点。
>
> **事实来源**:本文档的字段名、约束与错误消息以 `src/schema/*.ts`(zod 校验定义)与 `src/router/*.ts`(路由实现)为最终依据整理。若文档与代码不一致,以代码为准。
> **事实来源**:业务接口以 `src/schema/*.ts` 与 `src/router/*.ts` 为准;认证以 `src/auth.ts` 中的 Better Auth 配置、`src/index.ts` 中的挂载路径及 `src/middleware/auth.ts` 中的业务鉴权规则为准。若文档与代码不一致,以代码为准。
>
> **重要免责**:错误消息文案(`error` 字段的中文内容)可能随时调整。**前端逻辑只能依赖 HTTP 状态码,严禁匹配错误消息字符串**。文档中列出消息原文仅供调试对照。
> **重要免责**:错误消息文案可能调整。**前端逻辑应依赖 HTTP 状态码及需要时的机器可读错误码,严禁匹配错误消息字符串**。文档中列出消息原文仅供调试对照。
## 目录
@@ -29,25 +29,25 @@
| 本地开发 | `http://localhost:3000`(`vc dev` 启动) |
| 生产 | 以部署地址为准 |
除 `/image` 成功响应直接返回图片字节外,响应均为 `application/json`(个别异常情形返回纯文本,见 2.2)。所有响应携带两个调试响应头:
业务接口除 `/image` 成功响应直接返回图片字节外,响应通常为 `application/json`(个别异常情形返回纯文本,见 2.2)。Better Auth 的 `/auth/*` 有独立响应格式,部分操作也可能重定向,见第 4 章。响应携带两个调试响应头:
- `X-Request-Id`:请求唯一 ID,反馈问题时请附上
- `X-Response-Time`:服务端处理耗时,如 `12ms`
### 1.2 命名与格式
- 请求与响应字段一律 **snake_case**(如 `cloud_id`、`page_size`、`received_like_count`)
- 业务接口使用 **snake_case**(如 `cloud_id`、`page_size`);Better Auth 的 `/auth/*` 接口使用其原生 **camelCase** 字段
- 日期时间一律为 **ISO 8601 字符串**(如 `2026-08-15T08:30:00.000Z`),可空的日期字段值为 `null`。唯一的例外见 2.3 第 5 条
- UUID 字段为标准 UUID 字符串
### 1.3 响应形态
响应**没有统一包装**,分两种形态:
业务成功响应**没有统一包装**,分两种形态;`/auth/*` 不使用下表中的业务信封:
| 场景 | 形态 | 示例 |
|---|---|---|
| 写操作成功(POST/PATCH/DELETE 的动作类端点) | 信封对象 | `{ "status_code": 200, "message": "删除成功" }` |
| 读操作成功(GET 及登录) | 裸数据 | 单资源为对象、列表为**数组**、登录为 `{ "access_token" }` |
| 读操作成功(业务 GET) | 裸数据 | 单资源为对象、列表为**数组** |
### 1.4 分页
@@ -60,7 +60,7 @@
⚠️ **响应不返回总数**,也没有 `has_more` 字段。判断「是否还有下一页」只能依靠「返回数组长度 < `page_size`」。超出数据范围的页返回 `200` + 空数组 `[]`,不是 404。
云图列表排序为 `uploaded_at` 倒序(同刻按 `id` 倒序);点赞列表按点赞时间倒序。
云图列表排序为 `uploaded_at` 倒序(同刻按 `id` 倒序);点赞列表按点赞时间倒序(同刻按云图 ID 倒序)。
### 1.5 共享数据模型
@@ -92,7 +92,7 @@
| `id` | uuid | 用户 ID |
| `name` | string | 用户名 |
| `email` | string | 邮箱(注意 2.3 第 7 条的暴露范围问题) |
| `avatar_id` | uuid\|null | 头像 ID(当前恒为 `null`,头像功能未上线) |
| `image` | string\|null | 用户头像 URL,由 Better Auth 管理;未设置时为 `null` |
| `cloud_count` | int | 云图数(该用户上传的云图总数) |
| `received_like_count` | int | 收到点赞数(名下云图获赞总和) |
| `last_online` | string\|null | 最后在线时间(ISO 8601) |
@@ -125,7 +125,7 @@
### 2.1 两种错误响应形状
本 API 存在**两种**错误响应形状,前端必须分别处理:
业务接口存在**两种**错误响应形状,前端必须分别处理。`/auth/*` 使用 Better Auth 自己的错误格式,见 3.5。
**① 业务错误**(绝大多数错误)——ErrorMessage 信封:
@@ -157,14 +157,14 @@
| 状态码 | 含义 | 前端通用动作 |
|---|---|---|
| 200 | 成功 | — |
| 201 | 创建成功(注册、上传云图) | — |
| 201 | 业务创建成功(如上传云图、管理员创建用户) | — |
| 304 | 图片 ETag 未变化,无响应 body | 继续使用本地缓存 |
| 400 | 参数校验失败(形状②),或业务规则拒绝(形状①,如「新密码不能与当前密码相同」) | 检查参数;形状②可解析 `error.message` 定位字段 |
| 401 | 未认证:未携带 token,或 token 无效/过期/已失效 | 清除本地登录态,跳转登录页 |
| 403 | 已认证但无权限:角色不足、邮箱未确认、帐号被禁用、越权访问他人资源 | 按消息场景提示 |
| 400 | 参数校验失败(形状②),或业务规则拒绝(形状①) | 检查参数;形状②可解析 `error.message` 定位字段 |
| 401 | 业务接口没有有效 Better Auth 会话,或邮箱未验证、帐号被禁用 | 重新获取会话,必要时引导登录或验证邮箱 |
| 403 | 业务接口已认证但权限不足,如角色不足或越权访问他人资源 | 按场景提示 |
| 404 | 资源不存在(形状①);**也可能是纯文本**(见下方警告) | 区分 content-type 后处理 |
| 405 | HTTP 方法不受端点支持 | 读取 `Allow` 响应头 |
| 409 | 唯一性冲突:邮箱/用户名已被占用 | 提示用户更换 |
| 409 | 部分业务接口发生唯一性冲突,如管理员创建用户或修改用户名 | 提示用户更换;普通注册见 3.5 |
| 413 | 上传请求体超过 21 MiB | 提示压缩图片后重试 |
| 500 | 服务器内部错误 | 提示稍后重试,反馈时附 `X-Request-Id` |
| 503 | 图片存储服务暂时不可用 | 稍后重试,反馈时附 `X-Request-Id` |
@@ -176,341 +176,126 @@
以下是文档写作时核实的现状描述,**未来可能修复**。前端如需为之写防御代码,请做好移除准备。
1. **两种错误形状并存**:400 校验错误不是业务错误信封(见 2.1)。
2. **部分 404 是纯文本而非 JSON**,来源有三:(a) 未实现的占位端点(各章已标注);(b) `GET /profile/me` 与 `GET /admin/users` 的数据库异常会从空 `catch` 落到纯文本 404;(c) 未匹配任何路由的路径。注册与邮箱确认失败始终返回 JSON 信封。
3. **静默续签只写 Cookie**:token 到期前的自动续签仅通过 `Set-Cookie` 下发新 token(见 3.4)。使用 Bearer header 模式的客户端**拿不到新 token**,30 分钟后必然 401,需重新登录。
4. **`POST /admin/user` 是假端点**:它原样回显请求体(**含明文密码**),并不创建用户。
2. **部分 404 是纯文本而非 JSON**,来源有三:(a) 未实现的占位端点(各章已标注);(b) `GET /profile/me` 与 `GET /admin/users` 的数据库异常会从空 `catch` 落到纯文本 404;(c) 未匹配任何路由的路径。Better Auth 认证端点使用自己的错误格式。
3. **认证端点使用 Better Auth 格式**:`/auth/*` 的字段、状态码与错误响应由 Better Auth 定义,不能用业务错误信封解析;见第 4 章。
4. **管理员创建用户需验证邮箱**:`POST /admin/user` 会通过 Better Auth 创建用户并尝试发送验证邮件;验证前不能登录。
5. **`GET /info/cloudtype`(列表)的 `created_at` 格式与其他端点不同**:是 PostgreSQL 原生字符串(如 `2026-08-15 08:30:00.123456+00`),而非 ISO 8601(`2026-08-15T08:30:00.000Z`)。详情端点 `/info/cloudtype/:id` 及其他所有端点均为 ISO 8601。前端解析该字段时需兼容两种格式。
6. **用户名/邮箱长度不在 API 层校验**:数据库限制用户名 ≤ 16 字符、邮箱 ≤ 64 字符,但 zod 校验不拦截。超长注册与超长改名(`PATCH /profile/me`)会返回 JSON 500。前端应自行限制输入长度。
6. **用户名/邮箱数据库长度限制**:用户名 ≤ 16 字符、邮箱 ≤ 64 字符;前端应限制输入长度。
7. **`GET /profile/:user_id` 向任意登录用户暴露对方邮箱**:任何登录用户都能查到任意用户的 `email`。前端展示他人资料时不应展示邮箱,也不应假设该接口未来仍返回邮箱。
8. **用户名唯一性的大小写规则不一致**:注册时的查重是大小写不敏感的(`Alice`/`alice` 视为重复),但数据库唯一约束与 `PATCH /profile/me` 改名是大小写敏感的(可改成仅大小写不同的名字)。
8. **用户名大小写**:数据库唯一约束区分大小写,注册和改名均受该约束。
---
## 3. 认证
### 3.1 机制概览
### 3.1 从旧认证接口迁移
- JWT(HS256),有效期 **30 分钟**
- 获取方式:`POST /auth/login` 成功后在响应体返回 `access_token`,**同时**通过 `Set-Cookie` 写入 HttpOnly Cookie
- 受保护端点接受两种携带方式(服务端优先读 header):
1. 请求头 `Authorization: Bearer <access_token>`
2. Cookie `token=<jwt>`(`HttpOnly; SameSite=Strict; Path=/; Max-Age=1800`;非开发/测试环境附加 `Secure`)
后端现在由 Better Auth 处理注册、登录、邮箱验证、会话和密码。旧认证路由已移除;请替换所有旧请求与响应解析:
### 3.2 Bearer 还是 Cookie?
| 模式 | 适用 | 注意事项 |
| 旧接口 | 当前接口 / 前端动作 | 主要变化 |
|---|---|---|
| Cookie | 浏览器前端(推荐) | 登录后浏览器自动携带;`fetch` 需设 `credentials: "include"`;HttpOnly 使 JS 无法读取 token,天然防 XSS 窃取;可享受静默续签 |
| Bearer | 非浏览器客户端(App、脚本) | 自行存储 `access_token` 并注入 header;**无法静默续签**(2.3 第 3 条),30 分钟后需重新登录 |
| `POST /auth/register` | `POST /auth/sign-up/email` | 请求仍为 `name`、`email`、`password`;成功为 Better Auth 的 `200`,不会自动登录 |
| `POST /auth/login` | `POST /auth/sign-in/email` | 使用 Better Auth 会话 Cookie,不再保存旧 JWT |
| `POST /auth/logout` | `POST /auth/sign-out` | 撤销当前会话;不要只清除前端状态 |
| `POST /auth/resend-confirmation` | `POST /auth/send-verification-email` | 请求 `{ "email": "..." }` |
| `POST /auth/confirm-email` | `GET /auth/verify-email?token=...` | token 从前端验证页的 URL 读取,放入查询参数 |
| `POST /auth/forgot-password` | `POST /auth/request-password-reset` | 请求 `{ "email": "..." }` |
| `POST /auth/reset-password` | `POST /auth/reset-password` | 路径相同,请求改为 `{ "token": "...", "newPassword": "..." }`;不再提交 `email` / `new_password` |
| `PATCH /auth/password` | `POST /auth/change-password` | 请求改为 `currentPassword`、`newPassword`,可选 `revokeOtherSessions` |
### 3.3 CORS
旧路由的 `status_code` / `message` 信封、JWT 及认证相关的 snake_case 请求字段不适用于新端点。普通注册不要提交 `role`:角色由服务端设置为 `user`;创建管理员用户走第 8 章的管理接口。业务接口的路径与 snake_case 字段仍按后续章节使用。
- 服务端仅放行**单个** origin(环境变量 `CORS_ORIGIN`,本地默认应为 `http://localhost:5173`),且 `credentials: true`
- 前端本地开发的服务端口/origin 必须与后端配置**完全一致**(协议、主机、端口),否则 Cookie 模式的请求会被浏览器拦截
- 放行的请求头:`Content-Type`、`Authorization`
- 私有图片通过 `<img src>` 直接加载时依赖 Cookie;生产环境前端与 API 必须位于同一 site(例如 `app.example.com` 与 `api.example.com`),否则 `SameSite=Strict` Cookie 不会随图片请求发送
### 3.2 浏览器接入
### 3.4 有效期、续签与失效
前端安装与后端当前版本一致的 `better-auth@1.7.6`,创建客户端时将 **API 地址连同 `/auth` 路径**传入 `baseURL`。服务端挂载路径是 `/auth`,不是 Better Auth 默认的 `/api/auth`;不要把业务 API 根地址直接用作认证客户端的完整 `baseURL`。下面是框架无关的示例;React 可从 `better-auth/react` 导入 `createAuthClient`。这与 [Better Auth 客户端的自定义路径配置](https://better-auth.com/docs/concepts/client)一致。
- token 有效期 30 分钟(Cookie 的 `Max-Age` 同为 1800 秒)
- **静默续签**:携带剩余有效期 ≤ 300 秒的 token 访问受保护端点时,服务端会在响应中**追加 `Set-Cookie` 写入新 token**。Cookie 模式下浏览器自动替换,前端无感知;Bearer 模式收不到新 token
- 续签不适用的路径:`POST /auth/logout`、`PATCH /auth/password`
- `/image` 请求只验证身份,不触发静默续签,避免一个页面的并发图片请求重复写 Cookie
- 认证中间件每次请求都会重新检查用户仍已确认邮箱且未被管理员禁用;任一条件不满足时,已有 JWT 也会被拒绝
- **立即失效**的情况:调用 `/auth/logout`、调用 `PATCH /auth/password` 改密、通过 `/auth/reset-password` 重置密码、用户被禁用,以及管理员修改了该用户的角色或密码(`PATCH /admin/users/:user_id` 见 8.3、`PATCH /admin/users/:user_id/password` 见 8.4)。除「用户被禁用」由中间件每次请求重新校验外,其余情况都会递增 `tokenVersion`,使该用户**所有已签发的 token 全部作废**(包括其他设备上的);用户自己改密与密码重置成功时还会写入过期 Cookie
```ts
import { createAuthClient } from "better-auth/client";
### 3.5 认证相关错误消息对照
const API_ORIGIN = "http://localhost:3000"; // 生产环境改成实际 API origin
export const authClient = createAuthClient({
baseURL: `${API_ORIGIN}/auth`,
fetchOptions: { credentials: "include" },
});
```
| 状态码 | 消息原文 | 场景 |
|---|---|---|
| 401 | `未登录` | 受保护端点未携带任何 token |
| 401 | `Token 无效或已过期` | token 签名无效、过期、已被吊销(登出、改密或密码重置后),或用户不再同时满足「邮箱已确认且未禁用」 |
| 403 | `权限不足` | 已登录但角色不满足(如非 admin 访问 `/admin`) |
Better Auth 浏览器客户端默认会携带凭证;上面显式写出该选项,便于与业务请求保持一致。业务接口使用原生 `fetch` 或其他 HTTP 客户端时,也要单独配置:
注意「帐号被禁用」在**登录时**报 403「帐号已被禁用」,但已登录后被禁用,后续请求报的是 401「Token 无效或已过期」。
```ts
const response = await fetch(`${API_ORIGIN}/profile/me`, {
credentials: "include",
});
```
---
浏览器会自动保存并发送 HttpOnly 会话 Cookie。不要尝试在 JavaScript 中读取 Cookie,也不要将登录响应中的 token 当作旧 JWT 存储。服务端 `CORS_ORIGIN` 必须与前端 origin(协议、域名、端口)精确匹配;生产环境 Cookie 为 `SameSite=None; Secure`,本地开发为 `SameSite=Lax`。`BETTER_AUTH_URL` 是后端公开地址;前端客户端仍需使用上面含 `/auth` 的 URL。跨源接入和凭证设置也见 [Better Auth 的 Hono 集成文档](https://better-auth.com/docs/integrations/hono)。
### 3.3 会话与业务权限
登录成功后,使用 `authClient.getSession()` 或对应框架的 `useSession()` 获取会话:
```ts
const { error } = await authClient.signIn.email({ email, password });
if (error) throw error;
const { data: session } = await authClient.getSession();
// session 为 { user, session } 或 null;退出时调用 await authClient.signOut()。
```
需要业务资料、统计及角色时,再请求 `GET /profile/me`,不要将 Better Auth 的 `user` 对象当作第 1.5 节的 UserProfile。完整客户端方法见 [Better Auth 会话管理](https://better-auth.com/docs/concepts/session-management)。
头像使用 Better Auth 的 `user.image` 字段。前端可调用 `authClient.updateUser({ image: "https://example.com/avatar.png" })` 设置头像 URL;业务资料接口和管理员用户列表均返回同一 `image` 值。原 `avatar_id` 字段已移除。
所有受保护的业务接口都再次检查会话、邮箱验证状态、禁用状态及角色。**业务接口**在无会话、邮箱未验证或帐号被禁用时返回 `401`;会话有效但角色或资源权限不足时返回 `403`。管理员修改角色或密码后会撤销目标用户的全部会话,前端应刷新登录状态。
两种可选认证端点行为不同:`GET /cloud/:cloud_id` 对无效/过期凭证按匿名处理;`GET|HEAD /image/:cloud_id/:variant` 在显式提供无效凭证时返回 `401`。同时发送 `Authorization` 和 Cookie 时,业务接口优先使用 Bearer 凭证。
### 3.4 邮件与密码页面
注册后会发送验证邮件,用户需先验证邮箱再登录;验证成功后也不会自动登录。当前邮箱验证 token 有效期为 24 小时,密码重置 token 默认有效期为 1 小时。后端发出的邮件仍指向前端 `/verify-email?token=...` 与 `/reset-password?email=...&token=...` 页面;它们是**前端页面**,不是 API。验证页应将 token 交给 `GET /auth/verify-email?token=...`,重置页应以 token 和新密码调用 `POST /auth/reset-password`。邮件中的 `email` 可用于页面展示,重置请求不需要它。密码重置成功会撤销该用户的全部会话。
重发验证邮件使用 `authClient.sendVerificationEmail({ email })`;申请密码重置使用 `authClient.requestPasswordReset({ email })`。用户不存在时,密码重置申请仍返回通用成功结果,不要据此判断邮箱是否已注册。验证及重置流程见 [Better Auth 邮箱密码文档](https://better-auth.com/docs/authentication/email-password)。
### 3.5 认证错误与非浏览器客户端
Better Auth 端点的错误是其原生结构,通常包含 `code`、`message`;客户端调用返回 `{ data, error }`。按 HTTP 状态、客户端的 `error` 和必要时的 `error.code` 处理,不要匹配英文错误文案,也不要按业务接口的 `status_code` 或 Zod 形状解析。未验证邮箱登录会被 Better Auth 拒绝(当前版本的错误码为 `EMAIL_NOT_VERIFIED`),此时可显示验证页并提供重发邮件操作。
当前配置要求邮箱验证,且注册后不自动登录。Better Auth 对重复邮箱注册可能返回与新邮箱相同的 **200** 响应和临时生成的用户对象;因此注册成功仅表示「请查收验证邮件」,**不能仅凭响应中的 `user.id` 判断账户已创建,也不能将其用作已登录用户 ID**。应在验证后重新登录。该行为来自当前安装的 Better Auth `1.7.6`。
非浏览器客户端可使用已启用的 Bearer 插件。登录成功响应头 `set-auth-token` 暴露会话 token,后续请求使用 `Authorization: Bearer <token>`;这不是旧 JWT,也不是密码重置或邮箱验证 token。前端浏览器仍使用 Cookie。[Better Auth Bearer 插件说明](https://better-auth.com/docs/plugins/bearer)。
## 4. 端点 · 认证 `/auth`
### 4.1 `POST /auth/register` — 注册
以下为本项目已启用的 Better Auth 邮箱密码功能,密码至少 8 字符。请求 JSON 使用原生 **camelCase**,响应不带业务 `status_code` 信封。前端推荐调用第 3.2 节的客户端;直接请求时使用表中方法和路径。Better Auth 还会暴露其他内置或 Admin 插件端点,其输入、权限和响应以 [Better Auth 官方文档](https://better-auth.com/docs)及当前安装版本为准。
- **认证**:无(公开)
**请求体**(JSON):
| 参数 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|
| `name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(2.3 第 6 条) | 用户名,大小写不敏感查重 |
| `email` | string | 是 | 合法邮箱格式;⚠️ 数据库限 ≤ 64 字符 | 服务端去除首尾空白并转为小写后存储 |
| `password` | string | 是 | 无强度校验 | 密码 |
```json
{
"name": "cloudwatcher",
"email": "watcher@example.com",
"password": "s3cret-password"
}
```
**成功响应**:`201`
```json
{
"status_code": 201,
"message": "注册成功,请查收邮件并确认邮箱",
"email_sent": true
}
```
数据库提交成功但邮件暂时未发送时仍返回 `201`,`email_sent` 为 `false`,消息会提示稍后使用重发验证流程。用户、密码凭据和 24 小时有效的确认记录已经保留,不应重试注册。
**错误**:
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
| 操作 | 方法与路径 | Better Auth 客户端方法 | 请求与结果要点 |
|---|---|---|---|
| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 |
| 409 | 已确认邮箱重复注册(大小写不敏感) | `该邮箱已被注册` | 提示直接登录 |
| 409 | 未确认邮箱重复注册 | `该邮箱已被注册,请使用重发验证流程` | 引导至重发验证流程,不会覆盖原用户名或密码 |
| 409 | 用户名已使用(大小写不敏感) | `该用户名已被使用` | 提示换用户名 |
| 500 | 数据库、密码散列或服务端配置异常 | `注册失败` | 稍后重试;若此前已收到 201,不要重复注册 |
| 注册 | `POST /auth/sign-up/email` | `authClient.signUp.email({ name, email, password })` | 成功 `200`,`{ token: null, user }`;发送验证邮件,不建立会话 |
| 登录 | `POST /auth/sign-in/email` | `authClient.signIn.email({ email, password })` | 验证邮箱后才可登录;成功 `200` 并设置会话 Cookie |
| 当前会话 | `GET /auth/get-session` | `authClient.getSession()` | 有效会话返回 `{ user, session }`;无会话返回 `null` |
| 登出当前会话 | `POST /auth/sign-out` | `authClient.signOut()` | 需携带当前 Cookie 或 Bearer;撤销当前会话 |
| 重发验证邮件 | `POST /auth/send-verification-email` | `authClient.sendVerificationEmail({ email })` | `{ email }`;匿名请求对不存在或已验证邮箱也可能返回 `{ status: true }` |
| 确认邮箱 | `GET /auth/verify-email?token=...` | 可用 `fetch` 发 GET | 查询参数 `token`;无 `callbackURL` 时成功返回包含 `status: true` 的 JSON |
| 申请密码重置 | `POST /auth/request-password-reset` | `authClient.requestPasswordReset({ email })` | `{ email }`;返回通用结果,不泄露账户是否存在 |
| 重置密码 | `POST /auth/reset-password` | `authClient.resetPassword({ token, newPassword })` | `{ token, newPassword }`;成功 `{ status: true }`,撤销该用户全部会话 |
| 修改当前密码 | `POST /auth/change-password` | `authClient.changePassword({ currentPassword, newPassword, revokeOtherSessions })` | 需登录;可选择撤销其他会话 |
**注意事项**:
验证邮箱页面的请求示例:
- 新用户的 `is_disabled` 为 `false`,`email_verified_at` 为空;两个状态相互独立。注册后必须先确认邮箱,再调用 `/auth/login`
- 注册响应**不返回 token、不写 Cookie**
- 确认邮件由 `Opencloud <opencloud@catpl.top>` 发出,同时包含 `https://cloud.catpl.top/verify-email?token=...` 链接和原始 token 备用文本。数据库仅保存使用 `JWT_SECRET` 生成的 HMAC 摘要
- ⚠️ 并发注册(同一邮箱/用户名几乎同时提交)命中数据库唯一约束时,冲突消息文案与上方表格不同(邮箱冲突会返回 `该邮箱已被注册;若尚未验证,请使用重发验证流程`)。按本文档免责声明,前端仍只依据状态码 `409` 处理
```ts
const token = new URLSearchParams(location.search).get("token");
if (!token) throw new Error("缺少验证 token");
### 4.2 `POST /auth/resend-confirmation` — 重发确认邮件
- **认证**:无(公开)
**请求体**(JSON):
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `email` | string | 是 | 与注册、登录相同:服务端去除首尾空白并转为小写;不接受旧字段 `user_email` |
```json
{
"email": "watcher@example.com"
}
const response = await fetch(
`${API_ORIGIN}/auth/verify-email?token=${encodeURIComponent(token)}`,
{ credentials: "include" },
);
if (!response.ok) throw new Error("邮箱验证失败");
// 验证成功后引导用户登录;当前配置不会自动创建会话。
```
**成功响应**:`200`
上面的 `API_ORIGIN` 与第 3.2 节相同。直接调用 `GET /auth/verify-email` 时不需要旧接口的 JSON 请求体;若传入 `callbackURL`,Better Auth 可能改为重定向,前端应按重定向处理。
```json
{
"status_code": 200,
"message": "如果该邮箱符合条件,我们将发送验证邮件"
}
```
未知邮箱、已确认邮箱、被管理员禁用、处于 60 秒冷却期、符合发送条件以及邮件供应商失败,都会收到完全相同的状态码和响应体。前端不能根据该响应判断邮件是否实际发送,也不应显示「邮箱存在」之类的提示。
只有尚未确认、未被管理员禁用且不在冷却期内的用户会触发邮件发送。成功签发新 token 后,同一用户此前未消费的邮箱确认 token 全部失效;新 token 仍有 24 小时有效期,邮件内容与注册邮件相同。已消费记录会保留作为审计信息。
重叠请求会按用户串行处理,至多签发一个可用 token。数据库签发或 Resend 失败不会改变上述公共响应。
**错误**:请求体校验失败返回 `400` zod 形状(2.1)。
### 4.3 `POST /auth/confirm-email` — 确认邮箱
- **认证**:无(公开)
前端从确认链接读取 `token`,再提交:
```json
{
"token": "0123456789abcdef0123456789abcdef"
}
```
**成功响应**:`200`
```json
{
"status_code": 200,
"message": "邮箱确认成功,请重新登录"
}
```
确认会在一个数据库事务中消费未过期、未消费且用途为 `email_confirmation` 的记录,并写入用户的邮箱确认时间。它不会签发 JWT、不会写认证 Cookie,也绝不修改管理员禁用状态。
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 400 | token 未知、过期、已消费,或来自其他用途 | `确认链接无效或已过期` | 统一提示链接无效,并引导重发 |
| 500 | 数据库或服务端配置异常 | `确认邮箱失败` | 稍后重试 |
### 4.4 `POST /auth/login` — 登录
- **认证**:无(公开)
**请求体**(JSON):
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `email` | string | 是 | 服务端去除首尾空白、转为小写后匹配 |
| `password` | string | 是 | 密码 |
```json
{
"email": "watcher@example.com",
"password": "s3cret-password"
}
```
**成功响应**:`200`,同时写入认证 Cookie(见 3.1)
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**错误**:
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 |
| 401 | 邮箱不存在或密码错误(两者共用,防枚举) | `邮箱或密码错误` | 统一提示「邮箱或密码错误」 |
| 403 | 帐号被管理员禁用 | `帐号已被禁用` | 提示联系管理员 |
| 403 | 密码正确但邮箱尚未确认 | `邮箱尚未验证` | 引导至重发验证流程 |
| 500 | 服务端异常 | `登录失败` | 稍后重试 |
### 4.5 `POST /auth/logout` — 登出
- **认证**:需登录(任意角色)
- **请求体**:无
**成功响应**:`200`,同时写入过期 Cookie
```json
{
"status_code": 200,
"message": "已登出"
}
```
**错误**:
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 401 | 未登录 / token 失效 | 见 3.5 | 本地直接清理登录态即可 |
| 500 | 数据库异常 | `连接数据库发生错误,登出失败` | 提示重试;⚠️ 此时服务端 token 可能仍有效 |
**注意事项**:登出会使该用户**所有设备**的 token 立即失效(`tokenVersion` 递增),不只是当前会话。
### 4.6 `POST /auth/forgot-password` — 申请重置密码
- **认证**:无(公开)
**请求体**(JSON):
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `email` | string | 是 | 服务端去除首尾空白并转为小写;不接受旧字段 `user_email` |
```json
{
"email": "watcher@example.com"
}
```
**成功响应**:`200`
```json
{
"status_code": 200,
"message": "如果该邮箱符合条件,我们将发送密码重置邮件"
}
```
未知邮箱、邮箱未确认、被管理员禁用、处于 60 秒冷却期、符合发送条件以及邮件供应商失败,都会收到完全相同的状态码和响应体。只有邮箱已确认、未被禁用且不在冷却期内的用户会触发邮件发送。
新签发的 `password_reset` token 有效期为 30 分钟,并使此前未消费的密码重置 token 失效;它与邮箱确认 token 用 purpose 隔离。邮件由 `Opencloud <opencloud@catpl.top>` 发出,链接到 `https://cloud.catpl.top/reset-password?email=...&token=...`。数据库和 Resend 失败不改变公共响应。
**错误**:请求体校验失败返回 `400` zod 形状(2.1)。没有 `/auth/forget-password` 兼容路径。
### 4.7 `POST /auth/reset-password` — 重置密码
- **认证**:无(公开)
**请求体**(JSON):
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `email` | string | 是 | 邮件链接中的邮箱;服务端再次规范化 |
| `token` | string | 是 | 邮件链接中的原始 token |
| `new_password` | string | 是 | 新密码;不能与当前密码相同,不额外扩大密码策略 |
```json
{
"email": "watcher@example.com",
"token": "0123456789abcdef0123456789abcdef",
"new_password": "new-s3cret-password"
}
```
**成功响应**:`200`,同时写入过期认证 Cookie
```json
{
"status_code": 200,
"message": "密码重置成功,请使用新密码重新登录"
}
```
成功时,服务端在同一数据库事务中消费 token、更新密码哈希和凭据更新时间,并递增 `tokenVersion`。此前在所有设备签发的 JWT 随即失效;响应不会签发新 JWT,用户必须重新登录。
token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose、有效期、未消费状态和目标用户;用户在消费时仍须邮箱已确认且未被管理员禁用。重置不会修改邮箱确认时间或管理员禁用状态。
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 400 | token 未知、过期、已消费、已被替换、用途错误、邮箱不匹配,或用户不再符合状态要求 | `重置链接无效或已过期` | 统一提示链接无效,重新申请 |
| 400 | 新密码与当前密码相同 | `新密码不能与当前密码相同` | 要求输入不同密码 |
| 500 | 数据库或服务端配置异常 | `重置密码失败` | 稍后重试 |
### 4.8 `PATCH /auth/password` — 修改密码
- **认证**:需登录(任意角色)
**请求体**(JSON):
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `current_password` | string | 是 | 当前密码 |
| `new_password` | string | 是 | 新密码,无强度校验 |
```json
{
"current_password": "s3cret-password",
"new_password": "new-s3cret-password"
}
```
**成功响应**:`200`,同时写入过期 Cookie
```json
{
"status_code": 200,
"message": "密码修改成功,请重新登录"
}
```
**错误**:
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 400 | 新旧密码相同 | `新密码不能与当前密码相同` | 表单前置校验可避免 |
| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 |
| 401 | 当前密码错误 | `当前密码错误` | 提示重新输入 |
| 401 | 未登录 / token 失效 | 见 3.5 | 跳登录 |
| 404 | 用户无凭据记录(数据异常) | `未找到用户凭据` | 反馈后端,附 `X-Request-Id` |
| 500 | 服务端异常 | `修改密码时发生错误` | 稍后重试 |
**注意事项**:改密成功后**所有已签发 token 立即失效**(含当前使用的这个),前端必须清理登录态并跳转登录页。
管理员的业务管理操作继续使用第 8 章 `/admin/*` 路由。它们保持原业务响应格式,并在修改角色或密码后撤销目标会话;直接调用 Better Auth Admin 插件端点不会自动执行这些业务路由的附加操作。
---
@@ -605,7 +390,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
### 5.3 `GET /cloud/:cloud_id` — 云图详情
- **认证**:可选。匿名访客按公开可见规则过滤;上传者本人可见自己的全部云图;admin 可见全部
- 无效/过期 token 按匿名处理,**不会**返回 401
- 无效/过期会话凭证按匿名处理,**不会**返回 401
**路径参数**:
@@ -645,7 +430,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 404 | 云类型不存在 | `云类型不存在` | 提示类型不存在 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
### 5.5 `POST /cloud/:cloud_id/like` — 点赞
### 5.5 `PUT /cloud/:cloud_id/like` — 点赞
- **认证**:需登录(任意角色)
- **请求体**:无
@@ -663,11 +448,11 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 |
| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 |
| 404 | 云图不存在,或不是公开可见状态 | `图片不存在` | 提示云图不可点赞;与「不存在」共用是设计意图 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
**注意事项**:**幂等**——重复点赞返回相同的 200,点赞数不会增加。前端无需在点击前查询是否已赞,但仍建议本地置灰防止连点。
**注意事项**:**幂等**——重复点赞返回相同的 200,点赞数不会增加。旧 `POST /cloud/:cloud_id/like` 已移除;前端改用 `PUT`。前端无需在点击前查询是否已赞,但仍建议本地置灰防止连点。
### 5.6 `DELETE /cloud/:cloud_id/like` — 取消点赞
@@ -687,7 +472,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 |
| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
**注意事项**:**完全幂等**——从未点赞、云图不存在,同样返回 200。没有 404 分支。
@@ -730,7 +515,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 400 | 分辨率超上限 | `图片分辨率超过限制` | 提示缩小尺寸 |
| 400 | 其他图片处理失败 | `压缩参数无效` / `图片处理失败` | 提示换图重试 |
| 400 | 表单字段校验失败(如 `is_hidden` 编码非法、缺字段) | zod 形状(2.1) | 检查表单 |
| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 |
| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 |
| 413 | 请求体超过 21 MiB | `上传文件过大` | 提示压缩图片 |
| 500 | 服务端异常(含存储/数据库失败) | `服务器发生内部错误` | 稍后重试;服务端已做失败补偿清理,可安全重试 |
@@ -771,7 +556,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
|---|---|---|---|
| 400 | 未提供任何修改字段 | zod 形状,issues 中含 `至少需要提供一个修改字段` | 表单前置校验 |
| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 |
| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 |
| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 |
| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 |
| 500 | 服务端异常(含 `type_id` 不存在) | `服务器发生内部错误` | 稍后重试 |
@@ -785,7 +570,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 |
| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 |
| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
@@ -822,7 +607,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 400 | ID 重复 / 数量超出 1–100 / 格式非法 | zod 形状,ID 重复时 issues 含 `图片 ID 不能重复` | 前端去重后提交 |
| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 |
| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
**注意事项**:**部分成功语义**——只删除属于当前用户的云图,他人或不存在的 ID 被静默跳过,消息中的 `N` 是实际删除数(可能小于请求数,甚至为 0)。前端应以 `N` 为准刷新列表,而不是假设全部删除成功。
@@ -867,7 +652,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 状态码 | 触发条件 | 前端建议动作 |
|---|---|---|
| 400 | Cloud ID 或 ImageVariant 非法 | 检查 URL |
| 401 | 显式提供的 JWT 无效、过期或已失效 | 清除本地登录态 |
| 401 | 显式提供的 Better Auth 会话无效或过期 | 清除本地登录态 |
| 404 | Cloud 不存在、当前用户不可见,或对应 MinIO 对象不存在 | 统一显示占位图,不推断具体原因 |
| 405 | 使用 GET/HEAD 之外的业务方法;响应含 `Allow: GET, HEAD` | 修正请求方法 |
| 500 | 数据库或应用内部错误 | 稍后重试 |
@@ -879,7 +664,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
## 6. 端点 · 个人资料 `/profile`
本章所有端点**都需要登录**(任意角色)。未登录统一返回 401(见 3.5),下文错误表不再重复列出。
本章所有端点**都需要登录**(任意角色)。未登录统一返回 401(见 3.3),下文错误表不再重复列出。
### 6.1 `GET /profile/me` — 我的资料
@@ -892,7 +677,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
"id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
"name": "cloudwatcher",
"email": "watcher@example.com",
"avatar_id": null,
"image": "https://example.com/avatar.png",
"cloud_count": 7,
"received_like_count": 42,
"last_online": "2026-08-15T08:30:00.000Z",
@@ -937,12 +722,13 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
### 6.3 `GET /profile/me/likes` — 我点赞过的云图
- **认证**:需登录
- **Query 参数**:分页参数 `page`、`page_size`,见 1.4
**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按点赞时间倒序。
**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按点赞时间倒序、同刻按云图 ID 倒序;默认返回第 1 页的 50 条,最多每页 100 条。超出范围的页返回 `[]`。
**注意事项**:包含**所有状态**的云图(含待审核/已驳回/已隐藏的),因为这些是你自己点过赞的——展示时如需隐藏非公开项请自行过滤 `status` 与 `is_hidden`。
**错误**:500 `服务器发生内部错误`。
**错误**:分页参数非法时返回 400(见 2.1);服务端异常返回 500 `服务器发生内部错误`。
### 6.4 `GET /profile/me/clouds` — 我上传的云图
@@ -970,22 +756,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
**注意事项**:⚠️ 响应包含对方 `email`(2.3 第 7 条)。展示他人资料页时不应展示邮箱字段。
### 6.6 `GET /profile/:user_id/likes` — 查看用户点赞记录
- **认证**:需登录,且为**本人或 admin**
**路径参数**:`user_id`(uuid)。
**成功响应**:`200`,CloudInfo 数组,按点赞时间倒序,结构同 5.1。
**错误**:
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 403 | 查看他人且非 admin | `无权查看该用户的点赞记录` | 前端应在进入页面前判断身份,避免触发 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
### 6.7 `GET /profile/:user_id/clouds` — 查看用户上传的云图
### 6.6 `GET /profile/:user_id/clouds` — 查看用户上传的云图
- **认证**:需登录,且为**本人或 admin**
@@ -1102,10 +873,11 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 400 | 参数校验失败(`user_role` 非法 / `user_id` 非 uuid) | zod 形状(2.1) | 检查参数 |
| 400 | 管理员尝试撤销自己的 admin 角色 | `不能撤销自己的管理员角色` | 保持当前管理员角色 |
| 404 | 用户不存在 | `用户不存在` | 提示用户不存在 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
**注意事项**:修改角色会同时递增 `tokenVersion`,**该用户所有已签发 token 立即失效,必须重新登录**(见 3.4)。前端改完角色后可提示目标用户重新登录。
**注意事项**:修改角色会撤销目标用户的全部 Better Auth 会话,必须重新登录。前端改完角色后可提示目标用户重新登录。
### 8.4 `PATCH /admin/users/:user_id/password` — 设置用户密码
@@ -1117,7 +889,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 参数 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|
| `new_password` | string | 是 | 无强度校验 | 新密码;管理员直接覆盖,无需旧密码 |
| `new_password` | string | 是 | 至少 8 字符 | 新密码;管理员直接覆盖,无需旧密码 |
```json
{
@@ -1138,17 +910,87 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 状态码 | 触发条件 | 消息原文 | 前端建议动作 |
|---|---|---|---|
| 400 | 参数校验失败(`user_id` 非 uuid) | zod 形状(2.1) | 检查参数 |
| 400 | 参数校验失败(`user_id` 非 uuid 或密码少于 8 字符) | zod 形状(2.1) | 检查参数 |
| 404 | 用户不存在(含凭据记录缺失) | `用户不存在` | 提示用户不存在 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
**注意事项**:成功后同样递增 `tokenVersion`,该用户所有已签发 token 立即失效,必须重新登录(见 3.4)。
**注意事项**:成功后会撤销目标用户的全部 Better Auth 会话,必须重新登录。
### 8.5 `POST /admin/user` — 创建用户
> ⚠️ **假端点**(2.3 第 4 条):通过 zod 校验后**原样回显请求体(含明文密码)**,不创建任何用户。响应为 `200 { name, email, password, role }`。请勿接入,更不要在任何持久化日志中记录其响应。
管理员认证后提交 `{ "name": "Alice", "email": "alice@example.com", "password": "password123", "role": "user" }`。通过 Better Auth Admin 插件创建用户和密码凭据,再尝试发送邮箱验证邮件。成功返回 `201`,包含 `status_code`、`message`、`user_id`、`email_sent`;用户名或邮箱冲突返回 `409`。密码至少 8 字符。
### 8.6 `GET /admin/clouds` — 全状态云图列表
### 8.6 `POST /admin/cloudtypes` — 添加云类型
- **认证**:admin
**请求体**(JSON):
| 参数 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|
| `name` | string | 是 | 去除首尾空白后非空 | 云类型名称 |
| `genus` | string\|null | 否 | 默认 `null` | 属;传 `null` 表示未设置 |
| `icon_id` | uuid\|null | 否 | 默认 `null` | 图标 ID;传 `null` 表示未设置 |
| `rarity` | int | 是 | — | 稀有度 |
| `description` | string | 是 | 最长 128 字符 | 描述,可为空字符串 |
```json
{
"name": "积云",
"genus": "Cumulus",
"icon_id": null,
"rarity": 1,
"description": "底部平坦、顶部蓬松的白色云块"
}
```
**成功响应**:`201`
```json
{
"status_code": 201,
"message": "云类型添加成功",
"cloud_type_id": 12
}
```
云类型 ID 由服务端生成;并发添加不会分配重复 ID。参数校验失败返回 400,数据库异常返回 500。
### 8.7 `PATCH /admin/cloudtypes/:cloud_type_id` — 修改云类型
- **认证**:admin
**路径参数**:`cloud_type_id`(int)。请求体字段与 8.6 相同,但均为可选;至少须提供一个字段。仅提交的字段会被修改,`genus` 和 `icon_id` 可显式传 `null` 清空。
**成功响应**:`200`
```json
{
"status_code": 200,
"message": "云类型修改成功"
}
```
**错误**:参数或空请求体无效返回 400;云类型不存在返回 404 `云类型不存在`;数据库异常返回 500。
### 8.8 `DELETE /admin/cloudtypes/:cloud_type_id` — 删除云类型
- **认证**:admin
**路径参数**:`cloud_type_id`(int)。
**成功响应**:`200`
```json
{
"status_code": 200,
"message": "云类型删除成功"
}
```
**错误**:参数无效返回 400;云类型不存在返回 404 `云类型不存在`;仍有云图使用该类型时返回 409 `云类型仍被云图使用,无法删除`;其他数据库异常返回 500。
### 8.9 `GET /admin/clouds` — 全状态云图列表
- **认证**:admin;可见任意状态与隐藏的云图
@@ -1167,7 +1009,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 |
| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 |
### 8.7 `POST /admin/clouds/review` — 批量审核云图
### 8.10 `POST /admin/clouds/review` — 批量审核云图
- **认证**:admin
@@ -1205,7 +1047,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
**注意事项**:**部分成功语义**——消息中的 `N` 只统计状态**实际发生变化**的云图;已是目标状态的与不存在的一律静默跳过。审核只改变审核状态,不影响 `is_hidden` 与点赞数据。`pending ↔ approved/rejected`、`approved ↔ rejected` 均可。
### 8.8 `DELETE /admin/clouds/:cloud_id` — 删除云图
### 8.11 `DELETE /admin/clouds/:cloud_id` — 删除云图
- **认证**:admin
@@ -1230,6 +1072,16 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose
**注意事项**:管理员删除不受审核状态或隐藏状态限制。删除会级联删除该云图的点赞记录,并尽力清理 MinIO 中的图片对象;清理失败不报错,可能残留孤儿对象(可接受)。删除后前端应立即刷新相关列表。
### 8.12 `GET /admin/users/:user_id/likes` — 查看用户点赞记录
- **认证**:admin
- **路径参数**:`user_id`(uuid)
- **Query 参数**:分页参数 `page`、`page_size`,见 1.4
**成功响应**:`200`,与 6.3 相同的 CloudInfo 数组和排序,包含该用户已点赞但后来被隐藏或改判的云图;没有点赞或页码超出范围时返回 `[]`。
**错误**:`user_id` 或分页参数非法时返回 400(见 2.1);用户不存在时返回 404 `{ "status_code": 404, "error": "用户不存在" }`;服务端异常返回 500 `服务器发生内部错误`。非管理员统一返回 403(本章规则)。旧 `GET /profile/:user_id/likes` 已移除;本人使用 6.3,管理员使用本端点。
---
## 9. 端点 · 系统
+359
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@vercel/speed-insights": "^2.0.0",
"better-auth": "^1.7.6",
"naive-ui": "^2.44.1",
"pinia": "^3.0.4",
"vue": "^3.5.34",
@@ -81,6 +82,135 @@
"node": ">=6.9.0"
}
},
"node_modules/@better-auth/core": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/core/-/core-1.7.6.tgz",
"integrity": "sha512-yBDDO0J4VCHT1qEZtzj6aMexX4etyLesitoxehcWHzPrzYqrFHwe/hgskye+imlGhQVOysuljMg8M8BA6GrtIg==",
"license": "MIT",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.41.1",
"@standard-schema/spec": "^1.1.0",
"zod": "^4.5.4"
},
"peerDependencies": {
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.2",
"@opentelemetry/api": "^1.9.0",
"better-call": "1.4.0",
"jose": "^6.1.0",
"kysely": "^0.28.5 || ^0.29.0",
"nanostores": "^1.0.1"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@better-auth/drizzle-adapter": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.6.tgz",
"integrity": "sha512-ENlhwC7kkTjquuiel3Efggik0hC4vtD7ppJ8FhsiHhRJA8K6RT4hbhzkfm6keNvjdnAeWwCejm7FmMO4GoSGpA==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2",
"drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0"
},
"peerDependenciesMeta": {
"drizzle-orm": {
"optional": true
}
}
},
"node_modules/@better-auth/kysely-adapter": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/kysely-adapter/-/kysely-adapter-1.7.6.tgz",
"integrity": "sha512-BV+DX2/z/6ozNC9DYSge78wjLerq6Bxd/lSPjzzuq9eEutkQDAJTuiaETzgGYqNV5LlEWi8TZFAQTEq/wavOEQ==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2",
"kysely": "^0.28.17 || ^0.29.0"
},
"peerDependenciesMeta": {
"kysely": {
"optional": true
}
}
},
"node_modules/@better-auth/memory-adapter": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/memory-adapter/-/memory-adapter-1.7.6.tgz",
"integrity": "sha512-ng1xv8k2JsEIjnqzJzdoVOQrnoCLRuk4EiQL9yYAThyg6zYzV00LynlhH+O9OMUZ+yGVpGqGZmr5xcTiUSV2uA==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2"
}
},
"node_modules/@better-auth/mongo-adapter": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/mongo-adapter/-/mongo-adapter-1.7.6.tgz",
"integrity": "sha512-GvBwZli8i4XINMAj9tlW7eV9E8Dr+JWqavXjUs2o81GZt/Vh8wiX+31KK1jQ4iOuejfjpLoXcoFtlIPgf/pVog==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2",
"mongodb": "^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
"mongodb": {
"optional": true
}
}
},
"node_modules/@better-auth/prisma-adapter": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/prisma-adapter/-/prisma-adapter-1.7.6.tgz",
"integrity": "sha512-31rz2OXIx2Q1L4Jhd5F71dafIWqder/Bo6vJ+eCgO5iZ0gvnv7fUmM95DvbVUoPC69XYpcIM/6PJmQ3M6RydWg==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2",
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
"@prisma/client": {
"optional": true
},
"prisma": {
"optional": true
}
}
},
"node_modules/@better-auth/telemetry": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/@better-auth/telemetry/-/telemetry-1.7.6.tgz",
"integrity": "sha512-xgRwPja2wTJtcu+OQZR2IiNpAmviddnW3n5Do5umXRugJFW3v9KlSmYjYm580mVsPnU4upvZkvSRl9v8DgSDtA==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "^1.7.6",
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.2"
}
},
"node_modules/@better-auth/utils": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/@better-auth/utils/-/utils-0.4.2.tgz",
"integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.0.1"
}
},
"node_modules/@better-fetch/fetch": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/@better-fetch/fetch/-/fetch-1.3.2.tgz",
"integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==",
"license": "MIT"
},
"node_modules/@css-render/plugin-bem": {
"version": "0.15.14",
"resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
@@ -213,6 +343,39 @@
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmmirror.com/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.43.0",
"resolved": "https://registry.npmmirror.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz",
"integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@oxc-project/types": {
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
@@ -505,6 +668,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -1189,6 +1358,136 @@
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/better-auth": {
"version": "1.7.6",
"resolved": "https://registry.npmmirror.com/better-auth/-/better-auth-1.7.6.tgz",
"integrity": "sha512-WTMqOpmTTj+oBoX7zzPOzL85342Upyf+/v0IkH1kvGRA85lV4JGRFIYMIRKqvjZ6ShsuL5VX/c9C13jtoZXcKA==",
"license": "MIT",
"dependencies": {
"@better-auth/core": "1.7.6",
"@better-auth/drizzle-adapter": "1.7.6",
"@better-auth/kysely-adapter": "1.7.6",
"@better-auth/memory-adapter": "1.7.6",
"@better-auth/mongo-adapter": "1.7.6",
"@better-auth/prisma-adapter": "1.7.6",
"@better-auth/telemetry": "1.7.6",
"@better-auth/utils": "0.4.2",
"@better-fetch/fetch": "1.3.2",
"@noble/ciphers": "^2.2.0",
"@noble/hashes": "^2.2.0",
"better-call": "1.4.0",
"defu": "^6.1.4",
"jose": "^6.2.3",
"kysely": "^0.28.17 || ^0.29.0",
"nanostores": "^1.3.0",
"zod": "^4.5.4"
},
"peerDependencies": {
"@lynx-js/react": "*",
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
"@sveltejs/kit": "^2.0.0",
"@tanstack/react-start": "^1.0.0",
"@tanstack/solid-start": "^1.0.0",
"drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1",
"drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0",
"mongodb": "^6.0.0 || ^7.0.0",
"mysql2": "^3.0.0",
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
"pg": "^8.0.0",
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"solid-js": "^1.0.0",
"svelte": "^4.0.0 || ^5.0.0",
"vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"@lynx-js/react": {
"optional": true
},
"@prisma/client": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"@tanstack/react-start": {
"optional": true
},
"@tanstack/solid-start": {
"optional": true
},
"drizzle-kit": {
"optional": true
},
"drizzle-orm": {
"optional": true
},
"mongodb": {
"optional": true
},
"mysql2": {
"optional": true
},
"next": {
"optional": true
},
"pg": {
"optional": true
},
"prisma": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vitest": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/better-call": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/better-call/-/better-call-1.4.0.tgz",
"integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==",
"license": "MIT",
"dependencies": {
"@better-auth/utils": "^0.5.0",
"@better-fetch/fetch": "^1.3.1",
"rou3": "^0.9.1",
"set-cookie-parser": "^3.1.2"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/better-call/node_modules/@better-auth/utils": {
"version": "0.5.0",
"resolved": "https://registry.npmmirror.com/@better-auth/utils/-/utils-0.5.0.tgz",
"integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^2.0.1"
}
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz",
@@ -1254,6 +1553,12 @@
"date-fns": "^3.0.0 || ^4.0.0"
}
},
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1379,6 +1684,24 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.12",
"resolved": "https://registry.npmmirror.com/jose/-/jose-6.2.12.tgz",
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/kysely": {
"version": "0.29.6",
"resolved": "https://registry.npmmirror.com/kysely/-/kysely-0.29.6.tgz",
"integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==",
"license": "MIT",
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1736,6 +2059,21 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nanostores": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/nanostores/-/nanostores-1.5.4.tgz",
"integrity": "sha512-4UXY7OAJEK0Y6V3SudBU7cXjAzTDc189R78dDOh0Xbm19gpKYto6WizSg58LJf12lTsRxKSLGyE117OiLpM4DQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1857,12 +2195,24 @@
"@rolldown/binding-win32-x64-msvc": "1.0.3"
}
},
"node_modules/rou3": {
"version": "0.9.2",
"resolved": "https://registry.npmmirror.com/rou3/-/rou3-0.9.2.tgz",
"integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==",
"license": "MIT"
},
"node_modules/seemly": {
"version": "0.3.10",
"resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz",
"integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
"license": "MIT"
},
"node_modules/set-cookie-parser": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
"integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2151,6 +2501,15 @@
"peerDependencies": {
"vue": "^3.0.11"
}
},
"node_modules/zod": {
"version": "4.6.5",
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.6.5.tgz",
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+1
View File
@@ -12,6 +12,7 @@
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@vercel/speed-insights": "^2.0.0",
"better-auth": "^1.7.6",
"naive-ui": "^2.44.1",
"pinia": "^3.0.4",
"vue": "^3.5.34",
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NButton, NIcon, useMessage } from 'naive-ui'
import { Heart } from '@vicons/tabler'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useLikesStore } from '@/stores/likes'
const props = defineProps<{ cloudId: string; count: number }>()
const route = useRoute()
const authStore = useAuthStore()
const likesStore = useLikesStore()
const message = useMessage()
const loading = ref(false)
const error = ref('')
const liked = computed(() => likesStore.isLiked(props.cloudId))
const count = computed(() => likesStore.countFor(props.cloudId, props.count))
watch(() => [authStore.user?.id, props.cloudId], async ([userId]) => {
error.value = ''
if (!userId) return
loading.value = true
try {
await likesStore.ensureLoaded()
} catch (e) {
error.value = e instanceof Error ? e.message : '点赞状态加载失败'
} finally {
loading.value = false
}
}, { immediate: true })
async function toggleLike() {
error.value = ''
try {
await likesStore.toggle(props.cloudId, props.count)
message.success(liked.value ? '已点赞' : '已取消点赞')
} catch (e) {
error.value = e instanceof Error ? e.message : '点赞操作失败'
}
}
</script>
<template>
<div class="mt-5 flex flex-wrap items-center gap-3">
<NButton
v-if="authStore.isLoggedIn"
type="default"
:class="['oc-panel-button', liked ? 'oc-panel-button--teal' : 'oc-panel-button--neutral']"
:loading="loading || likesStore.isPending(cloudId)"
@click="toggleLike"
>
<template #icon><NIcon><Heart /></NIcon></template>
{{ liked ? '取消点赞' : '点赞' }}
</NButton>
<RouterLink
v-else
:to="{ path: '/login', query: { redirect: route.fullPath } }"
class="oc-panel-button oc-panel-button--neutral no-underline"
>登录后点赞</RouterLink>
<span class="text-sm text-slate-600">{{ count }} 次点赞</span>
<span v-if="error" class="text-sm text-rose-700">{{ error }}</span>
</div>
</template>
+17 -9
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { NIcon, NSpace } from 'naive-ui'
import { NIcon, NSpace, useMessage } from 'naive-ui'
import { ChevronDown, Logout, Settings, Shield, User } from '@vicons/tabler'
import { useAuthStore } from '@/stores/auth'
import { RouterLink, useRoute, useRouter } from 'vue-router'
@@ -8,6 +8,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const message = useMessage()
const isMapRoute = computed(() => route.name === 'map')
const headerHidden = ref(false)
@@ -18,6 +19,9 @@ const inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800'
const displayUsername = computed(() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户')
const userEmail = computed(() => authStore.user?.email || '未绑定邮箱')
const avatarFailed = ref(false)
watch(() => authStore.user?.image, () => { avatarFailed.value = false })
let lastScrollY = 0
let accountCloseTimer: number | null = null
@@ -119,10 +123,14 @@ function closeAccountCard() {
}
async function handleLogout() {
await authStore.logout()
closeAccountCard()
if (route.meta.requiresAuth) {
router.push({ name: 'map' })
try {
await authStore.logout()
closeAccountCard()
if (route.meta.requiresAuth) {
void router.push({ name: 'map' })
}
} catch (error) {
message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。')
}
}
</script>
@@ -232,9 +240,8 @@ async function handleLogout() {
aria-haspopup="menu"
:aria-expanded="accountCardOpen"
>
<NIcon size="16" class="shrink-0">
<User />
</NIcon>
<img v-if="authStore.user?.image && !avatarFailed" :src="authStore.user.image" alt="" class="h-5 w-5 shrink-0 object-cover" @error="avatarFailed = true" />
<NIcon v-else size="16" class="shrink-0"><User /></NIcon>
<span class="truncate">@{{ displayUsername }}</span>
</button>
@@ -247,7 +254,8 @@ async function handleLogout() {
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800">
{{ displayUsername.slice(0, 1).toUpperCase() }}
<img v-if="authStore.user?.image && !avatarFailed" :src="authStore.user.image" :alt="displayUsername" class="h-full w-full object-cover" @error="avatarFailed = true" />
<template v-else>{{ displayUsername.slice(0, 1).toUpperCase() }}</template>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-slate-950">@{{ displayUsername }}</p>
+2 -5
View File
@@ -27,9 +27,6 @@ export class ApiError extends Error {
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
body?: BodyInit | Record<string, unknown> | null
auth?: boolean
// Kept for callers that explicitly disable retries. Cookie sessions do not
// use a refresh-and-retry flow, so this option is intentionally ignored.
retry?: boolean
}
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
@@ -105,7 +102,7 @@ async function toApiError(response: Response) {
}
async function request<T>(path: string, options: ApiRequestOptions = {}) {
const { body, auth = true, retry: _retry, ...requestOptions } = options
const { body, auth = true, ...requestOptions } = options
const headers = new Headers(requestOptions.headers)
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
@@ -142,7 +139,7 @@ export function toAuthUser(profile: BackendUserProfile): AuthUser {
id: profile.id,
email: profile.email,
username: profile.name,
avatar_id: profile.avatar_id,
image: profile.image,
role: profile.role,
is_disabled: profile.is_disabled,
cloud_count: profile.cloud_count,
+33
View File
@@ -0,0 +1,33 @@
import { createAuthClient } from 'better-auth/client'
import { API_URL } from '@/lib/api'
export const authClient = createAuthClient({
baseURL: `${API_URL}/auth`,
fetchOptions: { credentials: 'include' },
})
interface AuthClientFailure {
code?: string
message?: string
status?: number
}
export class AuthClientError extends Error {
code?: string
status?: number
constructor(failure: AuthClientFailure, fallback: string) {
super(failure.code === 'EMAIL_NOT_VERIFIED'
? '邮箱尚未验证,请先查收验证邮件。'
: failure.status === 429
? '操作过于频繁,请稍后重试。'
: fallback)
this.name = 'AuthClientError'
this.code = failure.code
this.status = failure.status
}
}
export function assertAuthResult(result: { error?: AuthClientFailure | null }, fallback: string) {
if (result.error) throw new AuthClientError(result.error, fallback)
}
+31 -46
View File
@@ -1,7 +1,8 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api'
import type { ActionResponse, BackendUserProfile, LoginResponse, RegisterResponse } from '@/types/api'
import { assertAuthResult, authClient } from '@/lib/authClient'
import type { BackendUserProfile } from '@/types/api'
import type { Profile } from '@/types/database'
export const useAuthStore = defineStore('auth', () => {
@@ -19,7 +20,7 @@ export const useAuthStore = defineStore('auth', () => {
profile.value = {
id: normalized.id,
username: normalized.username,
avatar_id: normalized.avatar_id,
image: normalized.image,
role: normalized.role,
is_disabled: normalized.is_disabled,
created_at: normalized.created_at,
@@ -37,62 +38,45 @@ export const useAuthStore = defineStore('auth', () => {
}
async function login(email: string, password: string) {
await apiRequest<LoginResponse>('/auth/login', {
method: 'POST',
auth: false,
body: { email, password },
})
assertAuthResult(await authClient.signIn.email({ email, password }), '登录失败,请检查邮箱和密码。')
const session = await authClient.getSession()
assertAuthResult(session, '获取登录会话失败,请稍后重试。')
if (!session.data) throw new Error('未能建立登录会话,请稍后重试。')
await fetchMe()
}
async function register(name: string, email: string, password: string) {
return await apiRequest<RegisterResponse>('/auth/register', {
method: 'POST',
auth: false,
body: { name, email, password },
})
assertAuthResult(await authClient.signUp.email({ name, email, password }), '注册失败,请稍后重试。')
}
async function resendConfirmation(email: string) {
return await apiRequest<ActionResponse>('/auth/resend-confirmation', {
method: 'POST',
auth: false,
body: { email },
})
assertAuthResult(await authClient.sendVerificationEmail({ email }), '验证邮件发送失败,请稍后重试。')
}
async function confirmEmail(token: string) {
return await apiRequest<ActionResponse>('/auth/confirm-email', {
method: 'POST',
auth: false,
body: { token },
})
assertAuthResult(await authClient.$fetch('/verify-email', {
method: 'GET',
query: { token },
}), '确认链接无效或已过期。')
}
async function sendPasswordReset(email: string) {
return await apiRequest<ActionResponse>('/auth/forgot-password', {
method: 'POST',
auth: false,
body: { email },
})
assertAuthResult(await authClient.requestPasswordReset({ email }), '重置邮件发送失败,请稍后重试。')
}
async function resetPassword(email: string, token: string, newPassword: string) {
const response = await apiRequest<ActionResponse>('/auth/reset-password', {
method: 'POST',
auth: false,
body: { email, token, new_password: newPassword },
})
async function resetPassword(token: string, newPassword: string) {
assertAuthResult(await authClient.resetPassword({ token, newPassword }), '密码重置失败,请重新申请重置邮件。')
clearAuth()
return response
}
async function logout() {
try {
await apiRequest('/auth/logout', { method: 'POST', retry: false })
} finally {
const result = await authClient.signOut()
if (result.error?.status === 401) {
clearAuth()
return
}
assertAuthResult(result, '退出登录失败,请稍后重试。')
clearAuth()
}
async function updateUsername(username: string) {
@@ -104,14 +88,11 @@ export const useAuthStore = defineStore('auth', () => {
}
async function updatePassword(currentPassword: string, newPassword: string) {
await apiRequest('/auth/password', {
method: 'PATCH',
body: {
current_password: currentPassword,
new_password: newPassword,
},
})
clearAuth()
assertAuthResult(await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: true,
}), '密码更新失败,请检查当前密码。')
}
async function initialize() {
@@ -121,7 +102,10 @@ export const useAuthStore = defineStore('auth', () => {
}
try {
await fetchMe()
const session = await authClient.getSession()
assertAuthResult(session, '获取登录会话失败,请稍后重试。')
if (session.data) await fetchMe()
else clearAuth()
} catch {
clearAuth()
} finally {
@@ -143,6 +127,7 @@ export const useAuthStore = defineStore('auth', () => {
resetPassword,
logout,
updateUsername,
refreshProfile: fetchMe,
updatePassword,
initialize,
}
+5 -1
View File
@@ -19,5 +19,9 @@ export const useCloudsStore = defineStore('clouds', () => {
}
}
return { cloudTypes, loading, fetchCloudTypes }
function setCloudTypes(types: CloudType[]) {
cloudTypes.value = types
}
return { cloudTypes, loading, fetchCloudTypes, setCloudTypes }
})
+6
View File
@@ -21,9 +21,15 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
}
}
function setCloudTypes(types: CloudType[]) {
cloudTypes.value = types
cloudTypesLoaded.value = true
}
return {
cloudTypes,
loadingCloudTypes,
fetchCloudTypes,
setCloudTypes,
}
})
+105
View File
@@ -0,0 +1,105 @@
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ActionResponse, BackendCloudInfo } from '@/types/api'
const PAGE_SIZE = 100
export const useLikesStore = defineStore('likes', () => {
const authStore = useAuthStore()
const likedIds = ref<Set<string>>(new Set())
const counts = ref<Record<string, number>>({})
const pendingIds = ref<Set<string>>(new Set())
const loadedForUser = ref<string | null>(null)
let loadPromise: Promise<void> | null = null
let loadingForUser: string | null = null
watch(() => authStore.user?.id, () => {
likedIds.value = new Set()
counts.value = {}
pendingIds.value = new Set()
loadedForUser.value = null
loadPromise = null
loadingForUser = null
})
function isLiked(cloudId: string) {
return likedIds.value.has(cloudId)
}
function countFor(cloudId: string, initialCount: number) {
return counts.value[cloudId] ?? initialCount
}
function isPending(cloudId: string) {
return pendingIds.value.has(cloudId)
}
async function ensureLoaded() {
const userId = authStore.user?.id
if (!userId || loadedForUser.value === userId) return
if (loadPromise && loadingForUser === userId) return loadPromise
loadingForUser = userId
const request = (async () => {
const nextIds = new Set<string>()
for (let page = 1; ; page++) {
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
const rows = await apiRequest<BackendCloudInfo[]>(`/profile/me/likes?${params}`)
rows.forEach(row => nextIds.add(row.id))
if (rows.length < PAGE_SIZE) break
}
if (authStore.user?.id === userId) {
likedIds.value = nextIds
loadedForUser.value = userId
}
})()
loadPromise = request
try {
await request
} finally {
if (loadPromise === request) {
loadPromise = null
loadingForUser = null
}
}
}
async function toggle(cloudId: string, initialCount: number) {
const userId = authStore.user?.id
if (!userId) throw new Error('请先登录后点赞。')
await ensureLoaded()
if (authStore.user?.id !== userId) throw new Error('登录状态已改变,请重试。')
if (isPending(cloudId)) return
const wasLiked = isLiked(cloudId)
pendingIds.value = new Set(pendingIds.value).add(cloudId)
try {
await apiRequest<ActionResponse>(`/cloud/${cloudId}/like`, {
method: wasLiked ? 'DELETE' : 'PUT',
})
if (authStore.user?.id !== userId) return
const nextIds = new Set(likedIds.value)
if (wasLiked) nextIds.delete(cloudId)
else nextIds.add(cloudId)
likedIds.value = nextIds
counts.value[cloudId] = Math.max(0, countFor(cloudId, initialCount) + (wasLiked ? -1 : 1))
try {
const cloud = await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`, { auth: false })
counts.value[cloudId] = cloud.received_like_count
if (cloud.owner.id === authStore.user?.id) await authStore.refreshProfile()
} catch {
// The mutation succeeded; keep the local count if a refresh fails.
}
} finally {
const nextPending = new Set(pendingIds.value)
nextPending.delete(cloudId)
pendingIds.value = nextPending
}
}
return { isLiked, countFor, isPending, ensureLoaded, toggle }
})
+12 -2
View File
@@ -2,7 +2,7 @@ import { ref } from 'vue'
import { defineStore } from 'pinia'
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ActionResponse, ApiCloud, BackendCloudInfo } from '@/types/api'
import type { ActionResponse, ApiCloud, BackendCloudInfo, BackendUserProfile } from '@/types/api'
import type { Profile } from '@/types/database'
export interface ProfileCloudItem {
@@ -101,12 +101,22 @@ export const useProfileStore = defineStore('profile-page', () => {
if (isOwnProfile && authStore.profile) {
profilesByKey.value[identifier] = authStore.profile
} else if (identifier === authStore.profile?.username && authStore.profile) {
profilesByKey.value[identifier] = authStore.profile
} else {
const owner = backendRows[0]?.owner
let publicProfile: BackendUserProfile | null = null
if (owner?.id && authStore.isLoggedIn) {
try {
publicProfile = await apiRequest<BackendUserProfile>(`/profile/${owner.id}`)
} catch {
// Public clouds can still be displayed if the profile lookup fails.
}
}
profilesByKey.value[identifier] = {
id: owner?.id || identifier,
username: owner?.name || identifier,
avatar_id: null,
image: publicProfile?.image ?? null,
role: 'user',
is_disabled: false,
created_at: '',
+6 -10
View File
@@ -4,7 +4,7 @@ export interface BackendUserProfile {
id: string
name: string
email: string
avatar_id: string | null
image: string | null
cloud_count: number
received_like_count: number
last_online: string | null
@@ -48,25 +48,21 @@ export interface ActionResponse {
message: string
}
export interface RegisterResponse extends ActionResponse {
email_sent: boolean
}
export interface LoginResponse {
access_token: string
}
export interface CloudCreateResponse extends ActionResponse {
cloud_id: string
}
export interface CloudTypeCreateResponse extends ActionResponse {
cloud_type_id: number
}
// Internal view model. The backend profile is normalized here so existing
// layout/admin consumers do not need to understand the wire representation.
export interface AuthUser {
id: string
email: string
username: string
avatar_id: string | null
image: string | null
role: Profile['role']
is_disabled: boolean
cloud_count: number
+1 -1
View File
@@ -27,7 +27,7 @@ export interface Cloud {
export interface Profile {
id: string
username: string
avatar_id: string | null
image: string | null
role: 'user' | 'admin'
is_disabled: boolean
created_at: string
+362 -6
View File
@@ -5,13 +5,16 @@ import {
Check,
ChevronUp,
ClipboardCheck,
Cloud,
Dashboard,
Edit,
Home,
Key,
LayoutSidebarLeftCollapse,
LayoutSidebarLeftExpand,
Logout,
Photo,
Plus,
Refresh,
Settings,
Shield,
@@ -23,14 +26,32 @@ import {
import { useRouter } from 'vue-router'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { apiRequest, toApiCloud, toAuthUser } from '@/lib/api'
import { apiRequest, toApiCloud, toAuthUser, toCloudType } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ActionResponse, ApiCloud, AuthUser, BackendCloudInfo, BackendUserProfile } from '@/types/api'
import { useCloudsStore } from '@/stores/clouds'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
import type {
ActionResponse,
ApiCloud,
AuthUser,
BackendCloudInfo,
BackendCloudType,
BackendUserProfile,
CloudTypeCreateResponse,
} from '@/types/api'
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
type AdminTab = 'dashboard' | 'review' | 'users' | 'cloud-types' | 'images'
type CloudStatus = 'pending' | 'approved' | 'rejected'
type ImageFilter = 'all' | CloudStatus
interface CloudTypeForm {
name: string
genus: string
iconId: string
rarity: string
description: string
}
interface AdminCloud {
id: string
user_id: string
@@ -60,6 +81,8 @@ interface DashboardStats {
}
const authStore = useAuthStore()
const cloudsStore = useCloudsStore()
const encyclopediaStore = useEncyclopediaStore()
const message = useMessage()
const router = useRouter()
@@ -80,6 +103,7 @@ const dashboardStats = ref<DashboardStats>({
})
const users = ref<AuthUser[]>([])
const images = ref<AdminCloud[]>([])
const cloudTypes = ref<BackendCloudType[]>([])
const selectedReviewIds = ref<Set<string>>(new Set())
const activeReviewId = ref<string | null>(null)
const selectedImageIds = ref<Set<string>>(new Set())
@@ -89,6 +113,10 @@ const passwordTarget = ref<AuthUser | null>(null)
const newPassword = ref('')
const confirmPassword = ref('')
const passwordError = ref('')
const cloudTypeEditorOpen = ref(false)
const editingCloudType = ref<BackendCloudType | null>(null)
const cloudTypeForm = ref<CloudTypeForm>(emptyCloudTypeForm())
const cloudTypeFormError = ref('')
const statusMeta = {
pending: { label: '待审核', chip: 'border-amber-200 bg-amber-100 text-amber-700' },
@@ -100,6 +128,7 @@ const tabs = [
{ key: 'dashboard', label: '数据看板', description: '运行概览与内容分布', icon: Dashboard },
{ key: 'review', label: '内容审核', description: '处理待审核上传', icon: ClipboardCheck },
{ key: 'users', label: '用户管理', description: '角色与登录密码', icon: Users },
{ key: 'cloud-types', label: '云类型管理', description: '分类资料与稀有度', icon: Cloud },
{ key: 'images', label: '图片管理', description: '内容审核与删除', icon: Photo },
] satisfies Array<{ key: AdminTab; label: string; description: string; icon: Component }>
@@ -191,7 +220,13 @@ function toAdminCloud(row: ApiCloud): AdminCloud {
function formatDateTime(iso: string | null) {
if (!iso) return '未知时间'
return new Date(iso).toLocaleString('zh-CN', {
const compatibleIso = (iso.includes('T') ? iso : iso.replace(' ', 'T'))
.replace(/\.(\d{3})\d+/, '.$1')
.replace(/([+-]\d{2})$/, '$1:00')
const date = new Date(compatibleIso)
if (Number.isNaN(date.getTime())) return '未知时间'
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
@@ -253,12 +288,21 @@ async function fetchImages() {
selectedImageIds.value = new Set([...selectedImageIds.value].filter(id => images.value.some(item => item.id === id)))
}
async function fetchCloudTypes() {
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
cloudTypes.value = rows
const normalized = rows.map(toCloudType)
cloudsStore.setCloudTypes(normalized)
encyclopediaStore.setCloudTypes(normalized)
}
async function loadAdminData() {
loading.value = true
loadError.value = ''
try {
await Promise.all([fetchUsers(), fetchImages()])
await Promise.all([fetchUsers(), fetchImages(), fetchCloudTypes()])
syncDashboardStats()
} catch (error) {
loadError.value = error instanceof Error ? error.message : '管理后台加载失败'
@@ -468,6 +512,131 @@ async function resetUserPassword() {
}
}
function emptyCloudTypeForm(): CloudTypeForm {
return {
name: '',
genus: '',
iconId: '',
rarity: '1',
description: '',
}
}
function openCloudTypeEditor(cloudType?: BackendCloudType) {
editingCloudType.value = cloudType ?? null
cloudTypeForm.value = cloudType
? {
name: cloudType.name,
genus: cloudType.genus ?? '',
iconId: cloudType.icon_id ?? '',
rarity: String(cloudType.rarity),
description: cloudType.description ?? '',
}
: emptyCloudTypeForm()
cloudTypeFormError.value = ''
cloudTypeEditorOpen.value = true
}
function closeCloudTypeEditor() {
if (actionLoading.value) return
cloudTypeEditorOpen.value = false
editingCloudType.value = null
cloudTypeFormError.value = ''
}
function cloudTypePayload() {
const name = cloudTypeForm.value.name.trim()
const genus = cloudTypeForm.value.genus.trim()
const iconId = cloudTypeForm.value.iconId.trim()
const description = cloudTypeForm.value.description
const rarity = Number(cloudTypeForm.value.rarity)
if (!name) {
cloudTypeFormError.value = '请输入云类型名称。'
return null
}
if (!Number.isInteger(rarity)) {
cloudTypeFormError.value = '稀有度必须是整数。'
return null
}
if (description.length > 128) {
cloudTypeFormError.value = '描述最多可填写 128 个字符。'
return null
}
if (iconId && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(iconId)) {
cloudTypeFormError.value = '图标 ID 必须是有效的 UUID。'
return null
}
return {
name,
genus: genus || null,
icon_id: iconId || null,
rarity,
description,
}
}
async function saveCloudType() {
cloudTypeFormError.value = ''
const payload = cloudTypePayload()
if (!payload) return
actionLoading.value = true
loadError.value = ''
try {
if (editingCloudType.value) {
await apiRequest<ActionResponse>(`/admin/cloudtypes/${editingCloudType.value.id}`, {
method: 'PATCH',
body: payload,
})
message.success('云类型已更新')
} else {
await apiRequest<CloudTypeCreateResponse>('/admin/cloudtypes', {
method: 'POST',
body: payload,
})
message.success('云类型已添加')
}
await fetchCloudTypes()
cloudTypeEditorOpen.value = false
editingCloudType.value = null
} catch (error) {
cloudTypeFormError.value = getErrorMessage(error, editingCloudType.value ? '云类型更新失败' : '云类型添加失败')
} finally {
actionLoading.value = false
}
}
function cloudTypeUsageCount(cloudTypeId: number) {
return images.value.filter(image => image.cloud_type_id === cloudTypeId).length
}
async function deleteCloudType(cloudType: BackendCloudType) {
const usageCount = cloudTypeUsageCount(cloudType.id)
if (usageCount > 0) {
message.warning(`“${cloudType.name}”仍被 ${usageCount} 张云图使用,无法删除`)
return
}
if (!window.confirm(`确定删除云类型“${cloudType.name}”吗?删除后无法在页面中恢复。`)) return
actionLoading.value = true
loadError.value = ''
try {
await apiRequest<ActionResponse>(`/admin/cloudtypes/${cloudType.id}`, { method: 'DELETE' })
await fetchCloudTypes()
message.success('云类型已删除')
} catch (error) {
const text = getErrorMessage(error, '云类型删除失败')
loadError.value = text
message.error(text)
} finally {
actionLoading.value = false
}
}
function toggleSidebar() {
accountMenuOpen.value = false
sidebarCollapsed.value = !sidebarCollapsed.value
@@ -477,8 +646,9 @@ async function handleLogout() {
accountMenuOpen.value = false
try {
await authStore.logout()
} finally {
await router.push('/')
} catch (error) {
message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。')
}
}
@@ -1167,6 +1337,103 @@ onMounted(loadAdminData)
</div>
</section>
<section v-else-if="activeTab === 'cloud-types'">
<div class="oc-panel-card mb-4 flex flex-col gap-4 border border-slate-200 bg-white p-4 sm:p-5 md:flex-row md:items-center md:justify-between">
<div>
<p class="oc-section-label">Cloud Catalog</p>
<h2 class="mt-2 text-xl font-bold text-slate-950">云类型管理</h2>
<p class="oc-field-help">共 {{ cloudTypes.length }} 个类型,可维护名称、云属、稀有度和百科描述。</p>
</div>
<NButton
type="default"
secondary
strong
class="oc-panel-button oc-panel-button--sky shrink-0"
@click="openCloudTypeEditor()"
>
<template #icon><NIcon><Plus /></NIcon></template>
添加云类型
</NButton>
</div>
<div v-if="cloudTypes.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<article
v-for="cloudType in cloudTypes"
:key="cloudType.id"
class="oc-panel-card flex min-w-0 flex-col border border-slate-200 bg-white"
>
<div class="flex items-start justify-between gap-4 border-b border-slate-200 bg-sky-50/60 px-4 py-3">
<div class="min-w-0">
<p class="font-mono text-xs font-semibold uppercase tracking-[0.14em] text-sky-700">Type {{ cloudType.id }}</p>
<h3 class="mt-1 truncate text-lg font-bold text-slate-950">{{ cloudType.name }}</h3>
<p class="mt-1 truncate text-sm text-slate-500">{{ cloudType.genus || '未设置云属' }}</p>
</div>
<span class="shrink-0 border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-semibold text-amber-800">
稀有度 {{ cloudType.rarity }}
</span>
</div>
<div class="flex flex-1 flex-col p-4">
<p class="min-h-12 text-sm leading-6 text-slate-700">
{{ cloudType.description || '暂无类型描述。' }}
</p>
<dl class="mt-4 grid gap-3 border-t border-slate-100 pt-4 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium text-slate-400">关联云图</dt>
<dd class="mt-1 text-sm font-semibold text-slate-900">{{ cloudTypeUsageCount(cloudType.id) }} 张</dd>
</div>
<div>
<dt class="text-xs font-medium text-slate-400">创建时间</dt>
<dd class="mt-1 text-sm text-slate-600">{{ formatDateTime(cloudType.created_at) }}</dd>
</div>
</dl>
<div v-if="cloudType.icon_id" class="mt-3 border border-slate-100 bg-slate-50 px-3 py-2">
<p class="text-xs font-medium text-slate-400">图标 ID</p>
<p class="mt-1 truncate font-mono text-xs text-slate-600" :title="cloudType.icon_id">{{ cloudType.icon_id }}</p>
</div>
</div>
<div class="flex flex-wrap gap-2 border-t border-slate-200 bg-slate-50 px-4 py-3">
<NButton
size="small"
type="default"
secondary
strong
class="oc-panel-button oc-panel-button--neutral"
:disabled="actionLoading"
@click="openCloudTypeEditor(cloudType)"
>
<template #icon><NIcon><Edit /></NIcon></template>
编辑
</NButton>
<NButton
size="small"
type="default"
secondary
strong
class="oc-panel-button oc-panel-button--danger"
:disabled="actionLoading || cloudTypeUsageCount(cloudType.id) > 0"
:title="cloudTypeUsageCount(cloudType.id) > 0 ? '仍有云图使用该类型,无法删除' : '删除云类型'"
@click="deleteCloudType(cloudType)"
>
<template #icon><NIcon><Trash /></NIcon></template>
删除
</NButton>
</div>
</article>
</div>
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10">
<NEmpty description="还没有云类型">
<template #extra>
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" @click="openCloudTypeEditor()">
添加第一个云类型
</NButton>
</template>
</NEmpty>
</div>
</section>
<section v-else>
<div class="oc-panel-card mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between">
<div>
@@ -1274,6 +1541,95 @@ onMounted(loadAdminData)
</main>
</div>
<Teleport to="body">
<div
v-if="cloudTypeEditorOpen"
class="fixed inset-0 z-[140] flex items-center justify-center overflow-y-auto bg-slate-950/60 px-4 py-6"
@click="closeCloudTypeEditor"
>
<form class="oc-modal-shell my-auto w-full max-w-2xl" @click.stop @submit.prevent="saveCloudType">
<div class="flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4 sm:px-6 sm:py-5">
<div>
<p class="oc-section-label">Cloud Type Editor</p>
<h3 class="mt-2 text-xl font-semibold text-slate-900">
{{ editingCloudType ? '编辑云类型' : '添加云类型' }}
</h3>
<p class="oc-field-help">
{{ editingCloudType ? `正在编辑类型 #${editingCloudType.id}` : '类型 ID 将由服务端自动生成。' }}
</p>
</div>
<button
type="button"
class="oc-icon-button"
aria-label="关闭云类型编辑窗口"
title="关闭云类型编辑窗口"
@click="closeCloudTypeEditor"
>
<NIcon size="20"><X /></NIcon>
</button>
</div>
<div class="grid gap-4 px-5 py-5 sm:grid-cols-2 sm:px-6">
<NAlert v-if="cloudTypeFormError" class="sm:col-span-2" type="error" :show-icon="false" :bordered="false">
{{ cloudTypeFormError }}
</NAlert>
<div>
<label class="oc-field-label">类型名称 <span class="oc-field-required">*</span></label>
<NInput
v-model:value="cloudTypeForm.name"
placeholder="例如:积云"
:input-props="{ name: 'cloud-type-name', required: true }"
/>
</div>
<div>
<label class="oc-field-label">云属</label>
<NInput v-model:value="cloudTypeForm.genus" placeholder="例如:Cumulus" />
<p class="oc-field-help">留空将保存为未设置。</p>
</div>
<div>
<label class="oc-field-label">稀有度 <span class="oc-field-required">*</span></label>
<input
v-model="cloudTypeForm.rarity"
class="oc-field-control"
type="number"
step="1"
inputmode="numeric"
name="cloud-type-rarity"
required
/>
<p class="oc-field-help">请输入整数,具体等级含义由后端数据约定。</p>
</div>
<div>
<label class="oc-field-label">图标 ID</label>
<NInput v-model:value="cloudTypeForm.iconId" placeholder="UUID,可留空" />
<p class="oc-field-help">留空可清除已有图标关联。</p>
</div>
<div class="sm:col-span-2">
<label class="oc-field-label">描述 <span class="oc-field-required">*</span></label>
<NInput
v-model:value="cloudTypeForm.description"
type="textarea"
:maxlength="128"
show-count
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="填写辨识特征;允许留空"
/>
</div>
</div>
<div class="flex items-center justify-end gap-3 border-t border-slate-200 px-5 py-4 sm:px-6">
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" :disabled="actionLoading" @click="closeCloudTypeEditor">
取消
</NButton>
<NButton attr-type="submit" type="default" secondary strong class="oc-panel-button oc-panel-button--sky" :loading="actionLoading">
{{ editingCloudType ? '保存修改' : '添加类型' }}
</NButton>
</div>
</form>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="passwordTarget"
+1 -1
View File
@@ -70,7 +70,7 @@ onUnmounted(() => { if (timer) clearInterval(timer) })
<div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
<h1 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h1>
<p class="mt-2 text-sm text-slate-500">输入注册邮箱,我们会发送有效期为 30 分钟的重置链接。</p>
<p class="mt-2 text-sm text-slate-500">输入注册邮箱,我们会发送有效期为 1 小时的重置链接。</p>
</div>
<NForm @submit.prevent="submit">
<NFormItem label="邮箱">
+5 -2
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
import { AuthClientError } from '@/lib/authClient'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
@@ -12,7 +13,7 @@ const email = ref(typeof route.query.email === 'string' ? route.query.email : ''
const password = ref('')
const error = ref('')
const loading = ref(false)
const needsEmailConfirmation = computed(() => error.value.includes('邮箱尚未验证'))
const needsEmailConfirmation = ref(false)
function getSafeRedirect(value: unknown) {
if (typeof value !== 'string' || !value.startsWith('/')) return '/'
@@ -29,11 +30,13 @@ function getSafeRedirect(value: unknown) {
async function handleLogin() {
error.value = ''
needsEmailConfirmation.value = false
loading.value = true
try {
await authStore.login(email.value, password.value)
void router.push(getSafeRedirect(route.query.redirect))
} catch (e: unknown) {
needsEmailConfirmation.value = e instanceof AuthClientError && e.code === 'EMAIL_NOT_VERIFIED'
error.value = e instanceof Error ? e.message : '登录失败,请稍后重试'
} finally {
loading.value = false
+7 -12
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
import { ApiError } from '@/lib/api'
import { AuthClientError } from '@/lib/authClient'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
@@ -15,9 +15,7 @@ const confirmPassword = ref('')
const error = ref('')
const loading = ref(false)
const registered = ref(false)
const emailDelivered = ref(true)
const registerConflict = ref(false)
const canResend = computed(() => registerConflict.value)
async function handleRegister() {
error.value = ''
@@ -48,12 +46,11 @@ async function handleRegister() {
loading.value = true
try {
const response = await authStore.register(trimmedName, trimmedEmail, password.value)
await authStore.register(trimmedName, trimmedEmail, password.value)
email.value = trimmedEmail
emailDelivered.value = response.email_sent
registered.value = true
} catch (e: unknown) {
if (e instanceof ApiError && e.status === 409) {
if (e instanceof AuthClientError && (e.status === 409 || e.code === 'USER_ALREADY_EXISTS')) {
registerConflict.value = true
error.value = '邮箱或用户名已被占用;若邮箱尚未验证,可以尝试重发验证邮件。'
} else {
@@ -93,11 +90,9 @@ async function handleRegister() {
<NCard class="oc-panel-card-xl min-w-0 h-full">
<NResult
v-if="registered"
:status="emailDelivered ? 'success' : 'warning'"
status="success"
title="确认你的邮箱"
:description="emailDelivered
? '确认邮件已经发送,请查收并点击链接完成注册。'
: '账号已创建,但验证邮件暂未送达。请使用重发验证功能,不要重复注册。'"
description="如该邮箱可以注册,请查收验证邮件并点击链接。验证后请重新登录。"
>
<template #footer>
<div class="space-y-4">
@@ -137,7 +132,7 @@ async function handleRegister() {
<NAlert v-if="error" type="error" class="mb-4">
{{ error }}
<div v-if="canResend" class="mt-2">
<div v-if="registerConflict" class="mt-2">
<RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="font-semibold text-rose-700 underline underline-offset-2">重发验证邮件</RouterLink>
</div>
</NAlert>
+5 -4
View File
@@ -2,6 +2,7 @@
import { computed, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
import { AuthClientError } from '@/lib/authClient'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
@@ -11,8 +12,8 @@ const email = typeof route.query.email === 'string' ? route.query.email : ''
const token = typeof route.query.token === 'string' ? route.query.token : ''
const password = ref('')
const confirmPassword = ref('')
const error = ref(!email || !token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '')
const invalid = ref(!email || !token)
const error = ref(!token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '')
const invalid = ref(!token)
const success = ref(false)
const loading = ref(false)
const countdown = ref(5)
@@ -46,12 +47,12 @@ async function submit() {
}
loading.value = true
try {
await authStore.resetPassword(email, token, password.value)
await authStore.resetPassword(token, password.value)
success.value = true
startCountdown()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试'
if (error.value.includes('无效') || error.value.includes('过期')) invalid.value = true
if (e instanceof AuthClientError && (e.status === 400 || e.code === 'INVALID_TOKEN')) invalid.value = true
} finally {
loading.value = false
}
+2
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { NAlert, NButton, NEmpty, NSkeleton } from 'naive-ui'
import { RouterLink, useRoute } from 'vue-router'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { apiRequest, imageUrl, toApiCloud } from '@/lib/api'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
@@ -137,6 +138,7 @@ watch(() => route.params.id, () => loadPage())
>
<p class="text-sm leading-7 text-slate-700">{{ selectedItem.description || '上传者没有留下额外说明。' }}</p>
<MiniLocationMap :latitude="selectedItem.latitude" :longitude="selectedItem.longitude" />
<CloudLikeButton :cloud-id="selectedItem.id" :count="selectedItem.received_like_count" />
</ImageDetailModal>
</div>
</template>
+4
View File
@@ -4,6 +4,7 @@ import { RouterLink } from 'vue-router'
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
@@ -29,6 +30,7 @@ interface GalleryCloud {
cloudTypeName: string
cloudTypeRarity: number
username: string
receivedLikeCount: number
}
const PAGE_SIZE = 50
@@ -98,6 +100,7 @@ function toGalleryCloud(row: ApiCloud) {
cloudTypeName: row.cloud_type_name || '未知',
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
username: row.username || '匿名',
receivedLikeCount: row.received_like_count,
} satisfies GalleryCloud
}
@@ -551,6 +554,7 @@ onUnmounted(() => {
:latitude="selectedCloud.latitude"
:longitude="selectedCloud.longitude"
/>
<CloudLikeButton :cloud-id="selectedCloud.id" :count="selectedCloud.receivedLikeCount" />
<template v-if="selectedCloudIsMine" #actions>
<div class="flex items-center justify-between gap-3">
+4
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
import { apiRequest, toApiCloud } from '@/lib/api'
@@ -22,6 +23,7 @@ interface CloudMarkerData {
username: string
capturedAt: string
createdAt: string
receivedLikeCount: number
}
const mapEl = ref<HTMLDivElement>()
@@ -417,6 +419,7 @@ function toCloudMarker(row: ApiCloud): CloudMarkerData {
username: row.username || '匿名',
capturedAt: row.captured_at || row.created_at,
createdAt: row.created_at,
receivedLikeCount: row.received_like_count,
}
}
@@ -862,6 +865,7 @@ onUnmounted(() => {
:latitude="previewCloud.latitude"
:longitude="previewCloud.longitude"
/>
<CloudLikeButton :cloud-id="previewCloud.id" :count="previewCloud.receivedLikeCount" />
</ImageDetailModal>
<QuickUploadModal
+3 -4
View File
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { RouterLink } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
const message = useMessage()
const usernameDraft = ref('')
@@ -75,8 +74,8 @@ async function savePassword() {
currentPassword.value = ''
newPassword.value = ''
confirmNewPassword.value = ''
message.success('密码已更新,请重新登录')
await router.push('/login')
success.value = '密码已更新。其他设备上的会话已退出。'
message.success('密码已更新')
} catch (e) {
error.value = e instanceof Error ? e.message : '密码更新失败'
} finally {
+14 -3
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
import { Settings } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
@@ -54,7 +55,11 @@ const errorMsg = computed(() => {
if (!viewedIdentifier.value) return '未找到要查看的用户。'
return profileStore.getError(viewedIdentifier.value, isOwnProfile.value)
})
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedIdentifier.value))
const profileData = computed<Profile | null>(() => isOwnProfile.value
? authStore.profile
: profileStore.getProfile(viewedIdentifier.value))
const profileImageFailed = ref(false)
watch(() => profileData.value?.image, () => { profileImageFailed.value = false })
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedIdentifier.value, isOwnProfile.value))
const receivedLikes = computed(() => isOwnProfile.value ? authStore.user?.received_like_count || 0 : 0)
@@ -439,10 +444,11 @@ watch(selectedUploadDate, async newValue => {
class="profile-avatar flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-3xl font-bold text-slate-900"
>
<img
v-if="profileData?.avatar_id"
:src="profileData.avatar_id"
v-if="profileData?.image && !profileImageFailed"
:src="profileData.image"
:alt="profileData.username"
class="h-full w-full object-cover"
@error="profileImageFailed = true"
/>
<template v-else>
{{ getProfileInitial(profileData) }}
@@ -757,6 +763,11 @@ watch(selectedUploadDate, async newValue => {
:latitude="selectedCloud.latitude"
:longitude="selectedCloud.longitude"
/>
<CloudLikeButton
v-if="selectedCloud.status === 'approved' && !selectedCloud.is_hidden"
:cloud-id="selectedCloud.id"
:count="selectedCloud.receivedLikeCount"
/>
<template v-if="isOwnProfile" #actions>
<div class="flex items-center justify-between gap-3">