From ea47eec47caa6cdace275c53daaea3ad9668036c Mon Sep 17 00:00:00 2001 From: Mplan Date: Tue, 29 Sep 2026 18:05:56 +0800 Subject: [PATCH] refactor: organize features and standardize frontend tooling Colocate feature views, components, stores, and upload logic; centralize shared map utilities without changing API behavior. Add ESLint, Prettier, EditorConfig, and VS Code settings; standardize four-space indentation and document the project structure and checks. Validation: npm run check, npm run build, and git diff --check. --- .editorconfig | 12 + .gitignore | 4 + .prettierignore | 10 + .prettierrc.json | 10 + .vscode/extensions.json | 3 + .vscode/settings.json | 11 + AGENTS.md | 110 +- API.md | 561 ++-- README.md | 25 +- docs/project-structure.md | 80 + docs/style-system.md | 44 +- eslint.config.js | 53 + index.html | 22 +- package-lock.json | 1430 ++++++++++ package.json | 78 +- scripts/check-style-system.mjs | 176 +- src/App.vue | 52 +- src/components/cloud/CloudEditModal.vue | 232 -- src/components/cloud/CloudLikeButton.vue | 64 - 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 | 715 ++--- .../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 | 275 +- src/lib/authClient.ts | 33 - 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 | 134 - src/stores/clouds.ts | 27 - src/stores/encyclopedia.ts | 35 - src/stores/likes.ts | 105 - src/stores/profile.ts | 220 -- 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 | 116 +- src/types/database.ts | 52 +- src/types/router.d.ts | 16 +- src/views/admin/AdminView.vue | 1763 ------------ src/views/auth/AuthConfirmView.vue | 74 - src/views/auth/ForgotPasswordView.vue | 87 - src/views/auth/LoginView.vue | 150 -- src/views/auth/RegisterView.vue | 150 -- src/views/auth/ResendConfirmationView.vue | 83 - src/views/auth/ResetPasswordView.vue | 94 - src/views/community/CommunityView.vue | 14 - src/views/encyclopedia/CloudTypeView.vue | 144 - src/views/encyclopedia/EncyclopediaView.vue | 92 - src/views/gallery/GalleryView.vue | 594 ----- src/views/map/MapView.vue | 965 ------- src/views/profile/ProfileSettingsView.vue | 219 -- src/views/profile/ProfileView.vue | 814 ------ 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 +- 104 files changed, 14116 insertions(+), 10576 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/CloudLikeButton.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/authClient.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/likes.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 f8415c3..a3abe90 100644 --- a/API.md +++ b/API.md @@ -24,10 +24,10 @@ ### 1.1 Base URL -| 环境 | 地址 | -|---|---| +| 环境 | 地址 | +| -------- | ---------------------------------------- | | 本地开发 | `http://localhost:3000`(`vc dev` 启动) | -| 生产 | 以部署地址为准 | +| 生产 | 以部署地址为准 | 业务接口除 `/image` 成功响应直接返回图片字节外,响应通常为 `application/json`(个别异常情形返回纯文本,见 2.2)。Better Auth 的 `/auth/*` 有独立响应格式,部分操作也可能重定向,见第 4 章。响应携带两个调试响应头: @@ -44,19 +44,19 @@ 业务成功响应**没有统一包装**,分两种形态;`/auth/*` 不使用下表中的业务信封: -| 场景 | 形态 | 示例 | -|---|---|---| +| 场景 | 形态 | 示例 | +| -------------------------------------------- | -------- | ----------------------------------------------- | | 写操作成功(POST/PATCH/DELETE 的动作类端点) | 信封对象 | `{ "status_code": 200, "message": "删除成功" }` | -| 读操作成功(业务 GET) | 裸数据 | 单资源为对象、列表为**数组** | +| 读操作成功(业务 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。 @@ -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 条的暴露范围问题) | -| `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) | +| 字段 | 类型 | 说明 | +| --------------------- | ------------ | ---------------------------------------------------- | +| `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`) @@ -154,20 +154,20 @@ ### 2.2 状态码总表 -| 状态码 | 含义 | 前端通用动作 | -|---|---|---| -| 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` | +| 状态码 | 含义 | 前端通用动作 | +| ------ | ----------------------------------------------------------- | ---------------------------------------------- | +| 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。 @@ -192,16 +192,16 @@ 后端现在由 Better Auth 处理注册、登录、邮箱验证、会话和密码。旧认证路由已移除;请替换所有旧请求与响应解析: -| 旧接口 | 当前接口 / 前端动作 | 主要变化 | -|---|---|---| -| `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` | +| 旧接口 | 当前接口 / 前端动作 | 主要变化 | +| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------ | +| `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` | 旧路由的 `status_code` / `message` 信封、JWT 及认证相关的 snake_case 请求字段不适用于新端点。普通注册不要提交 `role`:角色由服务端设置为 `user`;创建管理员用户走第 8 章的管理接口。业务接口的路径与 snake_case 字段仍按后续章节使用。 @@ -210,21 +210,21 @@ 前端安装与后端当前版本一致的 `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)一致。 ```ts -import { createAuthClient } from "better-auth/client"; +import { createAuthClient } from 'better-auth/client' -const API_ORIGIN = "http://localhost:3000"; // 生产环境改成实际 API origin +const API_ORIGIN = 'http://localhost:3000' // 生产环境改成实际 API origin export const authClient = createAuthClient({ baseURL: `${API_ORIGIN}/auth`, - fetchOptions: { credentials: "include" }, -}); + fetchOptions: { credentials: 'include' }, +}) ``` Better Auth 浏览器客户端默认会携带凭证;上面显式写出该选项,便于与业务请求保持一致。业务接口使用原生 `fetch` 或其他 HTTP 客户端时,也要单独配置: ```ts const response = await fetch(`${API_ORIGIN}/profile/me`, { - credentials: "include", -}); + credentials: 'include', +}) ``` 浏览器会自动保存并发送 HttpOnly 会话 Cookie。不要尝试在 JavaScript 中读取 Cookie,也不要将登录响应中的 token 当作旧 JWT 存储。服务端 `CORS_ORIGIN` 必须与前端 origin(协议、域名、端口)精确匹配;生产环境 Cookie 为 `SameSite=None; Secure`,本地开发为 `SameSite=Lax`。`BETTER_AUTH_URL` 是后端公开地址;前端客户端仍需使用上面含 `/auth` 的 URL。跨源接入和凭证设置也见 [Better Auth 的 Hono 集成文档](https://better-auth.com/docs/integrations/hono)。 @@ -234,10 +234,10 @@ const response = await fetch(`${API_ORIGIN}/profile/me`, { 登录成功后,使用 `authClient.getSession()` 或对应框架的 `useSession()` 获取会话: ```ts -const { error } = await authClient.signIn.email({ email, password }); -if (error) throw error; +const { error } = await authClient.signIn.email({ email, password }) +if (error) throw error -const { data: session } = await authClient.getSession(); +const { data: session } = await authClient.getSession() // session 为 { user, session } 或 null;退出时调用 await authClient.signOut()。 ``` @@ -267,29 +267,28 @@ Better Auth 端点的错误是其原生结构,通常包含 `code`、`message` 以下为本项目已启用的 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 })` | 需登录;可选择撤销其他会话 | +| 操作 | 方法与路径 | 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 })` | 需登录;可选择撤销其他会话 | 验证邮箱页面的请求示例: ```ts -const token = new URLSearchParams(location.search).get("token"); -if (!token) throw new Error("缺少验证 token"); +const token = new URLSearchParams(location.search).get('token') +if (!token) throw new Error('缺少验证 token') -const response = await fetch( - `${API_ORIGIN}/auth/verify-email?token=${encodeURIComponent(token)}`, - { credentials: "include" }, -); -if (!response.ok) throw new Error("邮箱验证失败"); +const response = await fetch(`${API_ORIGIN}/auth/verify-email?token=${encodeURIComponent(token)}`, { + credentials: 'include', +}) +if (!response.ok) throw new Error('邮箱验证失败') // 验证成功后引导用户登录;当前配置不会自动创建会话。 ``` @@ -307,12 +306,12 @@ if (!response.ok) throw new Error("邮箱验证失败"); **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 | 否 | 默认 `""`(空 = 不过滤) | 搜索词 | 搜索逻辑: @@ -354,11 +353,11 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败(如 `page_size=0`) | zod 形状(2.1) | 检查参数 | -| 404 | 按用户名搜索但该用户不存在 | `该用户不存在` | 提示「没有找到这个用户」,不要显示为空列表 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | -------------------------------- | -------------------- | ------------------------------------------ | +| 400 | 参数校验失败(如 `page_size=0`) | zod 形状(2.1) | 检查参数 | +| 404 | 按用户名搜索但该用户不存在 | `该用户不存在` | 提示「没有找到这个用户」,不要显示为空列表 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:按云类型搜索时类型名不存在**不报错**,返回空数组(与用户名搜索的 404 行为不同)。 @@ -368,22 +367,22 @@ if (!response.ok) throw new Error("邮箱验证失败"); **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`。 @@ -394,19 +393,19 @@ if (!response.ok) throw new Error("邮箱验证失败"); **路径参数**: -| 参数 | 类型 | 说明 | -|---|---|---| +| 参数 | 类型 | 说明 | +| ---------- | ---- | ------- | | `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` — 按云类型列出云图 @@ -414,9 +413,9 @@ if (!response.ok) throw new Error("邮箱验证失败"); **路径参数**: -| 参数 | 类型 | 说明 | -|---|---|---| -| `cloud_type_id` | int | 云类型 ID(非数字返回 400) | +| 参数 | 类型 | 说明 | +| --------------- | ---- | --------------------------- | +| `cloud_type_id` | int | 云类型 ID(非数字返回 400) | **Query 参数**:分页参数,见 1.4。 @@ -424,11 +423,11 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败 | zod 形状(2.1) | 检查参数 | -| 404 | 云类型不存在 | `云类型不存在` | 提示类型不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ------------ | -------------------- | -------------- | +| 400 | 参数校验失败 | zod 形状(2.1) | 检查参数 | +| 404 | 云类型不存在 | `云类型不存在` | 提示类型不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | ### 5.5 `PUT /cloud/:cloud_id/like` — 点赞 @@ -446,11 +445,11 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | -| 404 | 云图不存在,或不是公开可见状态 | `图片不存在` | 提示云图不可点赞;与「不存在」共用是设计意图 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ------------------------------ | -------------------- | -------------------------------------------- | +| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | +| 404 | 云图不存在,或不是公开可见状态 | `图片不存在` | 提示云图不可点赞;与「不存在」共用是设计意图 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:**幂等**——重复点赞返回相同的 200,点赞数不会增加。旧 `POST /cloud/:cloud_id/like` 已移除;前端改用 `PUT`。前端无需在点击前查询是否已赞,但仍建议本地置灰防止连点。 @@ -470,10 +469,10 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ------------------- | -------------------- | ------------ | +| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:**完全幂等**——从未点赞、云图不存在,同样返回 200。没有 404 分支。 @@ -484,15 +483,15 @@ if (!response.ok) throw new Error("邮箱验证失败"); **表单字段**(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` @@ -506,18 +505,18 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 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 | 服务端异常(含存储/数据库失败) | `服务器发生内部错误` | 稍后重试;服务端已做失败补偿清理,可安全重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | --------------------------------------------------- | ------------------------------- | -------------------------------------------- | +| 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 | 服务端异常(含存储/数据库失败) | `服务器发生内部错误` | 稍后重试;服务端已做失败补偿清理,可安全重试 | **注意事项**: @@ -532,14 +531,14 @@ if (!response.ok) throw new Error("邮箱验证失败"); **请求体**(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 { @@ -552,13 +551,13 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 未提供任何修改字段 | zod 形状,issues 中含 `至少需要提供一个修改字段` | 表单前置校验 | -| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | -| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | -| 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` — 删除云图 @@ -568,11 +567,11 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | -| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | -------------------------- | -------------------- | -------------- | +| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | +| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | ### 5.10 `DELETE /cloud` — 批量删除云图 @@ -580,9 +579,9 @@ if (!response.ok) throw new Error("邮箱验证失败"); **请求体**(JSON): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `cloud_ids` | array | 是 | 1–100 个元素,不允许重复 | 元素为 `{ "cloud_id": "" }` 对象 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ----------- | ----- | ---- | ------------------------ | -------------------------------------- | +| `cloud_ids` | array | 是 | 1–100 个元素,不允许重复 | 元素为 `{ "cloud_id": "" }` 对象 | ```json { @@ -604,11 +603,11 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | ID 重复 / 数量超出 1–100 / 格式非法 | zod 形状,ID 重复时 issues 含 `图片 ID 不能重复` | 前端去重后提交 | -| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ----------------------------------- | ------------------------------------------------ | -------------- | +| 400 | ID 重复 / 数量超出 1–100 / 格式非法 | zod 形状,ID 重复时 issues 含 `图片 ID 不能重复` | 前端去重后提交 | +| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:**部分成功语义**——只删除属于当前用户的云图,他人或不存在的 ID 被静默跳过,消息中的 `N` 是实际删除数(可能小于请求数,甚至为 0)。前端应以 `N` 为准刷新列表,而不是假设全部删除成功。 @@ -620,10 +619,10 @@ if (!response.ok) throw new Error("邮箱验证失败"); **路径参数**: -| 参数 | 类型 | 说明 | -|---|---|---| -| `cloud_id` | uuid | Cloud ID,由上传响应、列表或详情接口取得 | -| `variant` | enum | `preview`(最大 640×640 WebP)或 `original`(上传原图) | +| 参数 | 类型 | 说明 | +| ---------- | ---- | ------------------------------------------------------- | +| `cloud_id` | uuid | Cloud ID,由上传响应、列表或详情接口取得 | +| `variant` | enum | `preview`(最大 640×640 WebP)或 `original`(上传原图) | 两种图片变体采用相同权限规则。示例: @@ -640,23 +639,23 @@ if (!response.ok) throw new Error("邮箱验证失败"); **缓存**: -| 调用方式 | `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 | 显式提供的 Better Auth 会话无效或过期 | 清除本地登录态 | -| 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`。 @@ -689,9 +688,9 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ---------------------------------------- | ---------------------- | ---------------------- | +| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | ### 6.2 `PATCH /profile/me` — 修改用户名 @@ -699,9 +698,9 @@ if (!response.ok) throw new Error("邮箱验证失败"); **请求体**(JSON): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `user_name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(超长返回 500) | 新用户名 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ----------- | ------ | ---- | --------------------------------------------------- | -------- | +| `user_name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(超长返回 500) | 新用户名 | ```json { @@ -713,11 +712,11 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | -| 409 | 用户名已被占用(数据库唯一约束,大小写敏感) | `用户名已被占用` | 提示换用户名 | -| 500 | 服务端异常(含用户名超长) | `服务器发生内部错误` | 前端应限制 ≤ 16 字符避免误报 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | -------------------------------------------- | -------------------- | ---------------------------- | +| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | +| 409 | 用户名已被占用(数据库唯一约束,大小写敏感) | `用户名已被占用` | 提示换用户名 | +| 500 | 服务端异常(含用户名超长) | `服务器发生内部错误` | 前端应限制 ≤ 16 字符避免误报 | ### 6.3 `GET /profile/me/likes` — 我点赞过的云图 @@ -748,11 +747,11 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | `user_id` 不是合法 uuid | zod 形状(2.1) | 检查链接 | -| 404 | 用户不存在 | `该用户不存在` | 提示用户不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ----------------------- | -------------------- | -------------- | +| 400 | `user_id` 不是合法 uuid | zod 形状(2.1) | 检查链接 | +| 404 | 用户不存在 | `该用户不存在` | 提示用户不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:⚠️ 响应包含对方 `email`(2.3 第 7 条)。展示他人资料页时不应展示邮箱字段。 @@ -766,10 +765,10 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 403 | 查看他人且非 admin | `无权查看该用户的图片` | 同上,前置判断 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ------------------ | ---------------------- | -------------- | +| 403 | 查看他人且非 admin | `无权查看该用户的图片` | 同上,前置判断 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:想查看某个用户的**公开**云图,应使用 `GET /cloud?filter=owner&value=<用户名>`(5.1)而非本端点——本端点是管理/个人视角,返回含非公开状态的完整列表。 @@ -811,11 +810,11 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | `cloud_type_id` 非数字 | zod 形状(2.1) | 检查链接 | -| 404 | 云类型不存在 | `未找到` | 提示类型不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ---------------------- | -------------------- | -------------- | +| 400 | `cloud_type_id` 非数字 | zod 形状(2.1) | 检查链接 | +| 404 | 云类型不存在 | `未找到` | 提示类型不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | --- @@ -835,9 +834,9 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ---------------------------------------- | ---------------------- | ---------------------- | +| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | **注意事项**:**无分页参数**,一次返回全部用户。用户量大时注意性能。 @@ -849,9 +848,9 @@ if (!response.ok) throw new Error("邮箱验证失败"); **请求体**(JSON): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `user_role` | enum | 是 | `user` / `admin` | 目标角色 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ----------- | ---- | ---- | ---------------- | -------- | +| `user_role` | enum | 是 | `user` / `admin` | 目标角色 | ```json { @@ -870,12 +869,12 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败(`user_role` 非法 / `user_id` 非 uuid) | zod 形状(2.1) | 检查参数 | -| 400 | 管理员尝试撤销自己的 admin 角色 | `不能撤销自己的管理员角色` | 保持当前管理员角色 | -| 404 | 用户不存在 | `用户不存在` | 提示用户不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ---------------------------------------------------- | -------------------------- | ------------------ | +| 400 | 参数校验失败(`user_role` 非法 / `user_id` 非 uuid) | zod 形状(2.1) | 检查参数 | +| 400 | 管理员尝试撤销自己的 admin 角色 | `不能撤销自己的管理员角色` | 保持当前管理员角色 | +| 404 | 用户不存在 | `用户不存在` | 提示用户不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:修改角色会撤销目标用户的全部 Better Auth 会话,必须重新登录。前端改完角色后可提示目标用户重新登录。 @@ -887,9 +886,9 @@ if (!response.ok) throw new Error("邮箱验证失败"); **请求体**(JSON): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `new_password` | string | 是 | 至少 8 字符 | 新密码;管理员直接覆盖,无需旧密码 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| -------------- | ------ | ---- | ----------- | ---------------------------------- | +| `new_password` | string | 是 | 至少 8 字符 | 新密码;管理员直接覆盖,无需旧密码 | ```json { @@ -908,11 +907,11 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | 参数校验失败(`user_id` 非 uuid 或密码少于 8 字符) | zod 形状(2.1) | 检查参数 | -| 404 | 用户不存在(含凭据记录缺失) | `用户不存在` | 提示用户不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | --------------------------------------------------- | -------------------- | -------------- | +| 400 | 参数校验失败(`user_id` 非 uuid 或密码少于 8 字符) | zod 形状(2.1) | 检查参数 | +| 404 | 用户不存在(含凭据记录缺失) | `用户不存在` | 提示用户不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:成功后会撤销目标用户的全部 Better Auth 会话,必须重新登录。 @@ -926,13 +925,13 @@ if (!response.ok) throw new Error("邮箱验证失败"); **请求体**(JSON): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `name` | string | 是 | 去除首尾空白后非空 | 云类型名称 | -| `genus` | string\|null | 否 | 默认 `null` | 属;传 `null` 表示未设置 | -| `icon_id` | uuid\|null | 否 | 默认 `null` | 图标 ID;传 `null` 表示未设置 | -| `rarity` | int | 是 | — | 稀有度 | -| `description` | string | 是 | 最长 128 字符 | 描述,可为空字符串 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ------------- | ------------ | ---- | ------------------ | ----------------------------- | +| `name` | string | 是 | 去除首尾空白后非空 | 云类型名称 | +| `genus` | string\|null | 否 | 默认 `null` | 属;传 `null` 表示未设置 | +| `icon_id` | uuid\|null | 否 | 默认 `null` | 图标 ID;传 `null` 表示未设置 | +| `rarity` | int | 是 | — | 稀有度 | +| `description` | string | 是 | 最长 128 字符 | 描述,可为空字符串 | ```json { @@ -996,18 +995,18 @@ if (!response.ok) throw new Error("邮箱验证失败"); **Query 参数**(在分页参数 1.4 基础上): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `status` | enum | 否 | `pending` / `approved` / `rejected` | 按审核状态过滤;不传返回全部状态 | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| -------- | ---- | ---- | ----------------------------------- | -------------------------------- | +| `status` | enum | 否 | `pending` / `approved` / `rejected` | 按审核状态过滤;不传返回全部状态 | **成功响应**:`200`,CloudInfo 数组(字段见 1.5),按上传时间倒序。 **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ----------------------- | -------------------- | ------------ | +| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | ### 8.10 `POST /admin/clouds/review` — 批量审核云图 @@ -1015,16 +1014,14 @@ if (!response.ok) throw new Error("邮箱验证失败"); **请求体**(JSON): -| 参数 | 类型 | 必填 | 约束 | 说明 | -|---|---|---|---|---| -| `cloud_ids` | array | 是 | 1–100 个元素,不允许重复 | 元素为 `{ "cloud_id": "" }` 对象 | -| `status` | enum | 是 | `approved` / `rejected` | 目标审核状态;**不支持打回 `pending`** | +| 参数 | 类型 | 必填 | 约束 | 说明 | +| ----------- | ----- | ---- | ------------------------ | -------------------------------------- | +| `cloud_ids` | array | 是 | 1–100 个元素,不允许重复 | 元素为 `{ "cloud_id": "" }` 对象 | +| `status` | enum | 是 | `approved` / `rejected` | 目标审核状态;**不支持打回 `pending`** | ```json { - "cloud_ids": [ - { "cloud_id": "3f8a2c1e-7b9d-4e5f-a1c2-9d8e7f6a5b4c" } - ], + "cloud_ids": [{ "cloud_id": "3f8a2c1e-7b9d-4e5f-a1c2-9d8e7f6a5b4c" }], "status": "approved" } ``` @@ -1040,10 +1037,10 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | ID 重复 / `status` 非法 / 数量超限 | zod 形状(2.1) | 检查请求 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ---------------------------------- | -------------------- | ------------ | +| 400 | ID 重复 / `status` 非法 / 数量超限 | zod 形状(2.1) | 检查请求 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:**部分成功语义**——消息中的 `N` 只统计状态**实际发生变化**的云图;已是目标状态的与不存在的一律静默跳过。审核只改变审核状态,不影响 `is_hidden` 与点赞数据。`pending ↔ approved/rejected`、`approved ↔ rejected` 均可。 @@ -1064,11 +1061,11 @@ if (!response.ok) throw new Error("邮箱验证失败"); **错误**: -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -|---|---|---|---| -| 400 | `cloud_id` 非 uuid | zod 形状(2.1) | 检查链接 | -| 404 | 云图不存在 | `图片不存在` | 提示云图不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +| ------ | ------------------ | -------------------- | -------------- | +| 400 | `cloud_id` 非 uuid | zod 形状(2.1) | 检查链接 | +| 404 | 云图不存在 | `图片不存在` | 提示云图不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | **注意事项**:管理员删除不受审核状态或隐藏状态限制。删除会级联删除该云图的点赞记录,并尽力清理 MinIO 中的图片对象;清理失败不报错,可能残留孤儿对象(可接受)。删除后前端应立即刷新相关列表。 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 8a73fc4..87dc82b 100644 --- a/src/components/layout/AppHeader.vue +++ b/src/components/layout/AppHeader.vue @@ -2,7 +2,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { NIcon, NSpace, useMessage } from 'naive-ui' import { ChevronDown, Logout, Settings, Shield, User } from '@vicons/tabler' -import { useAuthStore } from '@/stores/auth' +import { useAuthStore } from '@/features/auth/stores/auth' import { RouterLink, useRoute, useRouter } from 'vue-router' const authStore = useAuthStore() @@ -17,406 +17,477 @@ const accountCardOpen = ref(false) const activeNavClass = 'bg-teal-50 text-teal-800 ring-1 ring-teal-100' const inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800' -const displayUsername = computed(() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户') +const displayUsername = computed( + () => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户', +) const userEmail = computed(() => authStore.user?.email || '未绑定邮箱') const avatarFailed = ref(false) -watch(() => authStore.user?.image, () => { avatarFailed.value = false }) +watch( + () => authStore.user?.image, + () => { + avatarFailed.value = false + }, +) let lastScrollY = 0 let accountCloseTimer: number | null = null const HIDE_HEADER_EVENT = 'opencloud:hide-header' function showHeader(pin = false) { - headerHidden.value = false - headerPinnedOpen.value = pin + headerHidden.value = false + headerPinnedOpen.value = pin } function hideHeader() { - if (window.scrollY <= 96) return - if (headerPinnedOpen.value) return - headerHidden.value = true + if (window.scrollY <= 96) return + if (headerPinnedOpen.value) return + headerHidden.value = true } function releasePinnedHeader() { - headerPinnedOpen.value = false + headerPinnedOpen.value = false } function forceHideHeader() { - headerPinnedOpen.value = false - headerHidden.value = true + headerPinnedOpen.value = false + headerHidden.value = true } function handleScroll() { - const currentScrollY = window.scrollY - const delta = currentScrollY - lastScrollY + const currentScrollY = window.scrollY + const delta = currentScrollY - lastScrollY + + if (isMapRoute.value) { + lastScrollY = currentScrollY + return + } + + if (currentScrollY <= 64) { + showHeader(false) + lastScrollY = currentScrollY + return + } + + if (delta > 12) { + hideHeader() + } else if (delta < -8) { + showHeader(false) + } - if (isMapRoute.value) { lastScrollY = currentScrollY - return - } - - if (currentScrollY <= 64) { - showHeader(false) - lastScrollY = currentScrollY - return - } - - if (delta > 12) { - hideHeader() - } else if (delta < -8) { - showHeader(false) - } - - lastScrollY = currentScrollY } onMounted(() => { - lastScrollY = window.scrollY - window.addEventListener('scroll', handleScroll, { passive: true }) - window.addEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener) + lastScrollY = window.scrollY + window.addEventListener('scroll', handleScroll, { passive: true }) + window.addEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener) }) onUnmounted(() => { - window.removeEventListener('scroll', handleScroll) - window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener) - if (accountCloseTimer !== null) { - window.clearTimeout(accountCloseTimer) - accountCloseTimer = null - } + window.removeEventListener('scroll', handleScroll) + window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener) + if (accountCloseTimer !== null) { + window.clearTimeout(accountCloseTimer) + accountCloseTimer = null + } }) function syncHeaderForRoute() { - headerPinnedOpen.value = false - headerHidden.value = false - accountCardOpen.value = false + headerPinnedOpen.value = false + headerHidden.value = false + accountCardOpen.value = false } -watch(() => route.fullPath, () => { - syncHeaderForRoute() -}) +watch( + () => route.fullPath, + () => { + syncHeaderForRoute() + }, +) watch(isMapRoute, () => { - syncHeaderForRoute() + syncHeaderForRoute() }) onMounted(() => { - syncHeaderForRoute() + syncHeaderForRoute() }) function openAccountCard() { - if (accountCloseTimer !== null) { - window.clearTimeout(accountCloseTimer) - accountCloseTimer = null - } - accountCardOpen.value = true + if (accountCloseTimer !== null) { + window.clearTimeout(accountCloseTimer) + accountCloseTimer = null + } + accountCardOpen.value = true } function closeAccountCard() { - if (accountCloseTimer !== null) { - window.clearTimeout(accountCloseTimer) - } - accountCloseTimer = window.setTimeout(() => { - accountCardOpen.value = false - accountCloseTimer = null - }, 100) + if (accountCloseTimer !== null) { + window.clearTimeout(accountCloseTimer) + } + accountCloseTimer = window.setTimeout(() => { + accountCardOpen.value = false + accountCloseTimer = null + }, 100) } async function handleLogout() { - try { - await authStore.logout() - closeAccountCard() - if (route.meta.requiresAuth) { - void router.push({ name: 'map' }) + try { + await authStore.logout() + closeAccountCard() + if (route.meta.requiresAuth) { + void router.push({ name: 'map' }) + } + } catch (error) { + message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。') } - } catch (error) { - message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。') - } } - - - - - - - - - - -
- -
-
- + + 展开导航 + + + + 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 bf567b1..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,182 +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 + 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, ...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, - 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, - } + 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/authClient.ts b/src/lib/authClient.ts deleted file mode 100644 index 394a953..0000000 --- a/src/lib/authClient.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/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 52422cc..0000000 --- a/src/stores/auth.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { computed, ref } from 'vue' -import { defineStore } from 'pinia' -import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api' -import { assertAuthResult, authClient } from '@/lib/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/stores/clouds.ts b/src/stores/clouds.ts deleted file mode 100644 index ac49833..0000000 --- a/src/stores/clouds.ts +++ /dev/null @@ -1,27 +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 - } - } - - function setCloudTypes(types: CloudType[]) { - cloudTypes.value = types - } - - return { cloudTypes, loading, fetchCloudTypes, setCloudTypes } -}) diff --git a/src/stores/encyclopedia.ts b/src/stores/encyclopedia.ts deleted file mode 100644 index fd8bd1c..0000000 --- a/src/stores/encyclopedia.ts +++ /dev/null @@ -1,35 +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 - } - } - - function setCloudTypes(types: CloudType[]) { - cloudTypes.value = types - cloudTypesLoaded.value = true - } - - return { - cloudTypes, - loadingCloudTypes, - fetchCloudTypes, - setCloudTypes, - } -}) diff --git a/src/stores/likes.ts b/src/stores/likes.ts deleted file mode 100644 index 68bb63b..0000000 --- a/src/stores/likes.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { ref, watch } from 'vue' -import { defineStore } from 'pinia' -import { apiRequest } from '@/lib/api' -import { useAuthStore } from '@/stores/auth' -import type { ActionResponse, BackendCloudInfo } from '@/types/api' - -const PAGE_SIZE = 100 - -export const useLikesStore = defineStore('likes', () => { - const authStore = useAuthStore() - const likedIds = ref>(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/stores/profile.ts b/src/stores/profile.ts deleted file mode 100644 index fed9ae3..0000000 --- a/src/stores/profile.ts +++ /dev/null @@ -1,220 +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, 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/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 fa14979..c3cb752 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,94 +1,94 @@ import type { Cloud, CloudType, Profile } from '@/types/database' export interface BackendUserProfile { - id: string - name: string - email: string - image: string | null - cloud_count: number - received_like_count: number - last_online: string | null - role: Profile['role'] - is_disabled: boolean - created_at: string + 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 + 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 + cloud_type_id: number } // Internal view model. The backend profile is normalized here so existing // layout/admin consumers do not need to understand the wire representation. export interface AuthUser { - id: string - email: string - username: string - image: string | null - role: Profile['role'] - is_disabled: boolean - cloud_count: number - received_like_count: number - created_at: string + 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 6487840..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 - image: 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 0c04365..0000000 --- a/src/views/admin/AdminView.vue +++ /dev/null @@ -1,1763 +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 a1e3965..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 2621f42..0000000 --- a/src/views/auth/LoginView.vue +++ /dev/null @@ -1,150 +0,0 @@ - - - diff --git a/src/views/auth/RegisterView.vue b/src/views/auth/RegisterView.vue deleted file mode 100644 index 5c0d966..0000000 --- a/src/views/auth/RegisterView.vue +++ /dev/null @@ -1,150 +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 409ce0c..0000000 --- a/src/views/auth/ResetPasswordView.vue +++ /dev/null @@ -1,94 +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 0ddf83f..0000000 --- a/src/views/encyclopedia/CloudTypeView.vue +++ /dev/null @@ -1,144 +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 1772d2a..0000000 --- a/src/views/gallery/GalleryView.vue +++ /dev/null @@ -1,594 +0,0 @@ - - - diff --git a/src/views/map/MapView.vue b/src/views/map/MapView.vue deleted file mode 100644 index be12983..0000000 --- a/src/views/map/MapView.vue +++ /dev/null @@ -1,965 +0,0 @@ - - - - - diff --git a/src/views/profile/ProfileSettingsView.vue b/src/views/profile/ProfileSettingsView.vue deleted file mode 100644 index 40b2210..0000000 --- a/src/views/profile/ProfileSettingsView.vue +++ /dev/null @@ -1,219 +0,0 @@ - - - diff --git a/src/views/profile/ProfileView.vue b/src/views/profile/ProfileView.vue deleted file mode 100644 index 9dccee8..0000000 --- a/src/views/profile/ProfileView.vue +++ /dev/null @@ -1,814 +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)), + }, + }, + } })