From c78b2f8ca5e5251eb5d765ad220ee04d9618c59a Mon Sep 17 00:00:00 2001 From: Mplan Date: Sun, 16 Aug 2026 00:34:04 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(api)!:=20=E9=80=82=E9=85=8D=E6=96=B0?= =?UTF-8?q?=E7=89=88=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切换到 Cookie 会话和新版 DTO,并迁移画廊、地图、个人主页、百科及上传流程。停用后端尚未实现的账号与徽章功能,同时纳入新版 API 契约文档。 BREAKING CHANGE: 前端不再兼容旧 /api/v1 接口和 refresh token 流程。 --- .env.example | 2 +- API.md | 1023 +++++++++++++++++ src/components/cloud/CloudEditModal.vue | 51 +- .../cloud/EncyclopediaProgressCard.vue | 108 -- src/components/cloud/QuickUploadModal.vue | 42 +- src/components/layout/AppHeader.vue | 15 +- src/composables/useUpload.ts | 92 +- src/lib/api.ts | 197 +++- src/lib/canvas.ts | 37 - src/lib/cloudBadges.ts | 130 --- src/main.ts | 3 - src/router/index.ts | 14 +- src/stores/auth.ts | 95 +- src/stores/clouds.ts | 6 +- src/stores/encyclopedia.ts | 87 +- src/stores/profile.ts | 206 ++-- src/types/api.ts | 118 +- src/types/database.ts | 17 +- src/views/auth/AuthConfirmView.vue | 87 +- src/views/auth/ForgotPasswordView.vue | 176 +-- src/views/auth/LoginView.vue | 19 +- src/views/auth/RegisterView.vue | 181 +-- src/views/auth/ResetPasswordView.vue | 206 +--- src/views/auth/UnavailableAuthView.vue | 44 + src/views/encyclopedia/CloudTypeView.vue | 318 ++--- src/views/encyclopedia/EncyclopediaView.vue | 217 +--- src/views/gallery/GalleryView.vue | 129 +-- src/views/map/MapView.vue | 41 +- src/views/profile/ProfileSettingsView.vue | 34 +- src/views/profile/ProfileView.vue | 111 +- src/views/upload/UploadView.vue | 128 +-- 31 files changed, 1750 insertions(+), 2184 deletions(-) create mode 100644 API.md delete mode 100644 src/components/cloud/EncyclopediaProgressCard.vue delete mode 100644 src/lib/canvas.ts delete mode 100644 src/lib/cloudBadges.ts create mode 100644 src/views/auth/UnavailableAuthView.vue diff --git a/.env.example b/.env.example index dea8a32..cf88bd4 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -VITE_API_URL=http://localhost:8000/api/v1 +VITE_API_URL=http://localhost:3000 VITE_AMAP_KEY= VITE_SITE_URL=http://localhost:5173 diff --git a/API.md b/API.md new file mode 100644 index 0000000..f3103a6 --- /dev/null +++ b/API.md @@ -0,0 +1,1023 @@ +# OpenCloud API 前端开发文档 + +> **读者**:前端开发者。本文档覆盖 API 的全部端点:请求参数、响应结构、可能出现的错误与已知坑点。 +> +> **事实来源**:本文档的字段名、约束与错误消息以 `src/schema/*.ts`(zod 校验定义)与 `src/router/*.ts`(路由实现)为最终依据整理。若文档与代码不一致,以代码为准。 +> +> **重要免责**:错误消息文案(`error` 字段的中文内容)可能随时调整。**前端逻辑只能依赖 HTTP 状态码,严禁匹配错误消息字符串**。文档中列出消息原文仅供调试对照。 + +## 目录 + +1. 通用约定 +2. 错误处理与已知不一致 +3. 认证 +4. 端点 · 认证 `/auth` +5. 端点 · 云图 `/cloud` +6. 端点 · 个人资料 `/profile` +7. 端点 · 信息 `/info` +8. 端点 · 管理 `/admin` +9. 端点 · 系统 + +--- + +## 1. 通用约定 + +### 1.1 Base URL + +| 环境 | 地址 | +|---|---| +| 本地开发 | `http://localhost:3000`(`vc dev` 启动) | +| 生产 | 以部署地址为准 | + +除 `/image` 成功响应直接返回图片字节外,响应均为 `application/json`(个别异常情形返回纯文本,见 2.2)。所有响应携带两个调试响应头: + +- `X-Request-Id`:请求唯一 ID,反馈问题时请附上 +- `X-Response-Time`:服务端处理耗时,如 `12ms` + +### 1.2 命名与格式 + +- 请求与响应字段一律 **snake_case**(如 `cloud_id`、`page_size`、`received_like_count`) +- 日期时间一律为 **ISO 8601 字符串**(如 `2026-08-15T08:30:00.000Z`),可空的日期字段值为 `null`。唯一的例外见 2.3 第 6 条 +- UUID 字段为标准 UUID 字符串 + +### 1.3 响应形态 + +响应**没有统一包装**,分两种形态: + +| 场景 | 形态 | 示例 | +|---|---|---| +| 写操作成功(POST/PATCH/DELETE 的动作类端点) | 信封对象 | `{ "status_code": 200, "message": "删除成功" }` | +| 读操作成功(GET 及登录) | 裸数据 | 单资源为对象、列表为**数组**、登录为 `{ "access_token" }` | + +### 1.4 分页 + +所有分页端点使用相同的 query 参数: + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `page` | int | 否 | ≥ 1,默认 `1` | 页码 | +| `page_size` | int | 否 | 1–100,默认 `50` | 每页数量 | + +⚠️ **响应不返回总数**,也没有 `has_more` 字段。判断「是否还有下一页」只能依靠「返回数组长度 < `page_size`」。超出数据范围的页返回 `200` + 空数组 `[]`,不是 404。 + +云图列表排序为 `uploaded_at` 倒序(同刻按 `id` 倒序);点赞列表按点赞时间倒序。 + +### 1.5 共享数据模型 + +以下模型在多个端点复用,端点章节中直接引用名称。 + +**CloudInfo(云图)** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | uuid | 云图 ID | +| `owner` | object | 上传者:`{ id: uuid, name: string }` | +| `type` | object | 云类型:`{ id: int, name: string, genus: string\|null, icon_id: uuid\|null, rarity: int, description: string }` | +| `latitude` | number | 纬度,-90 ~ 90 | +| `longitude` | number | 经度,-180 ~ 180 | +| `description` | string | 描述,可能为空字符串 | +| `captured_at` | string\|null | 拍摄时间(ISO 8601) | +| `uploaded_at` | string | 上传时间(ISO 8601) | +| `updated_at` | string | 最后更新时间(ISO 8601) | +| `received_like_count` | int | 收到点赞数 | +| `status` | enum | 审核状态:`pending`(待审核)/ `approved`(已通过)/ `rejected`(已驳回) | +| `is_hidden` | boolean | 是否隐藏(上传者控制,与审核状态独立) | + +**公开可见规则**:未登录访客与无关用户只能看到 `status === "approved"` 且 `is_hidden === false` 的云图;上传者本人与 admin 不受限。 + +**UserProfile(用户)** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | uuid | 用户 ID | +| `name` | string | 用户名 | +| `email` | string | 邮箱(注意 2.3 第 8 条的暴露范围问题) | +| `avatar_id` | uuid\|null | 头像 ID(当前恒为 `null`,头像功能未上线) | +| `cloud_count` | int | 云图数(该用户上传的云图总数) | +| `received_like_count` | int | 收到点赞数(名下云图获赞总和) | +| `last_online` | string\|null | 最后在线时间(ISO 8601) | +| `role` | enum | `user` / `admin` | +| `is_disabled` | boolean | 是否被禁用 | +| `created_at` | string | 注册时间(ISO 8601) | + +**CloudTypeInfo(云类型)** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | 云类型 ID | +| `name` | string | 名称,如「积云」 | +| `genus` | string\|null | 属(拉丁名),如 `Cumulus` | +| `icon_id` | uuid\|null | 图标 ID | +| `rarity` | int | 稀有度 | +| `description` | string | 描述 | +| `created_at` | string | 创建时间。⚠️ 列表与详情端点的格式不一致,见 2.3 第 6 条 | + +### 1.6 上传约束(`POST /cloud`) + +- 请求体总大小上限 **21 MiB**,超出返回 413 +- 图片文件上限 **20 MB**;仅支持 **JPEG / PNG / WebP**;**不支持动图**(含动态 WebP、GIF) +- 图片分辨率过高会被拒绝(像素上限) +- multipart 表单字段均为字符串,注意 `is_hidden` 的编码规则(见 5.7) + +--- + +## 2. 错误处理与已知不一致 + +### 2.1 两种错误响应形状 + +本 API 存在**两种**错误响应形状,前端必须分别处理: + +**① 业务错误**(绝大多数错误)——ErrorMessage 信封: + +```json +{ + "status_code": 404, + "error": "未找到图片" +} +``` + +**② 参数校验失败(400)**——zod 原始结构,**不是**上面的信封: + +```json +{ + "success": false, + "error": { + "name": "ZodError", + "message": "[{\"code\":\"invalid_format\",\"path\":[\"email\"],\"message\":\"Invalid email address\"}]" + } +} +``` + +注意 `error.message` 是一个 **JSON 字符串**(issues 数组的序列化结果),需要二次 `JSON.parse` 才能拿到字段级错误明细;每个 issue 含 `path`(出错字段路径)、`code`、`message`。不需要逐字段提示时,直接弹「参数错误」即可。 + +**判别方式**:响应体含 `success: false` → 形状②;含 `status_code` → 形状①。 + +### 2.2 状态码总表 + +| 状态码 | 含义 | 前端通用动作 | +|---|---|---| +| 200 | 成功 | — | +| 201 | 创建成功(注册、上传云图) | — | +| 304 | 图片 ETag 未变化,无响应 body | 继续使用本地缓存 | +| 400 | 参数校验失败(形状②),或业务规则拒绝(形状①,如「新密码不能与当前密码相同」) | 检查参数;形状②可解析 `error.message` 定位字段 | +| 401 | 未认证:未携带 token,或 token 无效/过期/已失效 | 清除本地登录态,跳转登录页 | +| 403 | 已认证但无权限:角色不足、帐号被禁用、越权访问他人资源 | 按消息场景提示 | +| 404 | 资源不存在(形状①);**也可能是纯文本**(见下方警告) | 区分 content-type 后处理 | +| 405 | HTTP 方法不受端点支持 | 读取 `Allow` 响应头 | +| 409 | 唯一性冲突:邮箱/用户名已被占用 | 提示用户更换 | +| 413 | 上传请求体超过 21 MiB | 提示压缩图片后重试 | +| 500 | 服务器内部错误 | 提示稍后重试,反馈时附 `X-Request-Id` | +| 503 | 图片存储服务暂时不可用 | 稍后重试,反馈时附 `X-Request-Id` | + +⚠️ **404 有两种 body**:业务 404(资源不存在)是 JSON 信封;但「未实现的占位端点」和「个别端点的数据库异常」返回的是 **纯文本 `404 Not Found`**(`text/plain`),见 2.3 第 2 条。前端解析 404 响应前必须先判断 content-type。 + +### 2.3 ⚠️ 已知不一致清单 + +以下是文档写作时核实的现状描述,**未来可能修复**。前端如需为之写防御代码,请做好移除准备。 + +1. **两种错误形状并存**:400 校验错误不是业务错误信封(见 2.1)。 +2. **部分 404 是纯文本而非 JSON**,来源有三:(a) 未实现的占位端点(各章已标注);(b) 以下端点的 `catch` 为空,数据库异常时返回纯文本 404 而非 500——`POST /auth/register`、`GET /profile/me`、`GET /admin/users`;(c) 未匹配任何路由的路径。 +3. **注册即禁用**:新注册用户的 `is_disabled` 默认为 `true`,而确认邮箱的端点(`/auth/confirm-email`)尚未实现。即**当前注册成功后无法登录**(登录返回 403「帐号已被禁用」),需后端在数据库手动启用。这是流程断点,前端联调注册流程时务必知悉。 +4. **静默续签只写 Cookie**:token 到期前的自动续签仅通过 `Set-Cookie` 下发新 token(见 3.4)。使用 Bearer header 模式的客户端**拿不到新 token**,30 分钟后必然 401,需重新登录。 +5. **`POST /admin/user` 是假端点**:它原样回显请求体(**含明文密码**),并不创建用户。 +6. **`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。前端解析该字段时需兼容两种格式。 +7. **用户名/邮箱长度不在 API 层校验**:数据库限制用户名 ≤ 16 字符、邮箱 ≤ 64 字符,但 zod 校验不拦截。超长注册会因 2.3.2(b) 返回**纯文本 404**;超长改名(`PATCH /profile/me`)返回 500。前端应自行限制输入长度。 +8. **`GET /profile/:user_id` 向任意登录用户暴露对方邮箱**:任何登录用户都能查到任意用户的 `email`。前端展示他人资料时不应展示邮箱,也不应假设该接口未来仍返回邮箱。 +9. **用户名唯一性的大小写规则不一致**:注册时的查重是大小写不敏感的(`Alice`/`alice` 视为重复),但数据库唯一约束与 `PATCH /profile/me` 改名是大小写敏感的(可改成仅大小写不同的名字)。 +10. **README 与代码的 CORS 环境变量不一致**:README 写的是 `CORS_ORIGINS`(复数、逗号分隔),代码实际读取 `CORS_ORIGIN`(单数、单个 origin)。本地联调配置后端环境变量时用单数。 + +--- + +## 3. 认证 + +### 3.1 机制概览 + +- JWT(HS256),有效期 **30 分钟** +- 获取方式:`POST /auth/login` 成功后在响应体返回 `access_token`,**同时**通过 `Set-Cookie` 写入 HttpOnly Cookie +- 受保护端点接受两种携带方式(服务端优先读 header): + 1. 请求头 `Authorization: Bearer ` + 2. Cookie `token=`(`HttpOnly; SameSite=Strict; Path=/; Max-Age=1800`;非开发/测试环境附加 `Secure`) + +### 3.2 Bearer 还是 Cookie? + +| 模式 | 适用 | 注意事项 | +|---|---|---| +| Cookie | 浏览器前端(推荐) | 登录后浏览器自动携带;`fetch` 需设 `credentials: "include"`;HttpOnly 使 JS 无法读取 token,天然防 XSS 窃取;可享受静默续签 | +| Bearer | 非浏览器客户端(App、脚本) | 自行存储 `access_token` 并注入 header;**无法静默续签**(2.3 第 4 条),30 分钟后需重新登录 | + +### 3.3 CORS + +- 服务端仅放行**单个** origin(环境变量 `CORS_ORIGIN`,本地默认应为 `http://localhost:5173`),且 `credentials: true` +- 前端本地开发的服务端口/origin 必须与后端配置**完全一致**(协议、主机、端口),否则 Cookie 模式的请求会被浏览器拦截 +- 放行的请求头:`Content-Type`、`Authorization` +- 私有图片通过 `` 直接加载时依赖 Cookie;生产环境前端与 API 必须位于同一 site(例如 `app.example.com` 与 `api.example.com`),否则 `SameSite=Strict` Cookie 不会随图片请求发送 + +### 3.4 有效期、续签与失效 + +- token 有效期 30 分钟(Cookie 的 `Max-Age` 同为 1800 秒) +- **静默续签**:携带剩余有效期 ≤ 300 秒的 token 访问受保护端点时,服务端会在响应中**追加 `Set-Cookie` 写入新 token**。Cookie 模式下浏览器自动替换,前端无感知;Bearer 模式收不到新 token +- 续签不适用的路径:`POST /auth/logout`、`PATCH /auth/password` +- `/image` 请求只验证身份,不触发静默续签,避免一个页面的并发图片请求重复写 Cookie +- **立即失效**的三种情况:调用 `/auth/logout`、调用 `PATCH /auth/password` 改密、帐号被禁用。前两者会使该用户**所有已签发的 token 全部作废**(包括其他设备上的),并写入过期 Cookie + +### 3.5 认证相关错误消息对照 + +| 状态码 | 消息原文 | 场景 | +|---|---|---| +| 401 | `未登录` | 受保护端点未携带任何 token | +| 401 | `Token 无效或已过期` | token 签名无效、过期、已被吊销(登出/改密后)、或帐号已被禁用 | +| 403 | `权限不足` | 已登录但角色不满足(如非 admin 访问 `/admin`) | + +注意「帐号被禁用」在**登录时**报 403「帐号已被禁用」,但已登录后被禁用,后续请求报的是 401「Token 无效或已过期」。 + +--- + +## 4. 端点 · 认证 `/auth` + +### 4.1 `POST /auth/register` — 注册 + +- **认证**:无(公开) + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(2.3 第 7 条) | 用户名,大小写不敏感查重 | +| `email` | string | 是 | 合法邮箱格式;⚠️ 数据库限 ≤ 64 字符 | 邮箱,大小写不敏感查重 | +| `password` | string | 是 | 无强度校验 | 密码 | + +```json +{ + "name": "cloudwatcher", + "email": "watcher@example.com", + "password": "s3cret-password" +} +``` + +**成功响应**:`201` + +```json +{ + "status_code": 201, + "message": "注册成功" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | +| 409 | 邮箱已注册(大小写不敏感) | `该邮箱已被注册` | 提示换邮箱或直接登录 | +| 409 | 用户名已使用(大小写不敏感) | `该用户名已被使用` | 提示换用户名 | +| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | + +**注意事项**: + +- ⚠️ **注册成功后无法直接登录**:新用户默认 `is_disabled: true`,登录将返回 403(2.3 第 3 条)。联调时需后端手动启用帐号 +- 注册响应**不返回 token、不写 Cookie**,登录需另行调用 `/auth/login` + +### 4.2 `POST /auth/resend-confirmation` — 重发确认邮件 + +> ⚠️ **未实现**(占位端点)。请求体合法时当前返回纯文本 `404 Not Found`;请求体非法时仍会被 zod 校验拦截返回 400。请勿接入。 + +### 4.3 `POST /auth/confirm-email` — 确认邮箱 + +> ⚠️ **未实现**(占位端点),行为同 4.2。该端点是解除「注册即禁用」(2.3 第 3 条)的设计入口,上线前注册流程是断的。 + +### 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 | 帐号被禁用(含新注册未启用的帐号) | `帐号已被禁用` | 提示联系管理员 | +| 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/forget-password` — 申请重置密码 + +> ⚠️ **未实现**(占位端点),行为同 4.2。请勿接入。 + +### 4.7 `POST /auth/reset-password` — 重置密码 + +> ⚠️ **未实现**(占位端点),行为同 4.2。请勿接入。 + +### 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 立即失效**(含当前使用的这个),前端必须清理登录态并跳转登录页。 + +--- + +## 5. 端点 · 云图 `/cloud` + +### 5.1 `GET /cloud` — 云图列表(公开搜索) + +- **认证**:无(公开);仅返回公开可见(`approved` 且未隐藏)的云图 + +**Query 参数**(在分页参数 1.4 基础上): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `page` | int | 否 | 默认 `1` | 见 1.4 | +| `page_size` | int | 否 | 默认 `50`,最大 `100` | 见 1.4 | +| `filter` | enum | 否 | `type` / `owner`,默认 `type` | 搜索维度 | +| `value` | string | 否 | 默认 `""`(空 = 不过滤) | 搜索词 | + +搜索逻辑: + +- `value` 为空 → 不过滤,返回全部公开云图 +- `filter=owner`,**或** `value` 以 `@` 开头 → 按**用户名精确匹配**搜索该用户的公开云图(`@` 前缀会被去掉) +- 其他情况 → 按**云类型名称精确匹配**搜索 +- 均为精确匹配,不支持模糊搜索 + +**成功响应**:`200`,CloudInfo 数组(字段见 1.5) + +```json +[ + { + "id": "3f8a2c1e-7b9d-4e5f-a1c2-9d8e7f6a5b4c", + "owner": { + "id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", + "name": "cloudwatcher" + }, + "type": { + "id": 3, + "name": "积云", + "genus": "Cumulus", + "icon_id": null, + "rarity": 2, + "description": "底部平坦、顶部蓬松的白色云块" + }, + "latitude": 39.9042, + "longitude": 116.4074, + "description": "午后拍到的淡积云", + "captured_at": "2026-08-14T07:20:00.000Z", + "uploaded_at": "2026-08-15T08:30:00.000Z", + "updated_at": "2026-08-15T08:30:00.000Z", + "received_like_count": 12, + "status": "approved", + "is_hidden": false + } +] +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 参数校验失败(如 `page_size=0`) | zod 形状(2.1) | 检查参数 | +| 404 | 按用户名搜索但该用户不存在 | `该用户不存在` | 提示「没有找到这个用户」,不要显示为空列表 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:按云类型搜索时类型名不存在**不报错**,返回空数组(与用户名搜索的 404 行为不同)。 + +### 5.2 `GET /cloud/map` — 时间范围地图数据 + +- **认证**:无(公开);仅返回公开可见的云图 + +**Query 参数**: + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `start` | date string | 是 | 须 ≤ `end` | 范围起点,如 `2026-08-01T00:00:00Z` | +| `end` | date string | 是 | — | 范围终点 | +| `time_field` | enum | 否 | `captured_at` / `uploaded_at`,默认 `captured_at` | 按拍摄时间还是上传时间过滤 | +| `limit` | int | 否 | 1–1000,默认 `1000` | 返回上限(不分页) | + +**成功响应**:`200`,CloudInfo 数组,按 `time_field` 倒序(同刻按 `id` 倒序),结构同 5.1。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | `start` 晚于 `end` | zod 形状,issues 中含 `开始时间不能晚于结束时间` | 表单前置校验 | +| 400 | 其他参数校验失败 | zod 形状(2.1) | 检查参数 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:`time_field=captured_at`(默认)时,`captured_at` 为 `null` 的云图**不会出现在结果中**;需要全量数据时请用 `uploaded_at`。 + +### 5.3 `GET /cloud/:cloud_id` — 云图详情 + +- **认证**:可选。匿名访客按公开可见规则过滤;上传者本人可见自己的全部云图;admin 可见全部 +- 无效/过期 token 按匿名处理,**不会**返回 401 + +**路径参数**: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `cloud_id` | uuid | 云图 ID | + +**成功响应**:`200`,单个 CloudInfo 对象(字段见 1.5),结构同 5.1 的数组元素。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | `cloud_id` 不是合法 uuid | zod 形状(2.1) | 检查链接 | +| 404 | 云图不存在,**或**对当前用户不可见(待审核/已驳回/已隐藏) | `未找到图片` | 统一提示「云图不存在或不可见」——服务端故意不区分,防止泄露私有云图的存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +### 5.4 `GET /cloud/type/:cloud_type_id` — 按云类型列出云图 + +- **认证**:无(公开);仅返回公开可见的云图 + +**路径参数**: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `cloud_type_id` | int | 云类型 ID(非数字返回 400) | + +**Query 参数**:分页参数,见 1.4。 + +**成功响应**:`200`,CloudInfo 数组,结构同 5.1。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 参数校验失败 | zod 形状(2.1) | 检查参数 | +| 404 | 云类型不存在 | `云类型不存在` | 提示类型不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +### 5.5 `POST /cloud/:cloud_id/like` — 点赞 + +- **认证**:需登录(任意角色) +- **请求体**:无 + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "点赞成功" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 404 | 云图不存在,或不是公开可见状态 | `图片不存在` | 提示云图不可点赞;与「不存在」共用是设计意图 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:**幂等**——重复点赞返回相同的 200,点赞数不会增加。前端无需在点击前查询是否已赞,但仍建议本地置灰防止连点。 + +### 5.6 `DELETE /cloud/:cloud_id/like` — 取消点赞 + +- **认证**:需登录(任意角色) +- **请求体**:无 + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "取消点赞成功" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:**完全幂等**——从未点赞、云图不存在,同样返回 200。没有 404 分支。 + +### 5.7 `POST /cloud` — 上传云图 + +- **认证**:需登录(任意角色) +- **Content-Type**:`multipart/form-data`;请求体总上限 21 MiB(图片约束见 1.6) + +**表单字段**(multipart 字段均为字符串): + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `image` | file | 是 | 1 B – 20 MB,JPEG/PNG/WebP,非动图 | 图片文件 | +| `type_id` | int | 是 | 正整数 | 云类型 ID(从 `/info/cloudtype` 获取) | +| `latitude` | number | 是 | -90 ~ 90 | 纬度 | +| `longitude` | number | 是 | -180 ~ 180 | 经度 | +| `description` | string | 否 | ≤ 128 字符 | 描述 | +| `captured_at` | date string | 否 | 合法日期 | 拍摄时间 | +| `is_hidden` | string | **是** | 仅接受 `"true"` / `"false"` / `"1"` / `"0"` | 是否隐藏;其他写法(如 `"yes"`)返回 400 | + +**成功响应**:`201` + +```json +{ + "status_code": 201, + "message": "上传成功", + "cloud_id": "3f8a2c1e-7b9d-4e5f-a1c2-9d8e7f6a5b4c" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 图片为空文件 | `图片为空` | 提示重新选择 | +| 400 | 图片超过 20 MB | `图片大小超过 20MB 限制` | 提示压缩 | +| 400 | 格式不支持 | `仅支持 JPEG、PNG、WebP 格式` | 提示转换格式 | +| 400 | 动图 | `不支持动图` | 提示使用静态图 | +| 400 | 分辨率超上限 | `图片分辨率超过限制` | 提示缩小尺寸 | +| 400 | 其他图片处理失败 | `压缩参数无效` / `图片处理失败` | 提示换图重试 | +| 400 | 表单字段校验失败(如 `is_hidden` 编码非法、缺字段) | zod 形状(2.1) | 检查表单 | +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 413 | 请求体超过 21 MiB | `上传文件过大` | 提示压缩图片 | +| 500 | 服务端异常(含存储/数据库失败) | `服务器发生内部错误` | 稍后重试;服务端已做失败补偿清理,可安全重试 | + +**注意事项**: + +- 上传成功后云图为 `pending`(待审核),**不会出现在公开列表**,本人可在「我的云图」(6.4)中看到 +- `type_id` 指向不存在的云类型会导致 500(外键约束),前端应确保类型 ID 来自 `/info/cloudtype` + +### 5.8 `PATCH /cloud/:cloud_id` — 更新云图 + +- **认证**:需登录,且为上传者本人。**非本人的云图返回 404 而非 403**(防止泄露存在性) + +**路径参数**:`cloud_id`(uuid)。 + +**请求体**(JSON,至少提供一个字段,否则 400): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `latitude` | number | 否 | -90 ~ 90 | 纬度 | +| `longitude` | number | 否 | -180 ~ 180 | 经度 | +| `type_id` | int | 否 | 正整数 | ⚠️ 不校验存在性,非法值导致 500 | +| `description` | string | 否 | ≤ 128 字符 | 描述 | +| `captured_at` | date string | 否 | 合法日期 | 拍摄时间 | +| `is_hidden` | boolean | 否 | — | 是否隐藏(JSON 布尔值) | + +```json +{ + "description": "补充:拍摄于景山公园", + "is_hidden": false +} +``` + +**成功响应**:`200`,`{ "status_code": 200, "message": "更新成功" }` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 未提供任何修改字段 | zod 形状,issues 中含 `至少需要提供一个修改字段` | 表单前置校验 | +| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 | +| 500 | 服务端异常(含 `type_id` 不存在) | `服务器发生内部错误` | 稍后重试 | + +### 5.9 `DELETE /cloud/:cloud_id` — 删除云图 + +- **认证**:需登录,且为上传者本人(非本人返回 404,同 5.8) + +**成功响应**:`200`,`{ "status_code": 200, "message": "删除成功" }` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +### 5.10 `DELETE /cloud` — 批量删除云图 + +- **认证**:需登录;仅删除属于当前用户的云图 + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `cloud_ids` | array | 是 | 1–100 个元素,不允许重复 | 元素为 `{ "cloud_id": "" }` 对象 | + +```json +{ + "cloud_ids": [ + { "cloud_id": "3f8a2c1e-7b9d-4e5f-a1c2-9d8e7f6a5b4c" }, + { "cloud_id": "b4c5d6e7-f8a9-4b5c-9d0e-1f2a3b4c5d6e" } + ] +} +``` + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "成功删除 2 张图片" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | ID 重复 / 数量超出 1–100 / 格式非法 | zod 形状,ID 重复时 issues 含 `图片 ID 不能重复` | 前端去重后提交 | +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:**部分成功语义**——只删除属于当前用户的云图,他人或不存在的 ID 被静默跳过,消息中的 `N` 是实际删除数(可能小于请求数,甚至为 0)。前端应以 `N` 为准刷新列表,而不是假设全部删除成功。 + +### 5.11 `GET|HEAD /image/:cloud_id/:variant` — 读取图片变体 + +- **认证**:可选。匿名访客与无关用户只能读取具备 PublicVisibility 的 Cloud;上传者可读取自己的全部 Cloud;admin 可读取全部 Cloud +- **严格凭证**:完全不携带凭证时按匿名处理;一旦携带无效/过期 Bearer 或 Cookie,返回 401,不会降级为匿名。Bearer 与 Cookie 同时存在时 Bearer 优先 +- **响应**:应用先查询 Cloud 权限,再从 MinIO 流式代理图片;客户端不能提交 bucket 或 object key + +**路径参数**: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `cloud_id` | uuid | Cloud ID,由上传响应、列表或详情接口取得 | +| `variant` | enum | `preview`(最大 640×640 WebP)或 `original`(上传原图) | + +两种图片变体采用相同权限规则。示例: + +```html +云图 +``` + +**成功响应**: + +- `GET` 返回 200 和图片字节,`Content-Type` 仅可能为 `image/jpeg`、`image/png` 或 `image/webp` +- `HEAD` 返回与 GET 相同的授权结果和响应头,但没有 body,也不会读取 MinIO 对象 body +- 返回 `Content-Length`、`Content-Disposition: inline`、`ETag`、`Last-Modified` 与 `X-Content-Type-Options: nosniff` +- 携带匹配的 `If-None-Match` 时返回 304;认证和 PublicVisibility 检查仍会执行 + +**缓存**: + +| 调用方式 | `Cache-Control` | +|---|---| +| 匿名读取具备 PublicVisibility 的 Cloud | `public, no-cache` | +| 任何有效 Cookie/Bearer 请求 | `private, no-store` | + +响应同时包含 `Vary: Authorization, Cookie`。公开缓存每次复用前都必须回源验证,因此 Cloud 被隐藏或改判后立即失效。 + +**错误**: + +| 状态码 | 触发条件 | 前端建议动作 | +|---|---|---| +| 400 | Cloud ID 或 ImageVariant 非法 | 检查 URL | +| 401 | 显式提供的 JWT 无效、过期或已失效 | 清除本地登录态 | +| 404 | Cloud 不存在、当前用户不可见,或对应 MinIO 对象不存在 | 统一显示占位图,不推断具体原因 | +| 405 | 使用 GET/HEAD 之外的业务方法;响应含 `Allow: GET, HEAD` | 修正请求方法 | +| 500 | 数据库或应用内部错误 | 稍后重试 | +| 503 | MinIO 暂时不可用或对象 metadata 异常 | 稍后重试 | + +具备 PublicVisibility 的 Cloud 允许被其他网站嵌入;流量限制由部署网关/CDN/WAF 负责。端点不支持 Range 请求。CORS 预检 `OPTIONS` 是协议级例外,由全局中间件返回 204,并只声明 `GET, HEAD`。 + +--- + +## 6. 端点 · 个人资料 `/profile` + +本章所有端点**都需要登录**(任意角色)。未登录统一返回 401(见 3.5),下文错误表不再重复列出。 + +### 6.1 `GET /profile/me` — 我的资料 + +- **认证**:需登录 + +**成功响应**:`200`,UserProfile 对象(字段见 1.5) + +```json +{ + "id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", + "name": "cloudwatcher", + "email": "watcher@example.com", + "avatar_id": null, + "cloud_count": 7, + "received_like_count": 42, + "last_online": "2026-08-15T08:30:00.000Z", + "role": "user", + "is_disabled": false, + "created_at": "2026-07-01T10:00:00.000Z" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | + +### 6.2 `PATCH /profile/me` — 修改用户名 + +- **认证**:需登录 + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `user_name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(超长返回 500) | 新用户名 | + +```json +{ + "user_name": "newname" +} +``` + +**成功响应**:`200`,更新后的 UserProfile 对象(结构同 6.1)。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | +| 409 | 用户名已被占用(数据库唯一约束,大小写敏感) | `用户名已被占用` | 提示换用户名 | +| 500 | 服务端异常(含用户名超长) | `服务器发生内部错误` | 前端应限制 ≤ 16 字符避免误报 | + +### 6.3 `GET /profile/me/likes` — 我点赞过的云图 + +- **认证**:需登录 + +**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按点赞时间倒序。 + +**注意事项**:包含**所有状态**的云图(含待审核/已驳回/已隐藏的),因为这些是你自己点过赞的——展示时如需隐藏非公开项请自行过滤 `status` 与 `is_hidden`。 + +**错误**:500 `服务器发生内部错误`。 + +### 6.4 `GET /profile/me/clouds` — 我上传的云图 + +- **认证**:需登录 + +**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按上传时间倒序,包含所有状态(`pending`/`approved`/`rejected`)与隐藏的云图。这是上传后查看审核进度的入口。 + +**错误**:500 `服务器发生内部错误`。 + +### 6.5 `GET /profile/:user_id` — 查看用户资料 + +- **认证**:需登录(任意登录用户可查看任意用户) + +**路径参数**:`user_id`(uuid)。 + +**成功响应**:`200`,UserProfile 对象(结构同 6.1)。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | `user_id` 不是合法 uuid | zod 形状(2.1) | 检查链接 | +| 404 | 用户不存在 | `该用户不存在` | 提示用户不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:⚠️ 响应包含对方 `email`(2.3 第 8 条)。展示他人资料页时不应展示邮箱字段。 + +### 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` — 查看用户上传的云图 + +- **认证**:需登录,且为**本人或 admin** + +**路径参数**:`user_id`(uuid)。 + +**成功响应**:`200`,CloudInfo 数组,按上传时间倒序,结构同 5.1。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 403 | 查看他人且非 admin | `无权查看该用户的图片` | 同上,前置判断 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:想查看某个用户的**公开**云图,应使用 `GET /cloud?filter=owner&value=<用户名>`(5.1)而非本端点——本端点是管理/个人视角,返回含非公开状态的完整列表。 + +--- + +## 7. 端点 · 信息 `/info` + +### 7.1 `GET /info/cloudtype` — 云类型列表 + +- **认证**:无(公开) + +**成功响应**:`200`,CloudTypeInfo 数组(字段见 1.5),按 `id` 升序: + +```json +[ + { + "id": 1, + "name": "积云", + "genus": "Cumulus", + "icon_id": null, + "rarity": 1, + "description": "底部平坦、顶部蓬松的白色云块", + "created_at": "2026-01-01 00:00:00+00" + } +] +``` + +**注意事项**:⚠️ 本端点的 `created_at` 是 PostgreSQL 原生格式(上例),**不是** ISO 8601(2.3 第 6 条),解析时自行兼容。 + +**错误**:500 `服务器发生内部错误`。 + +### 7.2 `GET /info/cloudtype/:cloud_type_id` — 云类型详情 + +- **认证**:无(公开) + +**路径参数**:`cloud_type_id`(int)。 + +**成功响应**:`200`,单个 CloudTypeInfo 对象;本端点的 `created_at` 为 ISO 8601 格式。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | `cloud_type_id` 非数字 | zod 形状(2.1) | 检查链接 | +| 404 | 云类型不存在 | `未找到` | 提示类型不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +--- + +## 8. 端点 · 管理 `/admin` + +本章所有端点**仅 admin 角色可用**。未登录返回 401;已登录但非 admin 统一返回 403 `权限不足`,下文错误表不再重复列出。 + +### 8.1 `GET /admin/stats` — 统计数据 + +> ⚠️ **未实现**(占位端点)。当前任何请求返回纯文本 `404 Not Found`。请勿接入。 + +### 8.2 `GET /admin/users` — 用户列表 + +- **认证**:admin + +**成功响应**:`200`,UserProfile 数组(字段见 1.5)。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | + +**注意事项**:**无分页参数**,一次返回全部用户。用户量大时注意性能。 + +### 8.3 `PATCH /admin/users` — 更新用户 + +> ⚠️ **未实现**(占位端点)。当前任何请求返回纯文本 `404 Not Found`。请勿接入。 + +### 8.4 `POST /admin/user` — 创建用户 + +> ⚠️ **假端点**(2.3 第 5 条):通过 zod 校验后**原样回显请求体(含明文密码)**,不创建任何用户。响应为 `200 { name, email, password, role }`。请勿接入,更不要在任何持久化日志中记录其响应。 + +### 8.5 `GET /admin/clouds` — 全状态云图列表 + +- **认证**:admin;可见任意状态与隐藏的云图 + +**Query 参数**(在分页参数 1.4 基础上): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `status` | enum | 否 | `pending` / `approved` / `rejected` | 按审核状态过滤;不传返回全部状态 | + +**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按上传时间倒序。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +### 8.6 `POST /admin/clouds/review` — 批量审核云图 + +- **认证**:admin + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `cloud_ids` | array | 是 | 1–100 个元素,不允许重复 | 元素为 `{ "cloud_id": "" }` 对象 | +| `status` | enum | 是 | `approved` / `rejected` | 目标审核状态;**不支持打回 `pending`** | + +```json +{ + "cloud_ids": [ + { "cloud_id": "3f8a2c1e-7b9d-4e5f-a1c2-9d8e7f6a5b4c" } + ], + "status": "approved" +} +``` + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "成功审核 1 张" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | ID 重复 / `status` 非法 / 数量超限 | zod 形状(2.1) | 检查请求 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:**部分成功语义**——消息中的 `N` 只统计状态**实际发生变化**的云图;已是目标状态的与不存在的一律静默跳过。审核只改变审核状态,不影响 `is_hidden` 与点赞数据。`pending ↔ approved/rejected`、`approved ↔ rejected` 均可。 + +--- + +## 9. 端点 · 系统 + +### 9.1 `ALL /health` · 9.2 `ALL /status` + +> ⚠️ **均未实现**(占位端点,任意 HTTP 方法)。当前返回纯文本 `404 Not Found`。健康检查请直接探测任意已实现端点(如 `GET /info/cloudtype`)。 diff --git a/src/components/cloud/CloudEditModal.vue b/src/components/cloud/CloudEditModal.vue index bf1eab5..4a68bd1 100644 --- a/src/components/cloud/CloudEditModal.vue +++ b/src/components/cloud/CloudEditModal.vue @@ -1,17 +1,15 @@ - - - - diff --git a/src/components/cloud/QuickUploadModal.vue b/src/components/cloud/QuickUploadModal.vue index 66fcebb..2e99a20 100644 --- a/src/components/cloud/QuickUploadModal.vue +++ b/src/components/cloud/QuickUploadModal.vue @@ -91,9 +91,7 @@ function handleRemove(id: string) { function onCategoryChange(event: Event) { if (!activeItem.value) return const value = (event.target as HTMLSelectElement).value - if (value === 'other') { - activeItem.value.cloudCategoryId = 'other' - } else if (value === '') { + if (value === '') { activeItem.value.cloudCategoryId = null } else { activeItem.value.cloudCategoryId = Number(value) @@ -102,6 +100,7 @@ function onCategoryChange(event: Event) { } function formatDatetimeLocal(iso: string): string { + if (!iso) return '' const date = new Date(iso) const pad = (value: number) => String(value).padStart(2, '0') return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` @@ -110,8 +109,7 @@ function formatDatetimeLocal(iso: string): string { function onCapturedAtChange(event: Event) { if (!activeItem.value) return const value = (event.target as HTMLInputElement).value - if (!value) return - activeItem.value.capturedAt = new Date(value).toISOString() + activeItem.value.capturedAt = value ? new Date(value).toISOString() : '' delete activeItem.value.errors.capturedAt } @@ -169,7 +167,7 @@ async function handleSubmit() { const result = await uploadAll() if (!result.ok) { - errorMsg.value = '上传失败,请稍后重试' + errorMsg.value = result.errorMessage || '上传失败,请稍后重试' return } @@ -217,7 +215,7 @@ onBeforeUnmount(() => {
- + 图片审核通过后会出现在画廊和地图中。 @@ -248,7 +246,7 @@ onBeforeUnmount(() => { >

选择一张云图

-

支持 JPG、PNG,可点击或拖拽上传。

+

支持 JPEG、PNG、WebP,图片不超过 20MB。

@@ -362,7 +351,7 @@ async function handleLogout() { class="oc-nav-link shrink-0" :class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass" > - 图鉴 + 百科 diff --git a/src/composables/useUpload.ts b/src/composables/useUpload.ts index 40d0004..ecb83bc 100644 --- a/src/composables/useUpload.ts +++ b/src/composables/useUpload.ts @@ -3,15 +3,12 @@ import { apiRequest } from '@/lib/api' import { useAuthStore } from '@/stores/auth' import { useProfileStore } from '@/stores/profile' import type { CloudCreateResponse } from '@/types/api' -import type { CloudType } from '@/types/database' export interface UploadItem { id: string file: File preview: string - cloudCategoryId: number | 'other' | null - customCloudType: string - locationName: string + cloudCategoryId: number | null description: string isHidden: boolean latitude: number | null @@ -20,17 +17,9 @@ export interface UploadItem { errors: Record } -export interface UnlockedBadge { - cloudTypeId: number - cloudName: string - cloudNameEn: string - rarity: CloudType['rarity'] - unlockedAt: string -} - export interface UploadResult { ok: boolean - unlockedBadges: UnlockedBadge[] + errorMessage?: string } let nextId = 0 @@ -46,7 +35,7 @@ function readFileAsArrayBuffer(file: File): Promise { function extractExifDate(buffer: ArrayBuffer): string | null { const view = new DataView(buffer) - if (view.getUint16(0, false) !== 0xffd8) return null + if (view.byteLength < 4 || view.getUint16(0, false) !== 0xffd8) return null let offset = 2 while (offset < view.byteLength - 2) { @@ -89,8 +78,8 @@ function extractExifDate(buffer: ArrayBuffer): string | null { return null } -function appendOptional(form: FormData, key: string, value: string | number | null) { - if (value !== null && value !== '') form.append(key, String(value)) +function appendOptional(form: FormData, key: string, value: string) { + if (value.trim()) form.append(key, value.trim()) } export function useUpload() { @@ -103,14 +92,13 @@ export function useUpload() { const totalItems = ref(0) async function addFiles(files: File[]) { - const imageFiles = files.filter(file => file.type.startsWith('image/')) + const imageFiles = files.filter(file => ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) for (const file of imageFiles) { - let capturedAt = new Date().toISOString() + let capturedAt = '' try { - const exifDate = extractExifDate(await readFileAsArrayBuffer(file)) - if (exifDate) capturedAt = exifDate + capturedAt = extractExifDate(await readFileAsArrayBuffer(file)) || '' } catch { - // 无 EXIF 时使用当前时间。 + capturedAt = '' } items.value.push({ @@ -118,8 +106,6 @@ export function useUpload() { file, preview: URL.createObjectURL(file), cloudCategoryId: null, - customCloudType: '', - locationName: '', description: '', isHidden: false, latitude: null, @@ -145,15 +131,24 @@ export function useUpload() { function validateItem(item: UploadItem) { const errors: Record = {} if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别' - if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) { - errors.cloudCategory = '请输入自定义云型名称' + if (item.file.size === 0) errors.file = '图片文件为空' + if (item.file.size > 20 * 1024 * 1024) errors.file = '图片不能超过 20MB' + if (!['image/jpeg', 'image/png', 'image/webp'].includes(item.file.type)) { + errors.file = '仅支持 JPEG、PNG、WebP 格式' } - if (!item.capturedAt) errors.capturedAt = '请选择拍摄时间' - const hasLat = typeof item.latitude === 'number' && !isNaN(item.latitude) - const hasLng = typeof item.longitude === 'number' && !isNaN(item.longitude) - if (hasLat && !hasLng) errors.longitude = '经纬度必须同时填写' - if (hasLng && !hasLat) errors.latitude = '经纬度必须同时填写' + if (item.latitude === null || Number.isNaN(item.latitude)) { + errors.latitude = '请填写纬度' + } else if (item.latitude < -90 || item.latitude > 90) { + errors.latitude = '纬度范围为 -90 到 90' + } + if (item.longitude === null || Number.isNaN(item.longitude)) { + errors.longitude = '请填写经度' + } else if (item.longitude < -180 || item.longitude > 180) { + errors.longitude = '经度范围为 -180 到 180' + } + if (item.description.trim().length > 128) errors.description = '图片描述不能超过 128 个字符' + item.errors = errors return Object.keys(errors).length === 0 } @@ -163,13 +158,12 @@ export function useUpload() { } async function uploadAll(): Promise { - if (!validateAll() || !authStore.user) return { ok: false, unlockedBadges: [] } + if (!validateAll() || !authStore.user) return { ok: false } uploading.value = true totalItems.value = items.value.length currentItemIndex.value = 0 overallProgress.value = 0 - const badges = new Map() try { for (let index = 0; index < items.value.length; index++) { @@ -179,40 +173,28 @@ export function useUpload() { const form = new FormData() form.append('image', item.file) - if (item.cloudCategoryId === 'other') { - form.append('custom_cloud_type', item.customCloudType.trim()) - } else { - form.append('cloud_type_id', String(item.cloudCategoryId)) - } - appendOptional(form, 'latitude', item.latitude) - appendOptional(form, 'longitude', item.longitude) - appendOptional(form, 'location_name', item.locationName.trim()) - appendOptional(form, 'description', item.description.trim()) + form.append('type_id', String(item.cloudCategoryId)) + form.append('latitude', String(item.latitude)) + form.append('longitude', String(item.longitude)) + appendOptional(form, 'description', item.description) appendOptional(form, 'captured_at', item.capturedAt) form.append('is_hidden', String(item.isHidden)) - const result = await apiRequest('/clouds', { + await apiRequest('/cloud', { method: 'POST', body: form, }) - if (result.unlocked_badge) { - const badge = result.unlocked_badge - badges.set(badge.cloud_type_id, { - cloudTypeId: badge.cloud_type_id, - cloudName: badge.cloud_name, - cloudNameEn: badge.cloud_name_en, - rarity: badge.rarity, - unlockedAt: badge.unlocked_at, - }) - } overallProgress.value = Math.round((index + 1) / items.value.length * 100) } clearAll() profileStore.invalidateUser(authStore.user.id) - return { ok: true, unlockedBadges: [...badges.values()] } - } catch { - return { ok: false, unlockedBadges: [] } + return { ok: true } + } catch (error) { + return { + ok: false, + errorMessage: error instanceof Error ? error.message : '上传失败,请稍后重试', + } } finally { uploading.value = false } diff --git a/src/lib/api.ts b/src/lib/api.ts index dfc90b7..59fb71e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,14 +1,17 @@ -import type { AuthResponse } from '@/types/api' +import type { + ApiCloud, + AuthUser, + BackendCloudInfo, + BackendCloudType, + BackendUserProfile, +} from '@/types/api' +import type { CloudType } from '@/types/database' -const DEFAULT_API_URL = 'http://localhost:8000/api/v1' +const DEFAULT_API_URL = 'http://localhost:3000' export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '') -export const AUTH_UPDATED_EVENT = 'opencloud:auth-updated' export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired' -let accessToken: string | null = null -let refreshPromise: Promise | null = null - export class ApiError extends Error { status: number detail: unknown @@ -24,6 +27,8 @@ export class ApiError extends Error { interface ApiRequestOptions extends Omit { body?: BodyInit | Record | 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 } @@ -36,43 +41,71 @@ function isJsonBody(body: ApiRequestOptions['body']): body is Record item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '') +function validationMessage(value: unknown) { + if (typeof value !== 'string') return '' + + try { + const issues = JSON.parse(value) as unknown + if (!Array.isArray(issues)) return value + const messages = issues + .map(issue => issue && typeof issue === 'object' && 'message' in issue ? String(issue.message) : '') .filter(Boolean) - if (messages.length) return messages.join(';') + return messages.join(';') || value + } catch { + return value } +} + +function errorMessage(payload: unknown, fallback: string) { + if (!payload || typeof payload !== 'object') return fallback + + if ('detail' in payload) { + const detail = payload.detail + if (typeof detail === 'string') return detail + if (Array.isArray(detail)) { + const messages = detail + .map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '') + .filter(Boolean) + if (messages.length) return messages.join(';') + } + } + + if ('error' in payload) { + const error = payload.error + if (typeof error === 'string') return error + if (error && typeof error === 'object' && 'message' in error) { + return validationMessage(error.message) || fallback + } + } + return fallback } -async function toApiError(response: Response) { - let detail: unknown +async function readPayload(response: Response) { + const text = await response.text() + if (!text) return undefined + + const contentType = response.headers.get('content-type') || '' + if (!contentType.includes('json')) return text + try { - const payload = await response.json() as { detail?: unknown } - detail = payload.detail + return JSON.parse(text) as unknown } catch { - detail = undefined + return text } - - return new ApiError( - detailToMessage(detail, `请求失败(${response.status})`), - response.status, - detail, - ) } -export function setAccessToken(token: string | null) { - accessToken = token -} +async function toApiError(response: Response) { + const payload = await readPayload(response) + const fallback = typeof payload === 'string' && payload.trim() + ? payload.trim() + : `请求失败(${response.status})` -export function clearAccessToken() { - accessToken = null + return new ApiError(errorMessage(payload, fallback), response.status, payload) } async function request(path: string, options: ApiRequestOptions = {}) { - const { body, auth = true, retry = true, ...requestOptions } = options + const { body, auth = true, retry: _retry, ...requestOptions } = options const headers = new Headers(requestOptions.headers) let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined @@ -81,10 +114,6 @@ async function request(path: string, options: ApiRequestOptions = {}) { requestBody = JSON.stringify(body) } - if (auth && accessToken) { - headers.set('Authorization', `Bearer ${accessToken}`) - } - const response = await fetch(`${API_URL}${path}`, { ...requestOptions, body: requestBody, @@ -92,43 +121,85 @@ async function request(path: string, options: ApiRequestOptions = {}) { credentials: 'include', }) - if (response.status === 401 && auth && accessToken && retry) { - try { - const authResponse = await refreshSession() - window.dispatchEvent(new CustomEvent(AUTH_UPDATED_EVENT, { detail: authResponse })) - return request(path, { ...options, retry: false }) - } catch { - clearAccessToken() - window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) - } + if (response.status === 401 && auth && typeof window !== 'undefined') { + window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) } if (!response.ok) throw await toApiError(response) - if (response.status === 204) return undefined as T - - return response.json() as Promise + return await readPayload(response) as T } export function apiRequest(path: string, options: ApiRequestOptions = {}) { return request(path, options) } -export function refreshSession() { - if (!refreshPromise) { - refreshPromise = (async () => { - const response = await fetch(`${API_URL}/auth/refresh`, { - method: 'POST', - credentials: 'include', - }) - if (!response.ok) throw await toApiError(response) - - const authResponse = await response.json() as AuthResponse - setAccessToken(authResponse.access_token) - return authResponse - })().finally(() => { - refreshPromise = null - }) - } - - return refreshPromise +export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') { + return `${API_URL}/image/${encodeURIComponent(id)}/${variant}` +} + +export function toAuthUser(profile: BackendUserProfile): AuthUser { + return { + id: profile.id, + email: profile.email, + email_verified: false, + username: profile.name, + avatar_url: null, + role: profile.role, + is_disabled: profile.is_disabled, + cloud_count: profile.cloud_count, + public_cloud_count: profile.cloud_count, + collection_count: 0, + received_like_count: profile.received_like_count, + created_at: profile.created_at, + } +} + +export function toCloudType(row: BackendCloudType): CloudType { + return { + id: row.id, + name: row.name, + name_en: row.genus || '', + genus: row.genus || '', + // Compatibility value for the excluded legacy admin view. Migrated pages + // display rarity_value directly because API.md defines no semantic mapping. + rarity: 'common', + rarity_value: row.rarity, + description: row.description, + icon_url: null, + created_at: row.created_at, + } +} + +export function toApiCloud(row: BackendCloudInfo): ApiCloud { + return { + id: row.id, + user_id: row.owner.id, + cloud_type_id: row.type.id, + custom_cloud_type: null, + image_url: imageUrl(row.id, 'original'), + thumbnail_url: imageUrl(row.id, 'preview'), + latitude: row.latitude, + longitude: row.longitude, + location_name: null, + description: row.description, + captured_at: row.captured_at, + status: row.status, + is_hidden: row.is_hidden, + created_at: row.uploaded_at, + updated_at: row.updated_at, + cloud_type: { + id: row.type.id, + name: row.type.name, + name_en: row.type.genus || '', + rarity: 'common', + rarity_value: row.type.rarity, + }, + owner: { id: row.owner.id, username: row.owner.name }, + cloud_type_name: row.type.name, + cloud_type_rarity: 'common', + cloud_type_rarity_value: row.type.rarity, + username: row.owner.name, + favorite_count: row.received_like_count, + is_favorited: false, + } } diff --git a/src/lib/canvas.ts b/src/lib/canvas.ts deleted file mode 100644 index 4190083..0000000 --- a/src/lib/canvas.ts +++ /dev/null @@ -1,37 +0,0 @@ -const PATCH_FLAG = '__openCloudWillReadFrequentlyPatched__' - -type CanvasLikePrototype = { - getContext?: (contextId: string, options?: unknown) => unknown - [PATCH_FLAG]?: boolean -} - -function patch2DContextOptions(ctor: typeof HTMLCanvasElement | typeof OffscreenCanvas | undefined) { - const prototype = ctor?.prototype as CanvasLikePrototype | undefined - if (!prototype?.getContext || prototype[PATCH_FLAG]) return - - const originalGetContext = prototype.getContext - - prototype.getContext = function getContextWithReadHint(contextId: string, options?: unknown) { - if (contextId === '2d') { - const nextOptions = - options && typeof options === 'object' - ? { ...options as Record, willReadFrequently: true } - : { willReadFrequently: true } - - return originalGetContext.call(this, contextId, nextOptions) - } - - return originalGetContext.call(this, contextId, options) - } - - prototype[PATCH_FLAG] = true -} - -export function enableCanvasReadbackHint() { - if (typeof window === 'undefined') return - - patch2DContextOptions(window.HTMLCanvasElement) - if (typeof window.OffscreenCanvas !== 'undefined') { - patch2DContextOptions(window.OffscreenCanvas) - } -} diff --git a/src/lib/cloudBadges.ts b/src/lib/cloudBadges.ts deleted file mode 100644 index b8a406a..0000000 --- a/src/lib/cloudBadges.ts +++ /dev/null @@ -1,130 +0,0 @@ -export type BadgeRarity = 'common' | 'uncommon' | 'rare' - -export interface CloudBadgeCardInput { - cloudName: string - cloudNameEn: string - unlockedAt: string - username: string - rarity: BadgeRarity -} - -const rarityTheme = { - common: { - from: '#dbeafe', - to: '#f8fafc', - accent: '#0284c7', - shadow: 'rgba(2, 132, 199, 0.18)', - }, - uncommon: { - from: '#fef3c7', - to: '#fff7ed', - accent: '#d97706', - shadow: 'rgba(217, 119, 6, 0.22)', - }, - rare: { - from: '#fee2e2', - to: '#fff7ed', - accent: '#dc2626', - shadow: 'rgba(220, 38, 38, 0.22)', - }, -} satisfies Record - -function formatDate(iso: string) { - return new Date(iso).toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) -} - -function makeDownloadName(name: string) { - return `opencloud-badge-${name}.png` -} - -export async function downloadCloudBadgeCard(input: CloudBadgeCardInput) { - const canvas = document.createElement('canvas') - canvas.width = 1200 - canvas.height = 630 - - const ctx = canvas.getContext('2d') - if (!ctx) return - - const theme = rarityTheme[input.rarity] - const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height) - gradient.addColorStop(0, theme.from) - gradient.addColorStop(1, theme.to) - - ctx.fillStyle = gradient - ctx.fillRect(0, 0, canvas.width, canvas.height) - - ctx.fillStyle = 'rgba(255,255,255,0.72)' - ctx.beginPath() - ctx.arc(1010, 120, 140, 0, Math.PI * 2) - ctx.fill() - - ctx.fillStyle = 'rgba(255,255,255,0.45)' - ctx.beginPath() - ctx.arc(160, 520, 180, 0, Math.PI * 2) - ctx.fill() - - ctx.fillStyle = '#0f172a' - ctx.font = '700 44px sans-serif' - ctx.fillText('OpenCloud 云朵图鉴', 72, 88) - - ctx.fillStyle = 'rgba(15, 23, 42, 0.62)' - ctx.font = '400 24px sans-serif' - ctx.fillText('新徽章已解锁', 72, 126) - - ctx.shadowColor = theme.shadow - ctx.shadowBlur = 28 - ctx.fillStyle = '#ffffff' - ctx.beginPath() - ctx.roundRect(72, 170, 1056, 380, 32) - ctx.fill() - ctx.shadowBlur = 0 - - ctx.fillStyle = theme.accent - ctx.beginPath() - ctx.arc(210, 360, 92, 0, Math.PI * 2) - ctx.fill() - - ctx.fillStyle = '#ffffff' - ctx.font = '700 82px sans-serif' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillText(input.cloudName.slice(0, 1), 210, 360) - - ctx.textAlign = 'left' - ctx.textBaseline = 'alphabetic' - ctx.fillStyle = '#0f172a' - ctx.font = '700 64px sans-serif' - ctx.fillText(input.cloudName, 340, 300) - - ctx.fillStyle = 'rgba(15, 23, 42, 0.62)' - ctx.font = '400 30px sans-serif' - ctx.fillText(input.cloudNameEn, 340, 346) - - ctx.fillStyle = theme.accent - ctx.beginPath() - ctx.roundRect(340, 382, 168, 48, 24) - ctx.fill() - - ctx.fillStyle = '#ffffff' - ctx.font = '600 24px sans-serif' - ctx.fillText(input.rarity === 'common' ? '常见' : input.rarity === 'uncommon' ? '少见' : '罕见', 384, 414) - - ctx.fillStyle = '#334155' - ctx.font = '400 28px sans-serif' - ctx.fillText(`解锁者:${input.username}`, 340, 476) - ctx.fillText(`解锁日期:${formatDate(input.unlockedAt)}`, 340, 518) - - const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')) - if (!blob) return - - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = makeDownloadName(input.cloudNameEn.toLowerCase()) - link.click() - URL.revokeObjectURL(url) -} diff --git a/src/main.ts b/src/main.ts index 8cc76a3..16d5621 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,11 +3,8 @@ import { createPinia } from 'pinia' import App from './App.vue' import router from './router' import { useAuthStore } from './stores/auth' -import { enableCanvasReadbackHint } from './lib/canvas' import './style.css' -enableCanvasReadbackHint() - const app = createApp(App) const pinia = createPinia() app.use(pinia) diff --git a/src/router/index.ts b/src/router/index.ts index 2088a9c..f0e87c2 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -24,25 +24,25 @@ const router = createRouter({ { path: '/register', name: 'register', - component: () => import('@/views/auth/RegisterView.vue'), + component: () => import('@/views/auth/UnavailableAuthView.vue'), meta: { title: '注册', noindex: true }, }, { path: '/forgot-password', name: 'forgot-password', - component: () => import('@/views/auth/ForgotPasswordView.vue'), + component: () => import('@/views/auth/UnavailableAuthView.vue'), meta: { title: '忘记密码', noindex: true }, }, { path: '/auth/confirm', name: 'auth-confirm', - component: () => import('@/views/auth/AuthConfirmView.vue'), + component: () => import('@/views/auth/UnavailableAuthView.vue'), meta: { title: '确认邮箱', noindex: true }, }, { path: '/auth/reset-password', name: 'auth-reset-password', - component: () => import('@/views/auth/ResetPasswordView.vue'), + component: () => import('@/views/auth/UnavailableAuthView.vue'), meta: { title: '重置密码', noindex: true }, }, { @@ -57,8 +57,8 @@ const router = createRouter({ component: () => import('@/views/encyclopedia/EncyclopediaView.vue'), meta: { requiresAuth: true, - title: '云朵图鉴', - description: '浏览 10 种基础云属,了解云型特征,并通过拍摄云图点亮个人图鉴。', + title: '云朵百科', + description: '浏览基础云属,了解云型特征和社区公开观测记录。', noindex: true, }, }, @@ -109,7 +109,7 @@ const router = createRouter({ component: () => import('@/views/profile/ProfileView.vue'), meta: { title: '用户云图档案', - description: '查看用户公开分享的云图记录、图鉴进度和拍摄时间线。', + description: '按用户名查看用户公开分享的云图记录和拍摄时间线。', }, }, { diff --git a/src/stores/auth.ts b/src/stores/auth.ts index ef9547a..6d81756 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,59 +1,48 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' -import { - AUTH_EXPIRED_EVENT, - AUTH_UPDATED_EVENT, - apiRequest, - clearAccessToken, - refreshSession, - setAccessToken, -} from '@/lib/api' -import type { AuthResponse, AuthUser } from '@/types/api' +import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api' +import type { BackendUserProfile, LoginResponse } from '@/types/api' import type { Profile } from '@/types/database' export const useAuthStore = defineStore('auth', () => { - const user = ref(null) + const user = ref | null>(null) const profile = ref(null) const loading = ref(true) - let listenersAttached = false + let listenerAttached = false const isLoggedIn = computed(() => !!user.value) const isAdmin = computed(() => profile.value?.role === 'admin') - function applyAuth(auth: AuthResponse) { - setAccessToken(auth.access_token) - user.value = auth.user + function applyProfile(backendProfile: BackendUserProfile) { + const normalized = toAuthUser(backendProfile) + user.value = normalized profile.value = { - id: auth.user.id, - username: auth.user.username, - avatar_url: auth.user.avatar_url, - role: auth.user.role, - is_disabled: auth.user.is_disabled, - created_at: auth.user.created_at, + id: normalized.id, + username: normalized.username, + avatar_url: normalized.avatar_url, + role: normalized.role, + is_disabled: normalized.is_disabled, + created_at: normalized.created_at, } } function clearAuth() { - clearAccessToken() user.value = null profile.value = null } + async function fetchMe() { + const backendProfile = await apiRequest('/profile/me') + applyProfile(backendProfile) + } + async function login(email: string, password: string) { - const auth = await apiRequest('/auth/login', { + await apiRequest('/auth/login', { method: 'POST', auth: false, body: { email, password }, }) - applyAuth(auth) - } - - async function register(email: string, password: string, username: string) { - await apiRequest('/auth/register', { - method: 'POST', - auth: false, - body: { email, password, username }, - }) + await fetchMe() } async function logout() { @@ -65,48 +54,32 @@ export const useAuthStore = defineStore('auth', () => { } async function updateUsername(username: string) { - const updatedUser = await apiRequest('/profiles/me', { + const backendProfile = await apiRequest('/profile/me', { method: 'PATCH', - body: { username }, + body: { user_name: username }, }) - user.value = updatedUser - profile.value = { - id: updatedUser.id, - username: updatedUser.username, - avatar_url: updatedUser.avatar_url, - role: updatedUser.role, - is_disabled: updatedUser.is_disabled, - created_at: updatedUser.created_at, - } + applyProfile(backendProfile) } - async function updatePassword(password: string) { - const auth = await apiRequest('/auth/password', { + async function updatePassword(currentPassword: string, newPassword: string) { + await apiRequest('/auth/password', { method: 'PATCH', - body: { password }, - }) - applyAuth(auth) - } - - async function sendPasswordReset(email: string) { - await apiRequest('/auth/forgot-password', { - method: 'POST', - auth: false, - body: { email }, + body: { + current_password: currentPassword, + new_password: newPassword, + }, }) + clearAuth() } async function initialize() { - if (!listenersAttached) { - window.addEventListener(AUTH_UPDATED_EVENT, event => { - applyAuth((event as CustomEvent).detail) - }) + if (!listenerAttached) { window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth) - listenersAttached = true + listenerAttached = true } try { - applyAuth(await refreshSession()) + await fetchMe() } catch { clearAuth() } finally { @@ -121,11 +94,9 @@ export const useAuthStore = defineStore('auth', () => { isLoggedIn, isAdmin, login, - register, logout, updateUsername, updatePassword, - sendPasswordReset, initialize, } }) diff --git a/src/stores/clouds.ts b/src/stores/clouds.ts index bd36ec6..04ef585 100644 --- a/src/stores/clouds.ts +++ b/src/stores/clouds.ts @@ -1,6 +1,7 @@ import { ref } from 'vue' import { defineStore } from 'pinia' -import { apiRequest } from '@/lib/api' +import { apiRequest, toCloudType } from '@/lib/api' +import type { BackendCloudType } from '@/types/api' import type { CloudType } from '@/types/database' export const useCloudsStore = defineStore('clouds', () => { @@ -11,7 +12,8 @@ export const useCloudsStore = defineStore('clouds', () => { if (cloudTypes.value.length > 0) return loading.value = true try { - cloudTypes.value = await apiRequest('/cloud-types', { auth: false }) + const rows = await apiRequest('/info/cloudtype', { auth: false }) + cloudTypes.value = rows.map(toCloudType) } finally { loading.value = false } diff --git a/src/stores/encyclopedia.ts b/src/stores/encyclopedia.ts index e64f14d..bff48dc 100644 --- a/src/stores/encyclopedia.ts +++ b/src/stores/encyclopedia.ts @@ -1,104 +1,29 @@ -import { computed, ref } from 'vue' +import { ref } from 'vue' import { defineStore } from 'pinia' -import { apiRequest } from '@/lib/api' -import { useAuthStore } from '@/stores/auth' -import type { ApiCollection } from '@/types/api' -import type { CloudType, UserCollection } from '@/types/database' - -export interface CollectionPreviewCloud { - id: string - image_url: string - thumbnail_url: string | null - captured_at: string | null - created_at: string - location_name: string | null -} - -export interface CollectionEntry extends UserCollection { - firstCloud: CollectionPreviewCloud | null -} - -function getErrorMessage(error: unknown) { - return error instanceof Error ? error.message : '图鉴收藏加载失败' -} +import { apiRequest, toCloudType } from '@/lib/api' +import type { BackendCloudType } from '@/types/api' +import type { CloudType } from '@/types/database' export const useEncyclopediaStore = defineStore('encyclopedia', () => { - const authStore = useAuthStore() - const cloudTypes = ref([]) - const myCollection = ref([]) const loadingCloudTypes = ref(false) - const loadingCollection = ref(false) const cloudTypesLoaded = ref(false) - const collectionLoadedForUserId = ref(null) - const collectionError = ref('') - - const collectionMap = computed(() => new Map(myCollection.value.map(item => [item.cloud_type_id, item]))) - const unlockedCount = computed(() => myCollection.value.length) - const unlockProgress = computed(() => `${unlockedCount.value}/${cloudTypes.value.length || 10}`) - const unlockPercent = computed(() => cloudTypes.value.length - ? Math.round(unlockedCount.value / cloudTypes.value.length * 100) - : 0) async function fetchCloudTypes(force = false) { if (cloudTypesLoaded.value && !force) return loadingCloudTypes.value = true try { - cloudTypes.value = await apiRequest('/cloud-types', { auth: false }) + const rows = await apiRequest('/info/cloudtype', { auth: false }) + cloudTypes.value = rows.map(toCloudType) cloudTypesLoaded.value = true } finally { loadingCloudTypes.value = false } } - async function fetchMyCollection(force = false) { - const userId = authStore.user?.id ?? null - if (!userId) { - myCollection.value = [] - collectionLoadedForUserId.value = null - collectionError.value = '' - return - } - if (!force && collectionLoadedForUserId.value === userId) return - - loadingCollection.value = true - collectionError.value = '' - try { - const rows = await apiRequest('/collections/me') - myCollection.value = rows.map(({ first_cloud, cloud_type: _cloudType, ...collection }) => ({ - ...collection, - firstCloud: first_cloud, - })) - collectionLoadedForUserId.value = userId - } catch (error) { - myCollection.value = [] - collectionLoadedForUserId.value = userId - collectionError.value = getErrorMessage(error) - } finally { - loadingCollection.value = false - } - } - - function isUnlocked(cloudTypeId: number) { - return collectionMap.value.has(cloudTypeId) - } - - function getCollectionEntry(cloudTypeId: number) { - return collectionMap.value.get(cloudTypeId) ?? null - } - return { cloudTypes, - myCollection, loadingCloudTypes, - loadingCollection, - collectionError, - unlockedCount, - unlockProgress, - unlockPercent, fetchCloudTypes, - fetchMyCollection, - isUnlocked, - getCollectionEntry, } }) diff --git a/src/stores/profile.ts b/src/stores/profile.ts index f6e3646..d30bf00 100644 --- a/src/stores/profile.ts +++ b/src/stores/profile.ts @@ -1,124 +1,124 @@ import { ref } from 'vue' import { defineStore } from 'pinia' -import { apiRequest } from '@/lib/api' +import { ApiError, apiRequest, toApiCloud } from '@/lib/api' import { useAuthStore } from '@/stores/auth' -import type { ApiCloud, ApiPage, LikeStats, PublicProfile } from '@/types/api' -import type { CloudType, Profile } from '@/types/database' +import type { ActionResponse, ApiCloud, BackendCloudInfo } from '@/types/api' +import type { Profile } from '@/types/database' export interface ProfileCloudItem { id: string - cloud_type_id: number | null - custom_cloud_type: string | null + cloud_type_id: number + custom_cloud_type: null image_url: string - thumbnail_url: string | null - latitude: number | null - longitude: number | null - location_name: string | null + thumbnail_url: string + latitude: number + longitude: number + location_name: null description: string | null captured_at: string | null created_at: string status: 'pending' | 'approved' | 'rejected' is_hidden: boolean cloudTypeName: string - cloudTypeRarity: CloudType['rarity'] + cloudTypeRarity: number favoriteCount: number - isFavorited: boolean + isFavorited: false } function toProfileCloud(row: ApiCloud): ProfileCloudItem { return { id: row.id, - cloud_type_id: row.cloud_type_id, - custom_cloud_type: row.custom_cloud_type, + cloud_type_id: row.cloud_type_id as number, + custom_cloud_type: null, image_url: row.image_url, - thumbnail_url: row.thumbnail_url, - latitude: row.latitude, - longitude: row.longitude, - location_name: row.location_name, + thumbnail_url: row.thumbnail_url as string, + latitude: row.latitude as number, + longitude: row.longitude as number, + location_name: null, description: row.description, captured_at: row.captured_at, created_at: row.created_at, status: row.status, is_hidden: row.is_hidden, - cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知', - cloudTypeRarity: row.cloud_type_rarity || 'common', + cloudTypeName: row.cloud_type_name || '未知', + cloudTypeRarity: row.cloud_type_rarity_value ?? 0, favoriteCount: row.favorite_count, - isFavorited: row.is_favorited, - } -} - -function toProfile(profile: PublicProfile): Profile { - return { - ...profile, - role: 'user', - is_disabled: false, + isFavorited: false, } } export const useProfileStore = defineStore('profile-page', () => { const authStore = useAuthStore() - const profilesById = ref>({}) + const profilesByKey = ref>({}) const cloudsByKey = ref>({}) - const likeStatsByUserId = ref>({}) const loadingKeys = ref>({}) const errorByKey = ref>({}) - const makeKey = (userId: string, isOwnProfile: boolean) => `${userId}:${isOwnProfile ? 'own' : 'public'}` + const makeKey = (identifier: string, isOwnProfile: boolean) => `${identifier}:${isOwnProfile ? 'own' : 'public'}` - function getProfile(userId: string | null) { - return userId ? profilesById.value[userId] ?? null : null + function getProfile(identifier: string | null) { + return identifier ? profilesByKey.value[identifier] ?? null : null } - function getClouds(userId: string | null, isOwnProfile: boolean) { - return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : [] + function getClouds(identifier: string | null, isOwnProfile: boolean) { + return identifier ? cloudsByKey.value[makeKey(identifier, isOwnProfile)] ?? [] : [] } - function getLikeStats(userId: string | null) { - return userId ? likeStatsByUserId.value[userId] ?? null : null + function getError(identifier: string | null, isOwnProfile: boolean) { + return identifier ? errorByKey.value[makeKey(identifier, isOwnProfile)] ?? '' : '' } - function getError(userId: string | null, isOwnProfile: boolean) { - return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : '' + function isLoaded(identifier: string | null, isOwnProfile: boolean) { + return !!identifier && makeKey(identifier, isOwnProfile) in cloudsByKey.value } - function isLoaded(userId: string | null, isOwnProfile: boolean) { - return !!userId && makeKey(userId, isOwnProfile) in cloudsByKey.value + async function fetchAllPublicClouds(username: string) { + const rows: BackendCloudInfo[] = [] + for (let page = 1; ; page++) { + const params = new URLSearchParams({ + page: String(page), + page_size: '100', + filter: 'owner', + value: `@${username}`, + }) + let current: BackendCloudInfo[] + try { + current = await apiRequest(`/cloud?${params}`, { auth: false }) + } catch (error) { + if (error instanceof ApiError && error.status === 404) return rows + throw error + } + rows.push(...current) + if (current.length < 100) return rows + } } - async function fetchAllClouds(userId: string, isOwnProfile: boolean) { - const requestOptions = { auth: isOwnProfile } - const first = await apiRequest>(`/profiles/${userId}/clouds?page=1&page_size=100`, requestOptions) - if (first.total_pages <= 1) return first.items - - const remaining = await Promise.all( - Array.from({ length: first.total_pages - 1 }, (_, index) => - apiRequest>(`/profiles/${userId}/clouds?page=${index + 2}&page_size=100`, requestOptions), - ), - ) - return [first, ...remaining].flatMap(page => page.items) - } - - async function fetchProfilePage(userId: string, isOwnProfile: boolean, force = false) { - const key = makeKey(userId, isOwnProfile) - if (!force && key in cloudsByKey.value && profilesById.value[userId]) return + async function fetchProfilePage(identifier: string, isOwnProfile: boolean, force = false) { + const key = makeKey(identifier, isOwnProfile) + if (!force && key in cloudsByKey.value && profilesByKey.value[identifier]) return loadingKeys.value[key] = true errorByKey.value[key] = '' try { - const profilePromise = isOwnProfile && authStore.profile?.id === userId - ? Promise.resolve(authStore.profile) - : apiRequest(`/profiles/${userId}`, { auth: false }).then(toProfile) - const likeStatsPromise = isOwnProfile - ? apiRequest('/profiles/me/like-stats') - : Promise.resolve(null) - const [profile, cloudRows, likeStats] = await Promise.all([ - profilePromise, - fetchAllClouds(userId, isOwnProfile), - likeStatsPromise, - ]) - profilesById.value[userId] = profile - cloudsByKey.value[key] = cloudRows.map(toProfileCloud) - if (likeStats) likeStatsByUserId.value[userId] = likeStats + const backendRows = isOwnProfile + ? await apiRequest('/profile/me/clouds') + : await fetchAllPublicClouds(identifier) + const cloudRows = backendRows.map(toApiCloud).map(toProfileCloud) + + if (isOwnProfile && authStore.profile) { + profilesByKey.value[identifier] = authStore.profile + } else { + const owner = backendRows[0]?.owner + profilesByKey.value[identifier] = { + id: owner?.id || identifier, + username: owner?.name || identifier, + avatar_url: null, + role: 'user', + is_disabled: false, + created_at: '', + } + } + cloudsByKey.value[key] = cloudRows } catch (error) { errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败' cloudsByKey.value[key] = [] @@ -127,13 +127,13 @@ export const useProfileStore = defineStore('profile-page', () => { } } - function isLoading(userId: string | null, isOwnProfile: boolean) { - return !!userId && !!loadingKeys.value[makeKey(userId, isOwnProfile)] + function isLoading(identifier: string | null, isOwnProfile: boolean) { + return !!identifier && !!loadingKeys.value[makeKey(identifier, isOwnProfile)] } - function patchCachedCloud(userId: string, cloudId: string, patch: Partial) { + function patchCachedCloud(identifier: string, cloudId: string, patch: Partial) { for (const isOwnProfile of [true, false]) { - const key = makeKey(userId, isOwnProfile) + const key = makeKey(identifier, isOwnProfile) const current = cloudsByKey.value[key] if (!current) continue cloudsByKey.value[key] = current @@ -142,68 +142,68 @@ export const useProfileStore = defineStore('profile-page', () => { } } - function removeCachedClouds(userId: string, cloudIds: string[]) { - const idSet = new Set(cloudIds) + function removeCachedClouds(identifier: string, cloudIds: string[]) { + const ids = new Set(cloudIds) for (const isOwnProfile of [true, false]) { - const key = makeKey(userId, isOwnProfile) + const key = makeKey(identifier, isOwnProfile) const current = cloudsByKey.value[key] - if (current) cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id)) + if (current) cloudsByKey.value[key] = current.filter(item => !ids.has(item.id)) } } - function invalidateUser(userId: string) { - delete cloudsByKey.value[makeKey(userId, true)] - delete cloudsByKey.value[makeKey(userId, false)] + function invalidateUser(identifier: string) { + delete cloudsByKey.value[makeKey(identifier, true)] + delete cloudsByKey.value[makeKey(identifier, false)] } - async function updateCloud(userId: string, cloudId: string, patch: { - cloud_type_id: number | null - custom_cloud_type: string | null - latitude: number | null - longitude: number | null - location_name: string | null - description: string | null - captured_at: string | null + async function updateCloud(identifier: string, cloudId: string, patch: { + type_id: number + latitude: number + longitude: number + description?: string + captured_at?: string is_hidden: boolean }) { - const updated = toProfileCloud(await apiRequest(`/clouds/${cloudId}`, { + await apiRequest(`/cloud/${cloudId}`, { method: 'PATCH', body: patch, - })) - patchCachedCloud(userId, cloudId, updated) + }) + const updated = toProfileCloud(toApiCloud(await apiRequest(`/cloud/${cloudId}`))) + patchCachedCloud(identifier, cloudId, updated) return updated } - async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) { - await apiRequest(`/clouds/${cloudId}`, { + async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) { + await apiRequest(`/cloud/${cloudId}`, { method: 'PATCH', body: { is_hidden: isHidden }, }) - patchCachedCloud(userId, cloudId, { is_hidden: isHidden }) + patchCachedCloud(identifier, cloudId, { is_hidden: isHidden }) } - async function deleteClouds(userId: string, cloudIds: string[]) { + async function deleteClouds(identifier: string, cloudIds: string[]) { if (!cloudIds.length) return 0 if (cloudIds.length === 1) { - await apiRequest(`/clouds/${cloudIds[0]}`, { method: 'DELETE' }) + await apiRequest(`/cloud/${cloudIds[0]}`, { method: 'DELETE' }) } else { for (let offset = 0; offset < cloudIds.length; offset += 100) { - await apiRequest('/clouds/batch-delete', { - method: 'POST', - body: { ids: cloudIds.slice(offset, offset + 100) }, + await apiRequest('/cloud', { + method: 'DELETE', + body: { + cloud_ids: cloudIds.slice(offset, offset + 100).map(cloudId => ({ cloud_id: cloudId })), + }, }) } } - removeCachedClouds(userId, cloudIds) + removeCachedClouds(identifier, cloudIds) return cloudIds.length } return { getProfile, getClouds, - getLikeStats, getError, isLoaded, isLoading, diff --git a/src/types/api.ts b/src/types/api.ts index 377a390..52ff785 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,5 +1,63 @@ -import type { Cloud, CloudType, Profile, UserCollection } from '@/types/database' +import type { Cloud, CloudType, Profile } from '@/types/database' +export interface BackendUserProfile { + id: string + name: string + email: string + avatar_id: string | null + cloud_count: number + received_like_count: number + last_online: string | null + role: Profile['role'] + is_disabled: boolean + created_at: string +} + +export interface BackendCloudType { + id: number + name: string + genus: string | null + icon_id: string | null + rarity: number + description: string | null + created_at: string +} + +export interface BackendCloudOwner { + id: string + name: string +} + +export interface BackendCloudInfo { + id: string + owner: BackendCloudOwner + type: BackendCloudType + latitude: number + longitude: number + description: string | null + captured_at: string | null + uploaded_at: string + updated_at: string + received_like_count: number + status: Cloud['status'] + is_hidden: boolean +} + +export interface ActionResponse { + status_code: number + message: string +} + +export interface LoginResponse { + access_token: string +} + +export interface CloudCreateResponse extends ActionResponse { + cloud_id: string +} + +// 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 @@ -11,24 +69,7 @@ export interface AuthUser { cloud_count: number public_cloud_count: number collection_count: number - created_at: string -} - -export interface AuthResponse { - access_token: string - token_type: 'bearer' - expires_in: number - user: AuthUser -} - -export interface MeResponse { - user: AuthUser -} - -export interface PublicProfile { - id: string - username: string - avatar_url: string | null + received_like_count: number created_at: string } @@ -45,6 +86,7 @@ export interface ApiCloudTypeSummary { name: string name_en: string rarity: CloudType['rarity'] + rarity_value: number } export interface ApiCloudOwner { @@ -58,48 +100,12 @@ export interface ApiCloud extends Cloud { owner: ApiCloudOwner | null cloud_type_name: string | null cloud_type_rarity: CloudType['rarity'] | null + cloud_type_rarity_value: number | null username: string | null favorite_count: number is_favorited: boolean } -export interface LikeStatsPhoto { - cloud_id: string - image_url: string - thumbnail_url: string - favorite_count: number - captured_at: string | null - created_at: string -} - -export interface LikeStats { - user_id: string - username: string - total_likes: number - photos: LikeStatsPhoto[] -} - -export interface ApiCollection extends UserCollection { - cloud_type: ApiCloudTypeSummary - first_cloud: Pick< - ApiCloud, - 'id' | 'image_url' | 'thumbnail_url' | 'captured_at' | 'created_at' | 'location_name' - > | null -} - -export interface UnlockedBadge { - cloud_type_id: number - cloud_name: string - cloud_name_en: string - rarity: CloudType['rarity'] - unlocked_at: string -} - -export interface CloudCreateResponse { - cloud: ApiCloud - unlocked_badge: UnlockedBadge | null -} - export interface AdminStats { users: number images: number diff --git a/src/types/database.ts b/src/types/database.ts index 3da73ec..55703d7 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -4,6 +4,7 @@ export interface CloudType { name_en: string genus: string rarity: 'common' | 'uncommon' | 'rare' + rarity_value: number description: string | null icon_url: string | null created_at: string @@ -34,19 +35,3 @@ export interface Profile { is_disabled: boolean created_at: string } - -export interface UserCollection { - id: string - user_id: string - cloud_type_id: number - first_cloud_id: string | null - unlocked_at: string -} - -export interface Reaction { - id: string - user_id: string - cloud_id: string - emoji: string - created_at: string -} diff --git a/src/views/auth/AuthConfirmView.vue b/src/views/auth/AuthConfirmView.vue index 68421f4..abdc4e7 100644 --- a/src/views/auth/AuthConfirmView.vue +++ b/src/views/auth/AuthConfirmView.vue @@ -1,90 +1,7 @@ diff --git a/src/views/auth/ForgotPasswordView.vue b/src/views/auth/ForgotPasswordView.vue index e177096..abdc4e7 100644 --- a/src/views/auth/ForgotPasswordView.vue +++ b/src/views/auth/ForgotPasswordView.vue @@ -1,179 +1,7 @@ diff --git a/src/views/auth/LoginView.vue b/src/views/auth/LoginView.vue index d5f2a30..f78e4cb 100644 --- a/src/views/auth/LoginView.vue +++ b/src/views/auth/LoginView.vue @@ -51,19 +51,19 @@ async function handleLogin() { 你眼中的每一朵云
Collection
10
-
基础云属待收集
+
基础云属资料
Mode
Atlas
-
地图、图鉴、画廊一体
+
地图、百科、画廊一体
@@ -120,17 +120,8 @@ async function handleLogin() { -
-

- 没有账号? - 去注册 -

- - 忘记密码? - +
+ 注册与密码找回暂未开放;如需账号协助,请联系管理员。
diff --git a/src/views/auth/RegisterView.vue b/src/views/auth/RegisterView.vue index 07177c8..abdc4e7 100644 --- a/src/views/auth/RegisterView.vue +++ b/src/views/auth/RegisterView.vue @@ -1,184 +1,7 @@ diff --git a/src/views/auth/ResetPasswordView.vue b/src/views/auth/ResetPasswordView.vue index 342b2b8..abdc4e7 100644 --- a/src/views/auth/ResetPasswordView.vue +++ b/src/views/auth/ResetPasswordView.vue @@ -1,209 +1,7 @@ diff --git a/src/views/auth/UnavailableAuthView.vue b/src/views/auth/UnavailableAuthView.vue new file mode 100644 index 0000000..6cf7c49 --- /dev/null +++ b/src/views/auth/UnavailableAuthView.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/views/encyclopedia/CloudTypeView.vue b/src/views/encyclopedia/CloudTypeView.vue index 4f11650..77a4ae5 100644 --- a/src/views/encyclopedia/CloudTypeView.vue +++ b/src/views/encyclopedia/CloudTypeView.vue @@ -1,126 +1,60 @@ diff --git a/src/views/encyclopedia/EncyclopediaView.vue b/src/views/encyclopedia/EncyclopediaView.vue index c0ce7bf..9b9e46b 100644 --- a/src/views/encyclopedia/EncyclopediaView.vue +++ b/src/views/encyclopedia/EncyclopediaView.vue @@ -1,109 +1,33 @@ - - diff --git a/src/views/gallery/GalleryView.vue b/src/views/gallery/GalleryView.vue index e0e0e88..a9c5ae0 100644 --- a/src/views/gallery/GalleryView.vue +++ b/src/views/gallery/GalleryView.vue @@ -1,17 +1,17 @@ diff --git a/src/views/auth/ForgotPasswordView.vue b/src/views/auth/ForgotPasswordView.vue index abdc4e7..0092adb 100644 --- a/src/views/auth/ForgotPasswordView.vue +++ b/src/views/auth/ForgotPasswordView.vue @@ -1,7 +1,87 @@ diff --git a/src/views/auth/LoginView.vue b/src/views/auth/LoginView.vue index f78e4cb..8b93c7b 100644 --- a/src/views/auth/LoginView.vue +++ b/src/views/auth/LoginView.vue @@ -1,5 +1,5 @@ diff --git a/src/views/auth/ResendConfirmationView.vue b/src/views/auth/ResendConfirmationView.vue new file mode 100644 index 0000000..8724be3 --- /dev/null +++ b/src/views/auth/ResendConfirmationView.vue @@ -0,0 +1,83 @@ + + + diff --git a/src/views/auth/ResetPasswordView.vue b/src/views/auth/ResetPasswordView.vue index abdc4e7..a22dde3 100644 --- a/src/views/auth/ResetPasswordView.vue +++ b/src/views/auth/ResetPasswordView.vue @@ -1,7 +1,93 @@ diff --git a/src/views/auth/UnavailableAuthView.vue b/src/views/auth/UnavailableAuthView.vue deleted file mode 100644 index 6cf7c49..0000000 --- a/src/views/auth/UnavailableAuthView.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - -- 2.50.1 (Apple Git-155) From b0759becbbdfac8fbeaab4c1d8c99c730c4cb70d Mon Sep 17 00:00:00 2001 From: Mplan Date: Fri, 21 Aug 2026 02:45:34 +0800 Subject: [PATCH 3/6] =?UTF-8?q?style(ui):=20=E5=9B=BE=E7=89=87=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E6=94=B9=E4=B8=BA=E7=9B=B4=E8=A7=92=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E5=B9=B6=E8=B0=83=E6=95=B4=E5=85=B3=E9=97=AD=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/cloud/ImageDetailModal.vue | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/components/cloud/ImageDetailModal.vue b/src/components/cloud/ImageDetailModal.vue index 182df62..4394349 100644 --- a/src/components/cloud/ImageDetailModal.vue +++ b/src/components/cloud/ImageDetailModal.vue @@ -49,10 +49,10 @@ const layout = computed(() => { ? naturalWidth.value / naturalHeight.value : 4 / 3 const maxModalWidth = viewportWidth.value > 0 - ? Math.max(Math.min(viewportWidth.value * 0.92, viewportWidth.value - 32), 280) + ? Math.max(Math.min(viewportWidth.value * 0.95, viewportWidth.value - 32), 280) : 320 const maxImageHeight = viewportHeight.value > 0 - ? Math.max(viewportHeight.value * 0.86, 240) + ? Math.max(viewportHeight.value * 0.9, 240) : 360 const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio) @@ -187,11 +187,11 @@ onBeforeUnmount(() => { @@ -301,13 +301,7 @@ onBeforeUnmount(() => { transform: translateY(10px) scale(0.97); } -.image-detail-shell.image-detail-shell { - border-radius: 30px !important; -} - -.image-detail-close-button.image-detail-close-button, -.image-detail-info-button.image-detail-info-button, -.image-detail-panel-close.image-detail-panel-close { +.image-detail-info-button.image-detail-info-button { border-radius: 9999px !important; } -- 2.50.1 (Apple Git-155) From 306d709cc7525cc316b83a1011b767c967d6facb Mon Sep 17 00:00:00 2001 From: Mplan Date: Fri, 21 Aug 2026 02:46:28 +0800 Subject: [PATCH 4/6] =?UTF-8?q?refactor(api):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=AD=97=E6=AE=B5=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=87=86=E9=80=A0=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/cloud/CloudEditModal.vue | 2 +- src/components/cloud/QuickUploadModal.vue | 2 +- src/lib/api.ts | 15 ++------------- src/lib/cloudTypes.ts | 20 ++++++++++---------- src/stores/auth.ts | 2 +- src/stores/profile.ts | 12 +++--------- src/types/api.ts | 9 ++------- src/types/database.ts | 6 +----- src/views/admin/AdminView.vue | 6 +++--- src/views/encyclopedia/CloudTypeView.vue | 2 +- src/views/encyclopedia/EncyclopediaView.vue | 2 +- src/views/gallery/GalleryView.vue | 7 +------ src/views/map/MapView.vue | 5 +---- src/views/profile/ProfileView.vue | 5 ++--- src/views/upload/UploadView.vue | 2 +- 15 files changed, 31 insertions(+), 66 deletions(-) diff --git a/src/components/cloud/CloudEditModal.vue b/src/components/cloud/CloudEditModal.vue index 4a68bd1..936fe71 100644 --- a/src/components/cloud/CloudEditModal.vue +++ b/src/components/cloud/CloudEditModal.vue @@ -141,7 +141,7 @@ watch(() => props.initialValue, value => { > diff --git a/src/components/cloud/QuickUploadModal.vue b/src/components/cloud/QuickUploadModal.vue index 2e99a20..f4249d6 100644 --- a/src/components/cloud/QuickUploadModal.vue +++ b/src/components/cloud/QuickUploadModal.vue @@ -271,7 +271,7 @@ onBeforeUnmount(() => { >

{{ activeItem.errors.cloudCategory }}

diff --git a/src/lib/api.ts b/src/lib/api.ts index ab3cfa1..92ccb03 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -141,16 +141,11 @@ export function toAuthUser(profile: BackendUserProfile): AuthUser { return { id: profile.id, email: profile.email, - // Protected profile routes reject unverified accounts, so a successfully - // loaded current profile is necessarily email-verified. - email_verified: true, username: profile.name, - avatar_url: null, + avatar_id: profile.avatar_id, role: profile.role, is_disabled: profile.is_disabled, cloud_count: profile.cloud_count, - public_cloud_count: profile.cloud_count, - collection_count: 0, received_like_count: profile.received_like_count, created_at: profile.created_at, } @@ -160,14 +155,12 @@ export function toCloudType(row: BackendCloudType): CloudType { return { id: row.id, name: row.name, - name_en: row.genus || '', genus: row.genus || '', // Compatibility value for the excluded legacy admin view. Migrated pages // display rarity_value directly because API.md defines no semantic mapping. rarity: 'common', rarity_value: row.rarity, description: row.description, - icon_url: null, created_at: row.created_at, } } @@ -177,12 +170,10 @@ export function toApiCloud(row: BackendCloudInfo): ApiCloud { id: row.id, user_id: row.owner.id, cloud_type_id: row.type.id, - custom_cloud_type: null, image_url: imageUrl(row.id, 'original'), thumbnail_url: imageUrl(row.id, 'preview'), latitude: row.latitude, longitude: row.longitude, - location_name: null, description: row.description, captured_at: row.captured_at, status: row.status, @@ -192,7 +183,6 @@ export function toApiCloud(row: BackendCloudInfo): ApiCloud { cloud_type: { id: row.type.id, name: row.type.name, - name_en: row.type.genus || '', rarity: 'common', rarity_value: row.type.rarity, }, @@ -201,7 +191,6 @@ export function toApiCloud(row: BackendCloudInfo): ApiCloud { cloud_type_rarity: 'common', cloud_type_rarity_value: row.type.rarity, username: row.owner.name, - favorite_count: row.received_like_count, - is_favorited: false, + received_like_count: row.received_like_count, } } diff --git a/src/lib/cloudTypes.ts b/src/lib/cloudTypes.ts index a39464d..94b8821 100644 --- a/src/lib/cloudTypes.ts +++ b/src/lib/cloudTypes.ts @@ -1,14 +1,14 @@ export const CLOUD_TYPES = [ - { id: 1, name: '积云', nameEn: 'Cumulus' }, - { id: 2, name: '层云', nameEn: 'Stratus' }, - { id: 3, name: '卷云', nameEn: 'Cirrus' }, - { id: 4, name: '积雨云', nameEn: 'Cumulonimbus' }, - { id: 5, name: '层积云', nameEn: 'Stratocumulus' }, - { id: 6, name: '高积云', nameEn: 'Altocumulus' }, - { id: 7, name: '高层云', nameEn: 'Altostratus' }, - { id: 8, name: '雨层云', nameEn: 'Nimbostratus' }, - { id: 9, name: '卷层云', nameEn: 'Cirrostratus' }, - { id: 10, name: '卷积云', nameEn: 'Cirrocumulus' }, + { id: 1, name: '积云' }, + { id: 2, name: '层云' }, + { id: 3, name: '卷云' }, + { id: 4, name: '积雨云' }, + { id: 5, name: '层积云' }, + { id: 6, name: '高积云' }, + { id: 7, name: '高层云' }, + { id: 8, name: '雨层云' }, + { id: 9, name: '卷层云' }, + { id: 10, name: '卷积云' }, ] as const export function getCloudTypeName(id: string | string[]) { diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 450d684..df4def1 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -19,7 +19,7 @@ export const useAuthStore = defineStore('auth', () => { profile.value = { id: normalized.id, username: normalized.username, - avatar_url: normalized.avatar_url, + avatar_id: normalized.avatar_id, role: normalized.role, is_disabled: normalized.is_disabled, created_at: normalized.created_at, diff --git a/src/stores/profile.ts b/src/stores/profile.ts index d30bf00..c3822e2 100644 --- a/src/stores/profile.ts +++ b/src/stores/profile.ts @@ -8,12 +8,10 @@ import type { Profile } from '@/types/database' export interface ProfileCloudItem { id: string cloud_type_id: number - custom_cloud_type: null image_url: string thumbnail_url: string latitude: number longitude: number - location_name: null description: string | null captured_at: string | null created_at: string @@ -21,20 +19,17 @@ export interface ProfileCloudItem { is_hidden: boolean cloudTypeName: string cloudTypeRarity: number - favoriteCount: number - isFavorited: false + receivedLikeCount: number } function toProfileCloud(row: ApiCloud): ProfileCloudItem { return { id: row.id, cloud_type_id: row.cloud_type_id as number, - custom_cloud_type: null, image_url: row.image_url, thumbnail_url: row.thumbnail_url as string, latitude: row.latitude as number, longitude: row.longitude as number, - location_name: null, description: row.description, captured_at: row.captured_at, created_at: row.created_at, @@ -42,8 +37,7 @@ function toProfileCloud(row: ApiCloud): ProfileCloudItem { is_hidden: row.is_hidden, cloudTypeName: row.cloud_type_name || '未知', cloudTypeRarity: row.cloud_type_rarity_value ?? 0, - favoriteCount: row.favorite_count, - isFavorited: false, + receivedLikeCount: row.received_like_count, } } @@ -112,7 +106,7 @@ export const useProfileStore = defineStore('profile-page', () => { profilesByKey.value[identifier] = { id: owner?.id || identifier, username: owner?.name || identifier, - avatar_url: null, + avatar_id: null, role: 'user', is_disabled: false, created_at: '', diff --git a/src/types/api.ts b/src/types/api.ts index f3838f7..13b1b57 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -65,14 +65,11 @@ export interface CloudCreateResponse extends ActionResponse { export interface AuthUser { id: string email: string - email_verified: boolean username: string - avatar_url: string | null + avatar_id: string | null role: Profile['role'] is_disabled: boolean cloud_count: number - public_cloud_count: number - collection_count: number received_like_count: number created_at: string } @@ -88,7 +85,6 @@ export interface ApiPage { export interface ApiCloudTypeSummary { id: number name: string - name_en: string rarity: CloudType['rarity'] rarity_value: number } @@ -106,8 +102,7 @@ export interface ApiCloud extends Cloud { cloud_type_rarity: CloudType['rarity'] | null cloud_type_rarity_value: number | null username: string | null - favorite_count: number - is_favorited: boolean + received_like_count: number } export interface AdminStats { diff --git a/src/types/database.ts b/src/types/database.ts index 55703d7..610144a 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -1,12 +1,10 @@ export interface CloudType { id: number name: string - name_en: string genus: string rarity: 'common' | 'uncommon' | 'rare' rarity_value: number description: string | null - icon_url: string | null created_at: string } @@ -14,12 +12,10 @@ export interface Cloud { id: string user_id: string cloud_type_id: number | null - custom_cloud_type: string | null image_url: string thumbnail_url: string | null latitude: number | null longitude: number | null - location_name: string | null description: string | null captured_at: string | null status: 'pending' | 'approved' | 'rejected' @@ -30,7 +26,7 @@ export interface Cloud { export interface Profile { id: string username: string - avatar_url: string | null + avatar_id: string | null role: 'user' | 'admin' is_disabled: boolean created_at: string diff --git a/src/views/admin/AdminView.vue b/src/views/admin/AdminView.vue index 0479f19..d42293a 100644 --- a/src/views/admin/AdminView.vue +++ b/src/views/admin/AdminView.vue @@ -178,18 +178,18 @@ function toAdminCloud(row: ApiCloud): AdminCloud { id: row.id, user_id: row.user_id, cloud_type_id: row.cloud_type_id, - custom_cloud_type: row.custom_cloud_type, + custom_cloud_type: null, image_url: row.image_url, thumbnail_url: row.thumbnail_url, latitude: row.latitude, longitude: row.longitude, - location_name: row.location_name, + location_name: null, description: row.description, captured_at: row.captured_at, created_at: row.created_at, status: row.status, is_hidden: row.is_hidden, - cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知云型', + cloudTypeName: row.cloud_type_name || '未知云型', cloudTypeRarity: row.cloud_type_rarity || 'common', username: row.username || '匿名用户', } diff --git a/src/views/encyclopedia/CloudTypeView.vue b/src/views/encyclopedia/CloudTypeView.vue index 77a4ae5..af9fa3a 100644 --- a/src/views/encyclopedia/CloudTypeView.vue +++ b/src/views/encyclopedia/CloudTypeView.vue @@ -90,7 +90,7 @@ watch(() => route.params.id, () => loadPage())
稀有度 {{ cloudType.rarity_value }}

{{ cloudType.name }}

-

{{ cloudType.name_en || '未提供云属名称' }}

+

{{ cloudType.genus || '未提供云属名称' }}

diff --git a/src/views/encyclopedia/EncyclopediaView.vue b/src/views/encyclopedia/EncyclopediaView.vue index 9b9e46b..44d536b 100644 --- a/src/views/encyclopedia/EncyclopediaView.vue +++ b/src/views/encyclopedia/EncyclopediaView.vue @@ -44,7 +44,7 @@ onMounted(() => encyclopediaStore.fetchCloudTypes())

{{ cloudType.name }}

- {{ cloudType.name_en || '未提供云属名称' }} + {{ cloudType.genus || '未提供云属名称' }}

diff --git a/src/views/gallery/GalleryView.vue b/src/views/gallery/GalleryView.vue index a9c5ae0..2a7023e 100644 --- a/src/views/gallery/GalleryView.vue +++ b/src/views/gallery/GalleryView.vue @@ -17,10 +17,8 @@ interface GalleryCloud { id: string user_id: string cloud_type_id: number | null - custom_cloud_type: string | null image_url: string thumbnail_url: string | null - location_name: string | null description: string | null latitude: number | null longitude: number | null @@ -88,10 +86,8 @@ function toGalleryCloud(row: ApiCloud) { id: row.id, user_id: row.user_id, cloud_type_id: row.cloud_type_id, - custom_cloud_type: row.custom_cloud_type, image_url: row.image_url, thumbnail_url: row.thumbnail_url, - location_name: row.location_name, description: row.description, latitude: row.latitude, longitude: row.longitude, @@ -99,7 +95,7 @@ function toGalleryCloud(row: ApiCloud) { created_at: row.created_at, status: row.status, is_hidden: row.is_hidden, - cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知', + cloudTypeName: row.cloud_type_name || '未知', cloudTypeRarity: row.cloud_type_rarity_value ?? 0, username: row.username || '匿名', } satisfies GalleryCloud @@ -554,7 +550,6 @@ onUnmounted(() => {