From 0af0256c976ca4bdc99c93b67c73c6d403bfe820 Mon Sep 17 00:00:00 2001 From: Mplan Date: Tue, 29 Sep 2026 18:09:38 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E7=BB=84=E5=89=8D=E7=AB=AF=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E7=9B=AE=E5=BD=95=E5=B9=B6=E7=BB=9F=E4=B8=80=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=A7=84=E8=8C=83=E5=B7=A5=E5=85=B7=E9=93=BE=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://git.catpl.top/Mplan/opencloud/pulls/10 --- .editorconfig | 12 + .gitignore | 4 + .prettierignore | 10 + .prettierrc.json | 10 + .vscode/extensions.json | 3 + .vscode/settings.json | 11 + AGENTS.md | 110 +- API.md | 1031 +++---- README.md | 25 +- docs/project-structure.md | 80 + docs/style-system.md | 44 +- eslint.config.js | 53 + index.html | 22 +- package-lock.json | 1789 +++++++++++++ package.json | 77 +- scripts/check-style-system.mjs | 176 +- src/App.vue | 52 +- src/components/cloud/CloudEditModal.vue | 232 -- src/components/cloud/ImageDetailModal.vue | 307 --- src/components/cloud/MapPickerModal.vue | 219 -- src/components/cloud/MiniLocationMap.vue | 123 - src/components/cloud/QuickUploadModal.vue | 384 --- src/components/layout/AppHeader.vue | 717 ++--- .../profile/ContributionHeatmap.vue | 451 ---- src/composables/useUpload.ts | 216 -- src/features/admin/views/AdminView.vue | 2370 +++++++++++++++++ src/features/auth/authClient.ts | 35 + src/features/auth/stores/auth.ts | 155 ++ src/features/auth/views/AuthConfirmView.vue | 98 + .../auth/views/ForgotPasswordView.vue | 148 + src/features/auth/views/LoginView.vue | 165 ++ src/features/auth/views/RegisterView.vue | 240 ++ .../auth/views/ResendConfirmationView.vue | 139 + src/features/auth/views/ResetPasswordView.vue | 153 ++ src/features/clouds/cloudTypes.ts | 17 + .../clouds/components/CloudEditModal.vue | 265 ++ .../clouds/components/CloudLikeButton.vue | 74 + .../clouds/components/ImageDetailModal.vue | 322 +++ src/features/clouds/stores/clouds.ts | 27 + src/features/clouds/stores/likes.ts | 116 + .../community/views/CommunityView.vue | 15 + .../encyclopedia/stores/encyclopedia.ts | 35 + .../encyclopedia/views/CloudTypeView.vue | 256 ++ .../encyclopedia/views/EncyclopediaView.vue | 133 + src/features/gallery/views/GalleryView.vue | 658 +++++ src/features/map/views/MapView.vue | 1031 +++++++ .../components/ContributionHeatmap.vue | 468 ++++ src/features/profile/stores/profile.ts | 233 ++ .../profile/views/ProfileSettingsView.vue | 217 ++ src/features/profile/views/ProfileView.vue | 937 +++++++ .../system/views/AuthRequiredView.vue | 67 + src/features/system/views/ForbiddenView.vue | 41 + src/features/system/views/NotFoundView.vue | 41 + .../upload/components/QuickUploadModal.vue | 495 ++++ src/features/upload/composables/useUpload.ts | 218 ++ src/features/upload/views/UploadView.vue | 560 ++++ src/lib/amap.ts | 16 - src/lib/api.ts | 278 +- src/lib/cloudTypes.ts | 17 - src/lib/seo.ts | 86 +- src/lib/theme.ts | 104 +- src/main.ts | 6 +- src/router/index.ts | 323 +-- src/shared/map/amap.ts | 16 + src/shared/map/components/MapPickerModal.vue | 238 ++ src/shared/map/components/MiniLocationMap.vue | 138 + src/shared/map/types/amap.d.ts | 126 + src/stores/auth.ts | 149 -- src/stores/clouds.ts | 23 - src/stores/encyclopedia.ts | 29 - src/stores/profile.ts | 210 -- src/styles/base.css | 39 +- src/styles/components.css | 1179 ++++---- src/styles/tokens.css | 142 +- src/types/amap.d.ts | 126 - src/types/api.ts | 126 +- src/types/database.ts | 52 +- src/types/router.d.ts | 16 +- src/views/admin/AdminView.vue | 1407 ---------- src/views/auth/AuthConfirmView.vue | 74 - src/views/auth/ForgotPasswordView.vue | 87 - src/views/auth/LoginView.vue | 147 - src/views/auth/RegisterView.vue | 155 -- src/views/auth/ResendConfirmationView.vue | 83 - src/views/auth/ResetPasswordView.vue | 93 - src/views/community/CommunityView.vue | 14 - src/views/encyclopedia/CloudTypeView.vue | 142 - src/views/encyclopedia/EncyclopediaView.vue | 92 - src/views/gallery/GalleryView.vue | 590 ---- src/views/map/MapView.vue | 961 ------- src/views/profile/ProfileSettingsView.vue | 220 -- src/views/profile/ProfileView.vue | 803 ------ src/views/system/AuthRequiredView.vue | 51 - src/views/system/ForbiddenView.vue | 27 - src/views/system/NotFoundView.vue | 27 - src/views/upload/UploadView.vue | 403 --- tsconfig.app.json | 26 +- tsconfig.json | 7 +- tsconfig.node.json | 40 +- vercel.json | 64 +- vite.config.ts | 114 +- 101 files changed, 14644 insertions(+), 10309 deletions(-) create mode 100644 .editorconfig create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 docs/project-structure.md create mode 100644 eslint.config.js delete mode 100644 src/components/cloud/CloudEditModal.vue delete mode 100644 src/components/cloud/ImageDetailModal.vue delete mode 100644 src/components/cloud/MapPickerModal.vue delete mode 100644 src/components/cloud/MiniLocationMap.vue delete mode 100644 src/components/cloud/QuickUploadModal.vue delete mode 100644 src/components/profile/ContributionHeatmap.vue delete mode 100644 src/composables/useUpload.ts create mode 100644 src/features/admin/views/AdminView.vue create mode 100644 src/features/auth/authClient.ts create mode 100644 src/features/auth/stores/auth.ts create mode 100644 src/features/auth/views/AuthConfirmView.vue create mode 100644 src/features/auth/views/ForgotPasswordView.vue create mode 100644 src/features/auth/views/LoginView.vue create mode 100644 src/features/auth/views/RegisterView.vue create mode 100644 src/features/auth/views/ResendConfirmationView.vue create mode 100644 src/features/auth/views/ResetPasswordView.vue create mode 100644 src/features/clouds/cloudTypes.ts create mode 100644 src/features/clouds/components/CloudEditModal.vue create mode 100644 src/features/clouds/components/CloudLikeButton.vue create mode 100644 src/features/clouds/components/ImageDetailModal.vue create mode 100644 src/features/clouds/stores/clouds.ts create mode 100644 src/features/clouds/stores/likes.ts create mode 100644 src/features/community/views/CommunityView.vue create mode 100644 src/features/encyclopedia/stores/encyclopedia.ts create mode 100644 src/features/encyclopedia/views/CloudTypeView.vue create mode 100644 src/features/encyclopedia/views/EncyclopediaView.vue create mode 100644 src/features/gallery/views/GalleryView.vue create mode 100644 src/features/map/views/MapView.vue create mode 100644 src/features/profile/components/ContributionHeatmap.vue create mode 100644 src/features/profile/stores/profile.ts create mode 100644 src/features/profile/views/ProfileSettingsView.vue create mode 100644 src/features/profile/views/ProfileView.vue create mode 100644 src/features/system/views/AuthRequiredView.vue create mode 100644 src/features/system/views/ForbiddenView.vue create mode 100644 src/features/system/views/NotFoundView.vue create mode 100644 src/features/upload/components/QuickUploadModal.vue create mode 100644 src/features/upload/composables/useUpload.ts create mode 100644 src/features/upload/views/UploadView.vue delete mode 100644 src/lib/amap.ts delete mode 100644 src/lib/cloudTypes.ts create mode 100644 src/shared/map/amap.ts create mode 100644 src/shared/map/components/MapPickerModal.vue create mode 100644 src/shared/map/components/MiniLocationMap.vue create mode 100644 src/shared/map/types/amap.d.ts delete mode 100644 src/stores/auth.ts delete mode 100644 src/stores/clouds.ts delete mode 100644 src/stores/encyclopedia.ts delete mode 100644 src/stores/profile.ts delete mode 100644 src/types/amap.d.ts delete mode 100644 src/views/admin/AdminView.vue delete mode 100644 src/views/auth/AuthConfirmView.vue delete mode 100644 src/views/auth/ForgotPasswordView.vue delete mode 100644 src/views/auth/LoginView.vue delete mode 100644 src/views/auth/RegisterView.vue delete mode 100644 src/views/auth/ResendConfirmationView.vue delete mode 100644 src/views/auth/ResetPasswordView.vue delete mode 100644 src/views/community/CommunityView.vue delete mode 100644 src/views/encyclopedia/CloudTypeView.vue delete mode 100644 src/views/encyclopedia/EncyclopediaView.vue delete mode 100644 src/views/gallery/GalleryView.vue delete mode 100644 src/views/map/MapView.vue delete mode 100644 src/views/profile/ProfileSettingsView.vue delete mode 100644 src/views/profile/ProfileView.vue delete mode 100644 src/views/system/AuthRequiredView.vue delete mode 100644 src/views/system/ForbiddenView.vue delete mode 100644 src/views/system/NotFoundView.vue delete mode 100644 src/views/upload/UploadView.vue diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..da599f4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 34a578b..3bcf9b2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,12 +10,16 @@ lerna-debug.log* node_modules dist dist-ssr +coverage +.eslintcache +.prettiercache *.local .env # Editor directories and files .vscode/* !.vscode/extensions.json +!.vscode/settings.json .idea .DS_Store .claude diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c33fd26 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +dist-ssr/ +coverage/ +.agents/ +.codex/ +.claude/ +.env* +package-lock.json +skills-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..c3eca39 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "tabWidth": 4, + "useTabs": false, + "semi": false, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "all", + "arrowParens": "avoid", + "endOfLine": "lf" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..a1c7588 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..11f0d47 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.detectIndentation": false, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": ["javascript", "typescript", "vue"] +} diff --git a/AGENTS.md b/AGENTS.md index 1137cf9..cf71aa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,57 +3,68 @@ ## Commands - `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:styles` — validates shared style contracts and blocks deprecated palettes/classes - `npm run build` — `vue-tsc -b && vite build` (typecheck must pass or build aborts) -- `npx vue-tsc -b` — standalone typecheck (no script in package.json) +- `npm run typecheck` — standalone Vue / TypeScript typecheck - `npm run preview` — previews the production build on strict port `5173` -- No general linter, formatter, or test runner exists; `check:styles` is the project-specific visual-system check +- 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. ## Architecture - **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + FastAPI + 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` -- AMap loaded lazily via `src/lib/amap.ts` with type declarations in `src/types/amap.d.ts` +- 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 ## Directory Map -| Path | Purpose | -| --- | --- | -| `src/lib/` | Singletons and utilities: FastAPI client, amap, canvas patch, cloudTypes constants, cloudBadges canvas renderer, SEO meta builder | -| `src/stores/` | Pinia stores: auth, clouds (cloud_types cache), encyclopedia (collection + unlock tracking), profile (user pages + cloud CRUD) | -| `src/composables/` | Vue composables: `useUpload` (batch multipart upload, EXIF extraction, badge result handling) | -| `src/components/cloud/` | Cloud-related modals and widgets: ImageDetailModal, CloudEditModal, MapPickerModal, QuickUploadModal, MiniLocationMap | -| `src/components/layout/` | AppHeader (top nav bar with auth state) | -| `src/components/profile/` | ContributionHeatmap | -| `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/views/` | Route-level page components (see Routes below) | -| `src/types/` | TypeScript types: domain models (`database.ts`), API DTOs (`api.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) | -| `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/` | 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 | + +- 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`. ## Routes -| Path | View | Auth Required | -| --- | --- | :---: | -| `/` | MapView | No | -| `/login`, `/register`, `/forgot-password`, `/auth/confirm`, `/auth/reset-password` | Auth views | No | -| `/upload` | UploadView | Yes | -| `/encyclopedia` | EncyclopediaView | Yes | -| `/encyclopedia/:id` | CloudTypeView | Yes | -| `/gallery` | GalleryView | No | -| `/community` | CommunityView | No | -| `/profile` | ProfileView (own) | Yes | -| `/profile/settings` | ProfileSettingsView | Yes | -| `/profile/:id` | ProfileView (public) | No | -| `/admin` | AdminView | Yes (admin) | -| `/403` | ForbiddenView | No | -| `/401` | AuthRequiredView | No | -| `/:pathMatch(.*)*` | NotFoundView | No | +| Path | View | Auth Required | +| ---------------------------------------------------------------------------------- | -------------------- | :-----------: | +| `/` | MapView | No | +| `/login`, `/register`, `/forgot-password`, `/auth/confirm`, `/auth/reset-password` | Auth views | No | +| `/upload` | UploadView | Yes | +| `/encyclopedia` | EncyclopediaView | Yes | +| `/encyclopedia/:id` | CloudTypeView | Yes | +| `/gallery` | GalleryView | No | +| `/community` | CommunityView | No | +| `/profile` | ProfileView (own) | Yes | +| `/profile/settings` | ProfileSettingsView | Yes | +| `/profile/:id` | ProfileView (public) | No | +| `/admin` | AdminView | Yes (admin) | +| `/403` | ForbiddenView | No | +| `/401` | AuthRequiredView | No | +| `/:pathMatch(.*)*` | NotFoundView | No | - Route guards in `router/index.ts`: `requiresAuth` redirects to `/401` with the intended URL in the `redirect` query; `requiresAdmin` redirects to `/403` - SEO meta tags applied per-route via `lib/seo.ts` in `router.afterEach` @@ -117,7 +128,6 @@ - **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`. - **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`. -- **`lib/canvas.ts`**: patches `HTMLCanvasElement.getContext('2d')` to always pass `willReadFrequently: true` — needed for the badge card renderer in `lib/cloudBadges.ts`. ## Environment Variables @@ -137,11 +147,11 @@ Backend-only variables such as database credentials, JWT secrets, SMTP settings, - The design language is a clean sky atlas: airy sky-blue surfaces, fresh eucalyptus green, square editorial panels, pixel-cloud identity, and hard offset shadows. - Read `.agents/skills/opencloud-style/SKILL.md` before material visual changes. Treat `src/styles/tokens.css`, `src/styles/base.css`, `src/styles/components.css`, and `src/lib/theme.ts` as the implementation sources of truth. - Shared/stable decisions belong in layers: - - tokens and palette remapping → `src/styles/tokens.css` - - global baseline and square-corner policy → `src/styles/base.css` - - cross-page semantic classes → `src/styles/components.css` - - feature-specific map/slider/media details → the component's scoped style - - one-off layout and responsive composition → Tailwind in the template + - tokens and palette remapping → `src/styles/tokens.css` + - global baseline and square-corner policy → `src/styles/base.css` + - cross-page semantic classes → `src/styles/components.css` + - feature-specific map/slider/media details → the component's scoped style + - one-off layout and responsive composition → Tailwind in the template - The global interface is square by default. Ordinary cards, inputs, dropdowns, buttons, modals, and map controls use `0px` radius. Circular marker dots, media overlay controls, AMap-generated bubbles/cards, and the media-focused image-detail shell are explicit exceptions. - Use hard zero-blur offset shadows (`--oc-shadow-xs` through `--oc-shadow-xl`) for ordinary UI depth. Interactive controls may lift by `translate(-1px, -1px)` on hover. - Brand/interaction green is fresh eucalyptus: main `#34745d`, dark `#274c40`, light surface `#f2faf6`. @@ -160,18 +170,18 @@ Backend-only variables such as database credentials, JWT secrets, SMTP settings, - Global overrides are defined in `src/lib/theme.ts` and mounted by `App.vue`; keep brand/success colors aligned with `src/styles/tokens.css`. - **Do not rely on default Naive UI primary/secondary colors for page CTAs or panel actions.** Use the shared classes in `src/styles/components.css`. - Auth buttons use `oc-primary-button` with semantic variants: - - `oc-primary-button--teal` — login / account-access actions - - `oc-primary-button--sky` — register / create / forward actions + - `oc-primary-button--teal` — login / account-access actions + - `oc-primary-button--sky` — register / create / forward actions - Panel and utility buttons use `oc-panel-button` with semantic variants: - - `oc-panel-button--neutral` — white card-style utility actions - - `oc-panel-button--sky` — primary panel actions - - `oc-panel-button--teal` — active toggles / confirm actions - - `oc-panel-button--danger` — destructive actions - - `oc-panel-button--amber` — admin/state-toggle actions where warning emphasis fits better than danger + - `oc-panel-button--neutral` — white card-style utility actions + - `oc-panel-button--sky` — primary panel actions + - `oc-panel-button--teal` — active toggles / confirm actions + - `oc-panel-button--danger` — destructive actions + - `oc-panel-button--amber` — admin/state-toggle actions where warning emphasis fits better than danger - Card-like containers should prefer shared shadow classes instead of ad-hoc shadows: - - `oc-panel-card` - - `oc-panel-card-soft` - - `oc-empty-card` + - `oc-panel-card` + - `oc-panel-card-soft` + - `oc-empty-card` - When styling `NButton`, prefer `type="default"` plus the shared class when a custom panel button variant is intended. Otherwise Naive UI theme variables may override white backgrounds or semantic colors. - Primary and panel actions use dark text on light semantic surfaces; do not assume primary buttons should use white text. diff --git a/API.md b/API.md index 986c55f..a3abe90 100644 --- a/API.md +++ b/API.md @@ -2,9 +2,9 @@ > **读者**:前端开发者。本文档覆盖 API 的全部端点:请求参数、响应结构、可能出现的错误与已知坑点。 > -> **事实来源**:本文档的字段名、约束与错误消息以 `src/schema/*.ts`(zod 校验定义)与 `src/router/*.ts`(路由实现)为最终依据整理。若文档与代码不一致,以代码为准。 +> **事实来源**:业务接口以 `src/schema/*.ts` 与 `src/router/*.ts` 为准;认证以 `src/auth.ts` 中的 Better Auth 配置、`src/index.ts` 中的挂载路径及 `src/middleware/auth.ts` 中的业务鉴权规则为准。若文档与代码不一致,以代码为准。 > -> **重要免责**:错误消息文案(`error` 字段的中文内容)可能随时调整。**前端逻辑只能依赖 HTTP 状态码,严禁匹配错误消息字符串**。文档中列出消息原文仅供调试对照。 +> **重要免责**:错误消息文案可能调整。**前端逻辑应依赖 HTTP 状态码及需要时的机器可读错误码,严禁匹配错误消息字符串**。文档中列出消息原文仅供调试对照。 ## 目录 @@ -24,43 +24,43 @@ ### 1.1 Base URL -| 环境 | 地址 | -|---|---| +| 环境 | 地址 | +| -------- | ---------------------------------------- | | 本地开发 | `http://localhost:3000`(`vc dev` 启动) | -| 生产 | 以部署地址为准 | +| 生产 | 以部署地址为准 | -除 `/image` 成功响应直接返回图片字节外,响应均为 `application/json`(个别异常情形返回纯文本,见 2.2)。所有响应携带两个调试响应头: +业务接口除 `/image` 成功响应直接返回图片字节外,响应通常为 `application/json`(个别异常情形返回纯文本,见 2.2)。Better Auth 的 `/auth/*` 有独立响应格式,部分操作也可能重定向,见第 4 章。响应携带两个调试响应头: - `X-Request-Id`:请求唯一 ID,反馈问题时请附上 - `X-Response-Time`:服务端处理耗时,如 `12ms` ### 1.2 命名与格式 -- 请求与响应字段一律 **snake_case**(如 `cloud_id`、`page_size`、`received_like_count`) +- 业务接口使用 **snake_case**(如 `cloud_id`、`page_size`);Better Auth 的 `/auth/*` 接口使用其原生 **camelCase** 字段 - 日期时间一律为 **ISO 8601 字符串**(如 `2026-08-15T08:30:00.000Z`),可空的日期字段值为 `null`。唯一的例外见 2.3 第 5 条 - UUID 字段为标准 UUID 字符串 ### 1.3 响应形态 -响应**没有统一包装**,分两种形态: +业务成功响应**没有统一包装**,分两种形态;`/auth/*` 不使用下表中的业务信封: -| 场景 | 形态 | 示例 | -|---|---|---| +| 场景 | 形态 | 示例 | +| -------------------------------------------- | -------- | ----------------------------------------------- | | 写操作成功(POST/PATCH/DELETE 的动作类端点) | 信封对象 | `{ "status_code": 200, "message": "删除成功" }` | -| 读操作成功(GET 及登录) | 裸数据 | 单资源为对象、列表为**数组**、登录为 `{ "access_token" }` | +| 读操作成功(业务 GET) | 裸数据 | 单资源为对象、列表为**数组** | ### 1.4 分页 所有分页端点使用相同的 query 参数: -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `page` | int | 否 | ≥ 1,默认 `1` | 页码 | -| `page_size` | int | 否 | 1–100,默认 `50` | 每页数量 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ----------- | ---- | ---- | ---------------- | -------- | +| `page` | int | 否 | ≥ 1,默认 `1` | 页码 | +| `page_size` | int | 否 | 1–100,默认 `50` | 每页数量 | ⚠️ **响应不返回总数**,也没有 `has_more` 字段。判断「是否还有下一页」只能依靠「返回数组长度 < `page_size`」。超出数据范围的页返回 `200` + 空数组 `[]`,不是 404。 -云图列表排序为 `uploaded_at` 倒序(同刻按 `id` 倒序);点赞列表按点赞时间倒序。 +云图列表排序为 `uploaded_at` 倒序(同刻按 `id` 倒序);点赞列表按点赞时间倒序(同刻按云图 ID 倒序)。 ### 1.5 共享数据模型 @@ -68,49 +68,49 @@ **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 | 是否隐藏(上传者控制,与审核状态独立) | +| 字段 | 类型 | 说明 | +| --------------------- | ------------ | --------------------------------------------------------------------------------------------------------------- | +| `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 第 7 条的暴露范围问题) | -| `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) | +| 字段 | 类型 | 说明 | +| --------------------- | ------------ | ---------------------------------------------------- | +| `id` | uuid | 用户 ID | +| `name` | string | 用户名 | +| `email` | string | 邮箱(注意 2.3 第 7 条的暴露范围问题) | +| `image` | string\|null | 用户头像 URL,由 Better Auth 管理;未设置时为 `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 第 5 条 | +| 字段 | 类型 | 说明 | +| ------------- | ------------ | ------------------------------------------------------- | +| `id` | int | 云类型 ID | +| `name` | string | 名称,如「积云」 | +| `genus` | string\|null | 属(拉丁名),如 `Cumulus` | +| `icon_id` | uuid\|null | 图标 ID | +| `rarity` | int | 稀有度 | +| `description` | string | 描述 | +| `created_at` | string | 创建时间。⚠️ 列表与详情端点的格式不一致,见 2.3 第 5 条 | ### 1.6 上传约束(`POST /cloud`) @@ -125,7 +125,7 @@ ### 2.1 两种错误响应形状 -本 API 存在**两种**错误响应形状,前端必须分别处理: +业务接口存在**两种**错误响应形状,前端必须分别处理。`/auth/*` 使用 Better Auth 自己的错误格式,见 3.5。 **① 业务错误**(绝大多数错误)——ErrorMessage 信封: @@ -154,20 +154,20 @@ ### 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` | +| 状态码 | 含义 | 前端通用动作 | +| ------ | ----------------------------------------------------------- | ---------------------------------------------- | +| 200 | 成功 | — | +| 201 | 业务创建成功(如上传云图、管理员创建用户) | — | +| 304 | 图片 ETag 未变化,无响应 body | 继续使用本地缓存 | +| 400 | 参数校验失败(形状②),或业务规则拒绝(形状①) | 检查参数;形状②可解析 `error.message` 定位字段 | +| 401 | 业务接口没有有效 Better Auth 会话,或邮箱未验证、帐号被禁用 | 重新获取会话,必要时引导登录或验证邮箱 | +| 403 | 业务接口已认证但权限不足,如角色不足或越权访问他人资源 | 按场景提示 | +| 404 | 资源不存在(形状①);**也可能是纯文本**(见下方警告) | 区分 content-type 后处理 | +| 405 | HTTP 方法不受端点支持 | 读取 `Allow` 响应头 | +| 409 | 部分业务接口发生唯一性冲突,如管理员创建用户或修改用户名 | 提示用户更换;普通注册见 3.5 | +| 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。 @@ -176,341 +176,125 @@ 以下是文档写作时核实的现状描述,**未来可能修复**。前端如需为之写防御代码,请做好移除准备。 1. **两种错误形状并存**:400 校验错误不是业务错误信封(见 2.1)。 -2. **部分 404 是纯文本而非 JSON**,来源有三:(a) 未实现的占位端点(各章已标注);(b) `GET /profile/me` 与 `GET /admin/users` 的数据库异常会从空 `catch` 落到纯文本 404;(c) 未匹配任何路由的路径。注册与邮箱确认失败始终返回 JSON 信封。 -3. **静默续签只写 Cookie**:token 到期前的自动续签仅通过 `Set-Cookie` 下发新 token(见 3.4)。使用 Bearer header 模式的客户端**拿不到新 token**,30 分钟后必然 401,需重新登录。 -4. **`POST /admin/user` 是假端点**:它原样回显请求体(**含明文密码**),并不创建用户。 +2. **部分 404 是纯文本而非 JSON**,来源有三:(a) 未实现的占位端点(各章已标注);(b) `GET /profile/me` 与 `GET /admin/users` 的数据库异常会从空 `catch` 落到纯文本 404;(c) 未匹配任何路由的路径。Better Auth 认证端点使用自己的错误格式。 +3. **认证端点使用 Better Auth 格式**:`/auth/*` 的字段、状态码与错误响应由 Better Auth 定义,不能用业务错误信封解析;见第 4 章。 +4. **管理员创建用户需验证邮箱**:`POST /admin/user` 会通过 Better Auth 创建用户并尝试发送验证邮件;验证前不能登录。 5. **`GET /info/cloudtype`(列表)的 `created_at` 格式与其他端点不同**:是 PostgreSQL 原生字符串(如 `2026-08-15 08:30:00.123456+00`),而非 ISO 8601(`2026-08-15T08:30:00.000Z`)。详情端点 `/info/cloudtype/:id` 及其他所有端点均为 ISO 8601。前端解析该字段时需兼容两种格式。 -6. **用户名/邮箱长度不在 API 层校验**:数据库限制用户名 ≤ 16 字符、邮箱 ≤ 64 字符,但 zod 校验不拦截。超长注册与超长改名(`PATCH /profile/me`)会返回 JSON 500。前端应自行限制输入长度。 +6. **用户名/邮箱数据库长度限制**:用户名 ≤ 16 字符、邮箱 ≤ 64 字符;前端应限制输入长度。 7. **`GET /profile/:user_id` 向任意登录用户暴露对方邮箱**:任何登录用户都能查到任意用户的 `email`。前端展示他人资料时不应展示邮箱,也不应假设该接口未来仍返回邮箱。 -8. **用户名唯一性的大小写规则不一致**:注册时的查重是大小写不敏感的(`Alice`/`alice` 视为重复),但数据库唯一约束与 `PATCH /profile/me` 改名是大小写敏感的(可改成仅大小写不同的名字)。 +8. **用户名大小写**:数据库唯一约束区分大小写,注册和改名均受该约束。 --- ## 3. 认证 -### 3.1 机制概览 +### 3.1 从旧认证接口迁移 -- JWT(HS256),有效期 **30 分钟** -- 获取方式:`POST /auth/login` 成功后在响应体返回 `access_token`,**同时**通过 `Set-Cookie` 写入 HttpOnly Cookie -- 受保护端点接受两种携带方式(服务端优先读 header): - 1. 请求头 `Authorization: Bearer ` - 2. Cookie `token=`(`HttpOnly; SameSite=Strict; Path=/; Max-Age=1800`;非开发/测试环境附加 `Secure`) +后端现在由 Better Auth 处理注册、登录、邮箱验证、会话和密码。旧认证路由已移除;请替换所有旧请求与响应解析: -### 3.2 Bearer 还是 Cookie? +| 旧接口 | 当前接口 / 前端动作 | 主要变化 | +| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------ | +| `POST /auth/register` | `POST /auth/sign-up/email` | 请求仍为 `name`、`email`、`password`;成功为 Better Auth 的 `200`,不会自动登录 | +| `POST /auth/login` | `POST /auth/sign-in/email` | 使用 Better Auth 会话 Cookie,不再保存旧 JWT | +| `POST /auth/logout` | `POST /auth/sign-out` | 撤销当前会话;不要只清除前端状态 | +| `POST /auth/resend-confirmation` | `POST /auth/send-verification-email` | 请求 `{ "email": "..." }` | +| `POST /auth/confirm-email` | `GET /auth/verify-email?token=...` | token 从前端验证页的 URL 读取,放入查询参数 | +| `POST /auth/forgot-password` | `POST /auth/request-password-reset` | 请求 `{ "email": "..." }` | +| `POST /auth/reset-password` | `POST /auth/reset-password` | 路径相同,请求改为 `{ "token": "...", "newPassword": "..." }`;不再提交 `email` / `new_password` | +| `PATCH /auth/password` | `POST /auth/change-password` | 请求改为 `currentPassword`、`newPassword`,可选 `revokeOtherSessions` | -| 模式 | 适用 | 注意事项 | -|---|---|---| -| Cookie | 浏览器前端(推荐) | 登录后浏览器自动携带;`fetch` 需设 `credentials: "include"`;HttpOnly 使 JS 无法读取 token,天然防 XSS 窃取;可享受静默续签 | -| Bearer | 非浏览器客户端(App、脚本) | 自行存储 `access_token` 并注入 header;**无法静默续签**(2.3 第 3 条),30 分钟后需重新登录 | +旧路由的 `status_code` / `message` 信封、JWT 及认证相关的 snake_case 请求字段不适用于新端点。普通注册不要提交 `role`:角色由服务端设置为 `user`;创建管理员用户走第 8 章的管理接口。业务接口的路径与 snake_case 字段仍按后续章节使用。 -### 3.3 CORS +### 3.2 浏览器接入 -- 服务端仅放行**单个** 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 不会随图片请求发送 +前端安装与后端当前版本一致的 `better-auth@1.7.6`,创建客户端时将 **API 地址连同 `/auth` 路径**传入 `baseURL`。服务端挂载路径是 `/auth`,不是 Better Auth 默认的 `/api/auth`;不要把业务 API 根地址直接用作认证客户端的完整 `baseURL`。下面是框架无关的示例;React 可从 `better-auth/react` 导入 `createAuthClient`。这与 [Better Auth 客户端的自定义路径配置](https://better-auth.com/docs/concepts/client)一致。 -### 3.4 有效期、续签与失效 +```ts +import { createAuthClient } from 'better-auth/client' -- token 有效期 30 分钟(Cookie 的 `Max-Age` 同为 1800 秒) -- **静默续签**:携带剩余有效期 ≤ 300 秒的 token 访问受保护端点时,服务端会在响应中**追加 `Set-Cookie` 写入新 token**。Cookie 模式下浏览器自动替换,前端无感知;Bearer 模式收不到新 token -- 续签不适用的路径:`POST /auth/logout`、`PATCH /auth/password` -- `/image` 请求只验证身份,不触发静默续签,避免一个页面的并发图片请求重复写 Cookie -- 认证中间件每次请求都会重新检查用户仍已确认邮箱且未被管理员禁用;任一条件不满足时,已有 JWT 也会被拒绝 -- **立即失效**的情况:调用 `/auth/logout`、调用 `PATCH /auth/password` 改密、通过 `/auth/reset-password` 重置密码、用户被禁用,以及管理员修改了该用户的角色或密码(`PATCH /admin/users/:user_id` 见 8.3、`PATCH /admin/users/:user_id/password` 见 8.4)。除「用户被禁用」由中间件每次请求重新校验外,其余情况都会递增 `tokenVersion`,使该用户**所有已签发的 token 全部作废**(包括其他设备上的);用户自己改密与密码重置成功时还会写入过期 Cookie +const API_ORIGIN = 'http://localhost:3000' // 生产环境改成实际 API origin +export const authClient = createAuthClient({ + baseURL: `${API_ORIGIN}/auth`, + fetchOptions: { credentials: 'include' }, +}) +``` -### 3.5 认证相关错误消息对照 +Better Auth 浏览器客户端默认会携带凭证;上面显式写出该选项,便于与业务请求保持一致。业务接口使用原生 `fetch` 或其他 HTTP 客户端时,也要单独配置: -| 状态码 | 消息原文 | 场景 | -|---|---|---| -| 401 | `未登录` | 受保护端点未携带任何 token | -| 401 | `Token 无效或已过期` | token 签名无效、过期、已被吊销(登出、改密或密码重置后),或用户不再同时满足「邮箱已确认且未禁用」 | -| 403 | `权限不足` | 已登录但角色不满足(如非 admin 访问 `/admin`) | +```ts +const response = await fetch(`${API_ORIGIN}/profile/me`, { + credentials: 'include', +}) +``` -注意「帐号被禁用」在**登录时**报 403「帐号已被禁用」,但已登录后被禁用,后续请求报的是 401「Token 无效或已过期」。 +浏览器会自动保存并发送 HttpOnly 会话 Cookie。不要尝试在 JavaScript 中读取 Cookie,也不要将登录响应中的 token 当作旧 JWT 存储。服务端 `CORS_ORIGIN` 必须与前端 origin(协议、域名、端口)精确匹配;生产环境 Cookie 为 `SameSite=None; Secure`,本地开发为 `SameSite=Lax`。`BETTER_AUTH_URL` 是后端公开地址;前端客户端仍需使用上面含 `/auth` 的 URL。跨源接入和凭证设置也见 [Better Auth 的 Hono 集成文档](https://better-auth.com/docs/integrations/hono)。 ---- +### 3.3 会话与业务权限 + +登录成功后,使用 `authClient.getSession()` 或对应框架的 `useSession()` 获取会话: + +```ts +const { error } = await authClient.signIn.email({ email, password }) +if (error) throw error + +const { data: session } = await authClient.getSession() +// session 为 { user, session } 或 null;退出时调用 await authClient.signOut()。 +``` + +需要业务资料、统计及角色时,再请求 `GET /profile/me`,不要将 Better Auth 的 `user` 对象当作第 1.5 节的 UserProfile。完整客户端方法见 [Better Auth 会话管理](https://better-auth.com/docs/concepts/session-management)。 + +头像使用 Better Auth 的 `user.image` 字段。前端可调用 `authClient.updateUser({ image: "https://example.com/avatar.png" })` 设置头像 URL;业务资料接口和管理员用户列表均返回同一 `image` 值。原 `avatar_id` 字段已移除。 + +所有受保护的业务接口都再次检查会话、邮箱验证状态、禁用状态及角色。**业务接口**在无会话、邮箱未验证或帐号被禁用时返回 `401`;会话有效但角色或资源权限不足时返回 `403`。管理员修改角色或密码后会撤销目标用户的全部会话,前端应刷新登录状态。 + +两种可选认证端点行为不同:`GET /cloud/:cloud_id` 对无效/过期凭证按匿名处理;`GET|HEAD /image/:cloud_id/:variant` 在显式提供无效凭证时返回 `401`。同时发送 `Authorization` 和 Cookie 时,业务接口优先使用 Bearer 凭证。 + +### 3.4 邮件与密码页面 + +注册后会发送验证邮件,用户需先验证邮箱再登录;验证成功后也不会自动登录。当前邮箱验证 token 有效期为 24 小时,密码重置 token 默认有效期为 1 小时。后端发出的邮件仍指向前端 `/verify-email?token=...` 与 `/reset-password?email=...&token=...` 页面;它们是**前端页面**,不是 API。验证页应将 token 交给 `GET /auth/verify-email?token=...`,重置页应以 token 和新密码调用 `POST /auth/reset-password`。邮件中的 `email` 可用于页面展示,重置请求不需要它。密码重置成功会撤销该用户的全部会话。 + +重发验证邮件使用 `authClient.sendVerificationEmail({ email })`;申请密码重置使用 `authClient.requestPasswordReset({ email })`。用户不存在时,密码重置申请仍返回通用成功结果,不要据此判断邮箱是否已注册。验证及重置流程见 [Better Auth 邮箱密码文档](https://better-auth.com/docs/authentication/email-password)。 + +### 3.5 认证错误与非浏览器客户端 + +Better Auth 端点的错误是其原生结构,通常包含 `code`、`message`;客户端调用返回 `{ data, error }`。按 HTTP 状态、客户端的 `error` 和必要时的 `error.code` 处理,不要匹配英文错误文案,也不要按业务接口的 `status_code` 或 Zod 形状解析。未验证邮箱登录会被 Better Auth 拒绝(当前版本的错误码为 `EMAIL_NOT_VERIFIED`),此时可显示验证页并提供重发邮件操作。 + +当前配置要求邮箱验证,且注册后不自动登录。Better Auth 对重复邮箱注册可能返回与新邮箱相同的 **200** 响应和临时生成的用户对象;因此注册成功仅表示「请查收验证邮件」,**不能仅凭响应中的 `user.id` 判断账户已创建,也不能将其用作已登录用户 ID**。应在验证后重新登录。该行为来自当前安装的 Better Auth `1.7.6`。 + +非浏览器客户端可使用已启用的 Bearer 插件。登录成功响应头 `set-auth-token` 暴露会话 token,后续请求使用 `Authorization: Bearer `;这不是旧 JWT,也不是密码重置或邮箱验证 token。前端浏览器仍使用 Cookie。[Better Auth Bearer 插件说明](https://better-auth.com/docs/plugins/bearer)。 ## 4. 端点 · 认证 `/auth` -### 4.1 `POST /auth/register` — 注册 +以下为本项目已启用的 Better Auth 邮箱密码功能,密码至少 8 字符。请求 JSON 使用原生 **camelCase**,响应不带业务 `status_code` 信封。前端推荐调用第 3.2 节的客户端;直接请求时使用表中方法和路径。Better Auth 还会暴露其他内置或 Admin 插件端点,其输入、权限和响应以 [Better Auth 官方文档](https://better-auth.com/docs)及当前安装版本为准。 -- **认证**:无(公开) +| 操作 | 方法与路径 | Better Auth 客户端方法 | 请求与结果要点 | +| ------------ | ------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| 注册 | `POST /auth/sign-up/email` | `authClient.signUp.email({ name, email, password })` | 成功 `200`,`{ token: null, user }`;发送验证邮件,不建立会话 | +| 登录 | `POST /auth/sign-in/email` | `authClient.signIn.email({ email, password })` | 验证邮箱后才可登录;成功 `200` 并设置会话 Cookie | +| 当前会话 | `GET /auth/get-session` | `authClient.getSession()` | 有效会话返回 `{ user, session }`;无会话返回 `null` | +| 登出当前会话 | `POST /auth/sign-out` | `authClient.signOut()` | 需携带当前 Cookie 或 Bearer;撤销当前会话 | +| 重发验证邮件 | `POST /auth/send-verification-email` | `authClient.sendVerificationEmail({ email })` | `{ email }`;匿名请求对不存在或已验证邮箱也可能返回 `{ status: true }` | +| 确认邮箱 | `GET /auth/verify-email?token=...` | 可用 `fetch` 发 GET | 查询参数 `token`;无 `callbackURL` 时成功返回包含 `status: true` 的 JSON | +| 申请密码重置 | `POST /auth/request-password-reset` | `authClient.requestPasswordReset({ email })` | `{ email }`;返回通用结果,不泄露账户是否存在 | +| 重置密码 | `POST /auth/reset-password` | `authClient.resetPassword({ token, newPassword })` | `{ token, newPassword }`;成功 `{ status: true }`,撤销该用户全部会话 | +| 修改当前密码 | `POST /auth/change-password` | `authClient.changePassword({ currentPassword, newPassword, revokeOtherSessions })` | 需登录;可选择撤销其他会话 | -**请求体**(JSON): +验证邮箱页面的请求示例: -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(2.3 第 6 条) | 用户名,大小写不敏感查重 | -| `email` | string | 是 | 合法邮箱格式;⚠️ 数据库限 ≤ 64 字符 | 服务端去除首尾空白并转为小写后存储 | -| `password` | string | 是 | 无强度校验 | 密码 | +```ts +const token = new URLSearchParams(location.search).get('token') +if (!token) throw new Error('缺少验证 token') -```json -{ - "name": "cloudwatcher", - "email": "watcher@example.com", - "password": "s3cret-password" -} +const response = await fetch(`${API_ORIGIN}/auth/verify-email?token=${encodeURIComponent(token)}`, { + credentials: 'include', +}) +if (!response.ok) throw new Error('邮箱验证失败') +// 验证成功后引导用户登录;当前配置不会自动创建会话。 ``` -**成功响应**:`201` +上面的 `API_ORIGIN` 与第 3.2 节相同。直接调用 `GET /auth/verify-email` 时不需要旧接口的 JSON 请求体;若传入 `callbackURL`,Better Auth 可能改为重定向,前端应按重定向处理。 -```json -{ - "status_code": 201, - "message": "注册成功,请查收邮件并确认邮箱", - "email_sent": true -} -``` - -数据库提交成功但邮件暂时未发送时仍返回 `201`,`email_sent` 为 `false`,消息会提示稍后使用重发验证流程。用户、密码凭据和 24 小时有效的确认记录已经保留,不应重试注册。 - -**错误**: - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | -| 409 | 已确认邮箱重复注册(大小写不敏感) | `该邮箱已被注册` | 提示直接登录 | -| 409 | 未确认邮箱重复注册 | `该邮箱已被注册,请使用重发验证流程` | 引导至重发验证流程,不会覆盖原用户名或密码 | -| 409 | 用户名已使用(大小写不敏感) | `该用户名已被使用` | 提示换用户名 | -| 500 | 数据库、密码散列或服务端配置异常 | `注册失败` | 稍后重试;若此前已收到 201,不要重复注册 | - -**注意事项**: - -- 新用户的 `is_disabled` 为 `false`,`email_verified_at` 为空;两个状态相互独立。注册后必须先确认邮箱,再调用 `/auth/login` -- 注册响应**不返回 token、不写 Cookie** -- 确认邮件由 `Opencloud ` 发出,同时包含 `https://cloud.catpl.top/verify-email?token=...` 链接和原始 token 备用文本。数据库仅保存使用 `JWT_SECRET` 生成的 HMAC 摘要 -- ⚠️ 并发注册(同一邮箱/用户名几乎同时提交)命中数据库唯一约束时,冲突消息文案与上方表格不同(邮箱冲突会返回 `该邮箱已被注册;若尚未验证,请使用重发验证流程`)。按本文档免责声明,前端仍只依据状态码 `409` 处理 - -### 4.2 `POST /auth/resend-confirmation` — 重发确认邮件 - -- **认证**:无(公开) - -**请求体**(JSON): - -| 参数 | 类型 | 必填 | 说明 | -|---|---|---|---| -| `email` | string | 是 | 与注册、登录相同:服务端去除首尾空白并转为小写;不接受旧字段 `user_email` | - -```json -{ - "email": "watcher@example.com" -} -``` - -**成功响应**:`200` - -```json -{ - "status_code": 200, - "message": "如果该邮箱符合条件,我们将发送验证邮件" -} -``` - -未知邮箱、已确认邮箱、被管理员禁用、处于 60 秒冷却期、符合发送条件以及邮件供应商失败,都会收到完全相同的状态码和响应体。前端不能根据该响应判断邮件是否实际发送,也不应显示「邮箱存在」之类的提示。 - -只有尚未确认、未被管理员禁用且不在冷却期内的用户会触发邮件发送。成功签发新 token 后,同一用户此前未消费的邮箱确认 token 全部失效;新 token 仍有 24 小时有效期,邮件内容与注册邮件相同。已消费记录会保留作为审计信息。 - -重叠请求会按用户串行处理,至多签发一个可用 token。数据库签发或 Resend 失败不会改变上述公共响应。 - -**错误**:请求体校验失败返回 `400` zod 形状(2.1)。 - -### 4.3 `POST /auth/confirm-email` — 确认邮箱 - -- **认证**:无(公开) - -前端从确认链接读取 `token`,再提交: - -```json -{ - "token": "0123456789abcdef0123456789abcdef" -} -``` - -**成功响应**:`200` - -```json -{ - "status_code": 200, - "message": "邮箱确认成功,请重新登录" -} -``` - -确认会在一个数据库事务中消费未过期、未消费且用途为 `email_confirmation` 的记录,并写入用户的邮箱确认时间。它不会签发 JWT、不会写认证 Cookie,也绝不修改管理员禁用状态。 - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | token 未知、过期、已消费,或来自其他用途 | `确认链接无效或已过期` | 统一提示链接无效,并引导重发 | -| 500 | 数据库或服务端配置异常 | `确认邮箱失败` | 稍后重试 | - -### 4.4 `POST /auth/login` — 登录 - -- **认证**:无(公开) - -**请求体**(JSON): - -| 参数 | 类型 | 必填 | 说明 | -|---|---|---|---| -| `email` | string | 是 | 服务端去除首尾空白、转为小写后匹配 | -| `password` | string | 是 | 密码 | - -```json -{ - "email": "watcher@example.com", - "password": "s3cret-password" -} -``` - -**成功响应**:`200`,同时写入认证 Cookie(见 3.1) - -```json -{ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} -``` - -**错误**: - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | -| 401 | 邮箱不存在或密码错误(两者共用,防枚举) | `邮箱或密码错误` | 统一提示「邮箱或密码错误」 | -| 403 | 帐号被管理员禁用 | `帐号已被禁用` | 提示联系管理员 | -| 403 | 密码正确但邮箱尚未确认 | `邮箱尚未验证` | 引导至重发验证流程 | -| 500 | 服务端异常 | `登录失败` | 稍后重试 | - -### 4.5 `POST /auth/logout` — 登出 - -- **认证**:需登录(任意角色) -- **请求体**:无 - -**成功响应**:`200`,同时写入过期 Cookie - -```json -{ - "status_code": 200, - "message": "已登出" -} -``` - -**错误**: - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 401 | 未登录 / token 失效 | 见 3.5 | 本地直接清理登录态即可 | -| 500 | 数据库异常 | `连接数据库发生错误,登出失败` | 提示重试;⚠️ 此时服务端 token 可能仍有效 | - -**注意事项**:登出会使该用户**所有设备**的 token 立即失效(`tokenVersion` 递增),不只是当前会话。 - -### 4.6 `POST /auth/forgot-password` — 申请重置密码 - -- **认证**:无(公开) - -**请求体**(JSON): - -| 参数 | 类型 | 必填 | 说明 | -|---|---|---|---| -| `email` | string | 是 | 服务端去除首尾空白并转为小写;不接受旧字段 `user_email` | - -```json -{ - "email": "watcher@example.com" -} -``` - -**成功响应**:`200` - -```json -{ - "status_code": 200, - "message": "如果该邮箱符合条件,我们将发送密码重置邮件" -} -``` - -未知邮箱、邮箱未确认、被管理员禁用、处于 60 秒冷却期、符合发送条件以及邮件供应商失败,都会收到完全相同的状态码和响应体。只有邮箱已确认、未被禁用且不在冷却期内的用户会触发邮件发送。 - -新签发的 `password_reset` token 有效期为 30 分钟,并使此前未消费的密码重置 token 失效;它与邮箱确认 token 用 purpose 隔离。邮件由 `Opencloud ` 发出,链接到 `https://cloud.catpl.top/reset-password?email=...&token=...`。数据库和 Resend 失败不改变公共响应。 - -**错误**:请求体校验失败返回 `400` zod 形状(2.1)。没有 `/auth/forget-password` 兼容路径。 - -### 4.7 `POST /auth/reset-password` — 重置密码 - -- **认证**:无(公开) - -**请求体**(JSON): - -| 参数 | 类型 | 必填 | 说明 | -|---|---|---|---| -| `email` | string | 是 | 邮件链接中的邮箱;服务端再次规范化 | -| `token` | string | 是 | 邮件链接中的原始 token | -| `new_password` | string | 是 | 新密码;不能与当前密码相同,不额外扩大密码策略 | - -```json -{ - "email": "watcher@example.com", - "token": "0123456789abcdef0123456789abcdef", - "new_password": "new-s3cret-password" -} -``` - -**成功响应**:`200`,同时写入过期认证 Cookie - -```json -{ - "status_code": 200, - "message": "密码重置成功,请使用新密码重新登录" -} -``` - -成功时,服务端在同一数据库事务中消费 token、更新密码哈希和凭据更新时间,并递增 `tokenVersion`。此前在所有设备签发的 JWT 随即失效;响应不会签发新 JWT,用户必须重新登录。 - -token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose、有效期、未消费状态和目标用户;用户在消费时仍须邮箱已确认且未被管理员禁用。重置不会修改邮箱确认时间或管理员禁用状态。 - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | token 未知、过期、已消费、已被替换、用途错误、邮箱不匹配,或用户不再符合状态要求 | `重置链接无效或已过期` | 统一提示链接无效,重新申请 | -| 400 | 新密码与当前密码相同 | `新密码不能与当前密码相同` | 要求输入不同密码 | -| 500 | 数据库或服务端配置异常 | `重置密码失败` | 稍后重试 | - -### 4.8 `PATCH /auth/password` — 修改密码 - -- **认证**:需登录(任意角色) - -**请求体**(JSON): - -| 参数 | 类型 | 必填 | 说明 | -|---|---|---|---| -| `current_password` | string | 是 | 当前密码 | -| `new_password` | string | 是 | 新密码,无强度校验 | - -```json -{ - "current_password": "s3cret-password", - "new_password": "new-s3cret-password" -} -``` - -**成功响应**:`200`,同时写入过期 Cookie - -```json -{ - "status_code": 200, - "message": "密码修改成功,请重新登录" -} -``` - -**错误**: - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 新旧密码相同 | `新密码不能与当前密码相同` | 表单前置校验可避免 | -| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | -| 401 | 当前密码错误 | `当前密码错误` | 提示重新输入 | -| 401 | 未登录 / token 失效 | 见 3.5 | 跳登录 | -| 404 | 用户无凭据记录(数据异常) | `未找到用户凭据` | 反馈后端,附 `X-Request-Id` | -| 500 | 服务端异常 | `修改密码时发生错误` | 稍后重试 | - -**注意事项**:改密成功后**所有已签发 token 立即失效**(含当前使用的这个),前端必须清理登录态并跳转登录页。 +管理员的业务管理操作继续使用第 8 章 `/admin/*` 路由。它们保持原业务响应格式,并在修改角色或密码后撤销目标会话;直接调用 Better Auth Admin 插件端点不会自动执行这些业务路由的附加操作。 --- @@ -522,12 +306,12 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **Query 参数**(在分页参数 1.4 基础上): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `page` | int | 否 | 默认 `1` | 见 1.4 | -| `page_size` | int | 否 | 默认 `50`,最大 `100` | 见 1.4 | -| `filter` | enum | 否 | `type` / `owner`,默认 `type` | 搜索维度 | -| `value` | string | 否 | 默认 `""`(空 = 不过滤) | 搜索词 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ----------- | ------ | ---- | ----------------------------- | -------- | +| `page` | int | 否 | 默认 `1` | 见 1.4 | +| `page_size` | int | 否 | 默认 `50`,最大 `100` | 见 1.4 | +| `filter` | enum | 否 | `type` / `owner`,默认 `type` | 搜索维度 | +| `value` | string | 否 | 默认 `""`(空 = 不过滤) | 搜索词 | 搜索逻辑: @@ -569,11 +353,11 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败(如 `page_size=0`) | zod 形状(2.1) | 检查参数 | -| 404 | 按用户名搜索但该用户不存在 | `该用户不存在` | 提示「没有找到这个用户」,不要显示为空列表 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | -------------------------------- | -------------------- | ------------------------------------------ | +| 400 | 参数校验失败(如 `page_size=0`) | zod 形状(2.1) | 检查参数 | +| 404 | 按用户名搜索但该用户不存在 | `该用户不存在` | 提示「没有找到这个用户」,不要显示为空列表 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:按云类型搜索时类型名不存在**不报错**,返回空数组(与用户名搜索的 404 行为不同)。 @@ -583,45 +367,45 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **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` | 返回上限(不分页) | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ------------ | ----------- | ---- | ------------------------------------------------- | ----------------------------------- | +| `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 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ------------------ | ------------------------------------------------ | ------------ | +| 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 +- 无效/过期会话凭证按匿名处理,**不会**返回 401 **路径参数**: -| 参数 | 类型 | 说明 | -|---|---|---| +| 参数 | 类型 | 说明 | +| ---------- | ---- | ------- | | `cloud_id` | uuid | 云图 ID | **成功响应**:`200`,单个 CloudInfo 对象(字段见 1.5),结构同 5.1 的数组元素。 **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | `cloud_id` 不是合法 uuid | zod 形状(2.1) | 检查链接 | -| 404 | 云图不存在,**或**对当前用户不可见(待审核/已驳回/已隐藏) | `未找到图片` | 统一提示「云图不存在或不可见」——服务端故意不区分,防止泄露私有云图的存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ---------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------ | +| 400 | `cloud_id` 不是合法 uuid | zod 形状(2.1) | 检查链接 | +| 404 | 云图不存在,**或**对当前用户不可见(待审核/已驳回/已隐藏) | `未找到图片` | 统一提示「云图不存在或不可见」——服务端故意不区分,防止泄露私有云图的存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | ### 5.4 `GET /cloud/type/:cloud_type_id` — 按云类型列出云图 @@ -629,9 +413,9 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **路径参数**: -| 参数 | 类型 | 说明 | -|---|---|---| -| `cloud_type_id` | int | 云类型 ID(非数字返回 400) | +| 参数 | 类型 | 说明 | +| --------------- | ---- | --------------------------- | +| `cloud_type_id` | int | 云类型 ID(非数字返回 400) | **Query 参数**:分页参数,见 1.4。 @@ -639,13 +423,13 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败 | zod 形状(2.1) | 检查参数 | -| 404 | 云类型不存在 | `云类型不存在` | 提示类型不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ------------ | -------------------- | -------------- | +| 400 | 参数校验失败 | zod 形状(2.1) | 检查参数 | +| 404 | 云类型不存在 | `云类型不存在` | 提示类型不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | -### 5.5 `POST /cloud/:cloud_id/like` — 点赞 +### 5.5 `PUT /cloud/:cloud_id/like` — 点赞 - **认证**:需登录(任意角色) - **请求体**:无 @@ -661,13 +445,13 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | -| 404 | 云图不存在,或不是公开可见状态 | `图片不存在` | 提示云图不可点赞;与「不存在」共用是设计意图 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ------------------------------ | -------------------- | -------------------------------------------- | +| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | +| 404 | 云图不存在,或不是公开可见状态 | `图片不存在` | 提示云图不可点赞;与「不存在」共用是设计意图 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | -**注意事项**:**幂等**——重复点赞返回相同的 200,点赞数不会增加。前端无需在点击前查询是否已赞,但仍建议本地置灰防止连点。 +**注意事项**:**幂等**——重复点赞返回相同的 200,点赞数不会增加。旧 `POST /cloud/:cloud_id/like` 已移除;前端改用 `PUT`。前端无需在点击前查询是否已赞,但仍建议本地置灰防止连点。 ### 5.6 `DELETE /cloud/:cloud_id/like` — 取消点赞 @@ -685,10 +469,10 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ------------------- | -------------------- | ------------ | +| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:**完全幂等**——从未点赞、云图不存在,同样返回 200。没有 404 分支。 @@ -699,15 +483,15 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **表单字段**(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 | +| 字段 | 类型 | 必填 | 约束 | 说明 | +| ------------- | ----------- | ------ | ------------------------------------------- | ---------------------------------------- | +| `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` @@ -721,18 +505,18 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 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 | 服务端异常(含存储/数据库失败) | `服务器发生内部错误` | 稍后重试;服务端已做失败补偿清理,可安全重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | --------------------------------------------------- | ------------------------------- | -------------------------------------------- | +| 400 | 图片为空文件 | `图片为空` | 提示重新选择 | +| 400 | 图片超过 20 MB | `图片大小超过 20MB 限制` | 提示压缩 | +| 400 | 格式不支持 | `仅支持 JPEG、PNG、WebP 格式` | 提示转换格式 | +| 400 | 动图 | `不支持动图` | 提示使用静态图 | +| 400 | 分辨率超上限 | `图片分辨率超过限制` | 提示缩小尺寸 | +| 400 | 其他图片处理失败 | `压缩参数无效` / `图片处理失败` | 提示换图重试 | +| 400 | 表单字段校验失败(如 `is_hidden` 编码非法、缺字段) | zod 形状(2.1) | 检查表单 | +| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | +| 413 | 请求体超过 21 MiB | `上传文件过大` | 提示压缩图片 | +| 500 | 服务端异常(含存储/数据库失败) | `服务器发生内部错误` | 稍后重试;服务端已做失败补偿清理,可安全重试 | **注意事项**: @@ -747,14 +531,14 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **请求体**(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 布尔值) | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ------------- | ----------- | ---- | ---------- | ------------------------------- | +| `latitude` | number | 否 | -90 ~ 90 | 纬度 | +| `longitude` | number | 否 | -180 ~ 180 | 经度 | +| `type_id` | int | 否 | 正整数 | ⚠️ 不校验存在性,非法值导致 500 | +| `description` | string | 否 | ≤ 128 字符 | 描述 | +| `captured_at` | date string | 否 | 合法日期 | 拍摄时间 | +| `is_hidden` | boolean | 否 | — | 是否隐藏(JSON 布尔值) | ```json { @@ -767,13 +551,13 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 未提供任何修改字段 | zod 形状,issues 中含 `至少需要提供一个修改字段` | 表单前置校验 | -| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | -| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | -| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 | -| 500 | 服务端异常(含 `type_id` 不存在) | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | --------------------------------- | ------------------------------------------------ | -------------- | +| 400 | 未提供任何修改字段 | zod 形状,issues 中含 `至少需要提供一个修改字段` | 表单前置校验 | +| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | +| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | +| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 | +| 500 | 服务端异常(含 `type_id` 不存在) | `服务器发生内部错误` | 稍后重试 | ### 5.9 `DELETE /cloud/:cloud_id` — 删除云图 @@ -783,11 +567,11 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | -| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | -------------------------- | -------------------- | -------------- | +| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | +| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | ### 5.10 `DELETE /cloud` — 批量删除云图 @@ -795,9 +579,9 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **请求体**(JSON): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `cloud_ids` | array | 是 | 1–100 个元素,不允许重复 | 元素为 `{ "cloud_id": "" }` 对象 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ----------- | ----- | ---- | ------------------------ | -------------------------------------- | +| `cloud_ids` | array | 是 | 1–100 个元素,不允许重复 | 元素为 `{ "cloud_id": "" }` 对象 | ```json { @@ -819,11 +603,11 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | ID 重复 / 数量超出 1–100 / 格式非法 | zod 形状,ID 重复时 issues 含 `图片 ID 不能重复` | 前端去重后提交 | -| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ----------------------------------- | ------------------------------------------------ | -------------- | +| 400 | ID 重复 / 数量超出 1–100 / 格式非法 | zod 形状,ID 重复时 issues 含 `图片 ID 不能重复` | 前端去重后提交 | +| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:**部分成功语义**——只删除属于当前用户的云图,他人或不存在的 ID 被静默跳过,消息中的 `N` 是实际删除数(可能小于请求数,甚至为 0)。前端应以 `N` 为准刷新列表,而不是假设全部删除成功。 @@ -835,10 +619,10 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **路径参数**: -| 参数 | 类型 | 说明 | -|---|---|---| -| `cloud_id` | uuid | Cloud ID,由上传响应、列表或详情接口取得 | -| `variant` | enum | `preview`(最大 640×640 WebP)或 `original`(上传原图) | +| 参数 | 类型 | 说明 | +| ---------- | ---- | ------------------------------------------------------- | +| `cloud_id` | uuid | Cloud ID,由上传响应、列表或详情接口取得 | +| `variant` | enum | `preview`(最大 640×640 WebP)或 `original`(上传原图) | 两种图片变体采用相同权限规则。示例: @@ -855,23 +639,23 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **缓存**: -| 调用方式 | `Cache-Control` | -|---|---| -| 匿名读取具备 PublicVisibility 的 Cloud | `public, no-cache` | -| 任何有效 Cookie/Bearer 请求 | `private, no-store` | +| 调用方式 | `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 异常 | 稍后重试 | +| 状态码 | 触发条件 | 前端建议动作 | +| ------ | ------------------------------------------------------- | ------------------------------ | +| 400 | Cloud ID 或 ImageVariant 非法 | 检查 URL | +| 401 | 显式提供的 Better Auth 会话无效或过期 | 清除本地登录态 | +| 404 | Cloud 不存在、当前用户不可见,或对应 MinIO 对象不存在 | 统一显示占位图,不推断具体原因 | +| 405 | 使用 GET/HEAD 之外的业务方法;响应含 `Allow: GET, HEAD` | 修正请求方法 | +| 500 | 数据库或应用内部错误 | 稍后重试 | +| 503 | MinIO 暂时不可用或对象 metadata 异常 | 稍后重试 | 具备 PublicVisibility 的 Cloud 允许被其他网站嵌入;流量限制由部署网关/CDN/WAF 负责。端点不支持 Range 请求。CORS 预检 `OPTIONS` 是协议级例外,由全局中间件返回 204,并只声明 `GET, HEAD`。 @@ -879,7 +663,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose ## 6. 端点 · 个人资料 `/profile` -本章所有端点**都需要登录**(任意角色)。未登录统一返回 401(见 3.5),下文错误表不再重复列出。 +本章所有端点**都需要登录**(任意角色)。未登录统一返回 401(见 3.3),下文错误表不再重复列出。 ### 6.1 `GET /profile/me` — 我的资料 @@ -892,7 +676,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose "id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", "name": "cloudwatcher", "email": "watcher@example.com", - "avatar_id": null, + "image": "https://example.com/avatar.png", "cloud_count": 7, "received_like_count": 42, "last_online": "2026-08-15T08:30:00.000Z", @@ -904,9 +688,9 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ---------------------------------------- | ---------------------- | ---------------------- | +| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | ### 6.2 `PATCH /profile/me` — 修改用户名 @@ -914,9 +698,9 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **请求体**(JSON): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `user_name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(超长返回 500) | 新用户名 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ----------- | ------ | ---- | --------------------------------------------------- | -------- | +| `user_name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(超长返回 500) | 新用户名 | ```json { @@ -928,21 +712,22 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | -| 409 | 用户名已被占用(数据库唯一约束,大小写敏感) | `用户名已被占用` | 提示换用户名 | -| 500 | 服务端异常(含用户名超长) | `服务器发生内部错误` | 前端应限制 ≤ 16 字符避免误报 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | -------------------------------------------- | -------------------- | ---------------------------- | +| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | +| 409 | 用户名已被占用(数据库唯一约束,大小写敏感) | `用户名已被占用` | 提示换用户名 | +| 500 | 服务端异常(含用户名超长) | `服务器发生内部错误` | 前端应限制 ≤ 16 字符避免误报 | ### 6.3 `GET /profile/me/likes` — 我点赞过的云图 - **认证**:需登录 +- **Query 参数**:分页参数 `page`、`page_size`,见 1.4 -**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按点赞时间倒序。 +**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按点赞时间倒序、同刻按云图 ID 倒序;默认返回第 1 页的 50 条,最多每页 100 条。超出范围的页返回 `[]`。 **注意事项**:包含**所有状态**的云图(含待审核/已驳回/已隐藏的),因为这些是你自己点过赞的——展示时如需隐藏非公开项请自行过滤 `status` 与 `is_hidden`。 -**错误**:500 `服务器发生内部错误`。 +**错误**:分页参数非法时返回 400(见 2.1);服务端异常返回 500 `服务器发生内部错误`。 ### 6.4 `GET /profile/me/clouds` — 我上传的云图 @@ -962,30 +747,15 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | `user_id` 不是合法 uuid | zod 形状(2.1) | 检查链接 | -| 404 | 用户不存在 | `该用户不存在` | 提示用户不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ----------------------- | -------------------- | -------------- | +| 400 | `user_id` 不是合法 uuid | zod 形状(2.1) | 检查链接 | +| 404 | 用户不存在 | `该用户不存在` | 提示用户不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:⚠️ 响应包含对方 `email`(2.3 第 7 条)。展示他人资料页时不应展示邮箱字段。 -### 6.6 `GET /profile/:user_id/likes` — 查看用户点赞记录 - -- **认证**:需登录,且为**本人或 admin** - -**路径参数**:`user_id`(uuid)。 - -**成功响应**:`200`,CloudInfo 数组,按点赞时间倒序,结构同 5.1。 - -**错误**: - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 403 | 查看他人且非 admin | `无权查看该用户的点赞记录` | 前端应在进入页面前判断身份,避免触发 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | - -### 6.7 `GET /profile/:user_id/clouds` — 查看用户上传的云图 +### 6.6 `GET /profile/:user_id/clouds` — 查看用户上传的云图 - **认证**:需登录,且为**本人或 admin** @@ -995,10 +765,10 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 403 | 查看他人且非 admin | `无权查看该用户的图片` | 同上,前置判断 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ------------------ | ---------------------- | -------------- | +| 403 | 查看他人且非 admin | `无权查看该用户的图片` | 同上,前置判断 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:想查看某个用户的**公开**云图,应使用 `GET /cloud?filter=owner&value=<用户名>`(5.1)而非本端点——本端点是管理/个人视角,返回含非公开状态的完整列表。 @@ -1040,11 +810,11 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | `cloud_type_id` 非数字 | zod 形状(2.1) | 检查链接 | -| 404 | 云类型不存在 | `未找到` | 提示类型不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ---------------------- | -------------------- | -------------- | +| 400 | `cloud_type_id` 非数字 | zod 形状(2.1) | 检查链接 | +| 404 | 云类型不存在 | `未找到` | 提示类型不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | --- @@ -1064,9 +834,9 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ---------------------------------------- | ---------------------- | ---------------------- | +| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | **注意事项**:**无分页参数**,一次返回全部用户。用户量大时注意性能。 @@ -1078,9 +848,9 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **请求体**(JSON): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `user_role` | enum | 是 | `user` / `admin` | 目标角色 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ----------- | ---- | ---- | ---------------- | -------- | +| `user_role` | enum | 是 | `user` / `admin` | 目标角色 | ```json { @@ -1099,13 +869,14 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败(`user_role` 非法 / `user_id` 非 uuid) | zod 形状(2.1) | 检查参数 | -| 404 | 用户不存在 | `用户不存在` | 提示用户不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ---------------------------------------------------- | -------------------------- | ------------------ | +| 400 | 参数校验失败(`user_role` 非法 / `user_id` 非 uuid) | zod 形状(2.1) | 检查参数 | +| 400 | 管理员尝试撤销自己的 admin 角色 | `不能撤销自己的管理员角色` | 保持当前管理员角色 | +| 404 | 用户不存在 | `用户不存在` | 提示用户不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | -**注意事项**:修改角色会同时递增 `tokenVersion`,**该用户所有已签发 token 立即失效,必须重新登录**(见 3.4)。前端改完角色后可提示目标用户重新登录。 +**注意事项**:修改角色会撤销目标用户的全部 Better Auth 会话,必须重新登录。前端改完角色后可提示目标用户重新登录。 ### 8.4 `PATCH /admin/users/:user_id/password` — 设置用户密码 @@ -1115,9 +886,9 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **请求体**(JSON): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `new_password` | string | 是 | 无强度校验 | 新密码;管理员直接覆盖,无需旧密码 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| -------------- | ------ | ---- | ----------- | ---------------------------------- | +| `new_password` | string | 是 | 至少 8 字符 | 新密码;管理员直接覆盖,无需旧密码 | ```json { @@ -1136,53 +907,121 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败(`user_id` 非 uuid) | zod 形状(2.1) | 检查参数 | -| 404 | 用户不存在(含凭据记录缺失) | `用户不存在` | 提示用户不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | --------------------------------------------------- | -------------------- | -------------- | +| 400 | 参数校验失败(`user_id` 非 uuid 或密码少于 8 字符) | zod 形状(2.1) | 检查参数 | +| 404 | 用户不存在(含凭据记录缺失) | `用户不存在` | 提示用户不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | -**注意事项**:成功后同样递增 `tokenVersion`,该用户所有已签发 token 立即失效,必须重新登录(见 3.4)。 +**注意事项**:成功后会撤销目标用户的全部 Better Auth 会话,必须重新登录。 ### 8.5 `POST /admin/user` — 创建用户 -> ⚠️ **假端点**(2.3 第 4 条):通过 zod 校验后**原样回显请求体(含明文密码)**,不创建任何用户。响应为 `200 { name, email, password, role }`。请勿接入,更不要在任何持久化日志中记录其响应。 +管理员认证后提交 `{ "name": "Alice", "email": "alice@example.com", "password": "password123", "role": "user" }`。通过 Better Auth Admin 插件创建用户和密码凭据,再尝试发送邮箱验证邮件。成功返回 `201`,包含 `status_code`、`message`、`user_id`、`email_sent`;用户名或邮箱冲突返回 `409`。密码至少 8 字符。 -### 8.6 `GET /admin/clouds` — 全状态云图列表 - -- **认证**:admin;可见任意状态与隐藏的云图 - -**Query 参数**(在分页参数 1.4 基础上): - -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `status` | enum | 否 | `pending` / `approved` / `rejected` | 按审核状态过滤;不传返回全部状态 | - -**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按上传时间倒序。 - -**错误**: - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | - -### 8.7 `POST /admin/clouds/review` — 批量审核云图 +### 8.6 `POST /admin/cloudtypes` — 添加云类型 - **认证**:admin **请求体**(JSON): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `cloud_ids` | array | 是 | 1–100 个元素,不允许重复 | 元素为 `{ "cloud_id": "" }` 对象 | -| `status` | enum | 是 | `approved` / `rejected` | 目标审核状态;**不支持打回 `pending`** | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ------------- | ------------ | ---- | ------------------ | ----------------------------- | +| `name` | string | 是 | 去除首尾空白后非空 | 云类型名称 | +| `genus` | string\|null | 否 | 默认 `null` | 属;传 `null` 表示未设置 | +| `icon_id` | uuid\|null | 否 | 默认 `null` | 图标 ID;传 `null` 表示未设置 | +| `rarity` | int | 是 | — | 稀有度 | +| `description` | string | 是 | 最长 128 字符 | 描述,可为空字符串 | ```json { - "cloud_ids": [ - { "cloud_id": "3f8a2c1e-7b9d-4e5f-a1c2-9d8e7f6a5b4c" } - ], + "name": "积云", + "genus": "Cumulus", + "icon_id": null, + "rarity": 1, + "description": "底部平坦、顶部蓬松的白色云块" +} +``` + +**成功响应**:`201` + +```json +{ + "status_code": 201, + "message": "云类型添加成功", + "cloud_type_id": 12 +} +``` + +云类型 ID 由服务端生成;并发添加不会分配重复 ID。参数校验失败返回 400,数据库异常返回 500。 + +### 8.7 `PATCH /admin/cloudtypes/:cloud_type_id` — 修改云类型 + +- **认证**:admin + +**路径参数**:`cloud_type_id`(int)。请求体字段与 8.6 相同,但均为可选;至少须提供一个字段。仅提交的字段会被修改,`genus` 和 `icon_id` 可显式传 `null` 清空。 + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "云类型修改成功" +} +``` + +**错误**:参数或空请求体无效返回 400;云类型不存在返回 404 `云类型不存在`;数据库异常返回 500。 + +### 8.8 `DELETE /admin/cloudtypes/:cloud_type_id` — 删除云类型 + +- **认证**:admin + +**路径参数**:`cloud_type_id`(int)。 + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "云类型删除成功" +} +``` + +**错误**:参数无效返回 400;云类型不存在返回 404 `云类型不存在`;仍有云图使用该类型时返回 409 `云类型仍被云图使用,无法删除`;其他数据库异常返回 500。 + +### 8.9 `GET /admin/clouds` — 全状态云图列表 + +- **认证**:admin;可见任意状态与隐藏的云图 + +**Query 参数**(在分页参数 1.4 基础上): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| -------- | ---- | ---- | ----------------------------------- | -------------------------------- | +| `status` | enum | 否 | `pending` / `approved` / `rejected` | 按审核状态过滤;不传返回全部状态 | + +**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按上传时间倒序。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ----------------------- | -------------------- | ------------ | +| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +### 8.10 `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" } ``` @@ -1198,14 +1037,14 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | ID 重复 / `status` 非法 / 数量超限 | zod 形状(2.1) | 检查请求 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ---------------------------------- | -------------------- | ------------ | +| 400 | ID 重复 / `status` 非法 / 数量超限 | zod 形状(2.1) | 检查请求 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:**部分成功语义**——消息中的 `N` 只统计状态**实际发生变化**的云图;已是目标状态的与不存在的一律静默跳过。审核只改变审核状态,不影响 `is_hidden` 与点赞数据。`pending ↔ approved/rejected`、`approved ↔ rejected` 均可。 -### 8.8 `DELETE /admin/clouds/:cloud_id` — 删除云图 +### 8.11 `DELETE /admin/clouds/:cloud_id` — 删除云图 - **认证**:admin @@ -1222,14 +1061,24 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | `cloud_id` 非 uuid | zod 形状(2.1) | 检查链接 | -| 404 | 云图不存在 | `图片不存在` | 提示云图不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ------------------ | -------------------- | -------------- | +| 400 | `cloud_id` 非 uuid | zod 形状(2.1) | 检查链接 | +| 404 | 云图不存在 | `图片不存在` | 提示云图不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:管理员删除不受审核状态或隐藏状态限制。删除会级联删除该云图的点赞记录,并尽力清理 MinIO 中的图片对象;清理失败不报错,可能残留孤儿对象(可接受)。删除后前端应立即刷新相关列表。 +### 8.12 `GET /admin/users/:user_id/likes` — 查看用户点赞记录 + +- **认证**:admin +- **路径参数**:`user_id`(uuid) +- **Query 参数**:分页参数 `page`、`page_size`,见 1.4 + +**成功响应**:`200`,与 6.3 相同的 CloudInfo 数组和排序,包含该用户已点赞但后来被隐藏或改判的云图;没有点赞或页码超出范围时返回 `[]`。 + +**错误**:`user_id` 或分页参数非法时返回 400(见 2.1);用户不存在时返回 404 `{ "status_code": 404, "error": "用户不存在" }`;服务端异常返回 500 `服务器发生内部错误`。非管理员统一返回 403(本章规则)。旧 `GET /profile/:user_id/likes` 已移除;本人使用 6.3,管理员使用本端点。 + --- ## 9. 端点 · 系统 diff --git a/README.md b/README.md index 578c93a..eab26f0 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,29 @@ npm run dev ## 检查与构建 ```bash -npx vue-tsc -b +npm run check npm run build ``` + +`check` 依次执行 ESLint、Prettier 格式检查、项目样式检查和 TypeScript 类型检查。也可以单独运行: + +```bash +npm run lint # 检查 JS、TS 和 Vue 代码,警告也会导致失败 +npm run lint:fix # 自动修复可修复的代码问题 +npm run format:check # 检查格式,不修改文件 +npm run format # 格式化源码、配置和文档 +npm run check:styles # 检查项目视觉规范 +npm run typecheck # Vue / TypeScript 类型检查 +``` + +ESLint 使用 flat config,启用 JavaScript、TypeScript 和 Vue 推荐规则;Prettier 负责格式,使用 4 个空格缩进、单引号和无分号风格。通过 `eslint-config-prettier` 关闭冲突的格式规则,构建产物、依赖、环境文件和工具管理的目录不参与格式化。 + +VS Code 用户可安装工作区推荐的 Vue、ESLint、Prettier 扩展,保存时自动格式化并执行 ESLint 修复。 + +配置参考:[Vue ESLint 官方指南](https://eslint.vuejs.org/user-guide/)、[typescript-eslint 入门](https://typescript-eslint.io/getting-started/)、[Prettier 安装指南](https://prettier.io/docs/install)。 + +## 项目结构 + +业务代码按功能集中在 `src/features/`:页面、组件、store 和 composable 放入各自所属模块。跨业务的地图控件集中在 `src/shared/map/`,应用导航位于 `src/components/layout/`,API 客户端、全局样式和公共类型保留独立目录。 + +新增或移动文件时,参见[目录结构与依赖约定](docs/project-structure.md)。 diff --git a/docs/project-structure.md b/docs/project-structure.md new file mode 100644 index 0000000..8160ccc --- /dev/null +++ b/docs/project-structure.md @@ -0,0 +1,80 @@ +# 目录结构与依赖约定 + +按业务功能组织文件,使一次功能修改涉及的页面、组件和状态尽量集中。文件迁移只改变源码路径,接口请求、DTO、store 导出与 ID、组件 props/events、路由 URL 和页面行为保持不变。 + +## 目录职责 + +```text +src/ +├── main.ts # 初始化 Pinia、认证和路由 +├── App.vue # 全局 provider 与应用布局 +├── router/index.ts # 路由、懒加载、权限守卫与 SEO +├── features/ +│ ├── auth/ +│ │ ├── authClient.ts # 认证客户端与错误适配 +│ │ ├── stores/auth.ts +│ │ └── views/ # 登录、注册、邮箱验证、密码重置 +│ ├── clouds/ +│ │ ├── cloudTypes.ts # 云型常量与名称查询 +│ │ ├── components/ # 云图详情、编辑、点赞 +│ │ └── stores/ # clouds.ts、likes.ts +│ ├── upload/ +│ │ ├── components/QuickUploadModal.vue +│ │ ├── composables/useUpload.ts +│ │ └── views/UploadView.vue +│ ├── profile/ +│ │ ├── components/ContributionHeatmap.vue +│ │ ├── stores/profile.ts +│ │ └── views/ # 个人主页与账号设置 +│ ├── encyclopedia/ +│ │ ├── stores/encyclopedia.ts +│ │ └── views/ # 图鉴列表与云型详情 +│ └── {map,gallery,community,admin,system}/views/ +├── shared/map/ +│ ├── amap.ts # 高德加载器 +│ ├── types/amap.d.ts # 高德类型声明,与同名加载器分开放置 +│ └── components/ # MapPickerModal、MiniLocationMap +├── components/layout/ # 应用外壳:AppHeader +├── lib/ # api.ts、seo.ts、theme.ts +├── types/ # 公共 DTO、领域类型、路由元数据 +├── 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` | + +按职责归属文件:快捷上传虽然从地图打开,仍属于上传模块;云图点赞虽然在多个页面使用,仍属于云图业务;选点和小地图仅提供位置能力,放在公共地图模块。 + +## 依赖与新增文件 + +- 功能模块按需创建 `views/`、`components/`、`stores/`、`composables/`;只有页面的模块保留 `views/` 即可。 +- 业务组件放在所属功能模块。`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/` 统一提供。 +- 现有 `clouds` 与 `encyclopedia` store 保留各自的缓存行为;后续如需合并缓存,应作为独立行为变更验证。 +- 共享视觉规则遵循[样式体系](style-system.md),组件专属样式随组件迁移。 + +例如,新增上传进度组件放到 `features/upload/components/`;新增通用地图控件放到 `shared/map/components/`;新增个人主页统计 composable 放到 `features/profile/composables/`。 + +## 迁移验证 + +移动文件后更新静态 import、路由动态 import 和文档引用,并运行: + +```bash +npm run check +npm run build +git diff --check +``` diff --git a/docs/style-system.md b/docs/style-system.md index 0270cc8..fc6c881 100644 --- a/docs/style-system.md +++ b/docs/style-system.md @@ -4,14 +4,14 @@ ## 分层 -| 层级 | 文件 | 职责 | -| --- | --- | --- | -| 设计令牌 | `src/styles/tokens.css` | 颜色、字体、渐变、阴影、动效时长等稳定决策 | -| 基础规则 | `src/styles/base.css` | 页面基线、选中文本、全局圆角策略 | -| 组件规则 | `src/styles/components.css` | 页面 Hero、容器、表单、面板、按钮等共享语义类 | -| 第三方主题 | `src/lib/theme.ts` | Naive UI 的全局主题契约;值应与设计令牌保持一致 | -| 单组件样式 | Vue 文件的 ` diff --git a/src/components/cloud/MapPickerModal.vue b/src/components/cloud/MapPickerModal.vue deleted file mode 100644 index 277bfa4..0000000 --- a/src/components/cloud/MapPickerModal.vue +++ /dev/null @@ -1,219 +0,0 @@ - - - diff --git a/src/components/cloud/MiniLocationMap.vue b/src/components/cloud/MiniLocationMap.vue deleted file mode 100644 index 06e4d34..0000000 --- a/src/components/cloud/MiniLocationMap.vue +++ /dev/null @@ -1,123 +0,0 @@ - - - diff --git a/src/components/cloud/QuickUploadModal.vue b/src/components/cloud/QuickUploadModal.vue deleted file mode 100644 index f4249d6..0000000 --- a/src/components/cloud/QuickUploadModal.vue +++ /dev/null @@ -1,384 +0,0 @@ - - - diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue index 673bc55..87dc82b 100644 --- a/src/components/layout/AppHeader.vue +++ b/src/components/layout/AppHeader.vue @@ -1,13 +1,14 @@ - - - - - - - - - - -
- -
-
- + + 展开导航 + + + + diff --git a/src/components/profile/ContributionHeatmap.vue b/src/components/profile/ContributionHeatmap.vue deleted file mode 100644 index 838a888..0000000 --- a/src/components/profile/ContributionHeatmap.vue +++ /dev/null @@ -1,451 +0,0 @@ - - - - - diff --git a/src/composables/useUpload.ts b/src/composables/useUpload.ts deleted file mode 100644 index ecb83bc..0000000 --- a/src/composables/useUpload.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { ref } from 'vue' -import { apiRequest } from '@/lib/api' -import { useAuthStore } from '@/stores/auth' -import { useProfileStore } from '@/stores/profile' -import type { CloudCreateResponse } from '@/types/api' - -export interface UploadItem { - id: string - file: File - preview: string - cloudCategoryId: number | null - description: string - isHidden: boolean - latitude: number | null - longitude: number | null - capturedAt: string - errors: Record -} - -export interface UploadResult { - ok: boolean - errorMessage?: string -} - -let nextId = 0 - -function readFileAsArrayBuffer(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result as ArrayBuffer) - reader.onerror = reject - reader.readAsArrayBuffer(file) - }) -} - -function extractExifDate(buffer: ArrayBuffer): string | null { - const view = new DataView(buffer) - if (view.byteLength < 4 || view.getUint16(0, false) !== 0xffd8) return null - - let offset = 2 - while (offset < view.byteLength - 2) { - const marker = view.getUint16(offset, false) - offset += 2 - if ((marker & 0xff00) !== 0xff00) break - const length = view.getUint16(offset, false) - offset += 2 - - if (marker === 0xffe1) { - const exifStart = offset - const header = String.fromCharCode( - view.getUint8(exifStart), - view.getUint8(exifStart + 1), - view.getUint8(exifStart + 2), - view.getUint8(exifStart + 3), - ) - if (header !== 'Exif') break - - const tiffStart = exifStart + 6 - const isLE = view.getUint16(tiffStart, false) === 0x4949 - const ifdStart = tiffStart + view.getUint32(tiffStart + 4, isLE) - const numEntries = view.getUint16(ifdStart, isLE) - - for (let i = 0; i < numEntries; i++) { - const entryStart = ifdStart + 2 + i * 12 - if (entryStart + 12 > buffer.byteLength) break - if (view.getUint16(entryStart, isLE) !== 0x9003) continue - - const dataOffset = view.getUint32(entryStart + 8, isLE) - const dateStr = Array.from({ length: 20 }, (_, j) => - String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)), - ).join('') - const date = new Date(dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')) - if (!isNaN(date.getTime())) return date.toISOString() - } - } - offset += length - 2 - } - 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() - const items = ref([]) - const uploading = ref(false) - const overallProgress = ref(0) - const currentItemIndex = ref(0) - const totalItems = ref(0) - - async function addFiles(files: File[]) { - const imageFiles = files.filter(file => ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) - for (const file of imageFiles) { - let capturedAt = '' - try { - capturedAt = extractExifDate(await readFileAsArrayBuffer(file)) || '' - } catch { - capturedAt = '' - } - - items.value.push({ - id: String(++nextId), - file, - preview: URL.createObjectURL(file), - cloudCategoryId: null, - description: '', - isHidden: false, - latitude: null, - longitude: null, - capturedAt, - errors: {}, - }) - } - } - - function removeItem(id: string) { - const index = items.value.findIndex(item => item.id === id) - if (index === -1) return - URL.revokeObjectURL(items.value[index].preview) - items.value.splice(index, 1) - } - - function clearAll() { - items.value.forEach(item => URL.revokeObjectURL(item.preview)) - items.value = [] - } - - function validateItem(item: UploadItem) { - const errors: Record = {} - if (!item.cloudCategoryId) 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.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 - } - - function validateAll() { - return items.value.map(validateItem).every(Boolean) - } - - async function uploadAll(): Promise { - if (!validateAll() || !authStore.user) return { ok: false } - - uploading.value = true - totalItems.value = items.value.length - currentItemIndex.value = 0 - overallProgress.value = 0 - - try { - for (let index = 0; index < items.value.length; index++) { - const item = items.value[index] - 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('/cloud', { - method: 'POST', - body: form, - }) - overallProgress.value = Math.round((index + 1) / items.value.length * 100) - } - - clearAll() - profileStore.invalidateUser(authStore.user.id) - return { ok: true } - } catch (error) { - return { - ok: false, - errorMessage: error instanceof Error ? error.message : '上传失败,请稍后重试', - } - } finally { - uploading.value = false - } - } - - return { - items, - uploading, - overallProgress, - currentItemIndex, - totalItems, - addFiles, - removeItem, - clearAll, - validateItem, - validateAll, - uploadAll, - } -} diff --git a/src/features/admin/views/AdminView.vue b/src/features/admin/views/AdminView.vue new file mode 100644 index 0000000..c003bab --- /dev/null +++ b/src/features/admin/views/AdminView.vue @@ -0,0 +1,2370 @@ + + + + + diff --git a/src/features/auth/authClient.ts b/src/features/auth/authClient.ts new file mode 100644 index 0000000..487a839 --- /dev/null +++ b/src/features/auth/authClient.ts @@ -0,0 +1,35 @@ +import { createAuthClient } from 'better-auth/client' +import { API_URL } from '@/lib/api' + +export const authClient = createAuthClient({ + baseURL: `${API_URL}/auth`, + fetchOptions: { credentials: 'include' }, +}) + +interface AuthClientFailure { + code?: string + message?: string + status?: number +} + +export class AuthClientError extends Error { + code?: string + status?: number + + constructor(failure: AuthClientFailure, fallback: string) { + super( + failure.code === 'EMAIL_NOT_VERIFIED' + ? '邮箱尚未验证,请先查收验证邮件。' + : failure.status === 429 + ? '操作过于频繁,请稍后重试。' + : fallback, + ) + this.name = 'AuthClientError' + this.code = failure.code + this.status = failure.status + } +} + +export function assertAuthResult(result: { error?: AuthClientFailure | null }, fallback: string) { + if (result.error) throw new AuthClientError(result.error, fallback) +} diff --git a/src/features/auth/stores/auth.ts b/src/features/auth/stores/auth.ts new file mode 100644 index 0000000..eaec592 --- /dev/null +++ b/src/features/auth/stores/auth.ts @@ -0,0 +1,155 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api' +import { assertAuthResult, authClient } from '@/features/auth/authClient' +import type { BackendUserProfile } from '@/types/api' +import type { Profile } from '@/types/database' + +export const useAuthStore = defineStore('auth', () => { + const user = ref | null>(null) + const profile = ref(null) + const loading = ref(true) + let listenerAttached = false + + const isLoggedIn = computed(() => !!user.value) + const isAdmin = computed(() => profile.value?.role === 'admin') + + function applyProfile(backendProfile: BackendUserProfile) { + const normalized = toAuthUser(backendProfile) + user.value = normalized + profile.value = { + id: normalized.id, + username: normalized.username, + image: normalized.image, + role: normalized.role, + is_disabled: normalized.is_disabled, + created_at: normalized.created_at, + } + } + + function clearAuth() { + user.value = null + profile.value = null + } + + async function fetchMe() { + const backendProfile = await apiRequest('/profile/me') + applyProfile(backendProfile) + } + + async function login(email: string, password: string) { + assertAuthResult( + await authClient.signIn.email({ email, password }), + '登录失败,请检查邮箱和密码。', + ) + const session = await authClient.getSession() + assertAuthResult(session, '获取登录会话失败,请稍后重试。') + if (!session.data) throw new Error('未能建立登录会话,请稍后重试。') + await fetchMe() + } + + async function register(name: string, email: string, password: string) { + assertAuthResult( + await authClient.signUp.email({ name, email, password }), + '注册失败,请稍后重试。', + ) + } + + async function resendConfirmation(email: string) { + assertAuthResult( + await authClient.sendVerificationEmail({ email }), + '验证邮件发送失败,请稍后重试。', + ) + } + + async function confirmEmail(token: string) { + assertAuthResult( + await authClient.$fetch('/verify-email', { + method: 'GET', + query: { token }, + }), + '确认链接无效或已过期。', + ) + } + + async function sendPasswordReset(email: string) { + assertAuthResult( + await authClient.requestPasswordReset({ email }), + '重置邮件发送失败,请稍后重试。', + ) + } + + async function resetPassword(token: string, newPassword: string) { + assertAuthResult( + await authClient.resetPassword({ token, newPassword }), + '密码重置失败,请重新申请重置邮件。', + ) + clearAuth() + } + + async function logout() { + const result = await authClient.signOut() + if (result.error?.status === 401) { + clearAuth() + return + } + assertAuthResult(result, '退出登录失败,请稍后重试。') + clearAuth() + } + + async function updateUsername(username: string) { + const backendProfile = await apiRequest('/profile/me', { + method: 'PATCH', + body: { user_name: username }, + }) + applyProfile(backendProfile) + } + + async function updatePassword(currentPassword: string, newPassword: string) { + assertAuthResult( + await authClient.changePassword({ + currentPassword, + newPassword, + revokeOtherSessions: true, + }), + '密码更新失败,请检查当前密码。', + ) + } + + async function initialize() { + if (!listenerAttached) { + window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth) + listenerAttached = true + } + + try { + const session = await authClient.getSession() + assertAuthResult(session, '获取登录会话失败,请稍后重试。') + if (session.data) await fetchMe() + else clearAuth() + } catch { + clearAuth() + } finally { + loading.value = false + } + } + + return { + user, + profile, + loading, + isLoggedIn, + isAdmin, + login, + register, + resendConfirmation, + confirmEmail, + sendPasswordReset, + resetPassword, + logout, + updateUsername, + refreshProfile: fetchMe, + updatePassword, + initialize, + } +}) diff --git a/src/features/auth/views/AuthConfirmView.vue b/src/features/auth/views/AuthConfirmView.vue new file mode 100644 index 0000000..6dc2260 --- /dev/null +++ b/src/features/auth/views/AuthConfirmView.vue @@ -0,0 +1,98 @@ + + + diff --git a/src/features/auth/views/ForgotPasswordView.vue b/src/features/auth/views/ForgotPasswordView.vue new file mode 100644 index 0000000..e382444 --- /dev/null +++ b/src/features/auth/views/ForgotPasswordView.vue @@ -0,0 +1,148 @@ + + + diff --git a/src/features/auth/views/LoginView.vue b/src/features/auth/views/LoginView.vue new file mode 100644 index 0000000..8aae372 --- /dev/null +++ b/src/features/auth/views/LoginView.vue @@ -0,0 +1,165 @@ + + + diff --git a/src/features/auth/views/RegisterView.vue b/src/features/auth/views/RegisterView.vue new file mode 100644 index 0000000..938297e --- /dev/null +++ b/src/features/auth/views/RegisterView.vue @@ -0,0 +1,240 @@ + + + diff --git a/src/features/auth/views/ResendConfirmationView.vue b/src/features/auth/views/ResendConfirmationView.vue new file mode 100644 index 0000000..cd64fd1 --- /dev/null +++ b/src/features/auth/views/ResendConfirmationView.vue @@ -0,0 +1,139 @@ + + + diff --git a/src/features/auth/views/ResetPasswordView.vue b/src/features/auth/views/ResetPasswordView.vue new file mode 100644 index 0000000..6cb5e37 --- /dev/null +++ b/src/features/auth/views/ResetPasswordView.vue @@ -0,0 +1,153 @@ + + + diff --git a/src/features/clouds/cloudTypes.ts b/src/features/clouds/cloudTypes.ts new file mode 100644 index 0000000..e582a25 --- /dev/null +++ b/src/features/clouds/cloudTypes.ts @@ -0,0 +1,17 @@ +export const CLOUD_TYPES = [ + { 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[]) { + const cloudTypeId = Number(Array.isArray(id) ? id[0] : id) + return CLOUD_TYPES.find(item => item.id === cloudTypeId)?.name ?? '云型详情' +} diff --git a/src/features/clouds/components/CloudEditModal.vue b/src/features/clouds/components/CloudEditModal.vue new file mode 100644 index 0000000..9a77785 --- /dev/null +++ b/src/features/clouds/components/CloudEditModal.vue @@ -0,0 +1,265 @@ + + + diff --git a/src/features/clouds/components/CloudLikeButton.vue b/src/features/clouds/components/CloudLikeButton.vue new file mode 100644 index 0000000..6bf74cc --- /dev/null +++ b/src/features/clouds/components/CloudLikeButton.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/features/clouds/components/ImageDetailModal.vue b/src/features/clouds/components/ImageDetailModal.vue new file mode 100644 index 0000000..4e8084a --- /dev/null +++ b/src/features/clouds/components/ImageDetailModal.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/src/features/clouds/stores/clouds.ts b/src/features/clouds/stores/clouds.ts new file mode 100644 index 0000000..b6e02c1 --- /dev/null +++ b/src/features/clouds/stores/clouds.ts @@ -0,0 +1,27 @@ +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' + +export const useCloudsStore = defineStore('clouds', () => { + const cloudTypes = ref([]) + const loading = ref(false) + + async function fetchCloudTypes() { + if (cloudTypes.value.length > 0) return + loading.value = true + try { + const rows = await apiRequest('/info/cloudtype', { auth: false }) + cloudTypes.value = rows.map(toCloudType) + } finally { + loading.value = false + } + } + + function setCloudTypes(types: CloudType[]) { + cloudTypes.value = types + } + + return { cloudTypes, loading, fetchCloudTypes, setCloudTypes } +}) diff --git a/src/features/clouds/stores/likes.ts b/src/features/clouds/stores/likes.ts new file mode 100644 index 0000000..aaec463 --- /dev/null +++ b/src/features/clouds/stores/likes.ts @@ -0,0 +1,116 @@ +import { ref, watch } from 'vue' +import { defineStore } from 'pinia' +import { apiRequest } from '@/lib/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() + const likedIds = ref>(new Set()) + const counts = ref>({}) + const pendingIds = ref>(new Set()) + const loadedForUser = ref(null) + let loadPromise: Promise | null = null + let loadingForUser: string | null = null + + watch( + () => authStore.user?.id, + () => { + likedIds.value = new Set() + counts.value = {} + pendingIds.value = new Set() + loadedForUser.value = null + loadPromise = null + loadingForUser = null + }, + ) + + function isLiked(cloudId: string) { + return likedIds.value.has(cloudId) + } + + function countFor(cloudId: string, initialCount: number) { + return counts.value[cloudId] ?? initialCount + } + + function isPending(cloudId: string) { + return pendingIds.value.has(cloudId) + } + + async function ensureLoaded() { + const userId = authStore.user?.id + if (!userId || loadedForUser.value === userId) return + if (loadPromise && loadingForUser === userId) return loadPromise + + loadingForUser = userId + const request = (async () => { + const nextIds = new Set() + for (let page = 1; ; page++) { + const params = new URLSearchParams({ + page: String(page), + page_size: String(PAGE_SIZE), + }) + const rows = await apiRequest(`/profile/me/likes?${params}`) + rows.forEach(row => nextIds.add(row.id)) + if (rows.length < PAGE_SIZE) break + } + if (authStore.user?.id === userId) { + likedIds.value = nextIds + loadedForUser.value = userId + } + })() + loadPromise = request + try { + await request + } finally { + if (loadPromise === request) { + loadPromise = null + loadingForUser = null + } + } + } + + async function toggle(cloudId: string, initialCount: number) { + const userId = authStore.user?.id + if (!userId) throw new Error('请先登录后点赞。') + await ensureLoaded() + if (authStore.user?.id !== userId) throw new Error('登录状态已改变,请重试。') + if (isPending(cloudId)) return + + const wasLiked = isLiked(cloudId) + pendingIds.value = new Set(pendingIds.value).add(cloudId) + try { + await apiRequest(`/cloud/${cloudId}/like`, { + method: wasLiked ? 'DELETE' : 'PUT', + }) + if (authStore.user?.id !== userId) return + + const nextIds = new Set(likedIds.value) + if (wasLiked) nextIds.delete(cloudId) + else nextIds.add(cloudId) + likedIds.value = nextIds + counts.value[cloudId] = Math.max( + 0, + countFor(cloudId, initialCount) + (wasLiked ? -1 : 1), + ) + + try { + const cloud = await apiRequest(`/cloud/${cloudId}`, { + auth: false, + }) + counts.value[cloudId] = cloud.received_like_count + if (cloud.owner.id === authStore.user?.id) await authStore.refreshProfile() + } catch { + // The mutation succeeded; keep the local count if a refresh fails. + } + } finally { + const nextPending = new Set(pendingIds.value) + nextPending.delete(cloudId) + pendingIds.value = nextPending + } + } + + return { isLiked, countFor, isPending, ensureLoaded, toggle } +}) diff --git a/src/features/community/views/CommunityView.vue b/src/features/community/views/CommunityView.vue new file mode 100644 index 0000000..114d196 --- /dev/null +++ b/src/features/community/views/CommunityView.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/features/encyclopedia/stores/encyclopedia.ts b/src/features/encyclopedia/stores/encyclopedia.ts new file mode 100644 index 0000000..223a71a --- /dev/null +++ b/src/features/encyclopedia/stores/encyclopedia.ts @@ -0,0 +1,35 @@ +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' + +export const useEncyclopediaStore = defineStore('encyclopedia', () => { + const cloudTypes = ref([]) + const loadingCloudTypes = ref(false) + const cloudTypesLoaded = ref(false) + + async function fetchCloudTypes(force = false) { + if (cloudTypesLoaded.value && !force) return + loadingCloudTypes.value = true + try { + const rows = await apiRequest('/info/cloudtype', { auth: false }) + cloudTypes.value = rows.map(toCloudType) + cloudTypesLoaded.value = true + } finally { + loadingCloudTypes.value = false + } + } + + function setCloudTypes(types: CloudType[]) { + cloudTypes.value = types + cloudTypesLoaded.value = true + } + + return { + cloudTypes, + loadingCloudTypes, + fetchCloudTypes, + setCloudTypes, + } +}) diff --git a/src/features/encyclopedia/views/CloudTypeView.vue b/src/features/encyclopedia/views/CloudTypeView.vue new file mode 100644 index 0000000..3924eda --- /dev/null +++ b/src/features/encyclopedia/views/CloudTypeView.vue @@ -0,0 +1,256 @@ + + + diff --git a/src/features/encyclopedia/views/EncyclopediaView.vue b/src/features/encyclopedia/views/EncyclopediaView.vue new file mode 100644 index 0000000..7ac0c92 --- /dev/null +++ b/src/features/encyclopedia/views/EncyclopediaView.vue @@ -0,0 +1,133 @@ + + + diff --git a/src/features/gallery/views/GalleryView.vue b/src/features/gallery/views/GalleryView.vue new file mode 100644 index 0000000..11e03c3 --- /dev/null +++ b/src/features/gallery/views/GalleryView.vue @@ -0,0 +1,658 @@ + + + diff --git a/src/features/map/views/MapView.vue b/src/features/map/views/MapView.vue new file mode 100644 index 0000000..2d41326 --- /dev/null +++ b/src/features/map/views/MapView.vue @@ -0,0 +1,1031 @@ + + + + + diff --git a/src/features/profile/components/ContributionHeatmap.vue b/src/features/profile/components/ContributionHeatmap.vue new file mode 100644 index 0000000..0090348 --- /dev/null +++ b/src/features/profile/components/ContributionHeatmap.vue @@ -0,0 +1,468 @@ + + + + + diff --git a/src/features/profile/stores/profile.ts b/src/features/profile/stores/profile.ts new file mode 100644 index 0000000..f0e7d8a --- /dev/null +++ b/src/features/profile/stores/profile.ts @@ -0,0 +1,233 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { ApiError, apiRequest, toApiCloud } from '@/lib/api' +import { useAuthStore } from '@/features/auth/stores/auth' +import type { ActionResponse, ApiCloud, BackendCloudInfo, BackendUserProfile } from '@/types/api' +import type { Profile } from '@/types/database' + +export interface ProfileCloudItem { + id: string + cloud_type_id: number + image_url: string + thumbnail_url: string + latitude: number + longitude: number + description: string | null + captured_at: string | null + created_at: string + status: 'pending' | 'approved' | 'rejected' + is_hidden: boolean + cloudTypeName: string + cloudTypeRarity: number + receivedLikeCount: number +} + +function toProfileCloud(row: ApiCloud): ProfileCloudItem { + return { + id: row.id, + cloud_type_id: row.cloud_type_id as number, + image_url: row.image_url, + thumbnail_url: row.thumbnail_url as string, + latitude: row.latitude as number, + longitude: row.longitude as number, + 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 || '未知', + cloudTypeRarity: row.cloud_type_rarity_value ?? 0, + receivedLikeCount: row.received_like_count, + } +} + +export const useProfileStore = defineStore('profile-page', () => { + const authStore = useAuthStore() + const profilesByKey = ref>({}) + const cloudsByKey = ref>({}) + const loadingKeys = ref>({}) + const errorByKey = ref>({}) + + const makeKey = (identifier: string, isOwnProfile: boolean) => + `${identifier}:${isOwnProfile ? 'own' : 'public'}` + + function getProfile(identifier: string | null) { + return identifier ? (profilesByKey.value[identifier] ?? null) : null + } + + function getClouds(identifier: string | null, isOwnProfile: boolean) { + return identifier ? (cloudsByKey.value[makeKey(identifier, isOwnProfile)] ?? []) : [] + } + + function getError(identifier: string | null, isOwnProfile: boolean) { + return identifier ? (errorByKey.value[makeKey(identifier, isOwnProfile)] ?? '') : '' + } + + function isLoaded(identifier: string | null, isOwnProfile: boolean) { + 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(`/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 + + loadingKeys.value[key] = true + errorByKey.value[key] = '' + try { + 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 if (identifier === authStore.profile?.username && authStore.profile) { + profilesByKey.value[identifier] = authStore.profile + } else { + const owner = backendRows[0]?.owner + let publicProfile: BackendUserProfile | null = null + if (owner?.id && authStore.isLoggedIn) { + try { + publicProfile = await apiRequest(`/profile/${owner.id}`) + } catch { + // Public clouds can still be displayed if the profile lookup fails. + } + } + profilesByKey.value[identifier] = { + id: owner?.id || identifier, + username: owner?.name || identifier, + image: publicProfile?.image ?? 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] = [] + } finally { + loadingKeys.value[key] = false + } + } + + function isLoading(identifier: string | null, isOwnProfile: boolean) { + return !!identifier && !!loadingKeys.value[makeKey(identifier, isOwnProfile)] + } + + function patchCachedCloud( + identifier: string, + cloudId: string, + patch: Partial, + ) { + for (const isOwnProfile of [true, false]) { + const key = makeKey(identifier, isOwnProfile) + const current = cloudsByKey.value[key] + if (!current) continue + cloudsByKey.value[key] = current + .map(item => (item.id === cloudId ? { ...item, ...patch } : item)) + .filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden)) + } + } + + function removeCachedClouds(identifier: string, cloudIds: string[]) { + const ids = new Set(cloudIds) + for (const isOwnProfile of [true, false]) { + const key = makeKey(identifier, isOwnProfile) + const current = cloudsByKey.value[key] + if (current) cloudsByKey.value[key] = current.filter(item => !ids.has(item.id)) + } + } + + function invalidateUser(identifier: string) { + delete cloudsByKey.value[makeKey(identifier, true)] + 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(`/cloud/${cloudId}`, { + method: 'PATCH', + body: patch, + }) + const updated = toProfileCloud( + toApiCloud(await apiRequest(`/cloud/${cloudId}`)), + ) + patchCachedCloud(identifier, cloudId, updated) + return updated + } + + async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) { + await apiRequest(`/cloud/${cloudId}`, { + method: 'PATCH', + body: { 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(`/cloud/${cloudIds[0]}`, { method: 'DELETE' }) + } else { + for (let offset = 0; offset < cloudIds.length; offset += 100) { + await apiRequest('/cloud', { + method: 'DELETE', + body: { + cloud_ids: cloudIds + .slice(offset, offset + 100) + .map(cloudId => ({ cloud_id: cloudId })), + }, + }) + } + } + + removeCachedClouds(identifier, cloudIds) + return cloudIds.length + } + + return { + getProfile, + getClouds, + getError, + isLoaded, + isLoading, + fetchProfilePage, + invalidateUser, + updateCloud, + updateCloudVisibility, + deleteClouds, + } +}) diff --git a/src/features/profile/views/ProfileSettingsView.vue b/src/features/profile/views/ProfileSettingsView.vue new file mode 100644 index 0000000..10bf787 --- /dev/null +++ b/src/features/profile/views/ProfileSettingsView.vue @@ -0,0 +1,217 @@ + + + diff --git a/src/features/profile/views/ProfileView.vue b/src/features/profile/views/ProfileView.vue new file mode 100644 index 0000000..9d53480 --- /dev/null +++ b/src/features/profile/views/ProfileView.vue @@ -0,0 +1,937 @@ + + + + + diff --git a/src/features/system/views/AuthRequiredView.vue b/src/features/system/views/AuthRequiredView.vue new file mode 100644 index 0000000..f829cef --- /dev/null +++ b/src/features/system/views/AuthRequiredView.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/features/system/views/ForbiddenView.vue b/src/features/system/views/ForbiddenView.vue new file mode 100644 index 0000000..d05d09d --- /dev/null +++ b/src/features/system/views/ForbiddenView.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/features/system/views/NotFoundView.vue b/src/features/system/views/NotFoundView.vue new file mode 100644 index 0000000..6918d23 --- /dev/null +++ b/src/features/system/views/NotFoundView.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/features/upload/components/QuickUploadModal.vue b/src/features/upload/components/QuickUploadModal.vue new file mode 100644 index 0000000..25fb7ba --- /dev/null +++ b/src/features/upload/components/QuickUploadModal.vue @@ -0,0 +1,495 @@ + + + diff --git a/src/features/upload/composables/useUpload.ts b/src/features/upload/composables/useUpload.ts new file mode 100644 index 0000000..c3c1ac4 --- /dev/null +++ b/src/features/upload/composables/useUpload.ts @@ -0,0 +1,218 @@ +import { ref } from 'vue' +import { apiRequest } from '@/lib/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 + file: File + preview: string + cloudCategoryId: number | null + description: string + isHidden: boolean + latitude: number | null + longitude: number | null + capturedAt: string + errors: Record +} + +export interface UploadResult { + ok: boolean + errorMessage?: string +} + +let nextId = 0 + +function readFileAsArrayBuffer(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as ArrayBuffer) + reader.onerror = reject + reader.readAsArrayBuffer(file) + }) +} + +function extractExifDate(buffer: ArrayBuffer): string | null { + const view = new DataView(buffer) + if (view.byteLength < 4 || view.getUint16(0, false) !== 0xffd8) return null + + let offset = 2 + while (offset < view.byteLength - 2) { + const marker = view.getUint16(offset, false) + offset += 2 + if ((marker & 0xff00) !== 0xff00) break + const length = view.getUint16(offset, false) + offset += 2 + + if (marker === 0xffe1) { + const exifStart = offset + const header = String.fromCharCode( + view.getUint8(exifStart), + view.getUint8(exifStart + 1), + view.getUint8(exifStart + 2), + view.getUint8(exifStart + 3), + ) + if (header !== 'Exif') break + + const tiffStart = exifStart + 6 + const isLE = view.getUint16(tiffStart, false) === 0x4949 + const ifdStart = tiffStart + view.getUint32(tiffStart + 4, isLE) + const numEntries = view.getUint16(ifdStart, isLE) + + for (let i = 0; i < numEntries; i++) { + const entryStart = ifdStart + 2 + i * 12 + if (entryStart + 12 > buffer.byteLength) break + if (view.getUint16(entryStart, isLE) !== 0x9003) continue + + const dataOffset = view.getUint32(entryStart + 8, isLE) + const dateStr = Array.from({ length: 20 }, (_, j) => + String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)), + ).join('') + const date = new Date(dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')) + if (!isNaN(date.getTime())) return date.toISOString() + } + } + offset += length - 2 + } + 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() + const items = ref([]) + const uploading = ref(false) + const overallProgress = ref(0) + const currentItemIndex = ref(0) + const totalItems = ref(0) + + async function addFiles(files: File[]) { + const imageFiles = files.filter(file => + ['image/jpeg', 'image/png', 'image/webp'].includes(file.type), + ) + for (const file of imageFiles) { + let capturedAt: string + try { + capturedAt = extractExifDate(await readFileAsArrayBuffer(file)) || '' + } catch { + capturedAt = '' + } + + items.value.push({ + id: String(++nextId), + file, + preview: URL.createObjectURL(file), + cloudCategoryId: null, + description: '', + isHidden: false, + latitude: null, + longitude: null, + capturedAt, + errors: {}, + }) + } + } + + function removeItem(id: string) { + const index = items.value.findIndex(item => item.id === id) + if (index === -1) return + URL.revokeObjectURL(items.value[index].preview) + items.value.splice(index, 1) + } + + function clearAll() { + items.value.forEach(item => URL.revokeObjectURL(item.preview)) + items.value = [] + } + + function validateItem(item: UploadItem) { + const errors: Record = {} + if (!item.cloudCategoryId) 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.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 + } + + function validateAll() { + return items.value.map(validateItem).every(Boolean) + } + + async function uploadAll(): Promise { + if (!validateAll() || !authStore.user) return { ok: false } + + uploading.value = true + totalItems.value = items.value.length + currentItemIndex.value = 0 + overallProgress.value = 0 + + try { + for (let index = 0; index < items.value.length; index++) { + const item = items.value[index] + 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('/cloud', { + method: 'POST', + body: form, + }) + overallProgress.value = Math.round(((index + 1) / items.value.length) * 100) + } + + clearAll() + profileStore.invalidateUser(authStore.user.id) + return { ok: true } + } catch (error) { + return { + ok: false, + errorMessage: error instanceof Error ? error.message : '上传失败,请稍后重试', + } + } finally { + uploading.value = false + } + } + + return { + items, + uploading, + overallProgress, + currentItemIndex, + totalItems, + addFiles, + removeItem, + clearAll, + validateItem, + validateAll, + uploadAll, + } +} diff --git a/src/features/upload/views/UploadView.vue b/src/features/upload/views/UploadView.vue new file mode 100644 index 0000000..2e30cd0 --- /dev/null +++ b/src/features/upload/views/UploadView.vue @@ -0,0 +1,560 @@ + + + diff --git a/src/lib/amap.ts b/src/lib/amap.ts deleted file mode 100644 index aadbcb3..0000000 --- a/src/lib/amap.ts +++ /dev/null @@ -1,16 +0,0 @@ -import AMapLoader from '@amap/amap-jsapi-loader' - -export function loadAMap() { - return AMapLoader.load({ - key: import.meta.env.VITE_AMAP_KEY, - version: '2.0', - plugins: [ - 'AMap.Scale', - 'AMap.ToolBar', - 'AMap.ControlBar', - 'AMap.Geolocation', - 'AMap.Marker', - 'AMap.InfoWindow', - ], - }) -} diff --git a/src/lib/api.ts b/src/lib/api.ts index 091f6e9..0f42e04 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,9 +1,9 @@ import type { - ApiCloud, - AuthUser, - BackendCloudInfo, - BackendCloudType, - BackendUserProfile, + ApiCloud, + AuthUser, + BackendCloudInfo, + BackendCloudType, + BackendUserProfile, } from '@/types/api' import type { CloudType } from '@/types/database' @@ -13,185 +13,191 @@ 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 { - status: number - detail: unknown + status: number + detail: unknown - constructor(message: string, status: number, detail?: unknown) { - super(message) - this.name = 'ApiError' - this.status = status - this.detail = detail - } + constructor(message: string, status: number, detail?: unknown) { + super(message) + this.name = 'ApiError' + this.status = status + this.detail = detail + } } 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 + body?: BodyInit | Record | null + auth?: boolean } function isJsonBody(body: ApiRequestOptions['body']): body is Record { - return !!body - && typeof body === 'object' - && !(body instanceof FormData) - && !(body instanceof Blob) - && !(body instanceof URLSearchParams) - && !(body instanceof ArrayBuffer) + 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 '' + 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 - } + 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 + } } function errorMessage(payload: unknown, fallback: string) { - if (!payload || typeof payload !== 'object') return fallback + 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 ('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 + 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 fallback } async function readPayload(response: Response) { - const text = await response.text() - if (!text) return undefined + const text = await response.text() + if (!text) return undefined - const contentType = response.headers.get('content-type') || '' - if (!contentType.includes('json')) return text + const contentType = response.headers.get('content-type') || '' + if (!contentType.includes('json')) return text - try { - return JSON.parse(text) as unknown - } catch { - return text - } + try { + return JSON.parse(text) as unknown + } catch { + return text + } } async function toApiError(response: Response) { - const payload = await readPayload(response) - const fallback = typeof payload === 'string' && payload.trim() - ? payload.trim() - : `请求失败(${response.status})` + const payload = await readPayload(response) + const fallback = + typeof payload === 'string' && payload.trim() + ? payload.trim() + : `请求失败(${response.status})` - return new ApiError(errorMessage(payload, fallback), response.status, payload) + return new ApiError(errorMessage(payload, fallback), response.status, payload) } async function request(path: string, options: ApiRequestOptions = {}) { - const { body, auth = true, retry: _retry, ...requestOptions } = options - const headers = new Headers(requestOptions.headers) - let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined + const { body, auth = true, ...requestOptions } = options + const headers = new Headers(requestOptions.headers) + let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined - if (isJsonBody(body)) { - headers.set('Content-Type', 'application/json') - requestBody = JSON.stringify(body) - } + if (isJsonBody(body)) { + headers.set('Content-Type', 'application/json') + requestBody = JSON.stringify(body) + } - const response = await fetch(`${API_URL}${path}`, { - ...requestOptions, - body: requestBody, - headers, - credentials: 'include', - }) + const response = await fetch(`${API_URL}${path}`, { + ...requestOptions, + body: requestBody, + headers, + credentials: 'include', + }) - if (response.status === 401 && auth && typeof window !== 'undefined') { - 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) - return await readPayload(response) as T + if (!response.ok) throw await toApiError(response) + return (await readPayload(response)) as T } export function apiRequest(path: string, options: ApiRequestOptions = {}) { - return request(path, options) + return request(path, options) } export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') { - return `${API_URL}/image/${encodeURIComponent(id)}/${variant}` + return `${API_URL}/image/${encodeURIComponent(id)}/${variant}` } export function toAuthUser(profile: BackendUserProfile): AuthUser { - return { - id: profile.id, - email: profile.email, - username: profile.name, - avatar_id: profile.avatar_id, - 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, - } + 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, - } + 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, - } + 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, + } } diff --git a/src/lib/cloudTypes.ts b/src/lib/cloudTypes.ts deleted file mode 100644 index 94b8821..0000000 --- a/src/lib/cloudTypes.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const CLOUD_TYPES = [ - { 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[]) { - const cloudTypeId = Number(Array.isArray(id) ? id[0] : id) - return CLOUD_TYPES.find(item => item.id === cloudTypeId)?.name ?? '云型详情' -} diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 9532722..b679a2f 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -4,60 +4,64 @@ const defaultTitle = 'OpenCloud' const defaultDescription = 'OpenCloud - 活的天空地图,拍云、识别、收藏' function resolveSiteUrl() { - return (import.meta.env.VITE_SITE_URL || window.location.origin).replace(/\/$/, '') + return (import.meta.env.VITE_SITE_URL || window.location.origin).replace(/\/$/, '') } function upsertMeta(name: string, content: string) { - let element = document.querySelector(`meta[name="${name}"]`) - if (!element) { - element = document.createElement('meta') - element.name = name - document.head.appendChild(element) - } - element.content = content + let element = document.querySelector(`meta[name="${name}"]`) + if (!element) { + element = document.createElement('meta') + element.name = name + document.head.appendChild(element) + } + element.content = content } function upsertPropertyMeta(property: string, content: string) { - let element = document.querySelector(`meta[property="${property}"]`) - if (!element) { - element = document.createElement('meta') - element.setAttribute('property', property) - document.head.appendChild(element) - } - element.content = content + let element = document.querySelector(`meta[property="${property}"]`) + if (!element) { + element = document.createElement('meta') + element.setAttribute('property', property) + document.head.appendChild(element) + } + element.content = content } function upsertCanonical(href: string) { - let element = document.querySelector('link[rel="canonical"]') - if (!element) { - element = document.createElement('link') - element.rel = 'canonical' - document.head.appendChild(element) - } - element.href = href + let element = document.querySelector('link[rel="canonical"]') + if (!element) { + element = document.createElement('link') + element.rel = 'canonical' + document.head.appendChild(element) + } + element.href = href } export function applyRouteSeo(route: RouteLocationNormalizedLoaded) { - const title = typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title - const description = typeof route.meta.description === 'function' ? route.meta.description(route) : route.meta.description - const robots = route.meta.noindex ? 'noindex, nofollow' : 'index, follow' - const canonicalPath = route.meta.canonicalPath || route.path - const canonical = `${resolveSiteUrl()}${canonicalPath}` - const pageTitle = title ? `${title} | OpenCloud` : defaultTitle - const pageDescription = description || defaultDescription + const title = + typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title + const description = + typeof route.meta.description === 'function' + ? route.meta.description(route) + : route.meta.description + const robots = route.meta.noindex ? 'noindex, nofollow' : 'index, follow' + const canonicalPath = route.meta.canonicalPath || route.path + const canonical = `${resolveSiteUrl()}${canonicalPath}` + const pageTitle = title ? `${title} | OpenCloud` : defaultTitle + const pageDescription = description || defaultDescription - document.title = pageTitle - upsertMeta('description', pageDescription) - upsertMeta('robots', robots) - upsertCanonical(canonical) + document.title = pageTitle + upsertMeta('description', pageDescription) + upsertMeta('robots', robots) + upsertCanonical(canonical) - upsertPropertyMeta('og:site_name', 'OpenCloud') - upsertPropertyMeta('og:type', 'website') - upsertPropertyMeta('og:title', pageTitle) - upsertPropertyMeta('og:description', pageDescription) - upsertPropertyMeta('og:url', canonical) + upsertPropertyMeta('og:site_name', 'OpenCloud') + upsertPropertyMeta('og:type', 'website') + upsertPropertyMeta('og:title', pageTitle) + upsertPropertyMeta('og:description', pageDescription) + upsertPropertyMeta('og:url', canonical) - upsertMeta('twitter:card', 'summary') - upsertMeta('twitter:title', pageTitle) - upsertMeta('twitter:description', pageDescription) + upsertMeta('twitter:card', 'summary') + upsertMeta('twitter:title', pageTitle) + upsertMeta('twitter:description', pageDescription) } diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 27df7aa..3eb2303 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -2,57 +2,57 @@ import type { GlobalThemeOverrides } from 'naive-ui' /** Keep these values aligned with the semantic tokens in styles/tokens.css. */ export const openCloudThemeOverrides: GlobalThemeOverrides = { - common: { - primaryColor: '#34745d', - primaryColorHover: '#2d5f4e', - primaryColorPressed: '#274c40', - primaryColorSuppl: '#34745d', - infoColor: '#0369a1', - successColor: '#4d7f3b', - successColorHover: '#3f6633', - successColorPressed: '#35522c', - successColorSuppl: '#4d7f3b', - warningColor: '#d97706', - errorColor: '#dc2626', - borderRadius: '0px', - borderRadiusSmall: '0px', - fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif', - fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace', - bodyColor: '#eef4f8', - cardColor: '#ffffff', - modalColor: '#ffffff', - popoverColor: '#ffffff', - tableColor: '#ffffff', - textColorBase: '#0f172a', - textColor1: '#0f172a', - textColor2: '#334155', - textColor3: '#64748b', - textColorDisabled: 'var(--oc-color-text-disabled)', - dividerColor: '#d9e3ee', - borderColor: '#d9e3ee', - inputColor: '#ffffff', - }, - Button: { - borderRadiusTiny: '0px', - borderRadiusSmall: '0px', - borderRadiusMedium: '0px', - borderRadiusLarge: '0px', - textColorPrimary: '#0f172a', - textColorHoverPrimary: '#0f172a', - }, - Card: { - borderRadius: '0px', - borderRadiusEmbedded: '0px', - paddingMedium: '24px', - }, - Input: { borderRadius: '0px' }, - Select: { - peers: { - InternalSelection: { borderRadius: '0px' }, + common: { + primaryColor: '#34745d', + primaryColorHover: '#2d5f4e', + primaryColorPressed: '#274c40', + primaryColorSuppl: '#34745d', + infoColor: '#0369a1', + successColor: '#4d7f3b', + successColorHover: '#3f6633', + successColorPressed: '#35522c', + successColorSuppl: '#4d7f3b', + warningColor: '#d97706', + errorColor: '#dc2626', + borderRadius: '0px', + borderRadiusSmall: '0px', + fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif', + fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace', + bodyColor: '#eef4f8', + cardColor: '#ffffff', + modalColor: '#ffffff', + popoverColor: '#ffffff', + tableColor: '#ffffff', + textColorBase: '#0f172a', + textColor1: '#0f172a', + textColor2: '#334155', + textColor3: '#64748b', + textColorDisabled: 'var(--oc-color-text-disabled)', + dividerColor: '#d9e3ee', + borderColor: '#d9e3ee', + inputColor: '#ffffff', }, - }, - Tabs: { tabBorderRadius: '0px' }, - Tag: { borderRadius: '0px' }, - Alert: { borderRadius: '0px' }, - Modal: { borderRadius: '0px' }, + Button: { + borderRadiusTiny: '0px', + borderRadiusSmall: '0px', + borderRadiusMedium: '0px', + borderRadiusLarge: '0px', + textColorPrimary: '#0f172a', + textColorHoverPrimary: '#0f172a', + }, + Card: { + borderRadius: '0px', + borderRadiusEmbedded: '0px', + paddingMedium: '24px', + }, + Input: { borderRadius: '0px' }, + Select: { + peers: { + InternalSelection: { borderRadius: '0px' }, + }, + }, + Tabs: { tabBorderRadius: '0px' }, + Tag: { borderRadius: '0px' }, + Alert: { borderRadius: '0px' }, + Modal: { borderRadius: '0px' }, } diff --git a/src/main.ts b/src/main.ts index 16d5621..7a11b71 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' -import { useAuthStore } from './stores/auth' +import { useAuthStore } from '@/features/auth/stores/auth' import './style.css' const app = createApp(App) @@ -12,6 +12,6 @@ app.use(pinia) const authStore = useAuthStore() authStore.initialize().then(() => { - app.use(router) - app.mount('#app') + app.use(router) + app.mount('#app') }) diff --git a/src/router/index.ts b/src/router/index.ts index b57f7ba..2740780 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,176 +1,177 @@ import { createRouter, createWebHistory } from 'vue-router' -import { getCloudTypeName } from '@/lib/cloudTypes' +import { getCloudTypeName } from '@/features/clouds/cloudTypes' import { applyRouteSeo } from '@/lib/seo' -import { useAuthStore } from '@/stores/auth' +import { useAuthStore } from '@/features/auth/stores/auth' const router = createRouter({ - history: createWebHistory(import.meta.env.BASE_URL), - routes: [ - { - path: '/', - name: 'map', - component: () => import('@/views/map/MapView.vue'), - meta: { - title: '实时云图地图', - description: '在 OpenCloud 地图上浏览社区拍摄的云图,按位置查看天空观测记录。', - }, + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + name: 'map', + component: () => import('@/features/map/views/MapView.vue'), + meta: { + title: '实时云图地图', + description: '在 OpenCloud 地图上浏览社区拍摄的云图,按位置查看天空观测记录。', + }, + }, + { + path: '/login', + name: 'login', + component: () => import('@/features/auth/views/LoginView.vue'), + meta: { title: '登录', noindex: true }, + }, + { + path: '/register', + name: 'register', + component: () => import('@/features/auth/views/RegisterView.vue'), + meta: { title: '注册', noindex: true }, + }, + { + path: '/resend-confirmation', + name: 'resend-confirmation', + component: () => import('@/features/auth/views/ResendConfirmationView.vue'), + meta: { title: '重发验证邮件', noindex: true }, + }, + { + path: '/forgot-password', + name: 'forgot-password', + component: () => import('@/features/auth/views/ForgotPasswordView.vue'), + meta: { title: '忘记密码', noindex: true }, + }, + { + path: '/verify-email', + alias: '/auth/confirm', + name: 'auth-confirm', + component: () => import('@/features/auth/views/AuthConfirmView.vue'), + meta: { title: '确认邮箱', noindex: true }, + }, + { + path: '/reset-password', + alias: '/auth/reset-password', + name: 'auth-reset-password', + component: () => import('@/features/auth/views/ResetPasswordView.vue'), + meta: { title: '重置密码', noindex: true }, + }, + { + path: '/upload', + name: 'upload', + component: () => import('@/features/upload/views/UploadView.vue'), + meta: { requiresAuth: true, title: '上传云图', noindex: true }, + }, + { + path: '/encyclopedia', + name: 'encyclopedia', + component: () => import('@/features/encyclopedia/views/EncyclopediaView.vue'), + meta: { + requiresAuth: true, + title: '云朵百科', + description: '浏览基础云属,了解云型特征和社区公开观测记录。', + noindex: true, + }, + }, + { + path: '/encyclopedia/:id', + name: 'cloud-type', + component: () => import('@/features/encyclopedia/views/CloudTypeView.vue'), + meta: { + requiresAuth: true, + title: route => getCloudTypeName(route.params.id), + description: route => + `查看 ${getCloudTypeName(route.params.id)} 的识别要点、公开云图和社区观测记录。`, + noindex: true, + }, + }, + { + path: '/gallery', + name: 'gallery', + component: () => import('@/features/gallery/views/GalleryView.vue'), + meta: { + title: '云图画廊', + description: '按时间浏览 OpenCloud 社区公开分享的云图照片和观测记录。', + }, + }, + { + path: '/community', + name: 'community', + component: () => import('@/features/community/views/CommunityView.vue'), + meta: { + title: '社区', + description: '查看 OpenCloud 社区动态,发现更多天空观测和云图收藏。', + }, + }, + { + path: '/profile', + name: 'profile', + component: () => import('@/features/profile/views/ProfileView.vue'), + meta: { requiresAuth: true, title: '个人主页', noindex: true }, + }, + { + path: '/profile/settings', + name: 'profile-settings', + component: () => import('@/features/profile/views/ProfileSettingsView.vue'), + meta: { requiresAuth: true, title: '账号设置', noindex: true }, + }, + { + path: '/profile/:id', + name: 'user-profile', + component: () => import('@/features/profile/views/ProfileView.vue'), + meta: { + title: '用户云图档案', + description: '按用户名查看用户公开分享的云图记录和拍摄时间线。', + }, + }, + { + path: '/admin', + name: 'admin', + component: () => import('@/features/admin/views/AdminView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: '管理后台', noindex: true }, + }, + { + path: '/403', + name: 'forbidden', + component: () => import('@/features/system/views/ForbiddenView.vue'), + meta: { title: '无权访问', noindex: true }, + }, + { + path: '/401', + name: 'auth-required', + component: () => import('@/features/system/views/AuthRequiredView.vue'), + meta: { title: '需要登录', noindex: true }, + }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: () => import('@/features/system/views/NotFoundView.vue'), + meta: { title: '页面不存在', noindex: true }, + }, + ], + scrollBehavior() { + return { top: 0 } }, - { - path: '/login', - name: 'login', - component: () => import('@/views/auth/LoginView.vue'), - meta: { title: '登录', noindex: true }, - }, - { - path: '/register', - name: 'register', - component: () => import('@/views/auth/RegisterView.vue'), - meta: { title: '注册', noindex: true }, - }, - { - path: '/resend-confirmation', - name: 'resend-confirmation', - component: () => import('@/views/auth/ResendConfirmationView.vue'), - meta: { title: '重发验证邮件', noindex: true }, - }, - { - path: '/forgot-password', - name: 'forgot-password', - component: () => import('@/views/auth/ForgotPasswordView.vue'), - meta: { title: '忘记密码', noindex: true }, - }, - { - path: '/verify-email', - alias: '/auth/confirm', - name: 'auth-confirm', - component: () => import('@/views/auth/AuthConfirmView.vue'), - meta: { title: '确认邮箱', noindex: true }, - }, - { - path: '/reset-password', - alias: '/auth/reset-password', - name: 'auth-reset-password', - component: () => import('@/views/auth/ResetPasswordView.vue'), - meta: { title: '重置密码', noindex: true }, - }, - { - path: '/upload', - name: 'upload', - component: () => import('@/views/upload/UploadView.vue'), - meta: { requiresAuth: true, title: '上传云图', noindex: true }, - }, - { - path: '/encyclopedia', - name: 'encyclopedia', - component: () => import('@/views/encyclopedia/EncyclopediaView.vue'), - meta: { - requiresAuth: true, - title: '云朵百科', - description: '浏览基础云属,了解云型特征和社区公开观测记录。', - noindex: true, - }, - }, - { - path: '/encyclopedia/:id', - name: 'cloud-type', - component: () => import('@/views/encyclopedia/CloudTypeView.vue'), - meta: { - requiresAuth: true, - title: route => getCloudTypeName(route.params.id), - description: route => `查看 ${getCloudTypeName(route.params.id)} 的识别要点、公开云图和社区观测记录。`, - noindex: true, - }, - }, - { - path: '/gallery', - name: 'gallery', - component: () => import('@/views/gallery/GalleryView.vue'), - meta: { - title: '云图画廊', - description: '按时间浏览 OpenCloud 社区公开分享的云图照片和观测记录。', - }, - }, - { - path: '/community', - name: 'community', - component: () => import('@/views/community/CommunityView.vue'), - meta: { - title: '社区', - description: '查看 OpenCloud 社区动态,发现更多天空观测和云图收藏。', - }, - }, - { - path: '/profile', - name: 'profile', - component: () => import('@/views/profile/ProfileView.vue'), - meta: { requiresAuth: true, title: '个人主页', noindex: true }, - }, - { - path: '/profile/settings', - name: 'profile-settings', - component: () => import('@/views/profile/ProfileSettingsView.vue'), - meta: { requiresAuth: true, title: '账号设置', noindex: true }, - }, - { - path: '/profile/:id', - name: 'user-profile', - component: () => import('@/views/profile/ProfileView.vue'), - meta: { - title: '用户云图档案', - description: '按用户名查看用户公开分享的云图记录和拍摄时间线。', - }, - }, - { - path: '/admin', - name: 'admin', - component: () => import('@/views/admin/AdminView.vue'), - meta: { requiresAuth: true, requiresAdmin: true, title: '管理后台', noindex: true }, - }, - { - path: '/403', - name: 'forbidden', - component: () => import('@/views/system/ForbiddenView.vue'), - meta: { title: '无权访问', noindex: true }, - }, - { - path: '/401', - name: 'auth-required', - component: () => import('@/views/system/AuthRequiredView.vue'), - meta: { title: '需要登录', noindex: true }, - }, - { - path: '/:pathMatch(.*)*', - name: 'not-found', - component: () => import('@/views/system/NotFoundView.vue'), - meta: { title: '页面不存在', noindex: true }, - }, - ], - scrollBehavior() { - return { top: 0 } - }, }) router.beforeEach((to, _from, next) => { - const authStore = useAuthStore() + const authStore = useAuthStore() - if (to.meta.requiresAuth && !authStore.isLoggedIn) { - next({ - name: 'auth-required', - query: { - redirect: to.fullPath, - }, - }) - } else if (to.meta.requiresAdmin && !authStore.isAdmin) { - next({ name: 'forbidden' }) - } else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) { - next({ name: 'map' }) - } else { - next() - } + if (to.meta.requiresAuth && !authStore.isLoggedIn) { + next({ + name: 'auth-required', + query: { + redirect: to.fullPath, + }, + }) + } else if (to.meta.requiresAdmin && !authStore.isAdmin) { + next({ name: 'forbidden' }) + } else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) { + next({ name: 'map' }) + } else { + next() + } }) router.afterEach(to => { - applyRouteSeo(to) + applyRouteSeo(to) }) export default router diff --git a/src/shared/map/amap.ts b/src/shared/map/amap.ts new file mode 100644 index 0000000..516a7af --- /dev/null +++ b/src/shared/map/amap.ts @@ -0,0 +1,16 @@ +import AMapLoader from '@amap/amap-jsapi-loader' + +export function loadAMap() { + return AMapLoader.load({ + key: import.meta.env.VITE_AMAP_KEY, + version: '2.0', + plugins: [ + 'AMap.Scale', + 'AMap.ToolBar', + 'AMap.ControlBar', + 'AMap.Geolocation', + 'AMap.Marker', + 'AMap.InfoWindow', + ], + }) +} diff --git a/src/shared/map/components/MapPickerModal.vue b/src/shared/map/components/MapPickerModal.vue new file mode 100644 index 0000000..1e2cf03 --- /dev/null +++ b/src/shared/map/components/MapPickerModal.vue @@ -0,0 +1,238 @@ + + + diff --git a/src/shared/map/components/MiniLocationMap.vue b/src/shared/map/components/MiniLocationMap.vue new file mode 100644 index 0000000..c3f421a --- /dev/null +++ b/src/shared/map/components/MiniLocationMap.vue @@ -0,0 +1,138 @@ + + + diff --git a/src/shared/map/types/amap.d.ts b/src/shared/map/types/amap.d.ts new file mode 100644 index 0000000..65b5996 --- /dev/null +++ b/src/shared/map/types/amap.d.ts @@ -0,0 +1,126 @@ +declare namespace AMap { + class Map { + constructor(container: string | HTMLElement, opts?: MapOptions) + addControl(control: unknown): void + add(layer: unknown): void + remove(layer: unknown): void + destroy(): void + setCenter(center: [number, number]): void + setZoom(zoom: number, immediately?: boolean, duration?: number): void + setPitch(pitch: number): void + getPitch(): number + setRotation(rotation: number): void + getRotation(): number + on(event: string, callback: (...args: unknown[]) => void): void + off(event: string, callback: (...args: unknown[]) => void): void + getCenter(): { lng: number; lat: number } + getZoom(digits?: number): number + getAllOverlays(type?: string): unknown[] + addOverlay(overlay: unknown): void + removeOverlay(overlay: unknown): void + clearMap(): void + } + + interface MapOptions { + viewMode?: '2D' | '3D' + pitch?: number + rotation?: number + zoom?: number + zooms?: [number, number] + center?: [number, number] + mapStyle?: string + features?: string[] + layers?: unknown[] + skyColor?: string + maxPitch?: number + resizeEnable?: boolean + dragEnable?: boolean + zoomEnable?: boolean + keyboardEnable?: boolean + doubleClickZoom?: boolean + scrollWheel?: boolean + } + + class Marker { + constructor(opts?: MarkerOptions) + on(event: string, callback: (...args: unknown[]) => void): void + setPosition(position: [number, number]): void + getPosition(): { lng: number; lat: number } + setContent(content: string): void + setOpacity(opacity: number): void + setMap(map: Map | null): void + remove(): void + } + + interface MarkerOptions { + position?: [number, number] + content?: string | HTMLElement + offset?: Pixel + zIndex?: number + opacity?: number + title?: string + } + + class InfoWindow { + constructor(opts?: InfoWindowOptions) + open(map: Map, position?: [number, number]): void + close(): void + } + + interface InfoWindowOptions { + content?: string | HTMLElement + offset?: Pixel + size?: Size + isCustom?: boolean + } + + class Scale {} + class ToolBar { + constructor(opts?: Record) + } + class ControlBar { + constructor(opts?: Record) + } + class Geolocation { + constructor(opts?: Record) + getCurrentPosition( + onSuccess: (data: { position: { lng: number; lat: number } }) => void, + onError?: (error: unknown) => void, + ): void + } + + class TileLayer { + static Satellite: new () => TileLayer + static RoadNet: new () => TileLayer + setOpacity(opacity: number): void + } + + class Pixel { + constructor(x: number, y: number) + } + + class Size { + constructor(width: number, height: number) + } + + class MarkerCluster { + constructor(map: Map, markers: Marker[], opts?: Record) + } +} + +declare function AMapLoader_load(opts: { + key: string + version: string + plugins?: string[] +}): Promise + +declare namespace AMap { + function getConfig(): { appname?: string } +} + +declare module '@amap/amap-jsapi-loader' { + const AMapLoader: { + load: typeof AMapLoader_load + } + export default AMapLoader +} diff --git a/src/stores/auth.ts b/src/stores/auth.ts deleted file mode 100644 index df4def1..0000000 --- a/src/stores/auth.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { computed, ref } from 'vue' -import { defineStore } from 'pinia' -import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api' -import type { ActionResponse, BackendUserProfile, LoginResponse, RegisterResponse } from '@/types/api' -import type { Profile } from '@/types/database' - -export const useAuthStore = defineStore('auth', () => { - const user = ref | null>(null) - const profile = ref(null) - const loading = ref(true) - let listenerAttached = false - - const isLoggedIn = computed(() => !!user.value) - const isAdmin = computed(() => profile.value?.role === 'admin') - - function applyProfile(backendProfile: BackendUserProfile) { - const normalized = toAuthUser(backendProfile) - user.value = normalized - profile.value = { - id: normalized.id, - username: normalized.username, - avatar_id: normalized.avatar_id, - role: normalized.role, - is_disabled: normalized.is_disabled, - created_at: normalized.created_at, - } - } - - function clearAuth() { - user.value = null - profile.value = null - } - - async function fetchMe() { - const backendProfile = await apiRequest('/profile/me') - applyProfile(backendProfile) - } - - async function login(email: string, password: string) { - await apiRequest('/auth/login', { - method: 'POST', - auth: false, - body: { email, password }, - }) - await fetchMe() - } - - async function register(name: string, email: string, password: string) { - return await apiRequest('/auth/register', { - method: 'POST', - auth: false, - body: { name, email, password }, - }) - } - - async function resendConfirmation(email: string) { - return await apiRequest('/auth/resend-confirmation', { - method: 'POST', - auth: false, - body: { email }, - }) - } - - async function confirmEmail(token: string) { - return await apiRequest('/auth/confirm-email', { - method: 'POST', - auth: false, - body: { token }, - }) - } - - async function sendPasswordReset(email: string) { - return await apiRequest('/auth/forgot-password', { - method: 'POST', - auth: false, - body: { email }, - }) - } - - async function resetPassword(email: string, token: string, newPassword: string) { - const response = await apiRequest('/auth/reset-password', { - method: 'POST', - auth: false, - body: { email, token, new_password: newPassword }, - }) - clearAuth() - return response - } - - async function logout() { - try { - await apiRequest('/auth/logout', { method: 'POST', retry: false }) - } finally { - clearAuth() - } - } - - async function updateUsername(username: string) { - const backendProfile = await apiRequest('/profile/me', { - method: 'PATCH', - body: { user_name: username }, - }) - applyProfile(backendProfile) - } - - async function updatePassword(currentPassword: string, newPassword: string) { - await apiRequest('/auth/password', { - method: 'PATCH', - body: { - current_password: currentPassword, - new_password: newPassword, - }, - }) - clearAuth() - } - - async function initialize() { - if (!listenerAttached) { - window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth) - listenerAttached = true - } - - try { - await fetchMe() - } catch { - clearAuth() - } finally { - loading.value = false - } - } - - return { - user, - profile, - loading, - isLoggedIn, - isAdmin, - login, - register, - resendConfirmation, - confirmEmail, - sendPasswordReset, - resetPassword, - logout, - updateUsername, - updatePassword, - initialize, - } -}) diff --git a/src/stores/clouds.ts b/src/stores/clouds.ts deleted file mode 100644 index 04ef585..0000000 --- a/src/stores/clouds.ts +++ /dev/null @@ -1,23 +0,0 @@ -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' - -export const useCloudsStore = defineStore('clouds', () => { - const cloudTypes = ref([]) - const loading = ref(false) - - async function fetchCloudTypes() { - if (cloudTypes.value.length > 0) return - loading.value = true - try { - const rows = await apiRequest('/info/cloudtype', { auth: false }) - cloudTypes.value = rows.map(toCloudType) - } finally { - loading.value = false - } - } - - return { cloudTypes, loading, fetchCloudTypes } -}) diff --git a/src/stores/encyclopedia.ts b/src/stores/encyclopedia.ts deleted file mode 100644 index bff48dc..0000000 --- a/src/stores/encyclopedia.ts +++ /dev/null @@ -1,29 +0,0 @@ -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' - -export const useEncyclopediaStore = defineStore('encyclopedia', () => { - const cloudTypes = ref([]) - const loadingCloudTypes = ref(false) - const cloudTypesLoaded = ref(false) - - async function fetchCloudTypes(force = false) { - if (cloudTypesLoaded.value && !force) return - loadingCloudTypes.value = true - try { - const rows = await apiRequest('/info/cloudtype', { auth: false }) - cloudTypes.value = rows.map(toCloudType) - cloudTypesLoaded.value = true - } finally { - loadingCloudTypes.value = false - } - } - - return { - cloudTypes, - loadingCloudTypes, - fetchCloudTypes, - } -}) diff --git a/src/stores/profile.ts b/src/stores/profile.ts deleted file mode 100644 index c3822e2..0000000 --- a/src/stores/profile.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { ref } from 'vue' -import { defineStore } from 'pinia' -import { ApiError, apiRequest, toApiCloud } from '@/lib/api' -import { useAuthStore } from '@/stores/auth' -import type { ActionResponse, ApiCloud, BackendCloudInfo } from '@/types/api' -import type { Profile } from '@/types/database' - -export interface ProfileCloudItem { - id: string - cloud_type_id: number - image_url: string - thumbnail_url: string - latitude: number - longitude: number - description: string | null - captured_at: string | null - created_at: string - status: 'pending' | 'approved' | 'rejected' - is_hidden: boolean - cloudTypeName: string - cloudTypeRarity: number - receivedLikeCount: number -} - -function toProfileCloud(row: ApiCloud): ProfileCloudItem { - return { - id: row.id, - cloud_type_id: row.cloud_type_id as number, - image_url: row.image_url, - thumbnail_url: row.thumbnail_url as string, - latitude: row.latitude as number, - longitude: row.longitude as number, - 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 || '未知', - cloudTypeRarity: row.cloud_type_rarity_value ?? 0, - receivedLikeCount: row.received_like_count, - } -} - -export const useProfileStore = defineStore('profile-page', () => { - const authStore = useAuthStore() - const profilesByKey = ref>({}) - const cloudsByKey = ref>({}) - const loadingKeys = ref>({}) - const errorByKey = ref>({}) - - const makeKey = (identifier: string, isOwnProfile: boolean) => `${identifier}:${isOwnProfile ? 'own' : 'public'}` - - function getProfile(identifier: string | null) { - return identifier ? profilesByKey.value[identifier] ?? null : null - } - - function getClouds(identifier: string | null, isOwnProfile: boolean) { - return identifier ? cloudsByKey.value[makeKey(identifier, isOwnProfile)] ?? [] : [] - } - - function getError(identifier: string | null, isOwnProfile: boolean) { - return identifier ? errorByKey.value[makeKey(identifier, isOwnProfile)] ?? '' : '' - } - - function isLoaded(identifier: string | null, isOwnProfile: boolean) { - 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(`/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 - - loadingKeys.value[key] = true - errorByKey.value[key] = '' - try { - 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_id: 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] = [] - } finally { - loadingKeys.value[key] = false - } - } - - function isLoading(identifier: string | null, isOwnProfile: boolean) { - return !!identifier && !!loadingKeys.value[makeKey(identifier, isOwnProfile)] - } - - function patchCachedCloud(identifier: string, cloudId: string, patch: Partial) { - for (const isOwnProfile of [true, false]) { - const key = makeKey(identifier, isOwnProfile) - const current = cloudsByKey.value[key] - if (!current) continue - cloudsByKey.value[key] = current - .map(item => item.id === cloudId ? { ...item, ...patch } : item) - .filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden)) - } - } - - function removeCachedClouds(identifier: string, cloudIds: string[]) { - const ids = new Set(cloudIds) - for (const isOwnProfile of [true, false]) { - const key = makeKey(identifier, isOwnProfile) - const current = cloudsByKey.value[key] - if (current) cloudsByKey.value[key] = current.filter(item => !ids.has(item.id)) - } - } - - function invalidateUser(identifier: string) { - delete cloudsByKey.value[makeKey(identifier, true)] - 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(`/cloud/${cloudId}`, { - method: 'PATCH', - body: patch, - }) - const updated = toProfileCloud(toApiCloud(await apiRequest(`/cloud/${cloudId}`))) - patchCachedCloud(identifier, cloudId, updated) - return updated - } - - async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) { - await apiRequest(`/cloud/${cloudId}`, { - method: 'PATCH', - body: { 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(`/cloud/${cloudIds[0]}`, { method: 'DELETE' }) - } else { - for (let offset = 0; offset < cloudIds.length; offset += 100) { - await apiRequest('/cloud', { - method: 'DELETE', - body: { - cloud_ids: cloudIds.slice(offset, offset + 100).map(cloudId => ({ cloud_id: cloudId })), - }, - }) - } - } - - removeCachedClouds(identifier, cloudIds) - return cloudIds.length - } - - return { - getProfile, - getClouds, - getError, - isLoaded, - isLoading, - fetchProfilePage, - invalidateUser, - updateCloud, - updateCloudVisibility, - deleteClouds, - } -}) diff --git a/src/styles/base.css b/src/styles/base.css index cfa5722..fee5276 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -1,27 +1,26 @@ @layer base { - html, - body, - #app { - min-height: 100%; - } + html, + body, + #app { + min-height: 100%; + } - body { - color: var(--oc-color-text); - background: - linear-gradient(180deg, rgb(15 23 42 / 2%), rgb(15 23 42 / 0%)), - var(--oc-color-canvas); - font-family: var(--oc-font-sans); - } + body { + color: var(--oc-color-text); + background: + linear-gradient(180deg, rgb(15 23 42 / 2%), rgb(15 23 42 / 0%)), var(--oc-color-canvas); + font-family: var(--oc-font-sans); + } - ::selection { - background: var(--oc-color-selection); - } + ::selection { + background: var(--oc-color-selection); + } } @layer utilities { - /* The atlas language is square by default. Documented exceptions may opt out. */ - [class~='rounded'], - [class*='rounded-'] { - border-radius: 0 !important; - } + /* The atlas language is square by default. Documented exceptions may opt out. */ + [class~='rounded'], + [class*='rounded-'] { + border-radius: 0 !important; + } } diff --git a/src/styles/components.css b/src/styles/components.css index 0ab2554..f38657f 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1,632 +1,645 @@ @layer components { - /* Application and page structure */ - .oc-app-shell { - @apply flex min-h-screen flex-col; - background: var(--oc-gradient-canvas); - } + /* Application and page structure */ + .oc-app-shell { + @apply flex min-h-screen flex-col; + background: var(--oc-gradient-canvas); + } - .oc-app-grid { - @apply pointer-events-none absolute inset-0 opacity-35; - background-image: var(--oc-gradient-grid); - background-size: 32px 32px; - } + .oc-app-grid { + @apply pointer-events-none absolute inset-0 opacity-35; + background-image: var(--oc-gradient-grid); + background-size: 32px 32px; + } - .oc-page-hero { - @apply border-b border-sky-100; - background: var(--oc-gradient-page); - } + .oc-page-hero { + @apply border-b border-sky-100; + background: var(--oc-gradient-page); + } - .oc-page-hero--soft { - background: var(--oc-gradient-page-soft); - } + .oc-page-hero--soft { + background: var(--oc-gradient-page-soft); + } - .oc-container { - @apply mx-auto w-full px-4 sm:px-6 lg:px-8; - } + .oc-container { + @apply mx-auto w-full px-4 sm:px-6 lg:px-8; + } - .oc-container--narrow { - @apply max-w-4xl; - } + .oc-container--narrow { + @apply max-w-4xl; + } - .oc-container--content { - @apply max-w-6xl; - } + .oc-container--content { + @apply max-w-6xl; + } - .oc-container--wide { - @apply max-w-7xl; - } + .oc-container--wide { + @apply max-w-7xl; + } - .oc-page-hero__inner { - @apply py-10; - } + .oc-page-hero__inner { + @apply py-10; + } - .oc-page-hero__split { - @apply grid gap-6; - } - - @media (min-width: 900px) { .oc-page-hero__split { - grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr); - align-items: start; + @apply grid gap-6; } - .oc-page-hero__split--stretch { - align-items: stretch; + @media (min-width: 900px) { + .oc-page-hero__split { + grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr); + align-items: start; + } + + .oc-page-hero__split--stretch { + align-items: stretch; + } } - } - .oc-page-content { - @apply py-8; - } - - .oc-page-eyebrow { - @apply text-sm font-medium uppercase tracking-[0.24em] text-sky-700; - } - - .oc-page-title { - @apply mt-3 text-4xl font-bold text-slate-900; - } - - .oc-page-description { - @apply mt-4 max-w-2xl text-sm leading-7 text-slate-600; - } - - .oc-brand-mark { - @apply flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-100; - box-shadow: var(--oc-shadow-sm); - } - - /* Reusable text hierarchy */ - .oc-section-label { - @apply text-xs uppercase tracking-[0.18em] text-slate-500; - } - - .oc-stat-label { - @apply text-xs uppercase tracking-[0.2em] text-slate-500; - } - - .oc-stat-value { - @apply mt-2 text-2xl font-bold text-slate-900; - } - - .oc-meta { - @apply text-sm text-slate-500; - } - - .oc-field-label { - @apply mb-1 block text-sm font-medium text-slate-700; - } - - .oc-field-required { - @apply text-rose-500; - } - - .oc-field-control { - @apply w-full border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-800 outline-none transition-colors; - @apply focus:border-sky-500 focus:ring-2 focus:ring-sky-100; - } - - .oc-field-error { - @apply mt-1 text-sm text-rose-600; - } - - .oc-field-help { - @apply mt-1 text-sm text-slate-500; - } - - /* Surfaces */ - .oc-panel-card { - box-shadow: var(--oc-shadow-md) !important; - } - - .oc-panel-card-xl { - box-shadow: var(--oc-shadow-xl) !important; - } - - .oc-floating-panel { - box-shadow: var(--oc-shadow-lg); - } - - .oc-panel-card-soft { - box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%) !important; - } - - .oc-empty-card { - box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%); - } - - .oc-surface { - @apply border border-slate-200 bg-white; - box-shadow: var(--oc-shadow-md); - } - - .oc-modal-shell { - @apply border border-slate-200 bg-white; - box-shadow: var(--oc-shadow-xl); - } - - .oc-inset-panel { - @apply border border-slate-200 bg-slate-50 p-4; - } - - .oc-map-button { - @apply flex h-10 w-10 items-center justify-center border border-slate-200 bg-white text-slate-600; - box-shadow: var(--oc-shadow-xs); - } - - /* Shared interactive contracts */ - .oc-panel-button:not(.n-button), - .oc-header-button, - .oc-icon-button, - .oc-map-button, - .oc-choice-button, - .oc-text-button, - .oc-menu-item, - .oc-nav-link, - .oc-interactive-card { - transition: - background-color var(--oc-duration-fast) var(--oc-ease-standard), - color var(--oc-duration-fast) var(--oc-ease-standard), - border-color var(--oc-duration-fast) var(--oc-ease-standard), - box-shadow var(--oc-duration-fast) var(--oc-ease-standard), - transform var(--oc-duration-fast) var(--oc-ease-standard); - } - - .oc-panel-button:not(.n-button) { - @apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium; - } - - .oc-panel-button:not(.n-button):not(:disabled):hover, - .oc-header-button:not(:disabled):hover, - .oc-icon-button:not(:disabled):hover, - .oc-map-button:not(:disabled):hover, - .oc-choice-button:not(:disabled):hover { - transform: translate(-1px, -1px); - } - - .oc-panel-button--neutral:not(.n-button) { - @apply border-slate-200 bg-white text-slate-600; - box-shadow: var(--oc-shadow-xs); - } - - .oc-panel-button--neutral:not(.n-button):hover, - .oc-panel-button--neutral:not(.n-button):focus-visible { - @apply border-teal-200 bg-teal-50 text-teal-800; - box-shadow: var(--oc-shadow-primary); - } - - .oc-panel-button--sky:not(.n-button) { - @apply border-sky-200 bg-sky-100 text-sky-800; - box-shadow: var(--oc-shadow-sky); - } - - .oc-panel-button--sky:not(.n-button):hover, - .oc-panel-button--sky:not(.n-button):focus-visible { - @apply border-sky-300 bg-sky-50 text-sky-900; - box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%); - } - - .oc-panel-button--teal:not(.n-button) { - @apply border-teal-200 bg-teal-50 text-teal-800; - box-shadow: var(--oc-shadow-teal); - } - - .oc-panel-button--teal:not(.n-button):hover, - .oc-panel-button--teal:not(.n-button):focus-visible { - @apply border-teal-300 bg-teal-100 text-teal-900; - box-shadow: var(--oc-shadow-primary-hover); - } - - .oc-panel-button--danger:not(.n-button) { - @apply border-rose-200 bg-rose-50 text-rose-700; - box-shadow: 4px 4px 0 0 rgb(244 63 94 / 10%); - } - - .oc-panel-button--danger:not(.n-button):hover, - .oc-panel-button--danger:not(.n-button):focus-visible { - @apply border-rose-300 bg-rose-100 text-rose-800; - box-shadow: 5px 5px 0 0 rgb(244 63 94 / 14%); - } - - .oc-panel-button--amber:not(.n-button) { - @apply border-amber-200 bg-amber-50 text-amber-800; - box-shadow: 4px 4px 0 0 rgb(245 158 11 / 10%); - } - - .oc-panel-button--amber:not(.n-button):hover, - .oc-panel-button--amber:not(.n-button):focus-visible { - @apply border-amber-300 bg-amber-100 text-amber-900; - box-shadow: 5px 5px 0 0 rgb(245 158 11 / 14%); - } - - .oc-icon-button { - @apply flex h-10 w-10 shrink-0 items-center justify-center border border-slate-200 bg-white text-slate-600; - box-shadow: var(--oc-shadow-xs); - } - - .oc-icon-button:hover, - .oc-icon-button:focus-visible, - .oc-map-button:hover, - .oc-map-button:focus-visible { - @apply border-teal-200 bg-teal-50 text-teal-800; - box-shadow: var(--oc-shadow-primary); - } - - .oc-icon-button--small { - @apply h-8 w-8; - } - - .oc-icon-button--tiny { - @apply h-6 w-6 text-xs; - box-shadow: 2px 2px 0 0 rgb(15 23 42 / 8%); - } - - .oc-icon-button--sky { - @apply border-sky-200 bg-sky-50 text-sky-700; - box-shadow: 3px 3px 0 0 rgb(14 165 233 / 12%); - } - - .oc-icon-button--sky:hover, - .oc-icon-button--sky:focus-visible { - @apply border-sky-300 bg-sky-100 text-sky-900; - box-shadow: 4px 4px 0 0 rgb(14 165 233 / 16%); - } - - .oc-icon-button--danger { - @apply border-rose-300 bg-rose-500 text-white; - box-shadow: 3px 3px 0 0 rgb(159 18 57 / 18%); - } - - .oc-icon-button--danger:hover, - .oc-icon-button--danger:focus-visible { - @apply border-rose-400 bg-rose-600 text-white; - box-shadow: 4px 4px 0 0 rgb(159 18 57 / 22%); - } - - .oc-overlay-button { - @apply flex shrink-0 items-center justify-center text-white/75 hover:bg-white/10 hover:text-white; - transition: - background-color var(--oc-duration-fast) var(--oc-ease-standard), - color var(--oc-duration-fast) var(--oc-ease-standard), - transform var(--oc-duration-fast) var(--oc-ease-standard); - } - - .oc-text-button { - @apply inline-flex items-center font-medium text-sky-700 underline-offset-4 hover:text-sky-900 hover:underline; - } - - .oc-text-button--muted { - @apply text-slate-500 hover:text-slate-800; - } - - .oc-text-button--danger { - @apply text-rose-600 hover:text-rose-800; - } - - .oc-choice-button { - @apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium; - } - - .oc-choice-button--inactive { - @apply border-slate-200 bg-white text-slate-600 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; - box-shadow: var(--oc-shadow-xs); - } - - .oc-choice-button--active { - @apply border-teal-200 bg-teal-50 text-teal-800; - box-shadow: var(--oc-shadow-primary); - } - - .oc-menu-item { - @apply flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-teal-50 hover:text-teal-800; - } - - .oc-menu-item--danger { - @apply text-rose-600 hover:bg-rose-50 hover:text-rose-700; - } - - .oc-menu-item--amber { - @apply hover:bg-amber-50 hover:text-amber-700; - } - - .oc-nav-link { - @apply px-3 py-2 text-sm font-medium uppercase tracking-[0.12em]; - } - - .oc-header-button { - @apply inline-flex h-8 items-center justify-center border px-3 text-sm font-medium md:h-10 md:px-4; - } - - .oc-header-button--neutral { - @apply border-slate-200 bg-white/80 text-slate-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; - box-shadow: var(--oc-shadow-xs); - } - - .oc-header-button--sky { - @apply border-sky-200 bg-sky-100 text-sky-800 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-900; - box-shadow: var(--oc-shadow-sky); - } - - .oc-header-button--teal { - @apply border-teal-100 bg-white/80 text-teal-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; - box-shadow: var(--oc-shadow-teal); - } - - .oc-header-button:hover { - box-shadow: 5px 5px 0 0 rgb(15 23 42 / 8%); - } - - .oc-interactive-card:focus-visible { - @apply outline-none ring-2 ring-sky-600 ring-offset-2; - } - - .oc-interactive-card { - box-shadow: var(--oc-shadow-md); - } - - .oc-interactive-card:hover { - box-shadow: var(--oc-shadow-lg); - } - - .oc-interactive-card--selected { - @apply border-sky-500; - box-shadow: var(--oc-shadow-sky) !important; - } - - .oc-interactive-row { - transition: - background-color var(--oc-duration-fast) var(--oc-ease-standard), - color var(--oc-duration-fast) var(--oc-ease-standard), - box-shadow var(--oc-duration-fast) var(--oc-ease-standard); - } - - .oc-interactive-row:hover { - @apply bg-sky-50/60; - } - - .oc-interactive-row:focus-visible, - .oc-heatmap-cell:focus-visible { - @apply outline-none ring-2 ring-sky-600 ring-offset-2; - } - - .oc-heatmap-cell { - transition: - border-color var(--oc-duration-fast) var(--oc-ease-standard), - box-shadow var(--oc-duration-fast) var(--oc-ease-standard), - transform var(--oc-duration-fast) var(--oc-ease-standard); - } - - :is( - .oc-primary-button, - .oc-panel-button, + .oc-page-content { + @apply py-8; + } + + .oc-page-eyebrow { + @apply text-sm font-medium uppercase tracking-[0.24em] text-sky-700; + } + + .oc-page-title { + @apply mt-3 text-4xl font-bold text-slate-900; + } + + .oc-page-description { + @apply mt-4 max-w-2xl text-sm leading-7 text-slate-600; + } + + .oc-brand-mark { + @apply flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-100; + box-shadow: var(--oc-shadow-sm); + } + + /* Reusable text hierarchy */ + .oc-section-label { + @apply text-xs uppercase tracking-[0.18em] text-slate-500; + } + + .oc-stat-label { + @apply text-xs uppercase tracking-[0.2em] text-slate-500; + } + + .oc-stat-value { + @apply mt-2 text-2xl font-bold text-slate-900; + } + + .oc-meta { + @apply text-sm text-slate-500; + } + + .oc-field-label { + @apply mb-1 block text-sm font-medium text-slate-700; + } + + .oc-field-required { + @apply text-rose-500; + } + + .oc-field-control { + @apply w-full border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-800 outline-none transition-colors; + @apply focus:border-sky-500 focus:ring-2 focus:ring-sky-100; + } + + .oc-field-error { + @apply mt-1 text-sm text-rose-600; + } + + .oc-field-help { + @apply mt-1 text-sm text-slate-500; + } + + /* Surfaces */ + .oc-panel-card { + box-shadow: var(--oc-shadow-md) !important; + } + + .oc-panel-card-xl { + box-shadow: var(--oc-shadow-xl) !important; + } + + .oc-floating-panel { + box-shadow: var(--oc-shadow-lg); + } + + .oc-panel-card-soft { + box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%) !important; + } + + .oc-empty-card { + box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%); + } + + .oc-surface { + @apply border border-slate-200 bg-white; + box-shadow: var(--oc-shadow-md); + } + + .oc-modal-shell { + @apply border border-slate-200 bg-white; + box-shadow: var(--oc-shadow-xl); + } + + .oc-inset-panel { + @apply border border-slate-200 bg-slate-50 p-4; + } + + .oc-map-button { + @apply flex h-10 w-10 items-center justify-center border border-slate-200 bg-white text-slate-600; + box-shadow: var(--oc-shadow-xs); + } + + /* Shared interactive contracts */ + .oc-panel-button:not(.n-button), .oc-header-button, .oc-icon-button, - .oc-overlay-button, .oc-map-button, .oc-choice-button, .oc-text-button, .oc-menu-item, - .oc-nav-link - ):focus-visible { - outline: 2px solid rgb(2 132 199); - outline-offset: 2px; - } + .oc-nav-link, + .oc-interactive-card { + transition: + background-color var(--oc-duration-fast) var(--oc-ease-standard), + color var(--oc-duration-fast) var(--oc-ease-standard), + border-color var(--oc-duration-fast) var(--oc-ease-standard), + box-shadow var(--oc-duration-fast) var(--oc-ease-standard), + transform var(--oc-duration-fast) var(--oc-ease-standard); + } - .oc-overlay-button:focus-visible { - outline-color: rgb(125 211 252); - } + .oc-panel-button:not(.n-button) { + @apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium; + } - :is(.oc-panel-button, .oc-header-button, .oc-icon-button, .oc-map-button, .oc-choice-button):disabled { - @apply cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400 shadow-none; - transform: none !important; - } + .oc-panel-button:not(.n-button):not(:disabled):hover, + .oc-header-button:not(:disabled):hover, + .oc-icon-button:not(:disabled):hover, + .oc-map-button:not(:disabled):hover, + .oc-choice-button:not(:disabled):hover { + transform: translate(-1px, -1px); + } - .oc-map-button:disabled { - box-shadow: var(--oc-shadow-xs); - } + .oc-panel-button--neutral:not(.n-button) { + @apply border-slate-200 bg-white text-slate-600; + box-shadow: var(--oc-shadow-xs); + } - :is(.oc-panel-button, .oc-primary-button, .oc-header-button, .oc-icon-button, .oc-map-button, .oc-choice-button):not(:disabled):active { - transform: translate(0, 0); - } + .oc-panel-button--neutral:not(.n-button):hover, + .oc-panel-button--neutral:not(.n-button):focus-visible { + @apply border-teal-200 bg-teal-50 text-teal-800; + box-shadow: var(--oc-shadow-primary); + } - .oc-system-page { - @apply flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-10; - } + .oc-panel-button--sky:not(.n-button) { + @apply border-sky-200 bg-sky-100 text-sky-800; + box-shadow: var(--oc-shadow-sky); + } - .oc-system-card { - @apply w-full max-w-2xl border border-slate-200 bg-white px-8 py-14 text-center; - box-shadow: var(--oc-shadow-xl); - } + .oc-panel-button--sky:not(.n-button):hover, + .oc-panel-button--sky:not(.n-button):focus-visible { + @apply border-sky-300 bg-sky-50 text-sky-900; + box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%); + } - /* Naive UI button contract */ - .oc-primary-button.n-button, - .oc-panel-button.n-button { - transition: - background-color var(--oc-duration-fast) var(--oc-ease-standard), - color var(--oc-duration-fast) var(--oc-ease-standard), - border-color var(--oc-duration-fast) var(--oc-ease-standard), - box-shadow var(--oc-duration-fast) var(--oc-ease-standard), - transform var(--oc-duration-fast) var(--oc-ease-standard); - } + .oc-panel-button--teal:not(.n-button) { + @apply border-teal-200 bg-teal-50 text-teal-800; + box-shadow: var(--oc-shadow-teal); + } - .oc-primary-button.n-button.n-button--disabled, - .oc-panel-button.n-button.n-button--disabled { - border-color: rgb(226 232 240) !important; - background: rgb(241 245 249) !important; - color: var(--oc-color-text-disabled) !important; - box-shadow: none !important; - opacity: 0.72; - transform: none !important; - } + .oc-panel-button--teal:not(.n-button):hover, + .oc-panel-button--teal:not(.n-button):focus-visible { + @apply border-teal-300 bg-teal-100 text-teal-900; + box-shadow: var(--oc-shadow-primary-hover); + } - .oc-primary-button.n-button .n-button__content, - .oc-panel-button.n-button .n-button__content { - font-weight: 500; - } + .oc-panel-button--danger:not(.n-button) { + @apply border-rose-200 bg-rose-50 text-rose-700; + box-shadow: 4px 4px 0 0 rgb(244 63 94 / 10%); + } - .oc-primary-button.n-button .n-button__content { - letter-spacing: 0.12em; - } + .oc-panel-button--danger:not(.n-button):hover, + .oc-panel-button--danger:not(.n-button):focus-visible { + @apply border-rose-300 bg-rose-100 text-rose-800; + box-shadow: 5px 5px 0 0 rgb(244 63 94 / 14%); + } - .oc-primary-button.n-button:not(.n-button--disabled):hover, - .oc-panel-button.n-button:not(.n-button--disabled):hover, - .oc-panel-button.n-button:focus-visible { - transform: translate(-1px, -1px); - } + .oc-panel-button--amber:not(.n-button) { + @apply border-amber-200 bg-amber-50 text-amber-800; + box-shadow: 4px 4px 0 0 rgb(245 158 11 / 10%); + } - .oc-primary-button--teal.n-button { - border-color: var(--color-teal-200) !important; - background: white !important; - color: var(--color-teal-700) !important; - box-shadow: var(--oc-shadow-teal) !important; - } + .oc-panel-button--amber:not(.n-button):hover, + .oc-panel-button--amber:not(.n-button):focus-visible { + @apply border-amber-300 bg-amber-100 text-amber-900; + box-shadow: 5px 5px 0 0 rgb(245 158 11 / 14%); + } - .oc-primary-button--teal.n-button:hover, - .oc-primary-button--teal.n-button:focus-visible { - border-color: var(--color-teal-300) !important; - background: var(--color-teal-50) !important; - color: var(--color-teal-800) !important; - } + .oc-icon-button { + @apply flex h-10 w-10 shrink-0 items-center justify-center border border-slate-200 bg-white text-slate-600; + box-shadow: var(--oc-shadow-xs); + } - .oc-primary-button--teal.n-button:not(.n-button--disabled):hover { - box-shadow: var(--oc-shadow-primary-hover) !important; - } + .oc-icon-button:hover, + .oc-icon-button:focus-visible, + .oc-map-button:hover, + .oc-map-button:focus-visible { + @apply border-teal-200 bg-teal-50 text-teal-800; + box-shadow: var(--oc-shadow-primary); + } - .oc-primary-button--sky.n-button { - border-color: rgb(186 230 253) !important; - background: rgb(255 255 255) !important; - color: rgb(7 89 133) !important; - box-shadow: var(--oc-shadow-sky) !important; - } + .oc-icon-button--small { + @apply h-8 w-8; + } - .oc-primary-button--sky.n-button:hover, - .oc-primary-button--sky.n-button:focus-visible { - border-color: rgb(125 211 252) !important; - background: rgb(240 249 255) !important; - color: rgb(12 74 110) !important; - } + .oc-icon-button--tiny { + @apply h-6 w-6 text-xs; + box-shadow: 2px 2px 0 0 rgb(15 23 42 / 8%); + } - .oc-primary-button--sky.n-button:not(.n-button--disabled):hover { - box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%) !important; - } + .oc-icon-button--sky { + @apply border-sky-200 bg-sky-50 text-sky-700; + box-shadow: 3px 3px 0 0 rgb(14 165 233 / 12%); + } - .oc-panel-button.n-button { - --n-color: white !important; - --n-color-hover: var(--color-teal-50) !important; - --n-color-pressed: var(--color-teal-100) !important; - --n-border: 1px solid rgb(226 232 240) !important; - --n-border-hover: 1px solid var(--color-teal-200) !important; - --n-border-pressed: 1px solid var(--color-teal-300) !important; - --n-text-color: rgb(71 85 105) !important; - --n-text-color-hover: var(--color-teal-800) !important; - --n-text-color-pressed: var(--color-teal-700) !important; - border-color: rgb(226 232 240) !important; - background: white !important; - color: rgb(71 85 105) !important; - box-shadow: var(--oc-shadow-xs) !important; - } + .oc-icon-button--sky:hover, + .oc-icon-button--sky:focus-visible { + @apply border-sky-300 bg-sky-100 text-sky-900; + box-shadow: 4px 4px 0 0 rgb(14 165 233 / 16%); + } - .oc-panel-button--neutral.n-button:hover, - .oc-panel-button--neutral.n-button:focus-visible { - border-color: var(--color-teal-200) !important; - background: var(--color-teal-50) !important; - color: var(--color-teal-800) !important; - box-shadow: var(--oc-shadow-primary) !important; - } + .oc-icon-button--danger { + @apply border-rose-300 bg-rose-500 text-white; + box-shadow: 3px 3px 0 0 rgb(159 18 57 / 18%); + } - .oc-panel-button--sky.n-button { - --n-color: rgb(224 242 254) !important; - --n-color-hover: rgb(240 249 255) !important; - --n-color-pressed: rgb(224 242 254) !important; - --n-border: 1px solid rgb(186 230 253) !important; - --n-border-hover: 1px solid rgb(125 211 252) !important; - --n-border-pressed: 1px solid rgb(56 189 248) !important; - --n-text-color: rgb(7 89 133) !important; - --n-text-color-hover: rgb(12 74 110) !important; - --n-text-color-pressed: rgb(12 74 110) !important; - border-color: rgb(186 230 253) !important; - background: rgb(224 242 254) !important; - color: rgb(7 89 133) !important; - box-shadow: var(--oc-shadow-sky) !important; - } + .oc-icon-button--danger:hover, + .oc-icon-button--danger:focus-visible { + @apply border-rose-400 bg-rose-600 text-white; + box-shadow: 4px 4px 0 0 rgb(159 18 57 / 22%); + } - .oc-panel-button--sky.n-button:hover, - .oc-panel-button--sky.n-button:focus-visible { - border-color: rgb(125 211 252) !important; - background: rgb(240 249 255) !important; - color: rgb(12 74 110) !important; - box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%) !important; - } + .oc-overlay-button { + @apply flex shrink-0 items-center justify-center text-white/75 hover:bg-white/10 hover:text-white; + transition: + background-color var(--oc-duration-fast) var(--oc-ease-standard), + color var(--oc-duration-fast) var(--oc-ease-standard), + transform var(--oc-duration-fast) var(--oc-ease-standard); + } - .oc-panel-button--teal.n-button { - --n-color: var(--color-teal-50) !important; - --n-color-hover: var(--color-teal-100) !important; - --n-color-pressed: var(--color-teal-200) !important; - --n-border: 1px solid var(--color-teal-200) !important; - --n-border-hover: 1px solid var(--color-teal-300) !important; - --n-border-pressed: 1px solid var(--color-teal-400) !important; - --n-text-color: var(--color-teal-700) !important; - --n-text-color-hover: var(--color-teal-800) !important; - --n-text-color-pressed: var(--color-teal-800) !important; - border-color: var(--color-teal-200) !important; - background: var(--color-teal-50) !important; - color: var(--color-teal-700) !important; - box-shadow: var(--oc-shadow-teal) !important; - } + .oc-text-button { + @apply inline-flex items-center font-medium text-sky-700 underline-offset-4 hover:text-sky-900 hover:underline; + } - .oc-panel-button--teal.n-button:hover, - .oc-panel-button--teal.n-button:focus-visible { - border-color: var(--color-teal-300) !important; - background: var(--color-teal-100) !important; - color: var(--color-teal-800) !important; - box-shadow: var(--oc-shadow-primary-hover) !important; - } + .oc-text-button--muted { + @apply text-slate-500 hover:text-slate-800; + } - .oc-panel-button--danger.n-button { - --n-color: rgb(255 241 242) !important; - --n-color-hover: rgb(255 228 230) !important; - --n-color-pressed: rgb(255 228 230) !important; - --n-border: 1px solid rgb(254 205 211) !important; - --n-border-hover: 1px solid rgb(253 164 175) !important; - --n-border-pressed: 1px solid rgb(251 113 133) !important; - --n-text-color: rgb(190 24 93) !important; - --n-text-color-hover: rgb(159 18 57) !important; - --n-text-color-pressed: rgb(159 18 57) !important; - border-color: rgb(254 205 211) !important; - background: rgb(255 241 242) !important; - color: rgb(190 24 93) !important; - box-shadow: 4px 4px 0 0 rgb(244 63 94 / 10%) !important; - } + .oc-text-button--danger { + @apply text-rose-600 hover:text-rose-800; + } - .oc-panel-button--danger.n-button:hover, - .oc-panel-button--danger.n-button:focus-visible { - border-color: rgb(253 164 175) !important; - background: rgb(255 228 230) !important; - color: rgb(159 18 57) !important; - box-shadow: 5px 5px 0 0 rgb(244 63 94 / 14%) !important; - } + .oc-choice-button { + @apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium; + } - .oc-panel-button--amber.n-button { - --n-color: rgb(255 251 235) !important; - --n-color-hover: rgb(254 243 199) !important; - --n-color-pressed: rgb(253 230 138) !important; - --n-border: 1px solid rgb(253 230 138) !important; - --n-border-hover: 1px solid rgb(252 211 77) !important; - --n-border-pressed: 1px solid rgb(245 158 11) !important; - --n-text-color: rgb(146 64 14) !important; - --n-text-color-hover: rgb(120 53 15) !important; - --n-text-color-pressed: rgb(120 53 15) !important; - border-color: rgb(253 230 138) !important; - background: rgb(255 251 235) !important; - color: rgb(146 64 14) !important; - box-shadow: 4px 4px 0 0 rgb(245 158 11 / 10%) !important; - } + .oc-choice-button--inactive { + @apply border-slate-200 bg-white text-slate-600 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; + box-shadow: var(--oc-shadow-xs); + } - .oc-panel-button--amber.n-button:hover, - .oc-panel-button--amber.n-button:focus-visible { - border-color: rgb(252 211 77) !important; - background: rgb(254 243 199) !important; - color: rgb(120 53 15) !important; - box-shadow: 5px 5px 0 0 rgb(245 158 11 / 14%) !important; - } + .oc-choice-button--active { + @apply border-teal-200 bg-teal-50 text-teal-800; + box-shadow: var(--oc-shadow-primary); + } + + .oc-menu-item { + @apply flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-teal-50 hover:text-teal-800; + } + + .oc-menu-item--danger { + @apply text-rose-600 hover:bg-rose-50 hover:text-rose-700; + } + + .oc-menu-item--amber { + @apply hover:bg-amber-50 hover:text-amber-700; + } + + .oc-nav-link { + @apply px-3 py-2 text-sm font-medium uppercase tracking-[0.12em]; + } + + .oc-header-button { + @apply inline-flex h-8 items-center justify-center border px-3 text-sm font-medium md:h-10 md:px-4; + } + + .oc-header-button--neutral { + @apply border-slate-200 bg-white/80 text-slate-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; + box-shadow: var(--oc-shadow-xs); + } + + .oc-header-button--sky { + @apply border-sky-200 bg-sky-100 text-sky-800 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-900; + box-shadow: var(--oc-shadow-sky); + } + + .oc-header-button--teal { + @apply border-teal-100 bg-white/80 text-teal-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; + box-shadow: var(--oc-shadow-teal); + } + + .oc-header-button:hover { + box-shadow: 5px 5px 0 0 rgb(15 23 42 / 8%); + } + + .oc-interactive-card:focus-visible { + @apply outline-none ring-2 ring-sky-600 ring-offset-2; + } + + .oc-interactive-card { + box-shadow: var(--oc-shadow-md); + } + + .oc-interactive-card:hover { + box-shadow: var(--oc-shadow-lg); + } + + .oc-interactive-card--selected { + @apply border-sky-500; + box-shadow: var(--oc-shadow-sky) !important; + } + + .oc-interactive-row { + transition: + background-color var(--oc-duration-fast) var(--oc-ease-standard), + color var(--oc-duration-fast) var(--oc-ease-standard), + box-shadow var(--oc-duration-fast) var(--oc-ease-standard); + } + + .oc-interactive-row:hover { + @apply bg-sky-50/60; + } + + .oc-interactive-row:focus-visible, + .oc-heatmap-cell:focus-visible { + @apply outline-none ring-2 ring-sky-600 ring-offset-2; + } + + .oc-heatmap-cell { + transition: + border-color var(--oc-duration-fast) var(--oc-ease-standard), + box-shadow var(--oc-duration-fast) var(--oc-ease-standard), + transform var(--oc-duration-fast) var(--oc-ease-standard); + } + + :is( + .oc-primary-button, + .oc-panel-button, + .oc-header-button, + .oc-icon-button, + .oc-overlay-button, + .oc-map-button, + .oc-choice-button, + .oc-text-button, + .oc-menu-item, + .oc-nav-link + ):focus-visible { + outline: 2px solid rgb(2 132 199); + outline-offset: 2px; + } + + .oc-overlay-button:focus-visible { + outline-color: rgb(125 211 252); + } + + :is( + .oc-panel-button, + .oc-header-button, + .oc-icon-button, + .oc-map-button, + .oc-choice-button + ):disabled { + @apply cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400 shadow-none; + transform: none !important; + } + + .oc-map-button:disabled { + box-shadow: var(--oc-shadow-xs); + } + + :is( + .oc-panel-button, + .oc-primary-button, + .oc-header-button, + .oc-icon-button, + .oc-map-button, + .oc-choice-button + ):not(:disabled):active { + transform: translate(0, 0); + } + + .oc-system-page { + @apply flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-10; + } + + .oc-system-card { + @apply w-full max-w-2xl border border-slate-200 bg-white px-8 py-14 text-center; + box-shadow: var(--oc-shadow-xl); + } + + /* Naive UI button contract */ + .oc-primary-button.n-button, + .oc-panel-button.n-button { + transition: + background-color var(--oc-duration-fast) var(--oc-ease-standard), + color var(--oc-duration-fast) var(--oc-ease-standard), + border-color var(--oc-duration-fast) var(--oc-ease-standard), + box-shadow var(--oc-duration-fast) var(--oc-ease-standard), + transform var(--oc-duration-fast) var(--oc-ease-standard); + } + + .oc-primary-button.n-button.n-button--disabled, + .oc-panel-button.n-button.n-button--disabled { + border-color: rgb(226 232 240) !important; + background: rgb(241 245 249) !important; + color: var(--oc-color-text-disabled) !important; + box-shadow: none !important; + opacity: 0.72; + transform: none !important; + } + + .oc-primary-button.n-button .n-button__content, + .oc-panel-button.n-button .n-button__content { + font-weight: 500; + } + + .oc-primary-button.n-button .n-button__content { + letter-spacing: 0.12em; + } + + .oc-primary-button.n-button:not(.n-button--disabled):hover, + .oc-panel-button.n-button:not(.n-button--disabled):hover, + .oc-panel-button.n-button:focus-visible { + transform: translate(-1px, -1px); + } + + .oc-primary-button--teal.n-button { + border-color: var(--color-teal-200) !important; + background: white !important; + color: var(--color-teal-700) !important; + box-shadow: var(--oc-shadow-teal) !important; + } + + .oc-primary-button--teal.n-button:hover, + .oc-primary-button--teal.n-button:focus-visible { + border-color: var(--color-teal-300) !important; + background: var(--color-teal-50) !important; + color: var(--color-teal-800) !important; + } + + .oc-primary-button--teal.n-button:not(.n-button--disabled):hover { + box-shadow: var(--oc-shadow-primary-hover) !important; + } + + .oc-primary-button--sky.n-button { + border-color: rgb(186 230 253) !important; + background: rgb(255 255 255) !important; + color: rgb(7 89 133) !important; + box-shadow: var(--oc-shadow-sky) !important; + } + + .oc-primary-button--sky.n-button:hover, + .oc-primary-button--sky.n-button:focus-visible { + border-color: rgb(125 211 252) !important; + background: rgb(240 249 255) !important; + color: rgb(12 74 110) !important; + } + + .oc-primary-button--sky.n-button:not(.n-button--disabled):hover { + box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%) !important; + } + + .oc-panel-button.n-button { + --n-color: white !important; + --n-color-hover: var(--color-teal-50) !important; + --n-color-pressed: var(--color-teal-100) !important; + --n-border: 1px solid rgb(226 232 240) !important; + --n-border-hover: 1px solid var(--color-teal-200) !important; + --n-border-pressed: 1px solid var(--color-teal-300) !important; + --n-text-color: rgb(71 85 105) !important; + --n-text-color-hover: var(--color-teal-800) !important; + --n-text-color-pressed: var(--color-teal-700) !important; + border-color: rgb(226 232 240) !important; + background: white !important; + color: rgb(71 85 105) !important; + box-shadow: var(--oc-shadow-xs) !important; + } + + .oc-panel-button--neutral.n-button:hover, + .oc-panel-button--neutral.n-button:focus-visible { + border-color: var(--color-teal-200) !important; + background: var(--color-teal-50) !important; + color: var(--color-teal-800) !important; + box-shadow: var(--oc-shadow-primary) !important; + } + + .oc-panel-button--sky.n-button { + --n-color: rgb(224 242 254) !important; + --n-color-hover: rgb(240 249 255) !important; + --n-color-pressed: rgb(224 242 254) !important; + --n-border: 1px solid rgb(186 230 253) !important; + --n-border-hover: 1px solid rgb(125 211 252) !important; + --n-border-pressed: 1px solid rgb(56 189 248) !important; + --n-text-color: rgb(7 89 133) !important; + --n-text-color-hover: rgb(12 74 110) !important; + --n-text-color-pressed: rgb(12 74 110) !important; + border-color: rgb(186 230 253) !important; + background: rgb(224 242 254) !important; + color: rgb(7 89 133) !important; + box-shadow: var(--oc-shadow-sky) !important; + } + + .oc-panel-button--sky.n-button:hover, + .oc-panel-button--sky.n-button:focus-visible { + border-color: rgb(125 211 252) !important; + background: rgb(240 249 255) !important; + color: rgb(12 74 110) !important; + box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%) !important; + } + + .oc-panel-button--teal.n-button { + --n-color: var(--color-teal-50) !important; + --n-color-hover: var(--color-teal-100) !important; + --n-color-pressed: var(--color-teal-200) !important; + --n-border: 1px solid var(--color-teal-200) !important; + --n-border-hover: 1px solid var(--color-teal-300) !important; + --n-border-pressed: 1px solid var(--color-teal-400) !important; + --n-text-color: var(--color-teal-700) !important; + --n-text-color-hover: var(--color-teal-800) !important; + --n-text-color-pressed: var(--color-teal-800) !important; + border-color: var(--color-teal-200) !important; + background: var(--color-teal-50) !important; + color: var(--color-teal-700) !important; + box-shadow: var(--oc-shadow-teal) !important; + } + + .oc-panel-button--teal.n-button:hover, + .oc-panel-button--teal.n-button:focus-visible { + border-color: var(--color-teal-300) !important; + background: var(--color-teal-100) !important; + color: var(--color-teal-800) !important; + box-shadow: var(--oc-shadow-primary-hover) !important; + } + + .oc-panel-button--danger.n-button { + --n-color: rgb(255 241 242) !important; + --n-color-hover: rgb(255 228 230) !important; + --n-color-pressed: rgb(255 228 230) !important; + --n-border: 1px solid rgb(254 205 211) !important; + --n-border-hover: 1px solid rgb(253 164 175) !important; + --n-border-pressed: 1px solid rgb(251 113 133) !important; + --n-text-color: rgb(190 24 93) !important; + --n-text-color-hover: rgb(159 18 57) !important; + --n-text-color-pressed: rgb(159 18 57) !important; + border-color: rgb(254 205 211) !important; + background: rgb(255 241 242) !important; + color: rgb(190 24 93) !important; + box-shadow: 4px 4px 0 0 rgb(244 63 94 / 10%) !important; + } + + .oc-panel-button--danger.n-button:hover, + .oc-panel-button--danger.n-button:focus-visible { + border-color: rgb(253 164 175) !important; + background: rgb(255 228 230) !important; + color: rgb(159 18 57) !important; + box-shadow: 5px 5px 0 0 rgb(244 63 94 / 14%) !important; + } + + .oc-panel-button--amber.n-button { + --n-color: rgb(255 251 235) !important; + --n-color-hover: rgb(254 243 199) !important; + --n-color-pressed: rgb(253 230 138) !important; + --n-border: 1px solid rgb(253 230 138) !important; + --n-border-hover: 1px solid rgb(252 211 77) !important; + --n-border-pressed: 1px solid rgb(245 158 11) !important; + --n-text-color: rgb(146 64 14) !important; + --n-text-color-hover: rgb(120 53 15) !important; + --n-text-color-pressed: rgb(120 53 15) !important; + border-color: rgb(253 230 138) !important; + background: rgb(255 251 235) !important; + color: rgb(146 64 14) !important; + box-shadow: 4px 4px 0 0 rgb(245 158 11 / 10%) !important; + } + + .oc-panel-button--amber.n-button:hover, + .oc-panel-button--amber.n-button:focus-visible { + border-color: rgb(252 211 77) !important; + background: rgb(254 243 199) !important; + color: rgb(120 53 15) !important; + box-shadow: 5px 5px 0 0 rgb(245 158 11 / 14%) !important; + } } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index f500f00..19e0911 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -5,87 +5,87 @@ * values live here so the Vue and Naive UI layers share one visual contract. */ :root { - color-scheme: light; + color-scheme: light; - /* + /* * Tailwind compatibility palettes. * Existing teal/emerald utilities intentionally resolve to OpenCloud's * fresh eucalyptus and leaf colors instead of Tailwind's vivid defaults. */ - --color-teal-50: #f2faf6; - --color-teal-100: #ddf2e6; - --color-teal-200: #bce4cf; - --color-teal-300: #8fceb0; - --color-teal-400: #60b18d; - --color-teal-500: #438f71; - --color-teal-600: #34745d; - --color-teal-700: #2d5f4e; - --color-teal-800: #274c40; - --color-teal-900: #213e35; + --color-teal-50: #f2faf6; + --color-teal-100: #ddf2e6; + --color-teal-200: #bce4cf; + --color-teal-300: #8fceb0; + --color-teal-400: #60b18d; + --color-teal-500: #438f71; + --color-teal-600: #34745d; + --color-teal-700: #2d5f4e; + --color-teal-800: #274c40; + --color-teal-900: #213e35; - --color-emerald-50: #f4faef; - --color-emerald-100: #e5f3d9; - --color-emerald-200: #cce7b7; - --color-emerald-300: #a8d58d; - --color-emerald-400: #7fbc63; - --color-emerald-500: #609d48; - --color-emerald-600: #4d7f3b; - --color-emerald-700: #3f6633; - --color-emerald-800: #35522c; - --color-emerald-900: #2d4427; + --color-emerald-50: #f4faef; + --color-emerald-100: #e5f3d9; + --color-emerald-200: #cce7b7; + --color-emerald-300: #a8d58d; + --color-emerald-400: #7fbc63; + --color-emerald-500: #609d48; + --color-emerald-600: #4d7f3b; + --color-emerald-700: #3f6633; + --color-emerald-800: #35522c; + --color-emerald-900: #2d4427; - --oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif; - --oc-font-mono: 'IBM Plex Mono', 'SFMono-Regular', monospace; + --oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif; + --oc-font-mono: 'IBM Plex Mono', 'SFMono-Regular', monospace; - --oc-color-canvas: #eef4f8; - --oc-color-surface: #ffffff; - --oc-color-surface-muted: #f8fafc; - --oc-color-text: #0f172a; - --oc-color-text-secondary: #334155; - --oc-color-text-muted: #64748b; - --oc-color-text-disabled: #94a3b8; - --oc-color-border: #d9e3ee; - --oc-color-primary: #34745d; - --oc-color-primary-hover: #2d5f4e; - --oc-color-primary-pressed: #274c40; - --oc-color-info: #0369a1; - --oc-color-success: #4d7f3b; - --oc-color-warning: #d97706; - --oc-color-danger: #dc2626; - --oc-color-selection: rgb(52 116 93 / 20%); + --oc-color-canvas: #eef4f8; + --oc-color-surface: #ffffff; + --oc-color-surface-muted: #f8fafc; + --oc-color-text: #0f172a; + --oc-color-text-secondary: #334155; + --oc-color-text-muted: #64748b; + --oc-color-text-disabled: #94a3b8; + --oc-color-border: #d9e3ee; + --oc-color-primary: #34745d; + --oc-color-primary-hover: #2d5f4e; + --oc-color-primary-pressed: #274c40; + --oc-color-info: #0369a1; + --oc-color-success: #4d7f3b; + --oc-color-warning: #d97706; + --oc-color-danger: #dc2626; + --oc-color-selection: rgb(52 116 93 / 20%); - /* Contribution heatmap: no activity + GitHub-style contribution quartiles. */ - --oc-heatmap-empty: #f1f5f9; - --oc-heatmap-empty-border: #d9e3ee; - --oc-heatmap-level-1: #ddf2e6; - --oc-heatmap-level-1-border: #bce4cf; - --oc-heatmap-level-2: #8fceb0; - --oc-heatmap-level-2-border: #60b18d; - --oc-heatmap-level-3: #438f71; - --oc-heatmap-level-3-border: #34745d; - --oc-heatmap-level-4: #274c40; - --oc-heatmap-level-4-border: #213e35; + /* Contribution heatmap: no activity + GitHub-style contribution quartiles. */ + --oc-heatmap-empty: #f1f5f9; + --oc-heatmap-empty-border: #d9e3ee; + --oc-heatmap-level-1: #ddf2e6; + --oc-heatmap-level-1-border: #bce4cf; + --oc-heatmap-level-2: #8fceb0; + --oc-heatmap-level-2-border: #60b18d; + --oc-heatmap-level-3: #438f71; + --oc-heatmap-level-3-border: #34745d; + --oc-heatmap-level-4: #274c40; + --oc-heatmap-level-4-border: #213e35; - --oc-gradient-canvas: - radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%), - linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%); - --oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%); - --oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%); - --oc-gradient-grid: - linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px), - linear-gradient(90deg, rgb(15 23 42 / 3%) 1px, transparent 1px); + --oc-gradient-canvas: + radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%), + linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%); + --oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%); + --oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%); + --oc-gradient-grid: + linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px), + linear-gradient(90deg, rgb(15 23 42 / 3%) 1px, transparent 1px); - --oc-shadow-xs: 3px 3px 0 0 rgb(15 23 42 / 6%); - --oc-shadow-sm: 4px 4px 0 0 rgb(15 23 42 / 6%); - --oc-shadow-md: 6px 6px 0 0 rgb(15 23 42 / 5%); - --oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%); - --oc-shadow-xl: 12px 12px 0 0 rgb(15 23 42 / 8%); - --oc-shadow-sky: 4px 4px 0 0 rgb(14 165 233 / 14%); - --oc-shadow-primary: 4px 4px 0 0 rgb(52 116 93 / 14%); - --oc-shadow-primary-hover: 5px 5px 0 0 rgb(52 116 93 / 20%); - --oc-shadow-amber: 5px 5px 0 0 rgb(245 158 11 / 12%); - --oc-shadow-teal: var(--oc-shadow-primary); + --oc-shadow-xs: 3px 3px 0 0 rgb(15 23 42 / 6%); + --oc-shadow-sm: 4px 4px 0 0 rgb(15 23 42 / 6%); + --oc-shadow-md: 6px 6px 0 0 rgb(15 23 42 / 5%); + --oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%); + --oc-shadow-xl: 12px 12px 0 0 rgb(15 23 42 / 8%); + --oc-shadow-sky: 4px 4px 0 0 rgb(14 165 233 / 14%); + --oc-shadow-primary: 4px 4px 0 0 rgb(52 116 93 / 14%); + --oc-shadow-primary-hover: 5px 5px 0 0 rgb(52 116 93 / 20%); + --oc-shadow-amber: 5px 5px 0 0 rgb(245 158 11 / 12%); + --oc-shadow-teal: var(--oc-shadow-primary); - --oc-duration-fast: 160ms; - --oc-ease-standard: ease; + --oc-duration-fast: 160ms; + --oc-ease-standard: ease; } diff --git a/src/types/amap.d.ts b/src/types/amap.d.ts deleted file mode 100644 index 2c53705..0000000 --- a/src/types/amap.d.ts +++ /dev/null @@ -1,126 +0,0 @@ -declare namespace AMap { - class Map { - constructor(container: string | HTMLElement, opts?: MapOptions) - addControl(control: unknown): void - add(layer: unknown): void - remove(layer: unknown): void - destroy(): void - setCenter(center: [number, number]): void - setZoom(zoom: number, immediately?: boolean, duration?: number): void - setPitch(pitch: number): void - getPitch(): number - setRotation(rotation: number): void - getRotation(): number - on(event: string, callback: (...args: unknown[]) => void): void - off(event: string, callback: (...args: unknown[]) => void): void - getCenter(): { lng: number; lat: number } - getZoom(digits?: number): number - getAllOverlays(type?: string): unknown[] - addOverlay(overlay: unknown): void - removeOverlay(overlay: unknown): void - clearMap(): void - } - - interface MapOptions { - viewMode?: '2D' | '3D' - pitch?: number - rotation?: number - zoom?: number - zooms?: [number, number] - center?: [number, number] - mapStyle?: string - features?: string[] - layers?: unknown[] - skyColor?: string - maxPitch?: number - resizeEnable?: boolean - dragEnable?: boolean - zoomEnable?: boolean - keyboardEnable?: boolean - doubleClickZoom?: boolean - scrollWheel?: boolean - } - - class Marker { - constructor(opts?: MarkerOptions) - on(event: string, callback: (...args: unknown[]) => void): void - setPosition(position: [number, number]): void - getPosition(): { lng: number; lat: number } - setContent(content: string): void - setOpacity(opacity: number): void - setMap(map: Map | null): void - remove(): void - } - - interface MarkerOptions { - position?: [number, number] - content?: string | HTMLElement - offset?: Pixel - zIndex?: number - opacity?: number - title?: string - } - - class InfoWindow { - constructor(opts?: InfoWindowOptions) - open(map: Map, position?: [number, number]): void - close(): void - } - - interface InfoWindowOptions { - content?: string | HTMLElement - offset?: Pixel - size?: Size - isCustom?: boolean - } - - class Scale {} - class ToolBar { - constructor(opts?: Record) - } - class ControlBar { - constructor(opts?: Record) - } - class Geolocation { - constructor(opts?: Record) - getCurrentPosition( - onSuccess: (data: { position: { lng: number; lat: number } }) => void, - onError?: (error: unknown) => void, - ): void - } - - class TileLayer { - static Satellite: new () => TileLayer - static RoadNet: new () => TileLayer - setOpacity(opacity: number): void - } - - class Pixel { - constructor(x: number, y: number) - } - - class Size { - constructor(width: number, height: number) - } - - class MarkerCluster { - constructor(map: Map, markers: Marker[], opts?: Record) - } -} - -declare function AMapLoader_load(opts: { - key: string - version: string - plugins?: string[] -}): Promise - -declare namespace AMap { - function getConfig(): { appname?: string } -} - -declare module '@amap/amap-jsapi-loader' { - const AMapLoader: { - load: typeof AMapLoader_load - } - export default AMapLoader -} diff --git a/src/types/api.ts b/src/types/api.ts index 9a96902..c3cb752 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,98 +1,94 @@ 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 + 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 + 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 + 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 + 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 RegisterResponse extends ActionResponse { - email_sent: boolean -} - -export interface LoginResponse { - access_token: string + status_code: number + message: string } export interface CloudCreateResponse extends ActionResponse { - cloud_id: string + cloud_id: string +} + +export interface CloudTypeCreateResponse extends ActionResponse { + cloud_type_id: number } // Internal view model. The backend profile is normalized here so existing // layout/admin consumers do not need to understand the wire representation. export interface AuthUser { - id: string - email: string - username: string - avatar_id: string | null - role: Profile['role'] - is_disabled: boolean - cloud_count: number - received_like_count: number - created_at: string + 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 + id: number + name: string + rarity: CloudType['rarity'] + rarity_value: number } export interface ApiCloudOwner { - id: string - username: string + 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 + 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 } diff --git a/src/types/database.ts b/src/types/database.ts index 012df05..ed30fa0 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -1,34 +1,34 @@ export interface CloudType { - id: number - name: string - genus: string - icon_id: string | null - rarity: 'common' | 'uncommon' | 'rare' - rarity_value: number - description: string | null - created_at: string + id: number + name: string + genus: string + icon_id: string | null + rarity: 'common' | 'uncommon' | 'rare' + rarity_value: number + description: string | null + created_at: string } export interface Cloud { - id: string - user_id: string - cloud_type_id: number | null - image_url: string - thumbnail_url: string | null - latitude: number | null - longitude: number | null - description: string | null - captured_at: string | null - status: 'pending' | 'approved' | 'rejected' - is_hidden: boolean - created_at: string + id: string + user_id: string + cloud_type_id: number | null + image_url: string + thumbnail_url: string | null + latitude: number | null + longitude: number | null + description: string | null + captured_at: string | null + status: 'pending' | 'approved' | 'rejected' + is_hidden: boolean + created_at: string } export interface Profile { - id: string - username: string - avatar_id: string | null - role: 'user' | 'admin' - is_disabled: boolean - created_at: string + id: string + username: string + image: string | null + role: 'user' | 'admin' + is_disabled: boolean + created_at: string } diff --git a/src/types/router.d.ts b/src/types/router.d.ts index 6dcaa7e..2e9cca9 100644 --- a/src/types/router.d.ts +++ b/src/types/router.d.ts @@ -1,12 +1,12 @@ import type { RouteLocationNormalizedLoaded } from 'vue-router' declare module 'vue-router' { - interface RouteMeta { - title?: string | ((route: RouteLocationNormalizedLoaded) => string) - description?: string | ((route: RouteLocationNormalizedLoaded) => string) - canonicalPath?: string - noindex?: boolean - requiresAuth?: boolean - requiresAdmin?: boolean - } + interface RouteMeta { + title?: string | ((route: RouteLocationNormalizedLoaded) => string) + description?: string | ((route: RouteLocationNormalizedLoaded) => string) + canonicalPath?: string + noindex?: boolean + requiresAuth?: boolean + requiresAdmin?: boolean + } } diff --git a/src/views/admin/AdminView.vue b/src/views/admin/AdminView.vue deleted file mode 100644 index f17eafb..0000000 --- a/src/views/admin/AdminView.vue +++ /dev/null @@ -1,1407 +0,0 @@ - - - - - diff --git a/src/views/auth/AuthConfirmView.vue b/src/views/auth/AuthConfirmView.vue deleted file mode 100644 index 88c977f..0000000 --- a/src/views/auth/AuthConfirmView.vue +++ /dev/null @@ -1,74 +0,0 @@ - - - diff --git a/src/views/auth/ForgotPasswordView.vue b/src/views/auth/ForgotPasswordView.vue deleted file mode 100644 index 0092adb..0000000 --- a/src/views/auth/ForgotPasswordView.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - diff --git a/src/views/auth/LoginView.vue b/src/views/auth/LoginView.vue deleted file mode 100644 index 8b93c7b..0000000 --- a/src/views/auth/LoginView.vue +++ /dev/null @@ -1,147 +0,0 @@ - - - diff --git a/src/views/auth/RegisterView.vue b/src/views/auth/RegisterView.vue deleted file mode 100644 index 8d8ed7e..0000000 --- a/src/views/auth/RegisterView.vue +++ /dev/null @@ -1,155 +0,0 @@ - - - diff --git a/src/views/auth/ResendConfirmationView.vue b/src/views/auth/ResendConfirmationView.vue deleted file mode 100644 index 8724be3..0000000 --- a/src/views/auth/ResendConfirmationView.vue +++ /dev/null @@ -1,83 +0,0 @@ - - - diff --git a/src/views/auth/ResetPasswordView.vue b/src/views/auth/ResetPasswordView.vue deleted file mode 100644 index a22dde3..0000000 --- a/src/views/auth/ResetPasswordView.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - diff --git a/src/views/community/CommunityView.vue b/src/views/community/CommunityView.vue deleted file mode 100644 index f34297f..0000000 --- a/src/views/community/CommunityView.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/src/views/encyclopedia/CloudTypeView.vue b/src/views/encyclopedia/CloudTypeView.vue deleted file mode 100644 index 907f76f..0000000 --- a/src/views/encyclopedia/CloudTypeView.vue +++ /dev/null @@ -1,142 +0,0 @@ - - - diff --git a/src/views/encyclopedia/EncyclopediaView.vue b/src/views/encyclopedia/EncyclopediaView.vue deleted file mode 100644 index 443d36f..0000000 --- a/src/views/encyclopedia/EncyclopediaView.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - diff --git a/src/views/gallery/GalleryView.vue b/src/views/gallery/GalleryView.vue deleted file mode 100644 index 2a7023e..0000000 --- a/src/views/gallery/GalleryView.vue +++ /dev/null @@ -1,590 +0,0 @@ - - - diff --git a/src/views/map/MapView.vue b/src/views/map/MapView.vue deleted file mode 100644 index cb3bf52..0000000 --- a/src/views/map/MapView.vue +++ /dev/null @@ -1,961 +0,0 @@ - - - - - diff --git a/src/views/profile/ProfileSettingsView.vue b/src/views/profile/ProfileSettingsView.vue deleted file mode 100644 index 717a378..0000000 --- a/src/views/profile/ProfileSettingsView.vue +++ /dev/null @@ -1,220 +0,0 @@ - - - diff --git a/src/views/profile/ProfileView.vue b/src/views/profile/ProfileView.vue deleted file mode 100644 index 5b0d6fc..0000000 --- a/src/views/profile/ProfileView.vue +++ /dev/null @@ -1,803 +0,0 @@ - - - - - diff --git a/src/views/system/AuthRequiredView.vue b/src/views/system/AuthRequiredView.vue deleted file mode 100644 index 25a97f0..0000000 --- a/src/views/system/AuthRequiredView.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/src/views/system/ForbiddenView.vue b/src/views/system/ForbiddenView.vue deleted file mode 100644 index fbc768d..0000000 --- a/src/views/system/ForbiddenView.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/views/system/NotFoundView.vue b/src/views/system/NotFoundView.vue deleted file mode 100644 index 8b95fe0..0000000 --- a/src/views/system/NotFoundView.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/views/upload/UploadView.vue b/src/views/upload/UploadView.vue deleted file mode 100644 index 6c6f515..0000000 --- a/src/views/upload/UploadView.vue +++ /dev/null @@ -1,403 +0,0 @@ - - - diff --git a/tsconfig.app.json b/tsconfig.app.json index 3e52a52..1f71bf7 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,16 +1,16 @@ { - "extends": "@vue/tsconfig/tsconfig.dom.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "types": ["vite/client"], - "paths": { - "@/*": ["./src/*"] - }, + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "paths": { + "@/*": ["./src/*"] + }, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] } diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..eb69b0d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,4 @@ { - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] } diff --git a/tsconfig.node.json b/tsconfig.node.json index d3c52ea..63719f2 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,24 +1,24 @@ { - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "es2023", - "lib": ["ES2023"], - "module": "esnext", - "types": ["node"], - "skipLibCheck": true, + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, - /* Linting */ - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["vite.config.ts"] + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] } diff --git a/vercel.json b/vercel.json index 0e426d0..71b284c 100644 --- a/vercel.json +++ b/vercel.json @@ -1,36 +1,36 @@ { - "$schema": "https://openapi.vercel.sh/vercel.json", - "headers": [ - { - "source": "/(.*)", - "headers": [ + "$schema": "https://openapi.vercel.sh/vercel.json", + "headers": [ { - "key": "X-Frame-Options", - "value": "DENY" - }, - { - "key": "X-Content-Type-Options", - "value": "nosniff" - }, - { - "key": "Referrer-Policy", - "value": "strict-origin" - }, - { - "key": "Strict-Transport-Security", - "value": "max-age=31536000" - }, - { - "key": "Permissions-Policy", - "value": "camera=(), microphone=(), payment=(), usb=(), geolocation=(self)" + "source": "/(.*)", + "headers": [ + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), payment=(), usb=(), geolocation=(self)" + } + ] } - ] - } - ], - "rewrites": [ - { - "source": "/(.*)", - "destination": "/index.html" - } - ] + ], + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] } diff --git a/vite.config.ts b/vite.config.ts index a1924f0..27f5c7c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,78 +4,74 @@ import { fileURLToPath, URL } from 'node:url' import vue from '@vitejs/plugin-vue' import tailwindcss from '@tailwindcss/vite' -const publicRoutes = [ - '/', - '/gallery', - '/community', -] +const publicRoutes = ['/', '/gallery', '/community'] const DEFAULT_SITE_URL = 'http://localhost:5173' function getSiteUrl(value?: string) { - const siteUrl = new URL(value?.trim() || DEFAULT_SITE_URL) - if (!['http:', 'https:'].includes(siteUrl.protocol)) { - throw new Error('VITE_SITE_URL 必须使用 http 或 https 协议') - } - return siteUrl.href.replace(/\/$/, '') + const siteUrl = new URL(value?.trim() || DEFAULT_SITE_URL) + if (!['http:', 'https:'].includes(siteUrl.protocol)) { + throw new Error('VITE_SITE_URL 必须使用 http 或 https 协议') + } + return siteUrl.href.replace(/\/$/, '') } function seoFilesPlugin(siteUrl: string) { - return { - name: 'opencloud-seo-files', - closeBundle() { - mkdirSync('dist/.well-known', { recursive: true }) + return { + name: 'opencloud-seo-files', + closeBundle() { + mkdirSync('dist/.well-known', { recursive: true }) - const robots = [ - 'User-agent: *', - 'Allow: /', - 'Disallow: /admin', - 'Disallow: /auth/', - 'Disallow: /login', - 'Disallow: /register', - 'Disallow: /upload', - 'Disallow: /encyclopedia', - 'Disallow: /profile/settings', - `Sitemap: ${siteUrl}/sitemap.xml`, - '', - ].join('\n') + const robots = [ + 'User-agent: *', + 'Allow: /', + 'Disallow: /admin', + 'Disallow: /auth/', + 'Disallow: /login', + 'Disallow: /register', + 'Disallow: /upload', + 'Disallow: /encyclopedia', + 'Disallow: /profile/settings', + `Sitemap: ${siteUrl}/sitemap.xml`, + '', + ].join('\n') - const sitemapUrls = publicRoutes - .map(route => ` \n ${siteUrl}${route}\n `) - .join('\n') + const sitemapUrls = publicRoutes + .map(route => ` \n ${siteUrl}${route}\n `) + .join('\n') - const sitemap = [ - '', - '', - sitemapUrls, - '', - '', - ].join('\n') + const sitemap = [ + '', + '', + sitemapUrls, + '', + '', + ].join('\n') - writeFileSync('dist/robots.txt', robots) - writeFileSync('dist/sitemap.xml', sitemap) - }, - } + writeFileSync('dist/robots.txt', robots) + writeFileSync('dist/sitemap.xml', sitemap) + }, + } } export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), 'VITE_') - const siteUrl = getSiteUrl(env.VITE_SITE_URL) + const env = loadEnv(mode, process.cwd(), 'VITE_') + const siteUrl = getSiteUrl(env.VITE_SITE_URL) - return { - plugins: [vue(), tailwindcss(), seoFilesPlugin(siteUrl)], - server: { - port: 5173, - strictPort: true, - }, - preview: { - port: 5173, - strictPort: true, - }, - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), - }, - }, - } + return { + plugins: [vue(), tailwindcss(), seoFilesPlugin(siteUrl)], + server: { + port: 5173, + strictPort: true, + }, + preview: { + port: 5173, + strictPort: true, + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + } })