Author SHA1 Message Date
Mplan 8ba0212eec refactor(api)!: align client with canonical HTTP contract
Route all feature requests through business API modules, sync generated DTOs, and add communication tests and architecture documentation.

BREAKING CHANGE: migrate the client to plural resource paths, canonical snake_case fields, ISO timestamps, and explicit paginated responses.
2026-09-30 12:04:58 +08:00
Mplan 00c6eccb63 Merge branch 'main' of https://git.catpl.top/mplan/opencloud
# Conflicts:
#	API.md
#	src/stores/clouds.ts
#	src/stores/encyclopedia.ts
#	src/views/admin/AdminView.vue
2026-09-29 18:19:01 +08:00
Mplan 015e0dd227 feat(admin): add cloud type management 2026-08-27 06:19:17 +08:00
34 changed files with 1137 additions and 1623 deletions
+47 -57
View File
@@ -5,44 +5,44 @@
- `npm run dev` — Vite dev server with HMR
- `npm run lint` / `npm run lint:fix` — ESLint checks / automatic fixes for JS, TS, and Vue; warnings fail the check
- `npm run format:check` / `npm run format` — Prettier check / formatting for source, config, and docs
- `npm run check` — lint, formatting, style contracts, and typecheck
- `npm run check` — lint, formatting, style contracts, typecheck, and communication tests
- `npm run check:styles` — validates shared style contracts and blocks deprecated palettes/classes
- `npm run build` — `vue-tsc -b && vite build` (typecheck must pass or build aborts)
- `npm run typecheck` — standalone Vue / TypeScript typecheck
- `npm run preview` — previews the production build on strict port `5173`
- ESLint uses `eslint.config.js`; Prettier uses `.prettierrc.json`. Keep formatting rules in Prettier. No general test runner exists; `check:styles` remains the project-specific visual-system check.
- ESLint uses `eslint.config.js`; Prettier uses `.prettierrc.json`. Keep formatting rules in Prettier. Communication tests use Node’s built-in runner (`npm test`, Node 22.18+); `check:styles` checks the visual system.
## Architecture
- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + FastAPI + AMap (高德地图) + Naive UI**
- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + Hono + Better Auth + AMap (高德地图) + Naive UI**
- Path alias `@/` → `src/` (configured in both `vite.config.ts` and `tsconfig.app.json`)
- FastAPI client at `src/lib/api.ts`; API base URL comes from `VITE_API_URL`
- HTTP transport at `src/lib/api.ts`; business calls in `features/{clouds,profile,admin}/api.ts`; API base URL comes from `VITE_API_URL`
- AMap loaded lazily via `src/shared/map/amap.ts` with type declarations in `src/shared/map/types/amap.d.ts`
- UI language is Chinese (zh-CN)
- All persistence, storage, authorization, and admin operations go through the FastAPI backend
- All persistence, storage, authorization, and admin operations go through the Hono backend
## Directory Map
| Path | Purpose |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `src/features/` | Business modules; each owns its route views, components, stores, and composables as needed |
| `src/features/auth/` | Auth views, auth store, and Better Auth client |
| `src/features/clouds/` | Shared cloud detail/edit/like components, cloud-type and likes stores, cloud-type constants |
| `src/features/upload/` | UploadView, QuickUploadModal, and useUpload |
| `src/features/profile/` | Profile views, profile store, and ContributionHeatmap |
| `src/features/encyclopedia/` | Encyclopedia views and encyclopedia store |
| `src/features/{map,gallery,community,admin,system}/views/` | Remaining route views grouped by feature |
| `src/shared/map/` | Reusable location picker and mini map, AMap loader, and AMap declarations |
| `src/lib/` | Shared infrastructure: API client and adapters, SEO meta builder, Naive UI theme |
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components |
| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts |
| `src/lib/theme.ts` | Naive UI theme overrides aligned with the semantic tokens |
| `src/router/` | Route registration, lazy view imports, guards, and per-route SEO |
| `src/types/` | Shared domain models (`database.ts`), API DTOs (`api.ts`), and router meta (`router.d.ts`) |
| `docs/project-structure.md` | File ownership, dependency conventions, and examples for placing new code |
| `docs/style-system.md` | Concise implementation and naming guide for the visual system |
| `scripts/check-style-system.mjs` | Automated guard against deprecated palettes and malformed shared classes |
| Path | Purpose |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `src/features/` | Business modules; each owns its route views, components, stores, and composables as needed |
| `src/features/auth/` | Auth views, auth store, and Better Auth client |
| `src/features/clouds/` | Shared cloud detail/edit/like components, cloud-type and likes stores, cloud-type constants |
| `src/features/upload/` | UploadView, QuickUploadModal, and useUpload |
| `src/features/profile/` | Profile views, profile store, and ContributionHeatmap |
| `src/features/encyclopedia/` | Encyclopedia views and encyclopedia store |
| `src/features/{map,gallery,community,admin,system}/views/` | Remaining route views grouped by feature |
| `src/shared/map/` | Reusable location picker and mini map, AMap loader, and AMap declarations |
| `src/lib/` | Shared infrastructure: API client and adapters, SEO meta builder, Naive UI theme |
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components |
| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts |
| `src/lib/theme.ts` | Naive UI theme overrides aligned with the semantic tokens |
| `src/router/` | Route registration, lazy view imports, guards, and per-route SEO |
| `src/types/` | Frontend models (`models.ts`, `view-models.ts`), generated HTTP DTOs (`api-contract.ts`), and router meta (`router.d.ts`) |
| `docs/project-structure.md` | File ownership, dependency conventions, and examples for placing new code |
| `docs/style-system.md` | Concise implementation and naming guide for the visual system |
| `scripts/check-style-system.mjs` | Automated guard against deprecated palettes and malformed shared classes |
- When adding or moving source files, follow `docs/project-structure.md`: colocate business code under its owning feature; keep reusable location primitives in `shared/map` and application layout in `components/layout`.
- Import concrete files directly; route views remain lazy imports in `src/router/index.ts`.
@@ -71,45 +71,35 @@
## Auth Flow
- **`main.ts` initializes auth before mounting**: `authStore.initialize()` exchanges the HttpOnly refresh cookie for an access token. The app only mounts after this initial check.
- Access tokens live in memory only. Refresh cookies are managed by the backend and sent with `credentials: 'include'`.
- `src/lib/api.ts` performs one automatic refresh-and-retry after an authenticated request receives `401`.
- API auth state changes are synchronized back to the Pinia store through `opencloud:auth-updated` and `opencloud:auth-expired` window events.
- Email confirmation and password reset pages consume the opaque `token` query parameter generated by the backend.
- Passwords must contain at least 8 characters in registration, settings, and reset flows.
- Registration, email confirmation, login, password reset, profile creation, and uniqueness checks are backend responsibilities.
- `main.ts` calls `authStore.initialize()` before mounting: Better Auth checks the session cookie and then loads `/profiles/me`.
- The backend manages HttpOnly session cookies; browser requests use `credentials: 'include'`.
- `features/auth/authClient.ts` owns Better Auth SDK configuration and error adaptation. No custom access-token refresh or automatic mutation retry.
- Required business requests receiving 401 dispatch `opencloud:auth-expired`, which clears Pinia auth state. Public requests use `{ auth: false }` to suppress that event.
- Email confirmation and password reset consume the backend’s opaque `token` query parameter. Passwords require at least 8 characters.
## Stores
- **auth** — User session, profile, login/register/logout, username/password update, password reset. `initialize()` must be called before app mounts.
- **clouds** — Simple cache of cloud types returned by `GET /cloud-types`. Fetched once and shared across views.
- **encyclopedia** — Cloud types + user's collection (unlock state). Tracks `unlockPercent` for progress display. Depends on `authStore`.
- **encyclopedia** — Cloud-type catalog cache for encyclopedia pages; numeric rarity comes from the backend.
- **profile** — User profile pages. Fetches profile + cloud list per-user. Supports update/delete/visibility-toggle with optimistic cache patching; backend deletion also removes stored files.
## FastAPI Backend
## Backend and API Conventions
- Backend project lives in the sibling directory `../opencloud-backend`.
- Default development API URL is `http://localhost:8000/api/v1`; set `VITE_API_URL` for deployed environments.
- Frontend route guards are for navigation UX only. The backend enforces ownership, authentication, admin roles, and disabled-account rules.
- Images are uploaded as multipart form data. The backend stores originals, creates thumbnails, blurs coordinates, writes database records, and atomically unlocks badges.
## API Conventions
- All requests must go through `src/lib/api.ts`; do not call `fetch` directly from views, stores, or composables.
- API paths passed to `apiRequest()` are relative to `VITE_API_URL`, for example `/clouds` rather than `/api/v1/clouds`.
- Public requests explicitly use `{ auth: false }`. Authenticated requests use the default and receive a Bearer access token.
- JSON request bodies are plain objects. Image uploads use `FormData`; do not set the multipart `Content-Type` header manually.
- FastAPI errors use `detail`; `ApiError` converts string and validation-array details into a user-facing message.
- Backend DTO fields use `snake_case`. Page/store adapters may expose display-only camelCase fields such as `cloudTypeName`.
- Paginated endpoints return `items`, `page`, `page_size`, `total`, and `total_pages`.
- Cloud batch mutation endpoints accept at most 100 IDs. Profile and admin code split larger selections into chunks of 100.
- Current backend: sibling `../hono-api` (Hono + Better Auth + PostgreSQL + object storage), default `http://localhost:3000`.
- When changing requests, DTOs, pagination, or authentication, read `docs/api-architecture.md`; the backend `API.md` lists endpoints and migration details.
- Views/stores call their feature API module; only feature API modules call `lib/api.ts`. Better Auth SDK calls stay in the auth module.
- Backend `src/schemas/` generates `contracts/api.ts`; run frontend `npm run api:sync` to update `src/types/api-contract.ts`. For coordinated changes, run backend `contract:check` and frontend `api:check`; do not hand-edit generated DTOs.
- Use `query` objects, plain JSON bodies, and typed upload input. The API module handles URL encoding and FormData construction.
- Business JSON uses snake_case and ISO UTC date strings. Pagination returns `items/page/page_size/has_more`; use the explicit flag for navigation.
- Business errors use `{message, issues?}`; Better Auth keeps its SDK protocol. `ApiError` preserves HTTP status and error details.
- Batch mutations accept at most 100 UUID strings; feature API modules split larger selections. Completed batches remain applied after later failures.
- Backend permissions enforce ownership, verified sessions, roles, and disabled accounts; route guards provide navigation UX only.
## Gallery Pagination
- Page-based navigation (50 items/page), not infinite scroll.
- The backend resolves cloud-type/custom-type searches and `@username` searches.
- Each response includes `items`, `total`, `page`, `page_size`, and `total_pages`.
- Search debounced at 250ms.
- Page-based navigation, 50 items/page, with a 250ms search debounce.
- Backend resolves cloud-type names and `@username` searches. Next-page availability comes from `has_more`.
## Map Timeline
@@ -119,14 +109,14 @@
## Upload Flow
- `useUpload` handles file selection, previews, EXIF date extraction, validation, sequential multipart uploads, progress, and badge results. Thumbnail generation, coordinate blurring, storage, database insertion, and badge unlocking run on the backend.
- `useUpload` handles selection, previews, EXIF dates, validation, sequential uploads and progress. The cloud API module encodes multipart data; the backend creates previews, stores variants and inserts records. Coordinates are blurred by the current frontend forms.
- Batch items upload sequentially. If a later item fails, earlier successful uploads remain saved; there is no transactional rollback or resumable upload in the frontend.
- `UploadView` is the full-page batch uploader. `QuickUploadModal` is a single-image shortcut from the map page.
## Build & Deploy
- **Vercel** with `vercel.json`: SPA rewrites, security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). No CSP or HSTS configured.
- Vite dev and preview servers both use strict port `5173`, matching the backend's default `CORS_ORIGINS` and `FRONTEND_URL`.
- Vite dev and preview servers both use strict port `5173`, matching the backend’s `CORS_ORIGIN` development setting.
- **SEO plugin** in `vite.config.ts`: generates `robots.txt` and `sitemap.xml` at build time. Auth-only routes, including `/encyclopedia`, are excluded from the sitemap and disallowed in `robots.txt`.
## Environment Variables
@@ -137,10 +127,10 @@ Required for map features:
Optional:
- `VITE_API_URL` — FastAPI base URL including `/api/v1` (defaults to `http://localhost:8000/api/v1`)
- `VITE_API_URL` — Hono root URL (defaults to `http://localhost:3000`, without `/api/v1`)
- `VITE_SITE_URL` — canonical frontend URL used by route SEO and generated sitemap; set it explicitly in deployed environments
Backend-only variables such as database credentials, JWT secrets, SMTP settings, cookie policy, CORS origins, and upload paths belong in `../opencloud-backend/.env`, never in the Vite environment.
Backend-only variables such as database credentials, Better Auth secrets, email-provider settings, cookie policy, CORS origins, and object-storage settings belong in `../hono-api/.env`, never in the Vite environment.
## Visual System
+6 -1085
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -1,10 +1,10 @@
# OpenCloud 前端
Vue 3 + TypeScript + Vite 前端,通过 FastAPI 提供认证、云图、图鉴、个人主页和管理后台数据。
Vue 3 + TypeScript + Vite 前端,通过 Hono 后端和 Better Auth 提供认证、云图、图鉴、个人主页和管理后台数据。
## 本地开发
1. 在相邻的 `opencloud-backend` 项目中启动 FastAPI 服务。
1. 在相邻的 `hono-api` 项目中启动后端服务(该仓库的本地启动方式见其 README)。
2. 复制 `.env.example` 为 `.env`,配置高德地图 Key;如果后端不在默认地址,修改 `VITE_API_URL`。
3. 安装依赖并启动前端:
@@ -13,7 +13,7 @@ npm install
npm run dev
```
默认后端地址为 `http://localhost:8000/api/v1`。
默认后端地址为 `http://localhost:3000`。
开发服务器和本地 Preview 都固定使用 `http://localhost:5173`,与后端默认 CORS 配置保持一致。运行 Preview 前请先停止开发服务器。
## 检查与构建
@@ -23,7 +23,7 @@ npm run check
npm run build
```
`check` 依次执行 ESLint、Prettier 格式检查、项目样式检查和 TypeScript 类型检查。也可以单独运行:
`check` 依次执行 ESLint、Prettier 格式检查、项目样式检查、TypeScript 类型检查和通信层测试。也可以单独运行:
```bash
npm run lint # 检查 JS、TS 和 Vue 代码,警告也会导致失败
@@ -45,3 +45,5 @@ VS Code 用户可安装工作区推荐的 Vue、ESLint、Prettier 扩展,保
业务代码按功能集中在 `src/features/`:页面、组件、store 和 composable 放入各自所属模块。跨业务的地图控件集中在 `src/shared/map/`,应用导航位于 `src/components/layout/`,API 客户端、全局样式和公共类型保留独立目录。
新增或移动文件时,参见[目录结构与依赖约定](docs/project-structure.md)。
前后端配套修改时,参见[通信架构与契约同步](docs/api-architecture.md),先生成并同步契约,再运行 `npm run api:check`。该检查需要相邻的后端仓库;前端独立构建使用已提交的类型快照。
+55
View File
@@ -0,0 +1,55 @@
# 前后端通信约定
当前后端是相邻的 `hono-api` 仓库,默认根地址 `http://localhost:3000`。业务接口由 Hono 提供,认证由 Better Auth 提供;旧 `opencloud-backend` FastAPI 项目不参与当前调用。
## 职责
| 层 | 文件 | 负责内容 |
| ------------ | ------------------------------------------------- | ----------------------------------------------------------- |
| HTTP 传输 | `src/lib/api.ts` | Cookie、查询编码、JSON/FormData、HTTP 错误和 401 事件 |
| 业务调用 | `src/features/{clouds,profile,admin}/api.ts` | 资源路径、输入/输出类型、批量分块、分页收集、响应适配 |
| 响应适配 | `src/lib/api-adapters.ts` | 生成 DTO 到前端视图模型的明确映射,图片 URL |
| 后端契约 | `src/types/api-contract.ts` | 从后端 schema 生成并同步,禁止手改 |
| 前端模型 | `src/types/models.ts`、`src/types/view-models.ts` | 页面需要的 Cloud、CloudType、Profile、CloudDetail、AuthUser |
| 认证客户端 | `src/features/auth/authClient.ts` | Better Auth SDK 与其独立错误协议 |
| 页面与 store | `src/features/` | 交互、缓存、业务展示;调用业务 API 模块 |
页面不再直接调用 `apiRequest`;ESLint 会检查该规则。传输层不引用云图/用户模型,也不承担认证 SDK 的职责。后端数据库命名、HTTP 字段命名和页面显示字段各自有明确适配位置。稀有度统一使用后端数值,不再伪造 `common` 分类。
## 请求与响应
`apiRequest` 的路径相对于后端根地址,如 `/clouds`,不要加入 `/api/v1`。查询条件通过 `query` 对象传入,由传输层编码一次。JSON body 是普通对象,上传模块接受类型化参数并构建 FormData,浏览器生成 multipart Content-Type。
所有请求携带 Cookie。`auth: false` 表示该公开请求的 401 不触发清空登录状态,并不禁止携带 Cookie。需要登录的请求默认派发 `opencloud:auth-expired`。传输层不会重试写操作;网络错误、非法成功响应及结构化 HTTP 错误会抛给调用者。
业务错误为 `{message, issues?}`,保存在 `ApiError.message/status/detail`。成功 JSON 响应必须能解析;204 返回 undefined。图片 URL 直接用于媒体元素,不通过 JSON 请求函数。
云图分页使用 `{items, page, page_size, has_more}`。页面通过 `has_more` 控制下一页;热力图、点赞集合与当前管理统计需要完整集合时,由业务 API 使用 `collectPages` 收集。地图为上限 1000 条的时间范围数组,用户管理与云类型目录目前为非分页数组。没有实时订阅或跨页快照。
批量变更每次最多 100 个 UUID。已完成的删除批次立即修补缓存,后续批次失败不会回滚。上传仍逐张进行,失败前已上传的云图保留;没有恢复上传或自动整批重试。
## 契约同步与验证
后端 `src/schemas/` 是单一事实来源。后端生成的 `contracts/api.ts` 只含 HTTP 类型,不引入数据库、SDK、服务端配置或运行时包。前端保存类型快照,独立构建不依赖旁边存在后端仓库。
两仓库配套修改时:
```bash
# 在 hono-api 中
npm run contract:generate
npm run check
# 在 opencloud 中
npm run api:sync
npm run api:check
npm run check
npm run build
```
后端不在默认位置时,设置 `OPENCLOUD_API_DIR` 为实际路径。`api:check` 比较已生成的后端类型与前端快照,不会替代后端的 `contract:check`;两者都通过才能确认契约一致。前端 `npm test` 使用 Node 内置测试与 TypeScript 类型剥离,需要 Node 22.18+,不新增测试框架依赖。
## 部署与迁移
此次改动需要两端一起发布或回滚。旧路径 `/cloud`、`/profile`、`/image`、`/info/cloudtype` 改为 `/clouds`、`/profiles`、`/images`、`/cloud-types`;旧请求字段 `type_id/user_name/user_role` 改为 `cloud_type_id/username/role`。云图响应使用 `cloud_type/review_status`,日期统一 ISO UTC,批量 ID 为字符串数组。
认证仍在 `/auth/*`,VITE_API_URL 仍为后端根地址。完整端点与迁移表在后端 `API.md`。本次不需要数据库迁移,TypeScript 字段改名显式映射原数据库列。
+159
View File
@@ -0,0 +1,159 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1420" viewBox="0 0 1800 1420" role="img" aria-labelledby="title desc">
<title id="title">OpenCloud 当前架构</title>
<desc id="desc">前端 Vue 页面和 Pinia store 经业务 API 模块、HTTP 传输层访问 Hono 后端;后端路由使用校验、鉴权、Drizzle 和复用服务,集中序列化响应。Better Auth 独立处理认证,认证与业务共用 PostgreSQL,图片存于对象存储,邮件由 Resend 发送。下方展示从后端 Zod schema 生成并同步前端 TypeScript 契约的开发流程。</desc>
<defs>
<marker id="arrow" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto-start-reverse"><path d="M0 0 9 4.5 0 9" fill="#34745d"/></marker>
<marker id="return" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto"><path d="M0 0 9 4.5 0 9" fill="#0284c7"/></marker>
<marker id="build" markerWidth="9" markerHeight="9" refX="8" refY="4.5" orient="auto"><path d="M0 0 9 4.5 0 9" fill="#a16207"/></marker>
<style>
text { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif; fill: #243746; }
.title { font-size: 42px; font-weight: 750; letter-spacing: -1px; }
.section { font-size: 26px; font-weight: 700; }
.node { font-size: 23px; font-weight: 700; }
.body { font-size: 19px; }
.small { font-size: 16px; }
.muted { fill: #64748b; }
.code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; font-size: 16px; fill: #475569; }
.flow { fill: none; stroke: #34745d; stroke-width: 2.5; marker-end: url(#arrow); stroke-linejoin: round; }
.response { fill: none; stroke: #0284c7; stroke-width: 2.5; stroke-dasharray: 8 6; marker-end: url(#return); }
.build { fill: none; stroke: #a16207; stroke-width: 2.5; stroke-dasharray: 5 5; marker-end: url(#build); }
</style>
</defs>
<rect width="1800" height="1420" fill="#f6fafd"/>
<rect x="48" y="44" width="68" height="68" fill="#cbd5e1"/>
<rect x="44" y="40" width="68" height="68" fill="#e0f2fe" stroke="#bae6fd"/>
<path d="M59 71h8V59h21v7h11v21H59z" fill="#7dd3fc"/><path d="M82 50h13v13H82z" fill="#f5c66b"/>
<text x="138" y="79" class="title">OpenCloud · 当前系统架构</text>
<text x="140" y="111" class="body" style="fill:#64748b">运行时请求、认证与开发期契约同步 / 2026-09-30</text>
<path d="M 1255 63 H 1310" class="flow"/>
<text x="1325" y="70" class="small">调用 / 请求</text>
<path d="M 1255 100 H 1310" class="response"/>
<text x="1325" y="107" class="small">JSON 响应</text>
<path d="M 1515 100 H 1570" class="build"/>
<text x="1585" y="107" class="small">契约生成 / 同步</text>
<rect x="40" y="172" width="510" height="832" fill="#edf7fc" stroke="#cbd5e1"/>
<text x="64" y="213" class="section">01 浏览器 · opencloud</text>
<text x="64" y="244" class="small" style="fill:#64748b">Vue 3 · Pinia · Vue Router · Vite</text>
<rect x="680" y="172" width="650" height="832" fill="#f2faf6" stroke="#cbd5e1"/>
<text x="704" y="213" class="section">02 API 服务 · hono-api</text>
<text x="704" y="244" class="small" style="fill:#64748b">Hono · Better Auth · Zod · Drizzle</text>
<rect x="1450" y="172" width="310" height="832" fill="#f8fafc" stroke="#cbd5e1"/>
<text x="1474" y="213" class="section">03 数据与外部服务</text>
<text x="1474" y="244" class="small" style="fill:#64748b">持久化、图片与邮件</text>
<rect x="69" y="282" width="460" height="104" fill="#cbd5e1"/>
<rect x="65" y="278" width="460" height="104" fill="#fff" stroke="#b6c8d2"/>
<text x="87" y="314" class="node">页面 / 组件 / Pinia stores</text>
<text x="87" y="346" class="body">交互、加载状态、业务缓存</text>
<text x="87" y="373" class="code">src/features/</text>
<rect x="69" y="435" width="460" height="123" fill="#cbd5e1"/>
<rect x="65" y="431" width="460" height="123" fill="#fff" stroke="#b6c8d2"/>
<text x="87" y="467" class="node">业务 API 模块</text>
<text x="87" y="499" class="code">clouds/api.ts · profile/api.ts · admin/api.ts</text>
<text x="87" y="526" class="body">路径、分页、批量分块、响应适配</text>
<text x="87" y="553" class="code">api-adapters.ts → 前端视图模型</text>
<rect x="69" y="614" width="460" height="122" fill="#cbd5e1"/>
<rect x="65" y="610" width="460" height="122" fill="#fff" stroke="#b6c8d2"/>
<text x="87" y="646" class="node">HTTP 传输层</text>
<text x="87" y="678" class="code">src/lib/api.ts</text>
<text x="87" y="705" class="body">Cookie · 查询编码 · JSON / FormData</text>
<text x="87" y="732" class="body">ApiError · 401 认证失效事件</text>
<path d="M 295 382 V 431" class="flow"/>
<path d="M 295 554 V 610" class="flow"/>
<text x="66" y="775" class="small" style="fill:#64748b">浏览器媒体元素直接请求 /images/:id/:variant</text>
<rect x="69" y="828" width="460" height="111" fill="#cbd5e1"/>
<rect x="65" y="824" width="460" height="111" fill="#fff" stroke="#94b8aa"/>
<text x="87" y="860" class="node">独立认证客户端</text>
<text x="87" y="892" class="code">authClient.ts → Better Auth SDK</text>
<text x="87" y="919" class="body">登录 / 注册 / 会话 / 邮箱验证 / 密码</text>
<text x="66" y="976" class="small" style="fill:#64748b">AMap:浏览器按需加载地图 SDK</text>
<rect x="709" y="282" width="600" height="104" fill="#cbd5e1"/>
<rect x="705" y="278" width="600" height="104" fill="#fff" stroke="#b6c8d2"/>
<text x="727" y="314" class="node">统一响应序列化</text>
<text x="727" y="346" class="code">src/serializers.ts</text>
<text x="727" y="373" class="body">Cloud / CloudType / User → JSON · ISO UTC</text>
<rect x="709" y="435" width="600" height="123" fill="#cbd5e1"/>
<rect x="705" y="431" width="600" height="123" fill="#fff" stroke="#b6c8d2"/>
<text x="727" y="467" class="node">业务路由 + 按需复用服务</text>
<text x="727" y="499" class="code">/clouds · /cloud-types · /profiles · /admin</text>
<text x="727" y="526" class="body">Zod 请求校验 · 会话、角色与所有权校验</text>
<text x="727" y="553" class="body">Drizzle 查询 · 分页 / 点赞 / 压缩 / 存储</text>
<rect x="709" y="614" width="600" height="122" fill="#cbd5e1"/>
<rect x="705" y="610" width="600" height="122" fill="#fff" stroke="#b6c8d2"/>
<text x="727" y="646" class="node">Hono HTTP 入口</text>
<text x="727" y="678" class="code">src/index.ts</text>
<text x="727" y="705" class="body">CORS · request ID · 计时 · 错误处理</text>
<text x="727" y="732" class="body">资源路由分发;/images 代理图片流</text>
<path d="M 1005 610 V 554" class="flow"/>
<text x="1025" y="590" class="small" style="fill:#34745d">分发 / 校验</text>
<path d="M 1005 431 V 382" class="flow"/>
<text x="1025" y="412" class="small" style="fill:#34745d">查询结果</text>
<path d="M 525 669 H 705" class="flow"/>
<text x="554" y="626" class="small" style="fill:#34745d">HTTPS</text>
<text x="553" y="649" class="small" style="fill:#34745d">携带 Cookie</text>
<path d="M 705 323 H 612 V 704 H 525" class="response"/>
<text x="568" y="300" class="small" style="fill:#0284c7">响应</text>
<text x="706" y="775" class="small" style="fill:#64748b">JSON 业务协议与 Better Auth 原生协议分别处理</text>
<rect x="709" y="828" width="600" height="111" fill="#cbd5e1"/>
<rect x="705" y="824" width="600" height="111" fill="#fff" stroke="#b6c8d2"/>
<text x="727" y="860" class="node">Better Auth · /auth/*</text>
<text x="727" y="892" class="code">HttpOnly session cookie · Drizzle adapter</text>
<text x="727" y="919" class="body">认证表与业务表共用同一个 PostgreSQL</text>
<path d="M 525 880 H 705" class="flow"/>
<text x="554" y="861" class="code">/auth/*</text>
<text x="706" y="976" class="small" style="fill:#64748b">事务外图片存储:串行写入,上传失败执行补偿清理</text>
<rect x="1474" y="282" width="270" height="142" fill="#cbd5e1"/>
<rect x="1470" y="278" width="270" height="142" fill="#fff" stroke="#b6c8d2"/>
<text x="1492" y="314" class="node">PostgreSQL</text>
<text x="1492" y="346" class="body">用户 / 会话 / 凭据</text>
<text x="1492" y="373" class="body">云类型 / 云图 / 点赞</text>
<text x="1492" y="400" class="small">业务与认证共同使用</text>
<path d="M 1305 469 H 1394 V 350 H 1470" class="flow"/>
<text x="1360" y="326" class="small" style="fill:#34745d">Drizzle</text>
<rect x="1474" y="492" width="270" height="142" fill="#cbd5e1"/>
<rect x="1470" y="488" width="270" height="142" fill="#fff" stroke="#b6c8d2"/>
<text x="1492" y="524" class="node">对象存储</text>
<text x="1492" y="556" class="body">MinIO / S3 兼容接口</text>
<text x="1492" y="583" class="code">original + preview</text>
<text x="1492" y="610" class="small">上传、删除、图片读取</text>
<path d="M 1305 520 H 1415 V 556 H 1470" class="flow"/>
<text x="1353" y="500" class="small" style="fill:#34745d">storage</text>
<rect x="1474" y="828" width="270" height="111" fill="#cbd5e1"/>
<rect x="1470" y="824" width="270" height="111" fill="#fff" stroke="#b6c8d2"/>
<text x="1492" y="860" class="node">Resend</text>
<text x="1492" y="892" class="body">验证邮箱 / 密码重置</text>
<text x="1492" y="919" class="code">services/email.ts</text>
<path d="M 1305 880 H 1470" class="flow"/>
<text x="1341" y="861" class="small" style="fill:#34745d">发送邮件</text>
<text x="1471" y="687" class="small" style="fill:#64748b">公开可见规则</text>
<text x="1471" y="718" class="body">审核通过且未隐藏</text>
<text x="1471" y="747" class="small" style="fill:#64748b">上传者 / 管理员另行授权</text>
<rect x="40" y="1040" width="1720" height="266" fill="#fffbef" stroke="#e6d7b0"/>
<text x="65" y="1082" class="section">04 开发期 · 生成契约,阻止前后端类型漂移</text>
<text x="65" y="1113" class="body" style="fill:#64748b">只同步 TypeScript 类型;前端独立构建使用已提交快照,不加载后端运行时依赖。</text>
<rect x="69" y="1154" width="340" height="113" fill="#cbd5e1"/>
<rect x="65" y="1150" width="340" height="113" fill="#fff" stroke="#d6bf82"/>
<text x="87" y="1186" class="node">后端 Zod 定义</text>
<text x="87" y="1218" class="code">hono-api/src/schemas/</text>
<text x="87" y="1245" class="body">请求校验 + JSON 响应</text>
<rect x="584" y="1154" width="330" height="113" fill="#cbd5e1"/>
<rect x="580" y="1150" width="330" height="113" fill="#fff" stroke="#d6bf82"/>
<text x="602" y="1186" class="node">后端生成物</text>
<text x="602" y="1218" class="code">hono-api/contracts/api.ts</text>
<text x="602" y="1245" class="small">contract:check 检查过期</text>
<rect x="1074" y="1154" width="405" height="113" fill="#cbd5e1"/>
<rect x="1070" y="1150" width="405" height="113" fill="#fff" stroke="#d6bf82"/>
<text x="1092" y="1186" class="node">前端契约快照</text>
<text x="1092" y="1218" class="code">src/types/api-contract.ts</text>
<text x="1092" y="1245" class="body">业务 API 模块引用这些类型</text>
<path d="M 405 1207 H 580" class="build"/>
<text x="421" y="1185" class="code">contract:generate</text>
<path d="M 910 1207 H 1070" class="build"/>
<text x="944" y="1185" class="code">api:sync</text>
<text x="1510" y="1187" class="body">跨仓库核对</text>
<text x="1510" y="1220" class="code">api:check</text>
<text x="1510" y="1248" class="small" style="fill:#64748b">比对两端契约</text>
<text x="44" y="1357" class="small" style="fill:#34745d">通信约定</text>
<text x="155" y="1357" class="body">snake_case · ISO UTC · 分页 { items, page, page_size, has_more } · 错误 { message, issues? }</text>
<text x="44" y="1391" class="small" style="fill:#64748b">当前实现:刷新式加载;批量每次最多 100 个 ID;写请求不自动重试。图中省略页面内部组件与单个数据库索引。</text>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

+15 -15
View File
@@ -1,6 +1,6 @@
# 目录结构与依赖约定
按业务功能组织文件,使一次功能修改涉及的页面、组件和状态尽量集中。文件迁移只改变源码路径,接口请求、DTO、store 导出与 ID、组件 props/events、路由 URL 和页面行为保持不变。
按业务功能组织文件,使一次功能修改涉及的页面、组件和状态尽量集中。通信层按下文分工;接口变更与后端配套验证,见[通信架构](api-architecture.md)。
## 目录职责
@@ -35,25 +35,25 @@ src/
│ ├── types/amap.d.ts # 高德类型声明,与同名加载器分开放置
│ └── components/ # MapPickerModal、MiniLocationMap
├── components/layout/ # 应用外壳:AppHeader
├── lib/ # api.ts、seo.ts、theme.ts
├── types/ # 公共 DTO、领域类型、路由元数据
├── lib/ # api.ts、api-adapters.ts、pagination.ts、seo.ts、theme.ts
├── types/ # 生成的 API 契约、前端模型、路由元数据
├── styles/ # 全局设计令牌、基线、共享样式
└── style.css # 全局样式入口
```
## 文件归属
| 修改内容 | 归属位置 |
| -------------------------------------- | ---------------------------- |
| 登录、会话、验证邮件 | `features/auth/` |
| 多个页面复用的云图详情、编辑、点赞 | `features/clouds/` |
| 上传表单、上传弹窗、文件处理与上传状态 | `features/upload/` |
| 用户主页、设置、贡献热力图 | `features/profile/` |
| 图鉴列表、云型详情及其状态 | `features/encyclopedia/` |
| 首页地图时间轴、标记交互 | `features/map/` |
| 通用选点、位置预览、高德加载 | `shared/map/` |
| 应用导航和整体布局 | `components/layout/` |
| HTTP 请求与响应适配、公共 API 类型 | `lib/api.ts`、`types/api.ts` |
| 修改内容 | 归属位置 |
| -------------------------------------- | ------------------------------------------------------------ |
| 登录、会话、验证邮件 | `features/auth/` |
| 多个页面复用的云图详情、编辑、点赞 | `features/clouds/` |
| 上传表单、上传弹窗、文件处理与上传状态 | `features/upload/` |
| 用户主页、设置、贡献热力图 | `features/profile/` |
| 图鉴列表、云型详情及其状态 | `features/encyclopedia/` |
| 首页地图时间轴、标记交互 | `features/map/` |
| 通用选点、位置预览、高德加载 | `shared/map/` |
| 应用导航和整体布局 | `components/layout/` |
| HTTP 请求与响应适配、公共 API 类型 | `lib/api.ts`、`lib/api-adapters.ts`、`types/api-contract.ts` |
按职责归属文件:快捷上传虽然从地图打开,仍属于上传模块;云图点赞虽然在多个页面使用,仍属于云图业务;选点和小地图仅提供位置能力,放在公共地图模块。
@@ -63,7 +63,7 @@ src/
- 业务组件放在所属功能模块。`components/layout/` 仅存放应用布局,`lib/` 存放公共基础设施。
- 功能模块可以依赖公共地图能力、基础设施、公共类型和其他模块的具体组件、store 或 composable。`shared/map/`、`lib/` 和 `types/` 保持独立于业务模块;新增跨模块依赖时避免循环引用。
- 使用 `@/features/...`、`@/shared/...` 等具体文件路径,不增加统一导出所有页面和 store 的入口。路由继续在 `router/index.ts` 中动态导入各模块页面,保持按页面懒加载。
- API 请求继续使用 `lib/api.ts`,认证 SDK 调用集中在 `features/auth/authClient.ts` 和认证 store。DTO 与领域类型仍由 `types/` 统一提供。
- 页面与 store 调用所属功能的 `api.ts`,由该模块通过 `lib/api.ts` 发送请求。认证 SDK 调用集中在 `features/auth/authClient.ts` 和认证 store。生成 DTO 在 `types/api-contract.ts`,前端模型在 `types/models.ts` 与 `types/view-models.ts`。
- 现有 `clouds` 与 `encyclopedia` store 保留各自的缓存行为;后续如需合并缓存,应作为独立行为变更验证。
- 共享视觉规则遵循[样式体系](style-system.md),组件专属样式随组件迁移。
+23 -1
View File
@@ -43,11 +43,33 @@ export default defineConfig([
},
},
{
files: ['*.{js,mjs,cjs,ts,mts,cts}', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}'],
files: [
'*.{js,mjs,cjs,ts,mts,cts}',
'scripts/**/*.{js,mjs,cjs,ts,mts,cts}',
'tests/**/*.mjs',
],
languageOptions: {
globals: globals.node,
},
},
{
files: ['src/features/**/*.{ts,vue}'],
ignores: ['src/features/*/api.ts', 'src/features/auth/authClient.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@/lib/api',
importNames: ['apiRequest'],
message: '请通过所属业务的 api.ts 调用接口。',
},
],
},
],
},
},
// Keep formatting in Prettier; this must follow the rule presets.
prettier,
])
+5 -2
View File
@@ -10,10 +10,13 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "vue-tsc -b",
"check": "npm run lint && npm run format:check && npm run check:styles && npm run typecheck",
"check": "npm run lint && npm run format:check && npm run check:styles && npm run typecheck && npm test",
"check:styles": "node scripts/check-style-system.mjs",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"api:sync": "node scripts/sync-api-contract.mjs",
"api:check": "node scripts/sync-api-contract.mjs --check",
"test": "node --import ./scripts/test-resolver.mjs --test tests/*.test.mjs"
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
+17
View File
@@ -0,0 +1,17 @@
import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { format, resolveConfig } from 'prettier'
const source = resolve(process.env.OPENCLOUD_API_DIR || '../hono-api', 'contracts/api.ts')
const target = resolve('src/types/api-contract.ts')
const output = await format(await readFile(source, 'utf8'), {
...(await resolveConfig(target)),
filepath: target,
})
if (process.argv.includes('--check')) {
if ((await readFile(target, 'utf8')) !== output) {
throw new Error('API 契约已变化,请先在后端生成,再运行 npm run api:sync')
}
} else {
await writeFile(target, output)
}
+16
View File
@@ -0,0 +1,16 @@
import { registerHooks } from 'node:module'
import { existsSync } from 'node:fs'
const source = new URL('../src/', import.meta.url)
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.startsWith('@/')) {
return nextResolve(new URL(`${specifier.slice(2)}.ts`, source).href, context)
}
if (specifier.startsWith('.') && context.parentURL?.startsWith(source.href)) {
const candidate = new URL(`${specifier}.ts`, context.parentURL)
if (existsSync(candidate)) return nextResolve(candidate.href, context)
}
return nextResolve(specifier, context)
},
})
+68
View File
@@ -0,0 +1,68 @@
import { apiRequest } from '@/lib/api'
import { toCloudDetail, toAuthUser } from '@/lib/api-adapters'
import { collectPages } from '@/lib/pagination'
import type {
CloudPageResponse,
UserProfileResponse,
MessageResponse,
CreateCloudTypeBody,
UpdateCloudTypeBody,
CloudTypeCreateResponse,
ReviewCloudsBody,
SetUserRoleBody,
} from '@/types/api-contract'
export async function listUsers() {
return (await apiRequest<UserProfileResponse[]>('/admin/users')).map(toAuthUser)
}
export function listAdminClouds() {
return collectPages(async page => {
const result = await apiRequest<CloudPageResponse>('/admin/clouds', {
query: { page, page_size: 100 },
})
return { ...result, items: result.items.map(toCloudDetail) }
})
}
export async function reviewClouds(ids: string[], reviewStatus: ReviewCloudsBody['review_status']) {
for (let offset = 0; offset < ids.length; offset += 100) {
const body: ReviewCloudsBody = {
cloud_ids: ids.slice(offset, offset + 100),
review_status: reviewStatus,
}
await apiRequest<MessageResponse>('/admin/clouds/review', { method: 'POST', body })
}
}
export function deleteAdminCloud(id: string) {
return apiRequest<MessageResponse>(`/admin/clouds/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
}
export function setUserRole(id: string, role: SetUserRoleBody['role']) {
return apiRequest<MessageResponse>(`/admin/users/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: { role },
})
}
export function setUserPassword(id: string, newPassword: string) {
return apiRequest<MessageResponse>(`/admin/users/${encodeURIComponent(id)}/password`, {
method: 'PATCH',
body: { new_password: newPassword },
})
}
export function createCloudType(body: CreateCloudTypeBody) {
return apiRequest<CloudTypeCreateResponse>('/admin/cloud-types', { method: 'POST', body })
}
export function updateCloudType(id: number, body: UpdateCloudTypeBody) {
return apiRequest<MessageResponse>(`/admin/cloud-types/${id}`, { method: 'PATCH', body })
}
export function deleteCloudTypeRecord(id: number) {
return apiRequest<MessageResponse>(`/admin/cloud-types/${id}`, { method: 'DELETE' })
}
+32 -64
View File
@@ -36,19 +36,24 @@ import {
import { useRouter } from 'vue-router'
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
import { apiRequest, toApiCloud, toAuthUser, toCloudType } from '@/lib/api'
import { toCloudType } from '@/lib/api-adapters'
import { listCloudTypeRecords } from '@/features/clouds/api'
import {
listUsers,
listAdminClouds,
reviewClouds,
deleteAdminCloud,
setUserRole,
setUserPassword,
createCloudType,
updateCloudType,
deleteCloudTypeRecord,
} from '@/features/admin/api'
import type { CloudTypeResponse } from '@/types/api-contract'
import { useAuthStore } from '@/features/auth/stores/auth'
import { useCloudsStore } from '@/features/clouds/stores/clouds'
import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia'
import type {
ActionResponse,
ApiCloud,
AuthUser,
BackendCloudInfo,
BackendCloudType,
BackendUserProfile,
CloudTypeCreateResponse,
} from '@/types/api'
import type { CloudDetail, AuthUser } from '@/types/view-models'
type AdminTab = 'dashboard' | 'review' | 'users' | 'cloud-types' | 'images'
type CloudStatus = 'pending' | 'approved' | 'rejected'
@@ -113,7 +118,7 @@ const dashboardStats = ref<DashboardStats>({
})
const users = ref<AuthUser[]>([])
const images = ref<AdminCloud[]>([])
const cloudTypes = ref<BackendCloudType[]>([])
const cloudTypes = ref<CloudTypeResponse[]>([])
const selectedReviewIds = ref<Set<string>>(new Set())
const activeReviewId = ref<string | null>(null)
const selectedImageIds = ref<Set<string>>(new Set())
@@ -124,7 +129,7 @@ const newPassword = ref('')
const confirmPassword = ref('')
const passwordError = ref('')
const cloudTypeEditorOpen = ref(false)
const editingCloudType = ref<BackendCloudType | null>(null)
const editingCloudType = ref<CloudTypeResponse | null>(null)
const cloudTypeForm = ref<CloudTypeForm>(emptyCloudTypeForm())
const cloudTypeFormError = ref('')
@@ -217,7 +222,7 @@ const reviewStatusStats = computed(() => [
const selectedReviewCount = computed(() => selectedReviewIds.value.size)
function toAdminCloud(row: ApiCloud): AdminCloud {
function toAdminCloud(row: CloudDetail): AdminCloud {
return {
id: row.id,
user_id: row.user_id,
@@ -232,7 +237,7 @@ function toAdminCloud(row: ApiCloud): AdminCloud {
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || '未知云型',
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
cloudTypeRarity: row.cloud_type_rarity ?? 0,
username: row.username || '匿名用户',
}
}
@@ -290,23 +295,12 @@ function syncDashboardStats() {
}
async function fetchUsers() {
const rows = await apiRequest<BackendUserProfile[]>('/admin/users')
users.value = rows.map(toAuthUser)
const rows = await listUsers()
users.value = rows
}
async function fetchImages() {
const rows: BackendCloudInfo[] = []
const pageSize = 100
for (let page = 1; ; page++) {
const current = await apiRequest<BackendCloudInfo[]>(
`/admin/clouds?page=${page}&page_size=${pageSize}`,
)
rows.push(...current)
if (current.length < pageSize) break
}
images.value = rows.map(toApiCloud).map(toAdminCloud)
images.value = (await listAdminClouds()).map(toAdminCloud)
selectedReviewIds.value = new Set(
[...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)),
)
@@ -316,7 +310,7 @@ async function fetchImages() {
}
async function fetchCloudTypes() {
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
const rows = await listCloudTypeRecords()
cloudTypes.value = rows
const normalized = rows.map(toCloudType)
@@ -382,12 +376,6 @@ function toggleAllFilteredImageSelection() {
setSelectedImageIds(filteredImages.value.map(item => item.id))
}
function chunkIds(ids: string[]) {
return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) =>
ids.slice(index * 100, (index + 1) * 100),
)
}
async function updateCloudStatus(ids: string[], status: Exclude<CloudStatus, 'pending'>) {
if (!ids.length) return
@@ -395,15 +383,7 @@ async function updateCloudStatus(ids: string[], status: Exclude<CloudStatus, 'pe
loadError.value = ''
try {
for (const chunk of chunkIds(ids)) {
await apiRequest<ActionResponse>('/admin/clouds/review', {
method: 'POST',
body: {
cloud_ids: chunk.map(cloudId => ({ cloud_id: cloudId })),
status,
},
})
}
await reviewClouds(ids, status)
await fetchImages()
syncDashboardStats()
@@ -438,7 +418,7 @@ async function deleteImages(clouds: AdminCloud[]) {
for (const cloud of clouds) {
try {
await apiRequest<ActionResponse>(`/admin/clouds/${cloud.id}`, { method: 'DELETE' })
await deleteAdminCloud(cloud.id)
deletedIds.push(cloud.id)
} catch (error) {
firstError ||= getErrorMessage(error, '图片删除失败')
@@ -483,10 +463,7 @@ async function updateUserRole(user: AuthUser, role: AuthUser['role']) {
loadError.value = ''
try {
await apiRequest<ActionResponse>(`/admin/users/${user.id}`, {
method: 'PATCH',
body: { user_role: role },
})
await setUserRole(user.id, role)
users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item))
message.success('用户角色已更新;该用户需要重新登录')
} catch (error) {
@@ -526,10 +503,7 @@ async function resetUserPassword() {
actionLoading.value = true
try {
await apiRequest<ActionResponse>(`/admin/users/${user.id}/password`, {
method: 'PATCH',
body: { new_password: newPassword.value },
})
await setUserPassword(user.id, newPassword.value)
passwordTarget.value = null
message.success('密码已重置;该用户需要重新登录')
} catch (error) {
@@ -549,7 +523,7 @@ function emptyCloudTypeForm(): CloudTypeForm {
}
}
function openCloudTypeEditor(cloudType?: BackendCloudType) {
function openCloudTypeEditor(cloudType?: CloudTypeResponse) {
editingCloudType.value = cloudType ?? null
cloudTypeForm.value = cloudType
? {
@@ -613,16 +587,10 @@ async function saveCloudType() {
loadError.value = ''
try {
if (editingCloudType.value) {
await apiRequest<ActionResponse>(`/admin/cloudtypes/${editingCloudType.value.id}`, {
method: 'PATCH',
body: payload,
})
await updateCloudType(editingCloudType.value.id, payload)
message.success('云类型已更新')
} else {
await apiRequest<CloudTypeCreateResponse>('/admin/cloudtypes', {
method: 'POST',
body: payload,
})
await createCloudType(payload)
message.success('云类型已添加')
}
@@ -643,7 +611,7 @@ function cloudTypeUsageCount(cloudTypeId: number) {
return images.value.filter(image => image.cloud_type_id === cloudTypeId).length
}
async function deleteCloudType(cloudType: BackendCloudType) {
async function deleteCloudType(cloudType: CloudTypeResponse) {
const usageCount = cloudTypeUsageCount(cloudType.id)
if (usageCount > 0) {
message.warning(`“${cloudType.name}”仍被 ${usageCount} 张云图使用,无法删除`)
@@ -655,7 +623,7 @@ async function deleteCloudType(cloudType: BackendCloudType) {
actionLoading.value = true
loadError.value = ''
try {
await apiRequest<ActionResponse>(`/admin/cloudtypes/${cloudType.id}`, { method: 'DELETE' })
await deleteCloudTypeRecord(cloudType.id)
await fetchCloudTypes()
message.success('云类型已删除')
} catch (error) {
+8 -11
View File
@@ -1,12 +1,13 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api'
import { AUTH_EXPIRED_EVENT } from '@/lib/api'
import { assertAuthResult, authClient } from '@/features/auth/authClient'
import type { BackendUserProfile } from '@/types/api'
import type { Profile } from '@/types/database'
import { getMyProfile, updateMyUsername } from '@/features/profile/api'
import type { AuthUser } from '@/types/view-models'
import type { Profile } from '@/types/models'
export const useAuthStore = defineStore('auth', () => {
const user = ref<ReturnType<typeof toAuthUser> | null>(null)
const user = ref<AuthUser | null>(null)
const profile = ref<Profile | null>(null)
const loading = ref(true)
let listenerAttached = false
@@ -14,8 +15,7 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed(() => !!user.value)
const isAdmin = computed(() => profile.value?.role === 'admin')
function applyProfile(backendProfile: BackendUserProfile) {
const normalized = toAuthUser(backendProfile)
function applyProfile(normalized: AuthUser) {
user.value = normalized
profile.value = {
id: normalized.id,
@@ -33,7 +33,7 @@ export const useAuthStore = defineStore('auth', () => {
}
async function fetchMe() {
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me')
const backendProfile = await getMyProfile()
applyProfile(backendProfile)
}
@@ -98,10 +98,7 @@ export const useAuthStore = defineStore('auth', () => {
}
async function updateUsername(username: string) {
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me', {
method: 'PATCH',
body: { user_name: username },
})
const backendProfile = await updateMyUsername(username)
applyProfile(backendProfile)
}
+87
View File
@@ -0,0 +1,87 @@
import { apiRequest } from '@/lib/api'
import { imageUrl, toCloudDetail, toCloudType } from '@/lib/api-adapters'
import { collectPages } from '@/lib/pagination'
import type {
CloudResponse,
CloudPageResponse,
CloudTypeResponse,
CloudCreateResponse,
MessageResponse,
PublicCloudListQuery,
CloudMapQuery,
UpdateCloudBody,
UploadCloudForm,
PaginationQuery,
} from '@/types/api-contract'
export { imageUrl }
export async function listClouds(query: PublicCloudListQuery) {
const page = await apiRequest<CloudPageResponse>('/clouds', { query, auth: false })
return { ...page, items: page.items.map(toCloudDetail) }
}
export async function listCloudsByType(cloudTypeId: number, query: PaginationQuery) {
const page = await apiRequest<CloudPageResponse>(`/cloud-types/${cloudTypeId}/clouds`, {
query,
auth: false,
})
return { ...page, items: page.items.map(toCloudDetail) }
}
export async function listMapClouds(query: CloudMapQuery) {
const rows = await apiRequest<CloudResponse[]>('/clouds/map', { query, auth: false })
return rows.map(toCloudDetail)
}
export async function getCloud(cloudId: string, auth = true) {
return toCloudDetail(
await apiRequest<CloudResponse>(`/clouds/${encodeURIComponent(cloudId)}`, { auth }),
)
}
export async function listCloudTypeRecords() {
return apiRequest<CloudTypeResponse[]>('/cloud-types', { auth: false })
}
export async function listCloudTypes() {
return (await listCloudTypeRecords()).map(toCloudType)
}
export function updateCloud(cloudId: string, body: UpdateCloudBody) {
return apiRequest<MessageResponse>(`/clouds/${encodeURIComponent(cloudId)}`, {
method: 'PATCH',
body,
})
}
export async function deleteClouds(cloudIds: string[], onDeleted?: (ids: string[]) => void) {
for (let offset = 0; offset < cloudIds.length; offset += 100) {
const ids = cloudIds.slice(offset, offset + 100)
await apiRequest<MessageResponse>('/clouds', { method: 'DELETE', body: { cloud_ids: ids } })
onDeleted?.(ids)
}
}
export function uploadCloud(input: UploadCloudForm) {
const form = new FormData()
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) form.append(key, value instanceof Blob ? value : String(value))
}
return apiRequest<CloudCreateResponse>('/clouds', { method: 'POST', body: form })
}
export function setCloudLike(cloudId: string, liked: boolean) {
return apiRequest<MessageResponse>(`/clouds/${encodeURIComponent(cloudId)}/like`, {
method: liked ? 'PUT' : 'DELETE',
})
}
export async function listLikedClouds() {
return collectPages(async page => {
const result = await apiRequest<CloudPageResponse>('/profiles/me/likes', {
query: { page, page_size: 100 },
})
return { ...result, items: result.items.map(toCloudDetail) }
})
}
@@ -3,7 +3,7 @@ import { ref, watch } from 'vue'
import { NAlert, NButton, NIcon } from 'naive-ui'
import { Map as MapIcon, X } from '@vicons/tabler'
import MapPickerModal from '@/shared/map/components/MapPickerModal.vue'
import type { CloudType } from '@/types/database'
import type { CloudType } from '@/types/models'
export interface CloudEditFormValue {
cloudCategoryId: number | null
+3 -5
View File
@@ -1,8 +1,7 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { apiRequest, toCloudType } from '@/lib/api'
import type { BackendCloudType } from '@/types/api'
import type { CloudType } from '@/types/database'
import { listCloudTypes } from '@/features/clouds/api'
import type { CloudType } from '@/types/models'
export const useCloudsStore = defineStore('clouds', () => {
const cloudTypes = ref<CloudType[]>([])
@@ -12,8 +11,7 @@ export const useCloudsStore = defineStore('clouds', () => {
if (cloudTypes.value.length > 0) return
loading.value = true
try {
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
cloudTypes.value = rows.map(toCloudType)
cloudTypes.value = await listCloudTypes()
} finally {
loading.value = false
}
+7 -21
View File
@@ -1,10 +1,7 @@
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { apiRequest } from '@/lib/api'
import { getCloud, listLikedClouds, setCloudLike } from '@/features/clouds/api'
import { useAuthStore } from '@/features/auth/stores/auth'
import type { ActionResponse, BackendCloudInfo } from '@/types/api'
const PAGE_SIZE = 100
export const useLikesStore = defineStore('likes', () => {
const authStore = useAuthStore()
@@ -46,16 +43,8 @@ export const useLikesStore = defineStore('likes', () => {
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
}
const rows = await listLikedClouds()
const nextIds = new Set(rows.map(row => row.id))
if (authStore.user?.id === userId) {
likedIds.value = nextIds
loadedForUser.value = userId
@@ -82,9 +71,7 @@ export const useLikesStore = defineStore('likes', () => {
const wasLiked = isLiked(cloudId)
pendingIds.value = new Set(pendingIds.value).add(cloudId)
try {
await apiRequest<ActionResponse>(`/cloud/${cloudId}/like`, {
method: wasLiked ? 'DELETE' : 'PUT',
})
await setCloudLike(cloudId, !wasLiked)
if (authStore.user?.id !== userId) return
const nextIds = new Set(likedIds.value)
@@ -97,11 +84,10 @@ export const useLikesStore = defineStore('likes', () => {
)
try {
const cloud = await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`, {
auth: false,
})
const cloud = await getCloud(cloudId, false)
if (authStore.user?.id !== userId) return
counts.value[cloudId] = cloud.received_like_count
if (cloud.owner.id === authStore.user?.id) await authStore.refreshProfile()
if (cloud.owner?.id === authStore.user?.id) await authStore.refreshProfile()
} catch {
// The mutation succeeded; keep the local count if a refresh fails.
}
@@ -1,8 +1,7 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { apiRequest, toCloudType } from '@/lib/api'
import type { BackendCloudType } from '@/types/api'
import type { CloudType } from '@/types/database'
import { listCloudTypes } from '@/features/clouds/api'
import type { CloudType } from '@/types/models'
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
const cloudTypes = ref<CloudType[]>([])
@@ -13,8 +12,7 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
if (cloudTypesLoaded.value && !force) return
loadingCloudTypes.value = true
try {
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
cloudTypes.value = rows.map(toCloudType)
cloudTypes.value = await listCloudTypes()
cloudTypesLoaded.value = true
} finally {
loadingCloudTypes.value = false
@@ -5,17 +5,17 @@ import { RouterLink, useRoute } from 'vue-router'
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
import { apiRequest, imageUrl, toApiCloud } from '@/lib/api'
import { imageUrl, listCloudsByType } from '@/features/clouds/api'
import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia'
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
import type { CloudDetail } from '@/types/view-models'
const PAGE_SIZE = 24
const route = useRoute()
const encyclopediaStore = useEncyclopediaStore()
const loading = ref(true)
const loadError = ref('')
const gallery = ref<ApiCloud[]>([])
const selectedItem = ref<ApiCloud | null>(null)
const gallery = ref<CloudDetail[]>([])
const selectedItem = ref<CloudDetail | null>(null)
const currentPage = ref(1)
const hasNextPage = ref(false)
@@ -39,16 +39,10 @@ function formatDateTime(iso: string | null) {
}
async function loadGallery(page: number) {
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
const rows = await apiRequest<BackendCloudInfo[]>(
`/cloud/type/${cloudTypeId.value}?${params}`,
{
auth: false,
},
)
gallery.value = rows.map(toApiCloud)
const result = await listCloudsByType(cloudTypeId.value, { page, page_size: PAGE_SIZE })
gallery.value = result.items
currentPage.value = page
hasNextPage.value = rows.length === PAGE_SIZE
hasNextPage.value = result.has_more
}
async function loadPage(page = 1) {
@@ -144,7 +138,7 @@ watch(
? 'border-white/35 bg-slate-950/25 backdrop-blur'
: 'border-slate-200 bg-white'
"
>稀有度 {{ cloudType.rarity_value }}</span
>稀有度 {{ cloudType.rarity }}</span
>
<h1 class="mt-4 text-4xl font-bold">{{ cloudType.name }}</h1>
<p class="mt-2" :class="heroImage ? 'text-white/80' : 'text-slate-600'">
@@ -236,7 +230,7 @@ watch(
? `拍摄于 ${formatDateTime(selectedItem.captured_at)}`
: `上传于 ${formatDateTime(selectedItem.created_at)}`
"
:badge-label="`稀有度 ${cloudType.rarity_value}`"
:badge-label="`稀有度 ${cloudType.rarity}`"
badge-class="border-slate-200 bg-slate-50 text-slate-600"
@close="selectedItem = null"
>
@@ -2,7 +2,7 @@
import { onMounted } from 'vue'
import { NEmpty, NSkeleton } from 'naive-ui'
import { useRouter } from 'vue-router'
import { imageUrl } from '@/lib/api'
import { imageUrl } from '@/features/clouds/api'
import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia'
const router = useRouter()
@@ -99,7 +99,7 @@ onMounted(() => encyclopediaStore.fetchCloudTypes())
: 'border-slate-200 bg-white text-slate-600'
"
>
稀有度 {{ cloudType.rarity_value }}
稀有度 {{ cloudType.rarity }}
</span>
</div>
<p
+15 -13
View File
@@ -9,12 +9,14 @@ import CloudEditModal, {
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
import { ApiError } from '@/lib/api'
import { listClouds } from '@/features/clouds/api'
import type { PublicCloudListQuery } from '@/types/api-contract'
import { useAuthStore } from '@/features/auth/stores/auth'
import { useCloudsStore } from '@/features/clouds/stores/clouds'
import { useProfileStore } from '@/features/profile/stores/profile'
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
import type { CloudDetail } from '@/types/view-models'
interface GalleryCloud {
id: string
@@ -85,7 +87,7 @@ function formatCoordinate(value: number | null) {
const normalizedSearch = computed(() => searchQuery.value.trim())
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
function toGalleryCloud(row: ApiCloud) {
function toGalleryCloud(row: CloudDetail) {
return {
id: row.id,
user_id: row.user_id,
@@ -100,7 +102,7 @@ function toGalleryCloud(row: ApiCloud) {
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || '未知',
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
cloudTypeRarity: row.cloud_type_rarity ?? 0,
username: row.username || '匿名',
receivedLikeCount: row.received_like_count,
} satisfies GalleryCloud
@@ -111,23 +113,23 @@ async function loadPage(page: number) {
loadError.value = ''
try {
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
const query: PublicCloudListQuery = { page, page_size: PAGE_SIZE }
if (normalizedSearch.value) {
params.set('filter', isUserSearch.value ? 'owner' : 'type')
params.set('value', normalizedSearch.value)
query.filter = isUserSearch.value ? 'owner' : 'type'
query.value = normalizedSearch.value
} else if (selectedTypeId.value !== 'all') {
const typeName = cloudsStore.cloudTypes.find(
type => type.id === selectedTypeId.value,
)?.name
if (typeName) {
params.set('filter', 'type')
params.set('value', typeName)
query.filter = 'type'
query.value = typeName
}
}
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud?${params}`, { auth: false })
galleryItems.value = rows.map(toApiCloud).map(toGalleryCloud)
hasNextPage.value = rows.length === PAGE_SIZE
const result = await listClouds(query)
galleryItems.value = result.items.map(toGalleryCloud)
hasNextPage.value = result.has_more
currentPage.value = page
} catch (error) {
if (error instanceof ApiError && error.status === 404 && isUserSearch.value) {
@@ -232,7 +234,7 @@ async function submitEditModal(value: CloudEditFormValue) {
try {
const updated = await profileStore.updateCloud(authStore.user.id, current.id, {
type_id: value.cloudCategoryId,
cloud_type_id: value.cloudCategoryId,
latitude: blurCoordinate(value.latitude as number),
longitude: blurCoordinate(value.longitude as number),
description: value.description.trim(),
+8 -8
View File
@@ -4,10 +4,10 @@ import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
import QuickUploadModal from '@/features/upload/components/QuickUploadModal.vue'
import { apiRequest, toApiCloud } from '@/lib/api'
import { listMapClouds } from '@/features/clouds/api'
import { loadAMap } from '@/shared/map/amap'
import { useAuthStore } from '@/features/auth/stores/auth'
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
import type { CloudDetail } from '@/types/view-models'
import { NIcon } from 'naive-ui'
import {
Adjustments,
@@ -421,7 +421,7 @@ function handleZoomClick(direction: -1 | 1, event: MouseEvent) {
}
}
function toCloudMarker(row: ApiCloud): CloudMarkerData {
function toCloudMarker(row: CloudDetail): CloudMarkerData {
return {
id: row.id,
latitude: row.latitude as number,
@@ -430,7 +430,7 @@ function toCloudMarker(row: ApiCloud): CloudMarkerData {
thumbnailUrl: row.thumbnail_url,
description: row.description,
cloudTypeName: row.cloud_type_name || '未知',
rarity: row.cloud_type_rarity_value ?? 0,
rarity: row.cloud_type_rarity ?? 0,
username: row.username || '匿名',
capturedAt: row.captured_at || row.created_at,
createdAt: row.created_at,
@@ -443,14 +443,14 @@ async function fetchCloudsByRange(
start: Date,
end: Date,
): Promise<CloudMarkerData[]> {
const params = new URLSearchParams({
const query = {
start: start.toISOString(),
end: end.toISOString(),
time_field: field,
})
}
try {
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud/map?${params}`, { auth: false })
return rows.map(toApiCloud).map(toCloudMarker)
const rows = await listMapClouds(query)
return rows.map(toCloudMarker)
} catch (error) {
statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
return []
+44
View File
@@ -0,0 +1,44 @@
import { apiRequest, ApiError } from '@/lib/api'
import { toCloudDetail, toAuthUser } from '@/lib/api-adapters'
import { collectPages } from '@/lib/pagination'
import { listClouds } from '@/features/clouds/api'
import type { CloudPageResponse, UserProfileResponse } from '@/types/api-contract'
export async function getMyProfile() {
return toAuthUser(await apiRequest<UserProfileResponse>('/profiles/me'))
}
export async function updateMyUsername(username: string) {
return toAuthUser(
await apiRequest<UserProfileResponse>('/profiles/me', {
method: 'PATCH',
body: { username },
}),
)
}
export async function getUserProfile(userId: string) {
return toAuthUser(
await apiRequest<UserProfileResponse>(`/profiles/${encodeURIComponent(userId)}`),
)
}
export function listMyClouds() {
return collectPages(async page => {
const result = await apiRequest<CloudPageResponse>('/profiles/me/clouds', {
query: { page, page_size: 100 },
})
return { ...result, items: result.items.map(toCloudDetail) }
})
}
export async function listPublicClouds(username: string) {
try {
return await collectPages(page =>
listClouds({ page, page_size: 100, filter: 'owner', value: `@${username}` }),
)
} catch (error) {
if (error instanceof ApiError && error.status === 404) return []
throw error
}
}
+22 -71
View File
@@ -1,9 +1,15 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
import { getUserProfile, listMyClouds, listPublicClouds } from '@/features/profile/api'
import {
getCloud,
updateCloud as patchCloud,
deleteClouds as deleteCloudRecords,
} from '@/features/clouds/api'
import type { UpdateCloudBody } from '@/types/api-contract'
import { useAuthStore } from '@/features/auth/stores/auth'
import type { ActionResponse, ApiCloud, BackendCloudInfo, BackendUserProfile } from '@/types/api'
import type { Profile } from '@/types/database'
import type { CloudDetail, AuthUser } from '@/types/view-models'
import type { Profile } from '@/types/models'
export interface ProfileCloudItem {
id: string
@@ -22,7 +28,7 @@ export interface ProfileCloudItem {
receivedLikeCount: number
}
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
function toProfileCloud(row: CloudDetail): ProfileCloudItem {
return {
id: row.id,
cloud_type_id: row.cloud_type_id as number,
@@ -36,7 +42,7 @@ function toProfileCloud(row: ApiCloud): ProfileCloudItem {
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || '未知',
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
cloudTypeRarity: row.cloud_type_rarity ?? 0,
receivedLikeCount: row.received_like_count,
}
}
@@ -67,27 +73,6 @@ export const useProfileStore = defineStore('profile-page', () => {
return !!identifier && makeKey(identifier, 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<BackendCloudInfo[]>(`/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 fetchProfilePage(identifier: string, isOwnProfile: boolean, force = false) {
const key = makeKey(identifier, isOwnProfile)
if (!force && key in cloudsByKey.value && profilesByKey.value[identifier]) return
@@ -96,9 +81,9 @@ export const useProfileStore = defineStore('profile-page', () => {
errorByKey.value[key] = ''
try {
const backendRows = isOwnProfile
? await apiRequest<BackendCloudInfo[]>('/profile/me/clouds')
: await fetchAllPublicClouds(identifier)
const cloudRows = backendRows.map(toApiCloud).map(toProfileCloud)
? await listMyClouds()
: await listPublicClouds(identifier)
const cloudRows = backendRows.map(toProfileCloud)
if (isOwnProfile && authStore.profile) {
profilesByKey.value[identifier] = authStore.profile
@@ -106,17 +91,17 @@ export const useProfileStore = defineStore('profile-page', () => {
profilesByKey.value[identifier] = authStore.profile
} else {
const owner = backendRows[0]?.owner
let publicProfile: BackendUserProfile | null = null
let publicProfile: AuthUser | null = null
if (owner?.id && authStore.isLoggedIn) {
try {
publicProfile = await apiRequest<BackendUserProfile>(`/profile/${owner.id}`)
publicProfile = await getUserProfile(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,
username: owner?.username || identifier,
image: publicProfile?.image ?? null,
role: 'user',
is_disabled: false,
@@ -165,56 +150,22 @@ export const useProfileStore = defineStore('profile-page', () => {
delete cloudsByKey.value[makeKey(identifier, false)]
}
async function updateCloud(
identifier: string,
cloudId: string,
patch: {
type_id: number
latitude: number
longitude: number
description?: string
captured_at?: string
is_hidden: boolean
},
) {
await apiRequest<ActionResponse>(`/cloud/${cloudId}`, {
method: 'PATCH',
body: patch,
})
const updated = toProfileCloud(
toApiCloud(await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`)),
)
async function updateCloud(identifier: string, cloudId: string, patch: UpdateCloudBody) {
await patchCloud(cloudId, patch)
const updated = toProfileCloud(await getCloud(cloudId))
patchCachedCloud(identifier, cloudId, updated)
return updated
}
async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) {
await apiRequest<ActionResponse>(`/cloud/${cloudId}`, {
method: 'PATCH',
body: { is_hidden: isHidden },
})
await patchCloud(cloudId, { is_hidden: isHidden })
patchCachedCloud(identifier, cloudId, { is_hidden: isHidden })
}
async function deleteClouds(identifier: string, cloudIds: string[]) {
if (!cloudIds.length) return 0
if (cloudIds.length === 1) {
await apiRequest<ActionResponse>(`/cloud/${cloudIds[0]}`, { method: 'DELETE' })
} else {
for (let offset = 0; offset < cloudIds.length; offset += 100) {
await apiRequest<ActionResponse>('/cloud', {
method: 'DELETE',
body: {
cloud_ids: cloudIds
.slice(offset, offset + 100)
.map(cloudId => ({ cloud_id: cloudId })),
},
})
}
}
removeCachedClouds(identifier, cloudIds)
await deleteCloudRecords(cloudIds, deletedIds => removeCachedClouds(identifier, deletedIds))
return cloudIds.length
}
+2 -2
View File
@@ -13,7 +13,7 @@ import ContributionHeatmap from '@/features/profile/components/ContributionHeatm
import { useAuthStore } from '@/features/auth/stores/auth'
import { useCloudsStore } from '@/features/clouds/stores/clouds'
import { useProfileStore, type ProfileCloudItem } from '@/features/profile/stores/profile'
import type { Profile } from '@/types/database'
import type { Profile } from '@/types/models'
interface TimelineGroup {
key: string
@@ -325,7 +325,7 @@ async function submitEditModal(value: CloudEditFormValue) {
viewedIdentifier.value,
selectedCloud.value.id,
{
type_id: value.cloudCategoryId,
cloud_type_id: value.cloudCategoryId,
latitude: blurCoordinate(value.latitude as number),
longitude: blurCoordinate(value.longitude as number),
description: value.description.trim(),
+9 -18
View File
@@ -1,8 +1,7 @@
import { ref } from 'vue'
import { apiRequest } from '@/lib/api'
import { uploadCloud } from '@/features/clouds/api'
import { useAuthStore } from '@/features/auth/stores/auth'
import { useProfileStore } from '@/features/profile/stores/profile'
import type { CloudCreateResponse } from '@/types/api'
export interface UploadItem {
id: string
@@ -78,10 +77,6 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
return null
}
function appendOptional(form: FormData, key: string, value: string) {
if (value.trim()) form.append(key, value.trim())
}
export function useUpload() {
const authStore = useAuthStore()
const profileStore = useProfileStore()
@@ -173,18 +168,14 @@ export function useUpload() {
currentItemIndex.value = index + 1
overallProgress.value = Math.round((index / items.value.length) * 100)
const form = new FormData()
form.append('image', item.file)
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))
await apiRequest<CloudCreateResponse>('/cloud', {
method: 'POST',
body: form,
await uploadCloud({
image: item.file,
cloud_type_id: item.cloudCategoryId!,
latitude: item.latitude!,
longitude: item.longitude!,
description: item.description.trim() || undefined,
captured_at: item.capturedAt.trim() || undefined,
is_hidden: item.isHidden,
})
overallProgress.value = Math.round(((index + 1) / items.value.length) * 100)
}
+62
View File
@@ -0,0 +1,62 @@
import { API_URL } from './api'
import type { CloudResponse, CloudTypeResponse, UserProfileResponse } from '@/types/api-contract'
import type { CloudDetail, AuthUser } from '@/types/view-models'
import type { CloudType } from '@/types/models'
export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') {
return `${API_URL}/images/${encodeURIComponent(id)}/${variant}`
}
export function toAuthUser(profile: UserProfileResponse): AuthUser {
return {
id: profile.id,
email: profile.email,
username: profile.username,
image: profile.image,
role: profile.role,
is_disabled: profile.is_disabled,
cloud_count: profile.cloud_count,
received_like_count: profile.received_like_count,
created_at: profile.created_at,
}
}
export function toCloudType(row: CloudTypeResponse): CloudType {
return {
id: row.id,
name: row.name,
genus: row.genus || '',
icon_id: row.icon_id,
rarity: row.rarity,
description: row.description,
created_at: row.created_at,
}
}
export function toCloudDetail(row: CloudResponse): CloudDetail {
return {
id: row.id,
user_id: row.owner.id,
cloud_type_id: row.cloud_type.id,
image_url: imageUrl(row.id, 'original'),
thumbnail_url: imageUrl(row.id, 'preview'),
latitude: row.latitude,
longitude: row.longitude,
description: row.description,
captured_at: row.captured_at,
status: row.review_status,
is_hidden: row.is_hidden,
created_at: row.uploaded_at,
updated_at: row.updated_at,
cloud_type: {
id: row.cloud_type.id,
name: row.cloud_type.name,
rarity: row.cloud_type.rarity,
},
owner: { id: row.owner.id, username: row.owner.username },
cloud_type_name: row.cloud_type.name,
cloud_type_rarity: row.cloud_type.rarity,
username: row.owner.username,
received_like_count: row.received_like_count,
}
}
+28 -127
View File
@@ -1,15 +1,6 @@
import type {
ApiCloud,
AuthUser,
BackendCloudInfo,
BackendCloudType,
BackendUserProfile,
} from '@/types/api'
import type { CloudType } from '@/types/database'
const DEFAULT_API_URL = 'http://localhost:3000'
export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
export const API_URL = (import.meta.env?.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
export class ApiError extends Error {
@@ -26,64 +17,23 @@ export class ApiError extends Error {
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
body?: BodyInit | Record<string, unknown> | null
query?: Record<string, string | number | boolean | undefined>
auth?: boolean
}
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
return (
!!body &&
typeof body === 'object' &&
!(body instanceof FormData) &&
!(body instanceof Blob) &&
!(body instanceof URLSearchParams) &&
!(body instanceof ArrayBuffer)
)
}
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)
return messages.join(';') || value
} catch {
return value
}
if (!body || typeof body !== 'object') return false
const prototype = Object.getPrototypeOf(body)
return prototype === Object.prototype || prototype === null
}
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
return payload &&
typeof payload === 'object' &&
'message' in payload &&
typeof payload.message === 'string'
? payload.message
: fallback
}
async function readPayload(response: Response) {
@@ -111,7 +61,7 @@ async function toApiError(response: Response) {
}
async function request<T>(path: string, options: ApiRequestOptions = {}) {
const { body, auth = true, ...requestOptions } = options
const { body, query, auth = true, ...requestOptions } = options
const headers = new Headers(requestOptions.headers)
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
@@ -120,7 +70,12 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
requestBody = JSON.stringify(body)
}
const response = await fetch(`${API_URL}${path}`, {
const search = new URLSearchParams()
for (const [key, value] of Object.entries(query ?? {})) {
if (value !== undefined) search.set(key, String(value))
}
const url = `${API_URL}${path}${search.size ? `?${search}` : ''}`
const response = await fetch(url, {
...requestOptions,
body: requestBody,
headers,
@@ -132,72 +87,18 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
}
if (!response.ok) throw await toApiError(response)
return (await readPayload(response)) as T
if (response.status === 204) return undefined as T
const payload = await readPayload(response)
if (
!response.headers.get('content-type')?.includes('json') ||
typeof payload === 'string' ||
payload === undefined
) {
throw new ApiError('服务器返回了无效的 JSON 响应', response.status, payload)
}
return payload as T
}
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
return request<T>(path, options)
}
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,
username: profile.name,
image: profile.image,
role: profile.role,
is_disabled: profile.is_disabled,
cloud_count: profile.cloud_count,
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,
genus: row.genus || '',
icon_id: row.icon_id,
// 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,
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,
image_url: imageUrl(row.id, 'original'),
thumbnail_url: imageUrl(row.id, 'preview'),
latitude: row.latitude,
longitude: row.longitude,
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,
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,
received_like_count: row.received_like_count,
}
}
+16
View File
@@ -0,0 +1,16 @@
export interface Page<T> {
items: T[]
page: number
page_size: number
has_more: boolean
}
/** Collect only for screens that need a complete set (heatmaps, likes, admin). */
export async function collectPages<T>(load: (page: number) => Promise<Page<T>>): Promise<T[]> {
const items: T[] = []
for (let page = 1; ; page++) {
const result = await load(page)
items.push(...result.items)
if (!result.has_more) return items
}
}
+191
View File
@@ -0,0 +1,191 @@
// Generated from hono-api/src/schemas. Do not edit manually.
export type AdminCloudListQuery = {
page?: number
page_size?: number
review_status?: 'pending' | 'approved' | 'rejected'
}
export type CloudCreateResponse = { message: string; cloud_id: string }
export type CloudIdParams = { cloud_id: string }
export type CloudIdsBody = { cloud_ids: Array<string> }
export type CloudImageParams = {
cloud_id: string
variant: 'original' | 'preview'
}
export type CloudListResponse = Array<{
id: string
owner: { id: string; username: string }
cloud_type: {
id: number
name: string
genus: string | null
icon_id: string | null
rarity: number
description: string
}
latitude: number
longitude: number
description: string | null
captured_at: string | null
uploaded_at: string
updated_at: string
received_like_count: number
review_status: 'pending' | 'approved' | 'rejected'
is_hidden: boolean
}>
export type CloudMapQuery = {
start: string
end: string
time_field?: 'captured_at' | 'uploaded_at'
limit?: number
}
export type CloudPageResponse = {
items: Array<{
id: string
owner: { id: string; username: string }
cloud_type: {
id: number
name: string
genus: string | null
icon_id: string | null
rarity: number
description: string
}
latitude: number
longitude: number
description: string | null
captured_at: string | null
uploaded_at: string
updated_at: string
received_like_count: number
review_status: 'pending' | 'approved' | 'rejected'
is_hidden: boolean
}>
page: number
page_size: number
has_more: boolean
}
export type CloudResponse = {
id: string
owner: { id: string; username: string }
cloud_type: {
id: number
name: string
genus: string | null
icon_id: string | null
rarity: number
description: string
}
latitude: number
longitude: number
description: string | null
captured_at: string | null
uploaded_at: string
updated_at: string
received_like_count: number
review_status: 'pending' | 'approved' | 'rejected'
is_hidden: boolean
}
export type CloudTypeCreateResponse = {
message: string
cloud_type_id: number
}
export type CloudTypeIdParams = { cloud_type_id: number }
export type CloudTypeResponse = {
id: number
name: string
genus: string | null
icon_id: string | null
rarity: number
description: string
created_at: string
}
export type CloudTypeSummary = {
id: number
name: string
genus: string | null
icon_id: string | null
rarity: number
description: string
}
export type CreateCloudTypeBody = {
name: string
genus?: string | null
icon_id?: string | null
rarity: number
description: string
}
export type CreateUserBody = {
username: string
email: string
password: string
role: 'user' | 'admin'
}
export type ErrorResponse = {
issues?: Array<{ path: string; message: string }>
message: string
}
export type MessageResponse = { message: string }
export type PaginationQuery = { page?: number; page_size?: number }
export type PublicCloudListQuery = {
page?: number
page_size?: number
filter?: 'type' | 'owner'
value?: string
}
export type ReviewCloudsBody = {
cloud_ids: Array<string>
review_status: 'approved' | 'rejected'
}
export type ReviewStatus = 'pending' | 'approved' | 'rejected'
export type SetUserPasswordBody = { new_password: string }
export type SetUserRoleBody = { role: 'user' | 'admin' }
export type UpdateCloudBody = {
latitude?: number
longitude?: number
cloud_type_id?: number
description?: string
captured_at?: string
is_hidden?: boolean
}
export type UpdateCloudTypeBody = {
name?: string
genus?: string | null
icon_id?: string | null
rarity?: number
description?: string
}
export type UpdateUsernameBody = { username: string }
export type UploadCloudForm = {
image: File
cloud_type_id: number
latitude: number
longitude: number
description?: string
captured_at?: string
is_hidden: boolean | '0' | '1' | 'true' | 'false'
}
export type UserIdParams = { user_id: string }
export type UserListResponse = Array<{
id: string
username: string
email: string
image: string | null
cloud_count: number
received_like_count: number
last_online: string | null
role: 'user' | 'admin'
is_disabled: boolean
created_at: string
}>
export type UserProfileResponse = {
id: string
username: string
email: string
image: string | null
cloud_count: number
received_like_count: number
last_online: string | null
role: 'user' | 'admin'
is_disabled: boolean
created_at: string
}
-94
View File
@@ -1,94 +0,0 @@
import type { Cloud, CloudType, Profile } from '@/types/database'
export interface BackendUserProfile {
id: string
name: string
email: string
image: 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 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
image: string | null
role: Profile['role']
is_disabled: boolean
cloud_count: number
received_like_count: number
created_at: string
}
export interface ApiCloudTypeSummary {
id: number
name: string
rarity: CloudType['rarity']
rarity_value: number
}
export interface ApiCloudOwner {
id: string
username: string
}
export interface ApiCloud extends Cloud {
updated_at: string
cloud_type: ApiCloudTypeSummary | null
owner: ApiCloudOwner | null
cloud_type_name: string | null
cloud_type_rarity: CloudType['rarity'] | null
cloud_type_rarity_value: number | null
username: string | null
received_like_count: number
}
@@ -3,8 +3,7 @@ export interface CloudType {
name: string
genus: string
icon_id: string | null
rarity: 'common' | 'uncommon' | 'rare'
rarity_value: number
rarity: number
description: string | null
created_at: string
}
+36
View File
@@ -0,0 +1,36 @@
import type { Cloud, Profile } from '@/types/models'
// 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
image: string | null
role: Profile['role']
is_disabled: boolean
cloud_count: number
received_like_count: number
created_at: string
}
export interface CloudTypeSummary {
id: number
name: string
rarity: number
}
export interface CloudOwner {
id: string
username: string
}
export interface CloudDetail extends Cloud {
updated_at: string
cloud_type: CloudTypeSummary | null
owner: CloudOwner | null
cloud_type_name: string | null
cloud_type_rarity: number | null
username: string | null
received_like_count: number
}
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict'
import { afterEach, mock, test } from 'node:test'
import { apiRequest, ApiError, AUTH_EXPIRED_EVENT } from '../src/lib/api.ts'
import { listClouds, uploadCloud, deleteClouds } from '../src/features/clouds/api.ts'
import { collectPages } from '../src/lib/pagination.ts'
afterEach(() => mock.restoreAll())
const cloud = {
id: 'cloud-1',
owner: { id: 'owner-1', username: '观云者' },
cloud_type: { id: 1, name: '积云', genus: null, icon_id: null, rarity: 2, description: '' },
latitude: 0,
longitude: 0,
description: '',
captured_at: null,
uploaded_at: '2026-09-30T00:00:00.000Z',
updated_at: '2026-09-30T00:00:00.000Z',
received_like_count: 3,
review_status: 'approved',
is_hidden: false,
}
test('cloud API encodes search once and maps the server page into view models', async () => {
mock.method(globalThis, 'fetch', async (url, options) => {
const parsed = new URL(url)
assert.equal(parsed.pathname, '/clouds')
assert.equal(parsed.searchParams.get('value'), '@云 & +')
assert.equal(parsed.searchParams.has('filter'), false)
assert.equal(options.credentials, 'include')
return Response.json({ items: [cloud], page: 1, page_size: 1, has_more: false })
})
const page = await listClouds({ page: 1, page_size: 1, value: '@云 & +' })
assert.equal(page.has_more, false)
assert.equal(page.items[0].status, 'approved')
assert.equal(page.items[0].username, '观云者')
assert.equal(page.items[0].cloud_type_id, 1)
assert.match(page.items[0].image_url, /\/images\/cloud-1\/original$/)
})
test('JSON, typed arrays and multipart preserve their distinct encodings', async () => {
const calls = []
mock.method(globalThis, 'fetch', async (url, options) => {
calls.push({ url, ...options })
return Response.json({ message: '成功', cloud_id: 'new' }, { status: 201 })
})
await apiRequest('/example', { method: 'PATCH', body: { is_hidden: false } })
assert.equal(calls[0].headers.get('content-type'), 'application/json')
assert.equal(calls[0].body, '{"is_hidden":false}')
const bytes = new Uint8Array([1, 2])
await apiRequest('/example', { method: 'POST', body: bytes })
assert.equal(calls[1].body, bytes)
const file = new File(['image'], 'cloud.png', { type: 'image/png' })
await uploadCloud({
image: file,
cloud_type_id: 1,
latitude: 0,
longitude: 0,
is_hidden: false,
})
assert.equal(calls[2].headers.has('content-type'), false)
assert.equal(calls[2].body.get('is_hidden'), 'false')
assert.equal(calls[2].body.get('cloud_type_id'), '1')
assert.equal(calls[2].body.has('type_id'), false)
assert.equal(calls[2].body.get('image').name, file.name)
})
test('errors retain status and validation issues; invalid success responses fail early', async () => {
const payload = { message: '用户名已被占用', issues: [{ path: 'username', message: '重复' }] }
mock.method(globalThis, 'fetch', async () => Response.json(payload, { status: 409 }))
await assert.rejects(apiRequest('/profiles/me'), error => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 409)
assert.equal(error.message, payload.message)
assert.deepEqual(error.detail, payload)
return true
})
globalThis.fetch.mock.mockImplementation(async () => new Response('<html>gateway</html>'))
await assert.rejects(apiRequest('/clouds'), /无效的 JSON/)
globalThis.fetch.mock.mockImplementation(
async () => new Response('{oops', { headers: { 'content-type': 'application/json' } }),
)
await assert.rejects(apiRequest('/clouds'), /无效的 JSON/)
globalThis.fetch.mock.mockImplementation(async () => new Response(null, { status: 204 }))
assert.equal(await apiRequest('/clouds'), undefined)
})
test('401 expires required sessions only; transport never retries a mutation', async () => {
const events = new EventTarget()
let expired = 0
events.addEventListener(AUTH_EXPIRED_EVENT, () => expired++)
globalThis.window = events
try {
const fetch = mock.method(globalThis, 'fetch', async () =>
Response.json({ message: '失效' }, { status: 401 }),
)
await assert.rejects(apiRequest('/clouds', { method: 'POST', body: {} }))
assert.equal(expired, 1)
await assert.rejects(apiRequest('/clouds', { auth: false }))
assert.equal(expired, 1)
assert.equal(fetch.mock.callCount(), 2)
} finally {
delete globalThis.window
}
})
test('pagination follows explicit has_more even on a full final page', async () => {
const pages = []
const result = await collectPages(async page => {
pages.push(page)
return { items: [page], page, page_size: 1, has_more: page < 2 }
})
assert.deepEqual(result, [1, 2])
assert.deepEqual(pages, [1, 2])
})
test('batch deletion reports completed batches when a later request fails', async () => {
const completed = []
let calls = 0
mock.method(globalThis, 'fetch', async (_url, options) => {
const ids = JSON.parse(options.body).cloud_ids
assert.ok(ids.length <= 100)
assert.ok(ids.every(id => typeof id === 'string'))
return ++calls === 1
? Response.json({ message: '完成' })
: Response.json({ message: '失败' }, { status: 500 })
})
const ids = Array.from({ length: 101 }, (_, index) => String(index))
await assert.rejects(
deleteClouds(ids, batch => completed.push(...batch)),
/失败/,
)
assert.deepEqual(completed, ids.slice(0, 100))
})