Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0af0256c97 | ||
|
|
720ad29f06 | ||
|
|
cd70f89a28 |
@@ -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
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
VITE_API_URL=http://localhost:8000/api/v1
|
VITE_API_URL=http://localhost:3000
|
||||||
VITE_AMAP_KEY=
|
VITE_AMAP_KEY=
|
||||||
VITE_SITE_URL=http://localhost:5173
|
VITE_SITE_URL=http://localhost:5173
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
.eslintcache
|
||||||
|
.prettiercache
|
||||||
*.local
|
*.local
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.claude
|
.claude
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
coverage/
|
||||||
|
.agents/
|
||||||
|
.codex/
|
||||||
|
.claude/
|
||||||
|
.env*
|
||||||
|
package-lock.json
|
||||||
|
skills-lock.json
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||||
|
}
|
||||||
Vendored
+11
@@ -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"]
|
||||||
|
}
|
||||||
@@ -3,57 +3,68 @@
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `npm run dev` — Vite dev server with HMR
|
- `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 check:styles` — validates shared style contracts and blocks deprecated palettes/classes
|
||||||
- `npm run build` — `vue-tsc -b && vite build` (typecheck must pass or build aborts)
|
- `npm run 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`
|
- `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
|
## Architecture
|
||||||
|
|
||||||
- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + FastAPI + AMap (高德地图) + Naive UI**
|
- **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`)
|
- 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`
|
- 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)
|
- UI language is Chinese (zh-CN)
|
||||||
- All persistence, storage, authorization, and admin operations go through the FastAPI backend
|
- All persistence, storage, authorization, and admin operations go through the FastAPI backend
|
||||||
|
|
||||||
## Directory Map
|
## Directory Map
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
| --- | --- |
|
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
| `src/lib/` | Singletons and utilities: FastAPI client, amap, canvas patch, cloudTypes constants, cloudBadges canvas renderer, SEO meta builder |
|
| `src/features/` | Business modules; each owns its route views, components, stores, and composables as needed |
|
||||||
| `src/stores/` | Pinia stores: auth, clouds (cloud_types cache), encyclopedia (collection + unlock tracking), profile (user pages + cloud CRUD) |
|
| `src/features/auth/` | Auth views, auth store, and Better Auth client |
|
||||||
| `src/composables/` | Vue composables: `useUpload` (batch multipart upload, EXIF extraction, badge result handling) |
|
| `src/features/clouds/` | Shared cloud detail/edit/like components, cloud-type and likes stores, cloud-type constants |
|
||||||
| `src/components/cloud/` | Cloud-related modals and widgets: ImageDetailModal, CloudEditModal, MapPickerModal, QuickUploadModal, MiniLocationMap |
|
| `src/features/upload/` | UploadView, QuickUploadModal, and useUpload |
|
||||||
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
|
| `src/features/profile/` | Profile views, profile store, and ContributionHeatmap |
|
||||||
| `src/components/profile/` | ContributionHeatmap |
|
| `src/features/encyclopedia/` | Encyclopedia views and encyclopedia store |
|
||||||
| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components |
|
| `src/features/{map,gallery,community,admin,system}/views/` | Remaining route views grouped by feature |
|
||||||
| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts |
|
| `src/shared/map/` | Reusable location picker and mini map, AMap loader, and AMap declarations |
|
||||||
| `src/lib/theme.ts` | Naive UI theme overrides aligned with the semantic tokens |
|
| `src/lib/` | Shared infrastructure: API client and adapters, SEO meta builder, Naive UI theme |
|
||||||
| `src/views/` | Route-level page components (see Routes below) |
|
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
|
||||||
| `src/types/` | TypeScript types: domain models (`database.ts`), API DTOs (`api.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
|
| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components |
|
||||||
| `docs/style-system.md` | Concise implementation and naming guide for the visual system |
|
| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts |
|
||||||
| `scripts/check-style-system.mjs` | Automated guard against deprecated palettes and malformed shared classes |
|
| `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
|
## Routes
|
||||||
|
|
||||||
| Path | View | Auth Required |
|
| Path | View | Auth Required |
|
||||||
| --- | --- | :---: |
|
| ---------------------------------------------------------------------------------- | -------------------- | :-----------: |
|
||||||
| `/` | MapView | No |
|
| `/` | MapView | No |
|
||||||
| `/login`, `/register`, `/forgot-password`, `/auth/confirm`, `/auth/reset-password` | Auth views | No |
|
| `/login`, `/register`, `/forgot-password`, `/auth/confirm`, `/auth/reset-password` | Auth views | No |
|
||||||
| `/upload` | UploadView | Yes |
|
| `/upload` | UploadView | Yes |
|
||||||
| `/encyclopedia` | EncyclopediaView | Yes |
|
| `/encyclopedia` | EncyclopediaView | Yes |
|
||||||
| `/encyclopedia/:id` | CloudTypeView | Yes |
|
| `/encyclopedia/:id` | CloudTypeView | Yes |
|
||||||
| `/gallery` | GalleryView | No |
|
| `/gallery` | GalleryView | No |
|
||||||
| `/community` | CommunityView | No |
|
| `/community` | CommunityView | No |
|
||||||
| `/profile` | ProfileView (own) | Yes |
|
| `/profile` | ProfileView (own) | Yes |
|
||||||
| `/profile/settings` | ProfileSettingsView | Yes |
|
| `/profile/settings` | ProfileSettingsView | Yes |
|
||||||
| `/profile/:id` | ProfileView (public) | No |
|
| `/profile/:id` | ProfileView (public) | No |
|
||||||
| `/admin` | AdminView | Yes (admin) |
|
| `/admin` | AdminView | Yes (admin) |
|
||||||
| `/403` | ForbiddenView | No |
|
| `/403` | ForbiddenView | No |
|
||||||
| `/401` | AuthRequiredView | No |
|
| `/401` | AuthRequiredView | No |
|
||||||
| `/:pathMatch(.*)*` | NotFoundView | 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`
|
- 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`
|
- 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.
|
- **Vercel** with `vercel.json`: SPA rewrites, security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). No CSP or HSTS configured.
|
||||||
- Vite dev and preview servers both use strict port `5173`, matching the backend's default `CORS_ORIGINS` and `FRONTEND_URL`.
|
- Vite dev and preview servers both use strict port `5173`, matching the backend's 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`.
|
- **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
|
## 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.
|
- 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.
|
- 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:
|
- Shared/stable decisions belong in layers:
|
||||||
- tokens and palette remapping → `src/styles/tokens.css`
|
- tokens and palette remapping → `src/styles/tokens.css`
|
||||||
- global baseline and square-corner policy → `src/styles/base.css`
|
- global baseline and square-corner policy → `src/styles/base.css`
|
||||||
- cross-page semantic classes → `src/styles/components.css`
|
- cross-page semantic classes → `src/styles/components.css`
|
||||||
- feature-specific map/slider/media details → the component's scoped style
|
- feature-specific map/slider/media details → the component's scoped style
|
||||||
- one-off layout and responsive composition → Tailwind in the template
|
- 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.
|
- 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.
|
- 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`.
|
- 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`.
|
- 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`.
|
- **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:
|
- Auth buttons use `oc-primary-button` with semantic variants:
|
||||||
- `oc-primary-button--teal` — login / account-access actions
|
- `oc-primary-button--teal` — login / account-access actions
|
||||||
- `oc-primary-button--sky` — register / create / forward actions
|
- `oc-primary-button--sky` — register / create / forward actions
|
||||||
- Panel and utility buttons use `oc-panel-button` with semantic variants:
|
- Panel and utility buttons use `oc-panel-button` with semantic variants:
|
||||||
- `oc-panel-button--neutral` — white card-style utility actions
|
- `oc-panel-button--neutral` — white card-style utility actions
|
||||||
- `oc-panel-button--sky` — primary panel actions
|
- `oc-panel-button--sky` — primary panel actions
|
||||||
- `oc-panel-button--teal` — active toggles / confirm actions
|
- `oc-panel-button--teal` — active toggles / confirm actions
|
||||||
- `oc-panel-button--danger` — destructive 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--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:
|
- Card-like containers should prefer shared shadow classes instead of ad-hoc shadows:
|
||||||
- `oc-panel-card`
|
- `oc-panel-card`
|
||||||
- `oc-panel-card-soft`
|
- `oc-panel-card-soft`
|
||||||
- `oc-empty-card`
|
- `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.
|
- 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.
|
- Primary and panel actions use dark text on light semantic surfaces; do not assume primary buttons should use white text.
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,29 @@ npm run dev
|
|||||||
## 检查与构建
|
## 检查与构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx vue-tsc -b
|
npm run check
|
||||||
npm run build
|
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)。
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
+19
-25
@@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
## 分层
|
## 分层
|
||||||
|
|
||||||
| 层级 | 文件 | 职责 |
|
| 层级 | 文件 | 职责 |
|
||||||
| --- | --- | --- |
|
| ---------- | --------------------------- | ----------------------------------------------------- |
|
||||||
| 设计令牌 | `src/styles/tokens.css` | 颜色、字体、渐变、阴影、动效时长等稳定决策 |
|
| 设计令牌 | `src/styles/tokens.css` | 颜色、字体、渐变、阴影、动效时长等稳定决策 |
|
||||||
| 基础规则 | `src/styles/base.css` | 页面基线、选中文本、全局圆角策略 |
|
| 基础规则 | `src/styles/base.css` | 页面基线、选中文本、全局圆角策略 |
|
||||||
| 组件规则 | `src/styles/components.css` | 页面 Hero、容器、表单、面板、按钮等共享语义类 |
|
| 组件规则 | `src/styles/components.css` | 页面 Hero、容器、表单、面板、按钮等共享语义类 |
|
||||||
| 第三方主题 | `src/lib/theme.ts` | Naive UI 的全局主题契约;值应与设计令牌保持一致 |
|
| 第三方主题 | `src/lib/theme.ts` | Naive UI 的全局主题契约;值应与设计令牌保持一致 |
|
||||||
| 单组件样式 | Vue 文件的 `<style scoped>` | 只属于一个功能的滑杆、动画、地图或媒体细节 |
|
| 单组件样式 | Vue 文件的 `<style scoped>` | 只属于一个功能的滑杆、动画、地图或媒体细节 |
|
||||||
| 一次性布局 | Vue 模板中的 Tailwind 类 | flex/grid、间距、响应式排列及确实只出现一次的视觉调整 |
|
| 一次性布局 | Vue 模板中的 Tailwind 类 | flex/grid、间距、响应式排列及确实只出现一次的视觉调整 |
|
||||||
|
|
||||||
`src/style.css` 只负责按以上顺序装配全局样式,不再直接堆放组件规则。
|
`src/style.css` 只负责按以上顺序装配全局样式,不再直接堆放组件规则。
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓
|
|||||||
- 不直接使用 Tailwind 默认 `green-*`、`lime-*`,也不要在组件里写亮绿色十六进制值或 RGB。
|
- 不直接使用 Tailwind 默认 `green-*`、`lime-*`,也不要在组件里写亮绿色十六进制值或 RGB。
|
||||||
- 新增绿色前先判断它属于品牌交互还是成功状态,并优先使用已有语义类或设计令牌。
|
- 新增绿色前先判断它属于品牌交互还是成功状态,并优先使用已有语义类或设计令牌。
|
||||||
|
|
||||||
| 语义 | 主色 | 深色 | 浅色表面 |
|
| 语义 | 主色 | 深色 | 浅色表面 |
|
||||||
| --- | --- | --- | --- |
|
| --------- | --------- | --------- | --------- |
|
||||||
| 品牌/交互 | `#34745d` | `#274c40` | `#f2faf6` |
|
| 品牌/交互 | `#34745d` | `#274c40` | `#f2faf6` |
|
||||||
| 成功/通过 | `#4d7f3b` | `#35522c` | `#f4faef` |
|
| 成功/通过 | `#4d7f3b` | `#35522c` | `#f4faef` |
|
||||||
|
|
||||||
@@ -44,15 +44,15 @@ OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓
|
|||||||
|
|
||||||
```html
|
```html
|
||||||
<section class="oc-page-hero">
|
<section class="oc-page-hero">
|
||||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
<div class="oc-container oc-container--content oc-page-hero__inner">
|
||||||
<p class="oc-page-eyebrow">Cloud Upload</p>
|
<p class="oc-page-eyebrow">Cloud Upload</p>
|
||||||
<h1 class="oc-page-title">上传云图</h1>
|
<h1 class="oc-page-title">上传云图</h1>
|
||||||
<p class="oc-page-description">页面描述</p>
|
<p class="oc-page-description">页面描述</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main class="oc-container oc-container--content oc-page-content">
|
<main class="oc-container oc-container--content oc-page-content">
|
||||||
<!-- 页面内容 -->
|
<!-- 页面内容 -->
|
||||||
</main>
|
</main>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -63,9 +63,7 @@ Hero 同时包含左侧介绍和右侧状态卡时,使用 `oc-page-hero__split
|
|||||||
### 表单
|
### 表单
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<label class="oc-field-label">
|
<label class="oc-field-label"> 拍摄时间 <span class="oc-field-required">*</span> </label>
|
||||||
拍摄时间 <span class="oc-field-required">*</span>
|
|
||||||
</label>
|
|
||||||
<input class="oc-field-control" />
|
<input class="oc-field-control" />
|
||||||
<p class="oc-field-help">补充说明</p>
|
<p class="oc-field-help">补充说明</p>
|
||||||
<p class="oc-field-error">错误信息</p>
|
<p class="oc-field-error">错误信息</p>
|
||||||
@@ -88,12 +86,8 @@ Hero 同时包含左侧介绍和右侧状态卡时,使用 `oc-page-hero__split
|
|||||||
Naive UI 按钮继续使用 `type="default"`,视觉语义由共享类控制:
|
Naive UI 按钮继续使用 `type="default"`,视觉语义由共享类控制:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<NButton class="oc-panel-button oc-panel-button--neutral" type="default">
|
<NButton class="oc-panel-button oc-panel-button--neutral" type="default"> 取消 </NButton>
|
||||||
取消
|
<NButton class="oc-panel-button oc-panel-button--sky" type="default"> 保存 </NButton>
|
||||||
</NButton>
|
|
||||||
<NButton class="oc-panel-button oc-panel-button--sky" type="default">
|
|
||||||
保存
|
|
||||||
</NButton>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
页面级主操作使用 `oc-primary-button`;面板内操作使用 `oc-panel-button`。可用变体为 `neutral`、`sky`、`teal`、`amber`、`danger`,不要临时创建新的强调色。其中 `teal` 是兼容命名,视觉上使用的是清新桉叶绿。
|
页面级主操作使用 `oc-primary-button`;面板内操作使用 `oc-panel-button`。可用变体为 `neutral`、`sky`、`teal`、`amber`、`danger`,不要临时创建新的强调色。其中 `teal` 是兼容命名,视觉上使用的是清新桉叶绿。
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
import prettier from 'eslint-config-prettier/flat'
|
||||||
|
import vue from 'eslint-plugin-vue'
|
||||||
|
import globals from 'globals'
|
||||||
|
import ts from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores([
|
||||||
|
'dist/**',
|
||||||
|
'dist-ssr/**',
|
||||||
|
'coverage/**',
|
||||||
|
'.agents/**',
|
||||||
|
'.codex/**',
|
||||||
|
'.claude/**',
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,mjs,cjs,ts,mts,cts,vue}'],
|
||||||
|
extends: [js.configs.recommended],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,mts,cts,vue}'],
|
||||||
|
extends: [ts.configs.recommended],
|
||||||
|
rules: {
|
||||||
|
// vue-tsc checks undefined names, including ambient types in Vue scripts.
|
||||||
|
'no-undef': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.vue'],
|
||||||
|
extends: [vue.configs['flat/recommended']],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: ts.parser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{js,ts,vue}'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['*.{js,mjs,cjs,ts,mts,cts}', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Keep formatting in Prettier; this must follow the rule presets.
|
||||||
|
prettier,
|
||||||
|
])
|
||||||
+11
-11
@@ -1,14 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" sizes="any" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" sizes="any" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="OpenCloud - 活的天空地图,拍云、识别、收藏" />
|
<meta name="description" content="OpenCloud - 活的天空地图,拍云、识别、收藏" />
|
||||||
<title>OpenCloud</title>
|
<title>OpenCloud</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+1789
File diff suppressed because it is too large
Load Diff
+46
-31
@@ -1,33 +1,48 @@
|
|||||||
{
|
{
|
||||||
"name": "opencloud",
|
"name": "opencloud",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"check:styles": "node scripts/check-style-system.mjs",
|
"lint": "eslint . --max-warnings=0",
|
||||||
"build": "vue-tsc -b && vite build",
|
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||||
"preview": "vite preview"
|
"format": "prettier --write .",
|
||||||
},
|
"format:check": "prettier --check .",
|
||||||
"dependencies": {
|
"typecheck": "vue-tsc -b",
|
||||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
"check": "npm run lint && npm run format:check && npm run check:styles && npm run typecheck",
|
||||||
"@vercel/speed-insights": "^2.0.0",
|
"check:styles": "node scripts/check-style-system.mjs",
|
||||||
"naive-ui": "^2.44.1",
|
"build": "vue-tsc -b && vite build",
|
||||||
"pinia": "^3.0.4",
|
"preview": "vite preview"
|
||||||
"vue": "^3.5.34",
|
},
|
||||||
"vue-router": "^4.5.0"
|
"dependencies": {
|
||||||
},
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"devDependencies": {
|
"@vercel/speed-insights": "^2.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"better-auth": "^1.7.6",
|
||||||
"@types/node": "^24.12.3",
|
"naive-ui": "^2.44.1",
|
||||||
"@vercel/analytics": "^2.0.1",
|
"pinia": "^3.0.4",
|
||||||
"@vicons/tabler": "^0.13.0",
|
"vue": "^3.5.34",
|
||||||
"@vicons/utils": "^0.1.4",
|
"vue-router": "^4.5.0"
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
},
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"devDependencies": {
|
||||||
"tailwindcss": "^4.3.3",
|
"@eslint/js": "10.0.1",
|
||||||
"typescript": "~6.0.2",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"vite": "^8.0.16",
|
"@types/node": "^24.12.3",
|
||||||
"vue-tsc": "^3.2.8"
|
"@vercel/analytics": "^2.0.1",
|
||||||
}
|
"@vicons/tabler": "^0.13.0",
|
||||||
|
"@vicons/utils": "^0.1.4",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"eslint": "10.11.0",
|
||||||
|
"eslint-config-prettier": "10.1.8",
|
||||||
|
"eslint-plugin-vue": "10.11.1",
|
||||||
|
"globals": "17.12.0",
|
||||||
|
"prettier": "3.9.9",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "8.71.0",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vue-eslint-parser": "10.4.1",
|
||||||
|
"vue-tsc": "^3.2.8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,112 +5,118 @@ const projectRoot = new URL('..', import.meta.url)
|
|||||||
const sourceRoot = new URL('../src', import.meta.url)
|
const sourceRoot = new URL('../src', import.meta.url)
|
||||||
|
|
||||||
const deprecatedPatterns = [
|
const deprecatedPatterns = [
|
||||||
{
|
{
|
||||||
pattern: /\b(?:bg|border|ring|text)-(?:gray|indigo|purple|violet)-\d+\b/g,
|
pattern: /\b(?:bg|border|ring|text)-(?:gray|indigo|purple|violet)-\d+\b/g,
|
||||||
message: '使用项目约定的 slate/sky/自然绿色语义色,不要重新引入灰色或紫色系。',
|
message: '使用项目约定的 slate/sky/自然绿色语义色,不要重新引入灰色或紫色系。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /\b(?:bg|border|ring|text)-(?:green|lime)-\d+\b/g,
|
pattern: /\b(?:bg|border|ring|text)-(?:green|lime)-\d+\b/g,
|
||||||
message: '绿色必须使用项目重映射后的 teal 品牌色或 emerald 成功色,不要引入默认 green/lime 荧光色。',
|
message:
|
||||||
},
|
'绿色必须使用项目重映射后的 teal 品牌色或 emerald 成功色,不要引入默认 green/lime 荧光色。',
|
||||||
{
|
},
|
||||||
pattern: /#(?:f0fdfa|ccfbf1|99f6e4|5eead4|2dd4bf|14b8a6|0d9488|0f766e|115e59|134e4a)\b/gi,
|
{
|
||||||
message: '检测到旧版高饱和 teal 色值,请改用自然绿色设计令牌。',
|
pattern: /#(?:f0fdfa|ccfbf1|99f6e4|5eead4|2dd4bf|14b8a6|0d9488|0f766e|115e59|134e4a)\b/gi,
|
||||||
},
|
message: '检测到旧版高饱和 teal 色值,请改用自然绿色设计令牌。',
|
||||||
{
|
},
|
||||||
pattern: /#(?:c084fc|a855f7|9333ea|7e22ce)\b/gi,
|
{
|
||||||
message: '检测到禁用的紫色硬编码,请改用 rose 或项目语义令牌。',
|
pattern: /#(?:c084fc|a855f7|9333ea|7e22ce)\b/gi,
|
||||||
},
|
message: '检测到禁用的紫色硬编码,请改用 rose 或项目语义令牌。',
|
||||||
{
|
},
|
||||||
pattern: /#(?:1f2937|9ca3af)\b/gi,
|
{
|
||||||
message: '检测到默认 gray 调色板硬编码,请改用对应的 slate 色值或语义令牌。',
|
pattern: /#(?:1f2937|9ca3af)\b/gi,
|
||||||
},
|
message: '检测到默认 gray 调色板硬编码,请改用对应的 slate 色值或语义令牌。',
|
||||||
{
|
},
|
||||||
pattern: /#60a5fa\b/gi,
|
{
|
||||||
message: '检测到默认 blue-400 硬编码,请按用途改用 sky 或 amber 等项目语义色。',
|
pattern: /#60a5fa\b/gi,
|
||||||
},
|
message: '检测到默认 blue-400 硬编码,请按用途改用 sky 或 amber 等项目语义色。',
|
||||||
{
|
},
|
||||||
pattern: /text-xs uppercase tracking-\[0\.18em\] text-slate-500/g,
|
{
|
||||||
message: '重复的小节标签应使用 oc-section-label。',
|
pattern: /text-xs uppercase tracking-\[0\.18em\] text-slate-500/g,
|
||||||
vueOnly: true,
|
message: '重复的小节标签应使用 oc-section-label。',
|
||||||
},
|
vueOnly: true,
|
||||||
{
|
},
|
||||||
pattern: /border border-slate-200 bg-slate-50 p-4/g,
|
{
|
||||||
message: '标准内嵌信息面板应使用 oc-inset-panel。',
|
pattern: /border border-slate-200 bg-slate-50 p-4/g,
|
||||||
vueOnly: true,
|
message: '标准内嵌信息面板应使用 oc-inset-panel。',
|
||||||
},
|
vueOnly: true,
|
||||||
{
|
},
|
||||||
pattern: /border-b border-sky-100 bg-\[linear-gradient\(180deg,#e0f2fe_0%,#f8fafc_100%\)\]/g,
|
{
|
||||||
message: '标准页面头部应使用 oc-page-hero。',
|
pattern:
|
||||||
vueOnly: true,
|
/border-b border-sky-100 bg-\[linear-gradient\(180deg,#e0f2fe_0%,#f8fafc_100%\)\]/g,
|
||||||
},
|
message: '标准页面头部应使用 oc-page-hero。',
|
||||||
|
vueOnly: true,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const styleSourceExtensions = new Set(['.vue', '.css', '.ts'])
|
const styleSourceExtensions = new Set(['.vue', '.css', '.ts'])
|
||||||
|
|
||||||
async function collectStyleSourceFiles(directory) {
|
async function collectStyleSourceFiles(directory) {
|
||||||
const entries = await readdir(directory, { withFileTypes: true })
|
const entries = await readdir(directory, { withFileTypes: true })
|
||||||
const files = await Promise.all(entries.map(async (entry) => {
|
const files = await Promise.all(
|
||||||
const path = join(directory, entry.name)
|
entries.map(async entry => {
|
||||||
return entry.isDirectory() ? collectStyleSourceFiles(path) : [path]
|
const path = join(directory, entry.name)
|
||||||
}))
|
return entry.isDirectory() ? collectStyleSourceFiles(path) : [path]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
return files.flat().filter((path) => styleSourceExtensions.has(extname(path)))
|
return files.flat().filter(path => styleSourceExtensions.has(extname(path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineNumberAt(source, index) {
|
function lineNumberAt(source, index) {
|
||||||
return source.slice(0, index).split('\n').length
|
return source.slice(0, index).split('\n').length
|
||||||
}
|
}
|
||||||
|
|
||||||
const violations = []
|
const violations = []
|
||||||
|
|
||||||
for (const file of await collectStyleSourceFiles(sourceRoot.pathname)) {
|
for (const file of await collectStyleSourceFiles(sourceRoot.pathname)) {
|
||||||
const source = await readFile(file, 'utf8')
|
const source = await readFile(file, 'utf8')
|
||||||
|
|
||||||
for (const rule of deprecatedPatterns) {
|
for (const rule of deprecatedPatterns) {
|
||||||
if (rule.vueOnly && extname(file) !== '.vue') continue
|
if (rule.vueOnly && extname(file) !== '.vue') continue
|
||||||
|
|
||||||
for (const match of source.matchAll(rule.pattern)) {
|
for (const match of source.matchAll(rule.pattern)) {
|
||||||
violations.push({
|
violations.push({
|
||||||
file: relative(projectRoot.pathname, file),
|
file: relative(projectRoot.pathname, file),
|
||||||
line: lineNumberAt(source, match.index),
|
line: lineNumberAt(source, match.index),
|
||||||
message: rule.message,
|
message: rule.message,
|
||||||
value: match[0],
|
value: match[0],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const match of source.matchAll(/(?:^|\s)class="([^"]+)"/gm)) {
|
|
||||||
const classes = match[1].split(/\s+/)
|
|
||||||
const panelModifier = classes.some((name) => name.startsWith('oc-panel-button--'))
|
|
||||||
const primaryModifier = classes.some((name) => name.startsWith('oc-primary-button--'))
|
|
||||||
|
|
||||||
if (panelModifier && !classes.includes('oc-panel-button')) {
|
|
||||||
violations.push({
|
|
||||||
file: relative(projectRoot.pathname, file),
|
|
||||||
line: lineNumberAt(source, match.index),
|
|
||||||
message: 'oc-panel-button 修饰类必须与基础类一起使用。',
|
|
||||||
value: match[0],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (primaryModifier && !classes.includes('oc-primary-button')) {
|
for (const match of source.matchAll(/(?:^|\s)class="([^"]+)"/gm)) {
|
||||||
violations.push({
|
const classes = match[1].split(/\s+/)
|
||||||
file: relative(projectRoot.pathname, file),
|
const panelModifier = classes.some(name => name.startsWith('oc-panel-button--'))
|
||||||
line: lineNumberAt(source, match.index),
|
const primaryModifier = classes.some(name => name.startsWith('oc-primary-button--'))
|
||||||
message: 'oc-primary-button 修饰类必须与基础类一起使用。',
|
|
||||||
value: match[0],
|
if (panelModifier && !classes.includes('oc-panel-button')) {
|
||||||
})
|
violations.push({
|
||||||
|
file: relative(projectRoot.pathname, file),
|
||||||
|
line: lineNumberAt(source, match.index),
|
||||||
|
message: 'oc-panel-button 修饰类必须与基础类一起使用。',
|
||||||
|
value: match[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryModifier && !classes.includes('oc-primary-button')) {
|
||||||
|
violations.push({
|
||||||
|
file: relative(projectRoot.pathname, file),
|
||||||
|
line: lineNumberAt(source, match.index),
|
||||||
|
message: 'oc-primary-button 修饰类必须与基础类一起使用。',
|
||||||
|
value: match[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (violations.length > 0) {
|
if (violations.length > 0) {
|
||||||
console.error('样式体系检查失败:')
|
console.error('样式体系检查失败:')
|
||||||
for (const violation of violations) {
|
for (const violation of violations) {
|
||||||
console.error(`- ${violation.file}:${violation.line} ${violation.message} (${violation.value})`)
|
console.error(
|
||||||
}
|
`- ${violation.file}:${violation.line} ${violation.message} (${violation.value})`,
|
||||||
process.exitCode = 1
|
)
|
||||||
|
}
|
||||||
|
process.exitCode = 1
|
||||||
} else {
|
} else {
|
||||||
console.log('样式体系检查通过。')
|
console.log('样式体系检查通过。')
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-23
@@ -1,35 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { NConfigProvider, NDialogProvider, NGlobalStyle, NMessageProvider, NNotificationProvider } from 'naive-ui'
|
import {
|
||||||
|
NConfigProvider,
|
||||||
|
NDialogProvider,
|
||||||
|
NGlobalStyle,
|
||||||
|
NMessageProvider,
|
||||||
|
NNotificationProvider,
|
||||||
|
} from 'naive-ui'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||||
import { openCloudThemeOverrides } from '@/lib/theme'
|
import { openCloudThemeOverrides } from '@/lib/theme'
|
||||||
import { Analytics } from "@vercel/analytics/vue"
|
import { Analytics } from '@vercel/analytics/vue'
|
||||||
import { SpeedInsights } from "@vercel/speed-insights/vue"
|
import { SpeedInsights } from '@vercel/speed-insights/vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const showAppHeader = computed(() => route.name !== 'admin')
|
const showAppHeader = computed(() => route.name !== 'admin')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NConfigProvider :theme-overrides="openCloudThemeOverrides">
|
<NConfigProvider :theme-overrides="openCloudThemeOverrides">
|
||||||
<NGlobalStyle />
|
<NGlobalStyle />
|
||||||
<NDialogProvider>
|
<NDialogProvider>
|
||||||
<NNotificationProvider>
|
<NNotificationProvider>
|
||||||
<NMessageProvider>
|
<NMessageProvider>
|
||||||
<div class="oc-app-shell">
|
<div class="oc-app-shell">
|
||||||
<AppHeader v-if="showAppHeader" />
|
<AppHeader v-if="showAppHeader" />
|
||||||
<main class="relative flex-1">
|
<main class="relative flex-1">
|
||||||
<div class="oc-app-grid"></div>
|
<div class="oc-app-grid"></div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</NMessageProvider>
|
</NMessageProvider>
|
||||||
</NNotificationProvider>
|
</NNotificationProvider>
|
||||||
</NDialogProvider>
|
</NDialogProvider>
|
||||||
</NConfigProvider>
|
</NConfigProvider>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<SpeedInsights />
|
<SpeedInsights />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,275 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { NAlert, NButton, NIcon } from 'naive-ui'
|
|
||||||
import { Map as MapIcon, X } from '@vicons/tabler'
|
|
||||||
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
|
||||||
import type { CloudType } from '@/types/database'
|
|
||||||
|
|
||||||
export interface CloudEditFormValue {
|
|
||||||
cloudCategoryId: number | 'other' | null
|
|
||||||
customCloudType: string
|
|
||||||
capturedAt: string
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
locationName: string
|
|
||||||
description: string
|
|
||||||
isPublic: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
open: boolean
|
|
||||||
saving?: boolean
|
|
||||||
error?: string
|
|
||||||
cloudTypes: CloudType[]
|
|
||||||
initialValue: CloudEditFormValue | null
|
|
||||||
}>(), {
|
|
||||||
saving: false,
|
|
||||||
error: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
save: [value: CloudEditFormValue]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const form = ref<CloudEditFormValue>(createEmptyForm())
|
|
||||||
const mapPickerOpen = ref(false)
|
|
||||||
|
|
||||||
const hasCoordinates = computed(() => form.value.latitude !== null || form.value.longitude !== null)
|
|
||||||
|
|
||||||
function createEmptyForm(): CloudEditFormValue {
|
|
||||||
return {
|
|
||||||
cloudCategoryId: null,
|
|
||||||
customCloudType: '',
|
|
||||||
capturedAt: '',
|
|
||||||
latitude: null,
|
|
||||||
longitude: null,
|
|
||||||
locationName: '',
|
|
||||||
description: '',
|
|
||||||
isPublic: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneForm(value: CloudEditFormValue | null) {
|
|
||||||
return value ? { ...value } : createEmptyForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCategoryChange(event: Event) {
|
|
||||||
const value = (event.target as HTMLSelectElement).value
|
|
||||||
if (value === 'other') {
|
|
||||||
form.value.cloudCategoryId = 'other'
|
|
||||||
} else if (!value) {
|
|
||||||
form.value.cloudCategoryId = null
|
|
||||||
} else {
|
|
||||||
form.value.cloudCategoryId = Number(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLatInput(event: Event) {
|
|
||||||
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
|
||||||
form.value.latitude = Number.isNaN(value) ? null : value
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLngInput(event: Event) {
|
|
||||||
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
|
||||||
form.value.longitude = Number.isNaN(value) ? null : value
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCoordinates() {
|
|
||||||
form.value.latitude = null
|
|
||||||
form.value.longitude = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
|
||||||
form.value.latitude = payload.latitude
|
|
||||||
form.value.longitude = payload.longitude
|
|
||||||
mapPickerOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
function save() {
|
|
||||||
emit('save', { ...form.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.open, open => {
|
|
||||||
if (open) {
|
|
||||||
form.value = cloneForm(props.initialValue)
|
|
||||||
} else {
|
|
||||||
mapPickerOpen.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.initialValue, value => {
|
|
||||||
if (props.open) {
|
|
||||||
form.value = cloneForm(value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="open"
|
|
||||||
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/60 px-4 py-6"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<div class="oc-modal-shell w-full max-w-2xl" @click.stop>
|
|
||||||
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-semibold text-slate-900">编辑图片信息</h3>
|
|
||||||
<p class="oc-field-help">修改后会同步影响个人主页、画廊和地图中的展示。</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button"
|
|
||||||
aria-label="关闭编辑窗口"
|
|
||||||
title="关闭编辑窗口"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-h-[70vh] overflow-y-auto px-6 py-5">
|
|
||||||
<NAlert
|
|
||||||
v-if="error"
|
|
||||||
class="mb-5"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<div class="grid gap-5">
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">类别</label>
|
|
||||||
<select
|
|
||||||
:value="form.cloudCategoryId ?? ''"
|
|
||||||
class="oc-field-control"
|
|
||||||
@change="onCategoryChange"
|
|
||||||
>
|
|
||||||
<option value="" disabled>请选择</option>
|
|
||||||
<option v-for="ct in cloudTypes" :key="ct.id" :value="ct.id">
|
|
||||||
{{ ct.name }}({{ ct.name_en }})
|
|
||||||
</option>
|
|
||||||
<option value="other">其他</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="form.cloudCategoryId === 'other'">
|
|
||||||
<label class="oc-field-label">自定义云型</label>
|
|
||||||
<input
|
|
||||||
v-model="form.customCloudType"
|
|
||||||
type="text"
|
|
||||||
class="oc-field-control"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">拍摄时间</label>
|
|
||||||
<input
|
|
||||||
v-model="form.capturedAt"
|
|
||||||
type="datetime-local"
|
|
||||||
class="oc-field-control"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">位置名称</label>
|
|
||||||
<input
|
|
||||||
v-model="form.locationName"
|
|
||||||
type="text"
|
|
||||||
class="oc-field-control"
|
|
||||||
placeholder="如:北京、成都"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="mb-1 flex items-center justify-between gap-3">
|
|
||||||
<label class="oc-field-label mb-0">经纬度</label>
|
|
||||||
<button
|
|
||||||
v-if="hasCoordinates"
|
|
||||||
type="button"
|
|
||||||
class="oc-text-button oc-text-button--muted text-xs"
|
|
||||||
@click="clearCoordinates"
|
|
||||||
>
|
|
||||||
清空
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
|
||||||
<input
|
|
||||||
:value="form.latitude !== null ? form.latitude : ''"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="纬度"
|
|
||||||
class="oc-field-control"
|
|
||||||
@input="onLatInput"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
:value="form.longitude !== null ? form.longitude : ''"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="经度"
|
|
||||||
class="oc-field-control"
|
|
||||||
@input="onLngInput"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button oc-icon-button--sky"
|
|
||||||
title="地图选点"
|
|
||||||
aria-label="地图选点"
|
|
||||||
@click="mapPickerOpen = true"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><MapIcon /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">图片描述</label>
|
|
||||||
<textarea
|
|
||||||
v-model="form.description"
|
|
||||||
rows="4"
|
|
||||||
class="oc-field-control resize-none"
|
|
||||||
placeholder="描述一下这张图片..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3">
|
|
||||||
<span>
|
|
||||||
<span class="block text-sm font-medium text-slate-800">公开展示</span>
|
|
||||||
<span class="mt-1 block text-xs text-slate-500">关闭后不会出现在画廊、地图和公开主页中。</span>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
v-model="form.isPublic"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4">
|
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="close">取消</NButton>
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" :loading="saving" @click="save">
|
|
||||||
保存修改
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<MapPickerModal
|
|
||||||
:open="mapPickerOpen"
|
|
||||||
:latitude="form.latitude"
|
|
||||||
:longitude="form.longitude"
|
|
||||||
description="点击地图即可回填这张图片的经纬度。"
|
|
||||||
footer-text="保存时会继续做模糊化处理。"
|
|
||||||
@close="mapPickerOpen = false"
|
|
||||||
@confirm="confirmMapPicker"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { NCard } from 'naive-ui'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
unlockedCount: number
|
|
||||||
totalCount: number
|
|
||||||
isLoggedIn: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const displayedUnlockedCount = computed(() => {
|
|
||||||
if (!props.isLoggedIn) return 0
|
|
||||||
return Math.min(Math.max(props.unlockedCount, 0), props.totalCount)
|
|
||||||
})
|
|
||||||
const remainingCount = computed(() => Math.max(props.totalCount - displayedUnlockedCount.value, 0))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NCard class="oc-panel-card-soft border border-sky-100 bg-white/88 backdrop-blur" :bordered="false">
|
|
||||||
<div class="flex items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="oc-stat-label">Collection Status</p>
|
|
||||||
<p class="mt-1 text-base font-semibold text-slate-900">图鉴收集进度</p>
|
|
||||||
</div>
|
|
||||||
<p class="shrink-0 font-mono tabular-nums text-slate-500">
|
|
||||||
<span class="text-3xl font-bold text-sky-800">{{ displayedUnlockedCount }}</span>
|
|
||||||
<span class="ml-1 text-sm">/ {{ totalCount }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="mt-5 grid grid-cols-5 gap-2 sm:grid-cols-10"
|
|
||||||
role="progressbar"
|
|
||||||
aria-label="云属收集进度"
|
|
||||||
aria-valuemin="0"
|
|
||||||
:aria-valuemax="totalCount"
|
|
||||||
:aria-valuenow="displayedUnlockedCount"
|
|
||||||
:aria-valuetext="`已收录 ${displayedUnlockedCount} 种,共 ${totalCount} 种`"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-for="index in totalCount"
|
|
||||||
:key="index"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="encyclopedia-progress-cell aspect-square border border-slate-300 bg-slate-50"
|
|
||||||
:class="{ 'encyclopedia-progress-cell--active': index <= displayedUnlockedCount }"
|
|
||||||
:style="{ animationDelay: `${(index - 1) * 90}ms` }"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-4 border-t border-slate-200 pt-3 text-sm text-slate-500">
|
|
||||||
<template v-if="isLoggedIn">
|
|
||||||
已收录 {{ displayedUnlockedCount }} 种云属,还差 {{ remainingCount }} 种。
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
登录后可同步你的个人图鉴进度。
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
</NCard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.encyclopedia-progress-cell--active {
|
|
||||||
background: rgb(224 242 254);
|
|
||||||
border-color: rgb(125 211 252);
|
|
||||||
box-shadow: 3px 3px 0 0 rgb(3 105 161 / 14%);
|
|
||||||
animation:
|
|
||||||
encyclopedia-cell-activate 1ms linear both,
|
|
||||||
encyclopedia-cell-pulse 220ms ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes encyclopedia-cell-activate {
|
|
||||||
from {
|
|
||||||
background: rgb(248 250 252);
|
|
||||||
border-color: rgb(203 213 225);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
background: rgb(224 242 254);
|
|
||||||
border-color: rgb(125 211 252);
|
|
||||||
box-shadow: 3px 3px 0 0 rgb(3 105 161 / 14%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes encyclopedia-cell-pulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
28% {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
62% {
|
|
||||||
transform: scale(1.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.encyclopedia-progress-cell--active {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { InfoCircle, X } from '@vicons/tabler'
|
|
||||||
import { NIcon } from 'naive-ui'
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
open: boolean
|
|
||||||
imageUrl: string
|
|
||||||
thumbnailUrl?: string | null
|
|
||||||
imageAlt: string
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
badgeLabel?: string
|
|
||||||
badgeClass?: string
|
|
||||||
panelWidth?: string
|
|
||||||
}>(), {
|
|
||||||
subtitle: '',
|
|
||||||
thumbnailUrl: null,
|
|
||||||
badgeLabel: '',
|
|
||||||
badgeClass: '',
|
|
||||||
panelWidth: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const infoOpen = ref(false)
|
|
||||||
const imageLoaded = ref(false)
|
|
||||||
const previewLoaded = ref(false)
|
|
||||||
const naturalWidth = ref(0)
|
|
||||||
const naturalHeight = ref(0)
|
|
||||||
const viewportWidth = ref(0)
|
|
||||||
const viewportHeight = ref(0)
|
|
||||||
const modalVisible = ref(false)
|
|
||||||
const closing = ref(false)
|
|
||||||
let closeTimer: number | null = null
|
|
||||||
const previousBodyOverflow = ref<string | null>(null)
|
|
||||||
const previousDocumentOverflow = ref<string | null>(null)
|
|
||||||
|
|
||||||
function updateViewport() {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
viewportWidth.value = window.innerWidth
|
|
||||||
viewportHeight.value = window.innerHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
const layout = computed(() => {
|
|
||||||
const aspectRatio = naturalWidth.value > 0 && naturalHeight.value > 0
|
|
||||||
? naturalWidth.value / naturalHeight.value
|
|
||||||
: 4 / 3
|
|
||||||
const maxModalWidth = viewportWidth.value > 0
|
|
||||||
? Math.max(Math.min(viewportWidth.value * 0.92, viewportWidth.value - 32), 280)
|
|
||||||
: 320
|
|
||||||
const maxImageHeight = viewportHeight.value > 0
|
|
||||||
? Math.max(viewportHeight.value * 0.86, 240)
|
|
||||||
: 360
|
|
||||||
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
|
|
||||||
|
|
||||||
return {
|
|
||||||
imageWidth,
|
|
||||||
imageHeight: imageWidth / aspectRatio,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const imageStyle = computed(() => ({
|
|
||||||
width: `${Math.round(layout.value.imageWidth)}px`,
|
|
||||||
height: `${Math.round(layout.value.imageHeight)}px`,
|
|
||||||
}) as Record<string, string>)
|
|
||||||
|
|
||||||
function resetImageState() {
|
|
||||||
imageLoaded.value = false
|
|
||||||
previewLoaded.value = false
|
|
||||||
naturalWidth.value = 0
|
|
||||||
naturalHeight.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function lockPageScroll() {
|
|
||||||
if (typeof document === 'undefined' || previousBodyOverflow.value !== null) return
|
|
||||||
previousBodyOverflow.value = document.body.style.overflow
|
|
||||||
previousDocumentOverflow.value = document.documentElement.style.overflow
|
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
document.documentElement.style.overflow = 'hidden'
|
|
||||||
}
|
|
||||||
|
|
||||||
function unlockPageScroll() {
|
|
||||||
if (typeof document === 'undefined' || previousBodyOverflow.value === null) return
|
|
||||||
document.body.style.overflow = previousBodyOverflow.value
|
|
||||||
document.documentElement.style.overflow = previousDocumentOverflow.value ?? ''
|
|
||||||
previousBodyOverflow.value = null
|
|
||||||
previousDocumentOverflow.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.open, open => {
|
|
||||||
if (open) {
|
|
||||||
if (closeTimer !== null) {
|
|
||||||
window.clearTimeout(closeTimer)
|
|
||||||
closeTimer = null
|
|
||||||
}
|
|
||||||
closing.value = false
|
|
||||||
modalVisible.value = true
|
|
||||||
lockPageScroll()
|
|
||||||
infoOpen.value = false
|
|
||||||
resetImageState()
|
|
||||||
} else {
|
|
||||||
modalVisible.value = false
|
|
||||||
unlockPageScroll()
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
watch(() => props.imageUrl, () => {
|
|
||||||
resetImageState()
|
|
||||||
})
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
if (closing.value) return
|
|
||||||
closing.value = true
|
|
||||||
modalVisible.value = false
|
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
unlockPageScroll()
|
|
||||||
emit('close')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
closeTimer = window.setTimeout(() => {
|
|
||||||
closeTimer = null
|
|
||||||
unlockPageScroll()
|
|
||||||
emit('close')
|
|
||||||
}, 180)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleInfo() {
|
|
||||||
infoOpen.value = !infoOpen.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImageLoad(event: Event) {
|
|
||||||
const target = event.target as HTMLImageElement | null
|
|
||||||
if (!target) return
|
|
||||||
naturalWidth.value = target.naturalWidth
|
|
||||||
naturalHeight.value = target.naturalHeight
|
|
||||||
imageLoaded.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePreviewLoad(event: Event) {
|
|
||||||
const target = event.target as HTMLImageElement | null
|
|
||||||
if (!target) return
|
|
||||||
if (!naturalWidth.value || !naturalHeight.value) {
|
|
||||||
naturalWidth.value = target.naturalWidth
|
|
||||||
naturalHeight.value = target.naturalHeight
|
|
||||||
}
|
|
||||||
previewLoaded.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
updateViewport()
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.addEventListener('resize', updateViewport)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (closeTimer !== null) {
|
|
||||||
window.clearTimeout(closeTimer)
|
|
||||||
}
|
|
||||||
unlockPageScroll()
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.removeEventListener('resize', updateViewport)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition
|
|
||||||
appear
|
|
||||||
name="image-detail-modal"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="modalVisible"
|
|
||||||
class="fixed inset-0 z-[110] flex items-center justify-center bg-black/85 px-4 py-6"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden shadow-2xl"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="close"
|
|
||||||
class="image-detail-close-button oc-overlay-button absolute right-4 top-4 z-30 h-10 w-10 [&_svg]:stroke-[2.5]"
|
|
||||||
aria-label="关闭大图"
|
|
||||||
title="关闭大图"
|
|
||||||
>
|
|
||||||
<NIcon size="22">
|
|
||||||
<X />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="relative flex min-h-0 items-center justify-center overflow-hidden bg-slate-950"
|
|
||||||
:style="imageStyle"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!previewLoaded && !imageLoaded"
|
|
||||||
class="absolute inset-0 animate-pulse bg-[linear-gradient(110deg,#0f172a_0%,#1e293b_45%,#334155_55%,#0f172a_100%)] bg-[length:220%_100%]"
|
|
||||||
></div>
|
|
||||||
<img
|
|
||||||
v-if="thumbnailUrl"
|
|
||||||
:src="thumbnailUrl"
|
|
||||||
:alt="imageAlt"
|
|
||||||
class="absolute inset-0 block h-full w-full object-contain blur-[1px] transition-opacity duration-300"
|
|
||||||
:class="imageLoaded ? 'opacity-0' : 'opacity-100'"
|
|
||||||
@load="handlePreviewLoad"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
:src="imageUrl"
|
|
||||||
:alt="imageAlt"
|
|
||||||
class="relative block h-full w-full object-contain transition-opacity duration-300"
|
|
||||||
:class="imageLoaded ? 'opacity-100' : 'opacity-0'"
|
|
||||||
@load="handleImageLoad"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="toggleInfo"
|
|
||||||
class="image-detail-info-button oc-overlay-button absolute bottom-4 right-4 z-30 h-11 w-11 drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)]"
|
|
||||||
:aria-label="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
|
||||||
:title="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
|
||||||
>
|
|
||||||
<NIcon size="30">
|
|
||||||
<InfoCircle />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="infoOpen"
|
|
||||||
class="absolute bottom-16 right-4 z-20 flex max-h-[min(70vh,560px)] w-[min(360px,calc(100%-2rem))] flex-col overflow-hidden border border-white/70 bg-white/95 shadow-2xl backdrop-blur"
|
|
||||||
>
|
|
||||||
<div class="border-b border-slate-200 px-5 py-4">
|
|
||||||
<div class="pr-8">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<h2 class="text-xl font-bold text-slate-900">{{ title }}</h2>
|
|
||||||
<span
|
|
||||||
v-if="badgeLabel"
|
|
||||||
class="border px-3 py-1 text-xs font-medium"
|
|
||||||
:class="badgeClass"
|
|
||||||
>
|
|
||||||
{{ badgeLabel }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="subtitle" class="mt-2 text-sm text-slate-500">{{ subtitle }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="infoOpen = false"
|
|
||||||
class="image-detail-panel-close oc-icon-button oc-icon-button--small absolute right-3 top-3"
|
|
||||||
aria-label="隐藏详细信息"
|
|
||||||
title="隐藏详细信息"
|
|
||||||
>
|
|
||||||
<NIcon size="16"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="$slots.actions" class="border-t border-slate-200 px-5 py-4">
|
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.image-detail-modal-enter-active,
|
|
||||||
.image-detail-modal-leave-active {
|
|
||||||
transition: opacity 180ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-detail-modal-enter-active .image-detail-shell,
|
|
||||||
.image-detail-modal-leave-active .image-detail-shell {
|
|
||||||
transition:
|
|
||||||
opacity 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
||||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-detail-modal-enter-from,
|
|
||||||
.image-detail-modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-detail-modal-enter-from .image-detail-shell,
|
|
||||||
.image-detail-modal-leave-to .image-detail-shell {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px) scale(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-detail-shell.image-detail-shell {
|
|
||||||
border-radius: 30px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-detail-close-button.image-detail-close-button,
|
|
||||||
.image-detail-info-button.image-detail-info-button,
|
|
||||||
.image-detail-panel-close.image-detail-panel-close {
|
|
||||||
border-radius: 9999px !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
|
||||||
import { X } from '@vicons/tabler'
|
|
||||||
import { loadAMap } from '@/lib/amap'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
open: boolean
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
footerText?: string
|
|
||||||
}>(), {
|
|
||||||
title: '地图选点',
|
|
||||||
description: '点击地图即可回填经纬度,也可以继续手动修改。',
|
|
||||||
footerText: '建议点选大致拍摄位置,保存时会继续做模糊化处理。',
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
confirm: [payload: { latitude: number; longitude: number }]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
const pickedLat = ref<number | null>(null)
|
|
||||||
const pickedLng = ref<number | null>(null)
|
|
||||||
const mapEl = ref<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
let mapPickerAMap: typeof AMap | null = null
|
|
||||||
let mapPickerInstance: AMap.Map | null = null
|
|
||||||
let mapPickerMarker: AMap.Marker | null = null
|
|
||||||
|
|
||||||
function formatCoordinate(value: number | null) {
|
|
||||||
return value === null ? '未选择' : value.toFixed(6)
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyMapPicker() {
|
|
||||||
mapPickerMarker?.setMap(null)
|
|
||||||
mapPickerMarker = null
|
|
||||||
mapPickerInstance?.destroy()
|
|
||||||
mapPickerInstance = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeMarker(longitude: number, latitude: number) {
|
|
||||||
if (!mapPickerAMap || !mapPickerInstance) return
|
|
||||||
|
|
||||||
if (!mapPickerMarker) {
|
|
||||||
mapPickerMarker = new mapPickerAMap.Marker({
|
|
||||||
position: [longitude, latitude],
|
|
||||||
title: '拍摄位置',
|
|
||||||
content: '<div aria-hidden="true" class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(52,116,93,0.24)]"></div>',
|
|
||||||
offset: new mapPickerAMap.Pixel(-8, -8),
|
|
||||||
} as AMap.MarkerOptions)
|
|
||||||
mapPickerMarker.setMap(mapPickerInstance)
|
|
||||||
} else {
|
|
||||||
mapPickerMarker.setPosition([longitude, latitude])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractLngLat(event: unknown) {
|
|
||||||
const payload = event as { lnglat?: { lng?: number; lat?: number } } | undefined
|
|
||||||
const lng = payload?.lnglat?.lng
|
|
||||||
const lat = payload?.lnglat?.lat
|
|
||||||
|
|
||||||
if (typeof lng !== 'number' || typeof lat !== 'number') return null
|
|
||||||
return { lng, lat }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMapClick(event: unknown) {
|
|
||||||
const picked = extractLngLat(event)
|
|
||||||
if (!picked) return
|
|
||||||
|
|
||||||
pickedLng.value = picked.lng
|
|
||||||
pickedLat.value = picked.lat
|
|
||||||
placeMarker(picked.lng, picked.lat)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeMap() {
|
|
||||||
loading.value = true
|
|
||||||
errorMsg.value = ''
|
|
||||||
pickedLat.value = props.latitude
|
|
||||||
pickedLng.value = props.longitude
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
destroyMapPicker()
|
|
||||||
|
|
||||||
try {
|
|
||||||
mapPickerAMap = await loadAMap()
|
|
||||||
mapPickerAMap.getConfig().appname = 'amap-jsapi-skill'
|
|
||||||
|
|
||||||
const hasCoordinates =
|
|
||||||
typeof props.latitude === 'number' &&
|
|
||||||
typeof props.longitude === 'number'
|
|
||||||
|
|
||||||
const center: [number, number] = hasCoordinates
|
|
||||||
? [props.longitude as number, props.latitude as number]
|
|
||||||
: [104.07, 30.67]
|
|
||||||
|
|
||||||
mapPickerInstance = new mapPickerAMap.Map(mapEl.value!, {
|
|
||||||
viewMode: '2D',
|
|
||||||
pitch: 0,
|
|
||||||
rotation: 0,
|
|
||||||
zoom: hasCoordinates ? 11 : 4,
|
|
||||||
center,
|
|
||||||
mapStyle: 'amap://styles/normal',
|
|
||||||
resizeEnable: true,
|
|
||||||
} as AMap.MapOptions)
|
|
||||||
|
|
||||||
mapPickerInstance.on('click', handleMapClick)
|
|
||||||
|
|
||||||
if (hasCoordinates) {
|
|
||||||
placeMarker(props.longitude as number, props.latitude as number)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirm() {
|
|
||||||
if (pickedLat.value === null || pickedLng.value === null) return
|
|
||||||
emit('confirm', {
|
|
||||||
latitude: pickedLat.value,
|
|
||||||
longitude: pickedLng.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.open, open => {
|
|
||||||
if (open) {
|
|
||||||
initializeMap()
|
|
||||||
} else {
|
|
||||||
loading.value = false
|
|
||||||
errorMsg.value = ''
|
|
||||||
destroyMapPicker()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
destroyMapPicker()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="open"
|
|
||||||
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
|
|
||||||
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-semibold text-slate-900">{{ title }}</h3>
|
|
||||||
<p class="oc-field-help">{{ description }}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button"
|
|
||||||
aria-label="关闭地图选点窗口"
|
|
||||||
title="关闭地图选点窗口"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-6 py-5">
|
|
||||||
<div class="mb-4 flex flex-wrap items-center gap-3 border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
|
||||||
<span>当前选择:</span>
|
|
||||||
<span class="font-medium text-slate-900">纬度 {{ formatCoordinate(pickedLat) }}</span>
|
|
||||||
<span class="font-medium text-slate-900">经度 {{ formatCoordinate(pickedLng) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative overflow-hidden border border-slate-200 bg-slate-100">
|
|
||||||
<div ref="mapEl" class="h-[420px] w-full"></div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="absolute inset-0 flex items-center justify-center bg-white/80 text-sm font-medium text-slate-600"
|
|
||||||
>
|
|
||||||
正在加载地图...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="errorMsg"
|
|
||||||
class="absolute inset-0 flex items-center justify-center bg-white/90 px-6 text-center text-sm text-red-600"
|
|
||||||
>
|
|
||||||
{{ errorMsg }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4">
|
|
||||||
<p class="text-sm text-slate-500">{{ footerText }}</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="close">取消</NButton>
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
:disabled="pickedLat === null || pickedLng === null"
|
|
||||||
@click="confirm"
|
|
||||||
>
|
|
||||||
使用这个位置
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
import { loadAMap } from '@/lib/amap'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
locationName?: string | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const mapEl = ref<HTMLDivElement | null>(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
|
|
||||||
let amapLib: typeof AMap | null = null
|
|
||||||
let mapInstance: AMap.Map | null = null
|
|
||||||
let marker: AMap.Marker | null = null
|
|
||||||
|
|
||||||
const hasCoordinates = computed(() => {
|
|
||||||
return Number.isFinite(props.latitude) && Number.isFinite(props.longitude)
|
|
||||||
})
|
|
||||||
|
|
||||||
const center = computed<[number, number] | null>(() => {
|
|
||||||
if (!hasCoordinates.value) return null
|
|
||||||
return [props.longitude as number, props.latitude as number]
|
|
||||||
})
|
|
||||||
|
|
||||||
function destroyMap() {
|
|
||||||
marker?.setMap(null)
|
|
||||||
marker = null
|
|
||||||
mapInstance?.destroy()
|
|
||||||
mapInstance = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeMap() {
|
|
||||||
if (!center.value) {
|
|
||||||
destroyMap()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
errorMsg.value = ''
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
destroyMap()
|
|
||||||
|
|
||||||
if (!mapEl.value) {
|
|
||||||
loading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
amapLib = await loadAMap()
|
|
||||||
if ('getConfig' in amapLib) {
|
|
||||||
amapLib.getConfig().appname = 'amap-jsapi-skill'
|
|
||||||
}
|
|
||||||
|
|
||||||
mapInstance = new amapLib.Map(mapEl.value, {
|
|
||||||
viewMode: '2D',
|
|
||||||
zoom: 11,
|
|
||||||
center: center.value,
|
|
||||||
mapStyle: 'amap://styles/normal',
|
|
||||||
features: ['bg', 'road', 'building', 'point'],
|
|
||||||
resizeEnable: true,
|
|
||||||
dragEnable: true,
|
|
||||||
zoomEnable: true,
|
|
||||||
keyboardEnable: false,
|
|
||||||
doubleClickZoom: true,
|
|
||||||
scrollWheel: true,
|
|
||||||
} as AMap.MapOptions)
|
|
||||||
|
|
||||||
mapInstance.addControl(new amapLib.ToolBar({
|
|
||||||
position: 'RT',
|
|
||||||
offset: [8, 8],
|
|
||||||
} as Record<string, unknown>))
|
|
||||||
|
|
||||||
marker = new amapLib.Marker({
|
|
||||||
position: center.value,
|
|
||||||
title: props.locationName || '拍摄位置',
|
|
||||||
content: '<div class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(52,116,93,0.24)]"></div>',
|
|
||||||
offset: new amapLib.Pixel(-8, -8),
|
|
||||||
} as AMap.MarkerOptions)
|
|
||||||
marker.setMap(mapInstance)
|
|
||||||
} catch (error) {
|
|
||||||
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(center, () => {
|
|
||||||
initializeMap()
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
destroyMap()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="hasCoordinates" class="mt-5 overflow-hidden border border-slate-200 bg-white">
|
|
||||||
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<p class="oc-section-label">位置地图</p>
|
|
||||||
<p class="mt-1 text-sm font-medium text-slate-900">{{ locationName || '拍摄位置' }}</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-right text-[11px] leading-5 text-slate-500">
|
|
||||||
{{ latitude?.toFixed(4) }}<br />
|
|
||||||
{{ longitude?.toFixed(4) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative h-40 bg-sky-50">
|
|
||||||
<div ref="mapEl" class="h-full w-full"></div>
|
|
||||||
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-white/70 text-sm text-slate-500">
|
|
||||||
地图加载中...
|
|
||||||
</div>
|
|
||||||
<div v-else-if="errorMsg" class="absolute inset-0 flex items-center justify-center bg-white/85 px-4 text-center text-sm text-slate-500">
|
|
||||||
{{ errorMsg }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
import { NAlert, NButton, NIcon, NProgress } from 'naive-ui'
|
|
||||||
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
|
|
||||||
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
|
||||||
import { useCloudsStore } from '@/stores/clouds'
|
|
||||||
import { useUpload } from '@/composables/useUpload'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
open: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
uploaded: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const cloudsStore = useCloudsStore()
|
|
||||||
const {
|
|
||||||
items,
|
|
||||||
uploading,
|
|
||||||
overallProgress,
|
|
||||||
currentItemIndex,
|
|
||||||
totalItems,
|
|
||||||
addFiles,
|
|
||||||
removeItem,
|
|
||||||
clearAll,
|
|
||||||
validateAll,
|
|
||||||
uploadAll,
|
|
||||||
} = useUpload()
|
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const activeId = ref<string | null>(null)
|
|
||||||
const dragOver = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
const successMsg = ref(false)
|
|
||||||
const mapPickerOpen = ref(false)
|
|
||||||
|
|
||||||
const activeItem = computed(() => {
|
|
||||||
if (!activeId.value) return null
|
|
||||||
return items.value.find(item => item.id === activeId.value) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.open, open => {
|
|
||||||
if (open) {
|
|
||||||
cloudsStore.fetchCloudTypes()
|
|
||||||
errorMsg.value = ''
|
|
||||||
successMsg.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => items.value.length, () => {
|
|
||||||
if (items.value.length > 0 && !activeId.value) {
|
|
||||||
activeId.value = items.value[0].id
|
|
||||||
}
|
|
||||||
if (items.value.length === 0) {
|
|
||||||
activeId.value = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function addSelectedFiles(files: File[]) {
|
|
||||||
const previousLength = items.value.length
|
|
||||||
await addFiles(files)
|
|
||||||
if (items.value.length > previousLength) {
|
|
||||||
activeId.value = items.value[previousLength].id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(event: DragEvent) {
|
|
||||||
dragOver.value = false
|
|
||||||
if (event.dataTransfer?.files) {
|
|
||||||
addSelectedFiles(Array.from(event.dataTransfer.files))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileSelect(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
if (target.files?.length) {
|
|
||||||
addSelectedFiles(Array.from(target.files))
|
|
||||||
}
|
|
||||||
target.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemove(id: string) {
|
|
||||||
removeItem(id)
|
|
||||||
if (activeId.value === id) {
|
|
||||||
activeId.value = items.value[0]?.id ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCategoryChange(event: Event) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
const value = (event.target as HTMLSelectElement).value
|
|
||||||
if (value === 'other') {
|
|
||||||
activeItem.value.cloudCategoryId = 'other'
|
|
||||||
} else if (value === '') {
|
|
||||||
activeItem.value.cloudCategoryId = null
|
|
||||||
} else {
|
|
||||||
activeItem.value.cloudCategoryId = Number(value)
|
|
||||||
}
|
|
||||||
activeItem.value.errors = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDatetimeLocal(iso: string): string {
|
|
||||||
const date = new Date(iso)
|
|
||||||
const pad = (value: number) => String(value).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCapturedAtChange(event: Event) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
const value = (event.target as HTMLInputElement).value
|
|
||||||
if (!value) return
|
|
||||||
activeItem.value.capturedAt = new Date(value).toISOString()
|
|
||||||
delete activeItem.value.errors.capturedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLatInput(event: Event) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
|
||||||
activeItem.value.latitude = Number.isNaN(value) ? null : value
|
|
||||||
delete activeItem.value.errors.latitude
|
|
||||||
delete activeItem.value.errors.longitude
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLngInput(event: Event) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
|
||||||
activeItem.value.longitude = Number.isNaN(value) ? null : value
|
|
||||||
delete activeItem.value.errors.latitude
|
|
||||||
delete activeItem.value.errors.longitude
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePublicState(isPublic: boolean) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
activeItem.value.isHidden = !isPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMapPicker() {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
mapPickerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMapPicker() {
|
|
||||||
mapPickerOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
activeItem.value.latitude = payload.latitude
|
|
||||||
activeItem.value.longitude = payload.longitude
|
|
||||||
delete activeItem.value.errors.latitude
|
|
||||||
delete activeItem.value.errors.longitude
|
|
||||||
closeMapPicker()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
errorMsg.value = ''
|
|
||||||
successMsg.value = false
|
|
||||||
|
|
||||||
if (!validateAll()) {
|
|
||||||
const firstInvalid = items.value.find(item => Object.keys(item.errors).length > 0)
|
|
||||||
if (firstInvalid) {
|
|
||||||
activeId.value = firstInvalid.id
|
|
||||||
await nextTick()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await uploadAll()
|
|
||||||
if (!result.ok) {
|
|
||||||
errorMsg.value = '上传失败,请稍后重试'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
successMsg.value = true
|
|
||||||
emit('uploaded')
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
if (uploading.value) return
|
|
||||||
clearAll()
|
|
||||||
errorMsg.value = ''
|
|
||||||
successMsg.value = false
|
|
||||||
mapPickerOpen.value = false
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
clearAll()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="open"
|
|
||||||
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/55 px-4 py-6 backdrop-blur-sm"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
|
|
||||||
<div class="flex items-start justify-between border-b border-slate-200 px-5 py-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-sky-700">Quick Upload</p>
|
|
||||||
<h2 class="mt-2 text-xl font-bold text-slate-950">快速上传云图</h2>
|
|
||||||
<p class="oc-field-help">从地图当前位置带入经纬度,提交后进入审核队列。</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button"
|
|
||||||
aria-label="关闭快捷上传"
|
|
||||||
title="关闭快捷上传"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-h-[72vh] overflow-y-auto px-5 py-5">
|
|
||||||
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileSelect" />
|
|
||||||
|
|
||||||
<NAlert v-if="successMsg" type="success" :bordered="false" title="已提交审核" class="mb-4">
|
|
||||||
图片审核通过后会出现在画廊和地图中。
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<div v-if="uploading" class="mb-4 border border-sky-100 bg-sky-50 p-4">
|
|
||||||
<div class="mb-2 flex items-center justify-between text-sm">
|
|
||||||
<span class="font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}</span>
|
|
||||||
<span class="font-medium text-sky-700">{{ overallProgress }}%</span>
|
|
||||||
</div>
|
|
||||||
<NProgress
|
|
||||||
type="line"
|
|
||||||
:percentage="overallProgress"
|
|
||||||
:show-indicator="false"
|
|
||||||
color="var(--oc-color-info)"
|
|
||||||
rail-color="var(--oc-color-border)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="items.length === 0"
|
|
||||||
class="flex cursor-pointer flex-col items-center justify-center border-2 border-dashed px-6 py-12 text-center transition-colors"
|
|
||||||
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-slate-300 bg-slate-50 hover:border-sky-300 hover:bg-sky-50'"
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
@dragover.prevent="dragOver = true"
|
|
||||||
@dragleave.prevent="dragOver = false"
|
|
||||||
@drop.prevent="handleDrop"
|
|
||||||
>
|
|
||||||
<NIcon size="42" class="text-sky-600"><CloudUpload /></NIcon>
|
|
||||||
<p class="mt-3 text-base font-semibold text-slate-800">选择一张云图</p>
|
|
||||||
<p class="oc-field-help">支持 JPG、PNG,可点击或拖拽上传。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else-if="activeItem">
|
|
||||||
<div class="grid gap-5 md:grid-cols-[220px_minmax(0,1fr)]">
|
|
||||||
<div>
|
|
||||||
<div class="overflow-hidden border border-slate-900 bg-slate-950">
|
|
||||||
<img :src="activeItem.preview" alt="图片预览" class="h-56 w-full object-contain" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex items-center gap-2">
|
|
||||||
<button type="button" class="oc-panel-button oc-panel-button--neutral flex-1" @click="fileInput?.click()">重新选择</button>
|
|
||||||
<button type="button" class="oc-panel-button oc-panel-button--danger flex-1" @click="handleRemove(activeItem.id)">移除</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">类别 <span class="oc-field-required">*</span></label>
|
|
||||||
<select
|
|
||||||
:value="activeItem.cloudCategoryId ?? ''"
|
|
||||||
class="oc-field-control"
|
|
||||||
@change="onCategoryChange"
|
|
||||||
>
|
|
||||||
<option value="" disabled>请选择</option>
|
|
||||||
<option v-for="cloudType in cloudsStore.cloudTypes" :key="cloudType.id" :value="cloudType.id">
|
|
||||||
{{ cloudType.name }}({{ cloudType.name_en }})
|
|
||||||
</option>
|
|
||||||
<option value="other">其他</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
v-if="activeItem.cloudCategoryId === 'other'"
|
|
||||||
v-model="activeItem.customCloudType"
|
|
||||||
type="text"
|
|
||||||
placeholder="输入云的类型"
|
|
||||||
class="oc-field-control mt-2"
|
|
||||||
@input="activeItem.errors = {}"
|
|
||||||
/>
|
|
||||||
<p v-if="activeItem.errors.cloudCategory" class="oc-field-error">{{ activeItem.errors.cloudCategory }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">拍摄时间 <span class="oc-field-required">*</span></label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
|
||||||
class="oc-field-control"
|
|
||||||
@change="onCapturedAtChange"
|
|
||||||
/>
|
|
||||||
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">{{ activeItem.errors.capturedAt }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">经纬度</label>
|
|
||||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_40px] gap-2">
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
:value="activeItem.latitude ?? ''"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="纬度"
|
|
||||||
class="oc-field-control"
|
|
||||||
@input="onLatInput"
|
|
||||||
/>
|
|
||||||
<p v-if="activeItem.errors.latitude" class="oc-field-error">{{ activeItem.errors.latitude }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
:value="activeItem.longitude ?? ''"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="经度"
|
|
||||||
class="oc-field-control"
|
|
||||||
@input="onLngInput"
|
|
||||||
/>
|
|
||||||
<p v-if="activeItem.errors.longitude" class="oc-field-error">{{ activeItem.errors.longitude }}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button oc-icon-button--sky"
|
|
||||||
title="地图选点"
|
|
||||||
aria-label="地图选点"
|
|
||||||
@click="openMapPicker"
|
|
||||||
>
|
|
||||||
<NIcon size="20">
|
|
||||||
<MapIcon />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-slate-400">选填,可手动输入,也可以点击右侧地图按钮选点。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">位置名称</label>
|
|
||||||
<input
|
|
||||||
v-model="activeItem.locationName"
|
|
||||||
type="text"
|
|
||||||
placeholder="如:北京、成都"
|
|
||||||
class="oc-field-control"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">图片说明</label>
|
|
||||||
<textarea
|
|
||||||
v-model="activeItem.description"
|
|
||||||
rows="3"
|
|
||||||
placeholder="描述一下这张图片..."
|
|
||||||
class="oc-field-control resize-none"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3">
|
|
||||||
<span>
|
|
||||||
<span class="block text-sm font-medium text-slate-800">公开展示</span>
|
|
||||||
<span class="mt-1 block text-xs text-slate-500">关闭后不会出现在公开地图和画廊中。</span>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
:checked="!activeItem.isHidden"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-5 w-5 border-slate-300 text-sky-600 focus:ring-sky-500"
|
|
||||||
@change="updatePublicState(($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NAlert v-if="errorMsg" class="mt-4" type="error" :show-icon="false" title="上传失败">
|
|
||||||
{{ errorMsg }}
|
|
||||||
</NAlert>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 border-t border-slate-200 bg-slate-50 px-5 py-4">
|
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" :disabled="uploading" @click="close">关闭</NButton>
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" :disabled="uploading || items.length === 0" @click="handleSubmit">
|
|
||||||
{{ uploading ? '上传中...' : '提交审核' }}
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MapPickerModal
|
|
||||||
:open="mapPickerOpen"
|
|
||||||
:latitude="activeItem?.latitude ?? null"
|
|
||||||
:longitude="activeItem?.longitude ?? null"
|
|
||||||
description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
|
|
||||||
footer-text="建议点选大致拍摄位置,上传时会继续做模糊化处理。"
|
|
||||||
@close="closeMapPicker"
|
|
||||||
@confirm="confirmMapPicker"
|
|
||||||
/>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
+398
-319
@@ -1,13 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { NIcon, NSpace } from 'naive-ui'
|
import { NIcon, NSpace, useMessage } from 'naive-ui'
|
||||||
import { ChevronDown, Logout, Settings, Shield, User } from '@vicons/tabler'
|
import { 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'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
const isMapRoute = computed(() => route.name === 'map')
|
const isMapRoute = computed(() => route.name === 'map')
|
||||||
|
|
||||||
const headerHidden = ref(false)
|
const headerHidden = ref(false)
|
||||||
@@ -16,399 +17,477 @@ const accountCardOpen = ref(false)
|
|||||||
const activeNavClass = 'bg-teal-50 text-teal-800 ring-1 ring-teal-100'
|
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 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 userEmail = computed(() => authStore.user?.email || '未绑定邮箱')
|
||||||
|
const avatarFailed = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authStore.user?.image,
|
||||||
|
() => {
|
||||||
|
avatarFailed.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
let lastScrollY = 0
|
let lastScrollY = 0
|
||||||
let accountCloseTimer: number | null = null
|
let accountCloseTimer: number | null = null
|
||||||
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
||||||
|
|
||||||
function showHeader(pin = false) {
|
function showHeader(pin = false) {
|
||||||
headerHidden.value = false
|
headerHidden.value = false
|
||||||
headerPinnedOpen.value = pin
|
headerPinnedOpen.value = pin
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideHeader() {
|
function hideHeader() {
|
||||||
if (window.scrollY <= 96) return
|
if (window.scrollY <= 96) return
|
||||||
if (headerPinnedOpen.value) return
|
if (headerPinnedOpen.value) return
|
||||||
headerHidden.value = true
|
headerHidden.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function releasePinnedHeader() {
|
function releasePinnedHeader() {
|
||||||
headerPinnedOpen.value = false
|
headerPinnedOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function forceHideHeader() {
|
function forceHideHeader() {
|
||||||
headerPinnedOpen.value = false
|
headerPinnedOpen.value = false
|
||||||
headerHidden.value = true
|
headerHidden.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const currentScrollY = window.scrollY
|
const currentScrollY = window.scrollY
|
||||||
const delta = currentScrollY - lastScrollY
|
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
|
lastScrollY = currentScrollY
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentScrollY <= 64) {
|
|
||||||
showHeader(false)
|
|
||||||
lastScrollY = currentScrollY
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delta > 12) {
|
|
||||||
hideHeader()
|
|
||||||
} else if (delta < -8) {
|
|
||||||
showHeader(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
lastScrollY = currentScrollY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
lastScrollY = window.scrollY
|
lastScrollY = window.scrollY
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
window.addEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
window.addEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('scroll', handleScroll)
|
window.removeEventListener('scroll', handleScroll)
|
||||||
window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
||||||
if (accountCloseTimer !== null) {
|
if (accountCloseTimer !== null) {
|
||||||
window.clearTimeout(accountCloseTimer)
|
window.clearTimeout(accountCloseTimer)
|
||||||
accountCloseTimer = null
|
accountCloseTimer = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function syncHeaderForRoute() {
|
function syncHeaderForRoute() {
|
||||||
headerPinnedOpen.value = false
|
headerPinnedOpen.value = false
|
||||||
headerHidden.value = false
|
headerHidden.value = false
|
||||||
accountCardOpen.value = false
|
accountCardOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => route.fullPath, () => {
|
watch(
|
||||||
syncHeaderForRoute()
|
() => route.fullPath,
|
||||||
})
|
() => {
|
||||||
|
syncHeaderForRoute()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
watch(isMapRoute, () => {
|
watch(isMapRoute, () => {
|
||||||
syncHeaderForRoute()
|
syncHeaderForRoute()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncHeaderForRoute()
|
syncHeaderForRoute()
|
||||||
})
|
})
|
||||||
|
|
||||||
function openAccountCard() {
|
function openAccountCard() {
|
||||||
if (accountCloseTimer !== null) {
|
if (accountCloseTimer !== null) {
|
||||||
window.clearTimeout(accountCloseTimer)
|
window.clearTimeout(accountCloseTimer)
|
||||||
accountCloseTimer = null
|
accountCloseTimer = null
|
||||||
}
|
}
|
||||||
accountCardOpen.value = true
|
accountCardOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAccountCard() {
|
function closeAccountCard() {
|
||||||
if (accountCloseTimer !== null) {
|
if (accountCloseTimer !== null) {
|
||||||
window.clearTimeout(accountCloseTimer)
|
window.clearTimeout(accountCloseTimer)
|
||||||
}
|
}
|
||||||
accountCloseTimer = window.setTimeout(() => {
|
accountCloseTimer = window.setTimeout(() => {
|
||||||
accountCardOpen.value = false
|
accountCardOpen.value = false
|
||||||
accountCloseTimer = null
|
accountCloseTimer = null
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await authStore.logout()
|
try {
|
||||||
closeAccountCard()
|
await authStore.logout()
|
||||||
if (route.meta.requiresAuth) {
|
closeAccountCard()
|
||||||
router.push({ name: 'map' })
|
if (route.meta.requiresAuth) {
|
||||||
}
|
void router.push({ name: 'map' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-28 md:h-16'">
|
<div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-28 md:h-16'">
|
||||||
<header
|
<header
|
||||||
class="absolute inset-x-0 top-0 border-b border-slate-200/80 bg-white/88 backdrop-blur-xl transition-transform duration-300"
|
class="absolute inset-x-0 top-0 border-b border-slate-200/80 bg-white/88 backdrop-blur-xl transition-transform duration-300"
|
||||||
:class="headerHidden ? '-translate-y-full' : 'translate-y-0'"
|
:class="headerHidden ? '-translate-y-full' : 'translate-y-0'"
|
||||||
@mouseenter="showHeader(true)"
|
@mouseenter="showHeader(true)"
|
||||||
@mouseleave="releasePinnedHeader"
|
@mouseleave="releasePinnedHeader"
|
||||||
>
|
>
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-16 items-center justify-between">
|
<div class="flex h-16 items-center justify-between">
|
||||||
<RouterLink to="/" class="flex items-center gap-3">
|
<RouterLink to="/" class="flex items-center gap-3">
|
||||||
<div class="oc-brand-mark">
|
<div class="oc-brand-mark">
|
||||||
<svg
|
<svg
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
shape-rendering="crispEdges"
|
shape-rendering="crispEdges"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<rect x="11" y="3" width="1" height="1" fill="#f59e0b" />
|
<rect x="11" y="3" width="1" height="1" fill="#f59e0b" />
|
||||||
<rect x="10" y="4" width="3" height="1" fill="#fbbf24" />
|
<rect x="10" y="4" width="3" height="1" fill="#fbbf24" />
|
||||||
<rect x="11" y="5" width="1" height="1" fill="#f59e0b" />
|
<rect x="11" y="5" width="1" height="1" fill="#f59e0b" />
|
||||||
|
|
||||||
<rect x="5" y="7" width="7" height="1" fill="#38bdf8" />
|
<rect x="5" y="7" width="7" height="1" fill="#38bdf8" />
|
||||||
<rect x="4" y="8" width="8" height="1" fill="#38bdf8" />
|
<rect x="4" y="8" width="8" height="1" fill="#38bdf8" />
|
||||||
<rect x="4" y="9" width="7" height="1" fill="#38bdf8" />
|
<rect x="4" y="9" width="7" height="1" fill="#38bdf8" />
|
||||||
<rect x="5" y="10" width="4" height="1" fill="#38bdf8" />
|
<rect x="5" y="10" width="4" height="1" fill="#38bdf8" />
|
||||||
|
|
||||||
<rect x="5" y="5" width="4" height="1" fill="#ffffff" />
|
<rect x="5" y="5" width="4" height="1" fill="#ffffff" />
|
||||||
<rect x="4" y="6" width="7" height="1" fill="#ffffff" />
|
<rect x="4" y="6" width="7" height="1" fill="#ffffff" />
|
||||||
<rect x="3" y="7" width="8" height="1" fill="#ffffff" />
|
<rect x="3" y="7" width="8" height="1" fill="#ffffff" />
|
||||||
<rect x="3" y="8" width="7" height="1" fill="#ffffff" />
|
<rect x="3" y="8" width="7" height="1" fill="#ffffff" />
|
||||||
<rect x="4" y="9" width="5" height="1" fill="#ffffff" />
|
<rect x="4" y="9" width="5" height="1" fill="#ffffff" />
|
||||||
|
|
||||||
<rect x="4" y="11" width="8" height="1" fill="#7dd3fc" opacity="0.55" />
|
<rect
|
||||||
</svg>
|
x="4"
|
||||||
</div>
|
y="11"
|
||||||
<div>
|
width="8"
|
||||||
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-500">Live Sky Atlas</div>
|
height="1"
|
||||||
<div class="text-lg font-bold text-slate-900">OpenCloud</div>
|
fill="#7dd3fc"
|
||||||
</div>
|
opacity="0.55"
|
||||||
</RouterLink>
|
/>
|
||||||
|
</svg>
|
||||||
<nav class="oc-panel-card hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 md:flex">
|
|
||||||
<RouterLink
|
|
||||||
to="/"
|
|
||||||
class="oc-nav-link"
|
|
||||||
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
|
||||||
>
|
|
||||||
地图
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
to="/community"
|
|
||||||
class="oc-nav-link"
|
|
||||||
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
|
||||||
>
|
|
||||||
社区
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
to="/gallery"
|
|
||||||
class="oc-nav-link"
|
|
||||||
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
|
||||||
>
|
|
||||||
画廊
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-if="authStore.isLoggedIn"
|
|
||||||
to="/encyclopedia"
|
|
||||||
class="oc-nav-link"
|
|
||||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
|
||||||
>
|
|
||||||
图鉴
|
|
||||||
</RouterLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<NSpace align="center" :size="8" class="shrink-0 md:[&_.n-button]:px-4">
|
|
||||||
<template v-if="!authStore.loading">
|
|
||||||
<RouterLink
|
|
||||||
v-if="authStore.isLoggedIn"
|
|
||||||
to="/upload"
|
|
||||||
class="no-underline"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="oc-header-button oc-header-button--sky uppercase tracking-[0.12em]"
|
|
||||||
:class="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
|
|
||||||
>
|
|
||||||
上传
|
|
||||||
</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<template v-if="authStore.isLoggedIn">
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
@mouseenter="openAccountCard"
|
|
||||||
@mouseleave="closeAccountCard"
|
|
||||||
@focusin="openAccountCard"
|
|
||||||
@focusout="closeAccountCard"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-header-button oc-header-button--teal max-w-[8.5rem] gap-2 md:max-w-none"
|
|
||||||
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200' : ''"
|
|
||||||
aria-haspopup="menu"
|
|
||||||
:aria-expanded="accountCardOpen"
|
|
||||||
>
|
|
||||||
<NIcon size="16" class="shrink-0">
|
|
||||||
<User />
|
|
||||||
</NIcon>
|
|
||||||
<span class="truncate">@{{ displayUsername }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="accountCardOpen"
|
|
||||||
class="absolute right-0 top-full z-50 w-72 pt-3"
|
|
||||||
role="menu"
|
|
||||||
>
|
|
||||||
<div class="oc-floating-panel border border-slate-200 bg-white">
|
|
||||||
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800">
|
|
||||||
{{ displayUsername.slice(0, 1).toUpperCase() }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div>
|
||||||
<p class="truncate text-sm font-semibold text-slate-950">@{{ displayUsername }}</p>
|
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-500">
|
||||||
<p class="mt-1 truncate text-xs text-slate-500">{{ userEmail }}</p>
|
Live Sky Atlas
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-bold text-slate-900">OpenCloud</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</RouterLink>
|
||||||
<div class="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
|
||||||
<!-- <span class="border border-teal-100 bg-teal-50 px-2 py-1 text-teal-700">
|
<nav
|
||||||
|
class="oc-panel-card hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 md:flex"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
to="/"
|
||||||
|
class="oc-nav-link"
|
||||||
|
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
地图
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
to="/community"
|
||||||
|
class="oc-nav-link"
|
||||||
|
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
社区
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
to="/gallery"
|
||||||
|
class="oc-nav-link"
|
||||||
|
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
画廊
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="authStore.isLoggedIn"
|
||||||
|
to="/encyclopedia"
|
||||||
|
class="oc-nav-link"
|
||||||
|
:class="
|
||||||
|
route.name === 'encyclopedia' || route.name === 'cloud-type'
|
||||||
|
? activeNavClass
|
||||||
|
: inactiveNavClass
|
||||||
|
"
|
||||||
|
>
|
||||||
|
百科
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<NSpace align="center" :size="8" class="shrink-0 md:[&_.n-button]:px-4">
|
||||||
|
<template v-if="!authStore.loading">
|
||||||
|
<RouterLink
|
||||||
|
v-if="authStore.isLoggedIn"
|
||||||
|
to="/upload"
|
||||||
|
class="no-underline"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="oc-header-button oc-header-button--sky uppercase tracking-[0.12em]"
|
||||||
|
:class="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
|
||||||
|
>
|
||||||
|
上传
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<template v-if="authStore.isLoggedIn">
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
@mouseenter="openAccountCard"
|
||||||
|
@mouseleave="closeAccountCard"
|
||||||
|
@focusin="openAccountCard"
|
||||||
|
@focusout="closeAccountCard"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-header-button oc-header-button--teal max-w-[8.5rem] gap-2 md:max-w-none"
|
||||||
|
:class="
|
||||||
|
route.name === 'profile' ||
|
||||||
|
route.name === 'profile-settings'
|
||||||
|
? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="accountCardOpen"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="authStore.user?.image && !avatarFailed"
|
||||||
|
:src="authStore.user.image"
|
||||||
|
alt=""
|
||||||
|
class="h-5 w-5 shrink-0 object-cover"
|
||||||
|
@error="avatarFailed = true"
|
||||||
|
/>
|
||||||
|
<NIcon v-else size="16" class="shrink-0"><User /></NIcon>
|
||||||
|
<span class="truncate">@{{ displayUsername }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="accountCardOpen"
|
||||||
|
class="absolute right-0 top-full z-50 w-72 pt-3"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="oc-floating-panel border border-slate-200 bg-white"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="
|
||||||
|
authStore.user?.image &&
|
||||||
|
!avatarFailed
|
||||||
|
"
|
||||||
|
:src="authStore.user.image"
|
||||||
|
:alt="displayUsername"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
@error="avatarFailed = true"
|
||||||
|
/>
|
||||||
|
<template v-else>{{
|
||||||
|
displayUsername
|
||||||
|
.slice(0, 1)
|
||||||
|
.toUpperCase()
|
||||||
|
}}</template>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p
|
||||||
|
class="truncate text-sm font-semibold text-slate-950"
|
||||||
|
>
|
||||||
|
@{{ displayUsername }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-1 truncate text-xs text-slate-500"
|
||||||
|
>
|
||||||
|
{{ userEmail }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-3 flex items-center gap-2 text-xs text-slate-500"
|
||||||
|
>
|
||||||
|
<!-- <span class="border border-teal-100 bg-teal-50 px-2 py-1 text-teal-700">
|
||||||
{{ authStore.isAdmin ? '管理员' : '观测者' }}
|
{{ authStore.isAdmin ? '管理员' : '观测者' }}
|
||||||
</span>
|
</span>
|
||||||
<span>OpenCloud 账号</span> -->
|
<span>OpenCloud 账号</span> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/profile"
|
to="/profile"
|
||||||
class="oc-menu-item"
|
class="oc-menu-item"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<NIcon size="18"><User /></NIcon>
|
<NIcon size="18"><User /></NIcon>
|
||||||
<span>个人中心</span>
|
<span>个人中心</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/profile/settings"
|
to="/profile/settings"
|
||||||
class="oc-menu-item"
|
class="oc-menu-item"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<NIcon size="18"><Settings /></NIcon>
|
<NIcon size="18"><Settings /></NIcon>
|
||||||
<span>账号设置</span>
|
<span>账号设置</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="authStore.isAdmin"
|
v-if="authStore.isAdmin"
|
||||||
to="/admin"
|
to="/admin"
|
||||||
class="oc-menu-item oc-menu-item--amber"
|
class="oc-menu-item oc-menu-item--amber"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<NIcon size="18"><Shield /></NIcon>
|
<NIcon size="18"><Shield /></NIcon>
|
||||||
<span>管理后台</span>
|
<span>管理后台</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-slate-200 p-2">
|
<div class="border-t border-slate-200 p-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-menu-item oc-menu-item--danger px-2"
|
class="oc-menu-item oc-menu-item--danger px-2"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
>
|
>
|
||||||
<NIcon size="18"><Logout /></NIcon>
|
<NIcon size="18"><Logout /></NIcon>
|
||||||
<span>登出</span>
|
<span>登出</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<RouterLink to="/login" class="no-underline">
|
||||||
|
<span
|
||||||
|
class="oc-header-button oc-header-button--neutral"
|
||||||
|
:class="
|
||||||
|
route.name === 'login'
|
||||||
|
? 'bg-teal-50 ring-0 ring-teal-100 text-teal-800'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/register" class="no-underline">
|
||||||
|
<span
|
||||||
|
class="oc-header-button oc-header-button--sky"
|
||||||
|
:class="
|
||||||
|
route.name === 'register' ? 'ring-0 ring-sky-300' : ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</NSpace>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
<nav
|
||||||
<RouterLink
|
class="flex items-center gap-2 overflow-x-auto border-t border-slate-200/80 py-2 md:hidden"
|
||||||
to="/login"
|
|
||||||
class="no-underline"
|
|
||||||
>
|
>
|
||||||
<span
|
<RouterLink
|
||||||
class="oc-header-button oc-header-button--neutral"
|
to="/"
|
||||||
:class="route.name === 'login' ? 'bg-teal-50 ring-0 ring-teal-100 text-teal-800' : ''"
|
class="oc-nav-link shrink-0"
|
||||||
>
|
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
||||||
登录
|
>
|
||||||
</span>
|
地图
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/register"
|
to="/community"
|
||||||
class="no-underline"
|
class="oc-nav-link shrink-0"
|
||||||
|
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
社区
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
to="/gallery"
|
||||||
|
class="oc-nav-link shrink-0"
|
||||||
|
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
画廊
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="authStore.isLoggedIn"
|
||||||
|
to="/encyclopedia"
|
||||||
|
class="oc-nav-link shrink-0"
|
||||||
|
:class="
|
||||||
|
route.name === 'encyclopedia' || route.name === 'cloud-type'
|
||||||
|
? activeNavClass
|
||||||
|
: inactiveNavClass
|
||||||
|
"
|
||||||
|
>
|
||||||
|
百科
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Transition name="header-reveal">
|
||||||
|
<div
|
||||||
|
v-if="headerHidden"
|
||||||
|
class="pointer-events-none absolute inset-x-0 top-3 flex justify-center"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-header-button oc-header-button--neutral pointer-events-auto gap-2 bg-white/94 text-xs tracking-[0.16em] backdrop-blur"
|
||||||
|
@click="showHeader(true)"
|
||||||
>
|
>
|
||||||
<span
|
<NIcon size="15"><ChevronDown /></NIcon>
|
||||||
class="oc-header-button oc-header-button--sky"
|
<span>展开导航</span>
|
||||||
:class="route.name === 'register' ? 'ring-0 ring-sky-300' : ''"
|
</button>
|
||||||
>
|
</div>
|
||||||
注册
|
</Transition>
|
||||||
</span>
|
</div>
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</NSpace>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="flex items-center gap-2 overflow-x-auto border-t border-slate-200/80 py-2 md:hidden">
|
|
||||||
<RouterLink
|
|
||||||
to="/"
|
|
||||||
class="oc-nav-link shrink-0"
|
|
||||||
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
|
||||||
>
|
|
||||||
地图
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
to="/community"
|
|
||||||
class="oc-nav-link shrink-0"
|
|
||||||
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
|
||||||
>
|
|
||||||
社区
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
to="/gallery"
|
|
||||||
class="oc-nav-link shrink-0"
|
|
||||||
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
|
||||||
>
|
|
||||||
画廊
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-if="authStore.isLoggedIn"
|
|
||||||
to="/encyclopedia"
|
|
||||||
class="oc-nav-link shrink-0"
|
|
||||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
|
||||||
>
|
|
||||||
图鉴
|
|
||||||
</RouterLink>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<Transition name="header-reveal">
|
|
||||||
<div
|
|
||||||
v-if="headerHidden"
|
|
||||||
class="pointer-events-none absolute inset-x-0 top-3 flex justify-center"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-header-button oc-header-button--neutral pointer-events-auto gap-2 bg-white/94 text-xs tracking-[0.16em] backdrop-blur"
|
|
||||||
@click="showHeader(true)"
|
|
||||||
>
|
|
||||||
<NIcon size="15"><ChevronDown /></NIcon>
|
|
||||||
<span>展开导航</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.header-reveal-enter-active,
|
.header-reveal-enter-active,
|
||||||
.header-reveal-leave-active {
|
.header-reveal-leave-active {
|
||||||
transition:
|
transition:
|
||||||
opacity 240ms ease,
|
opacity 240ms ease,
|
||||||
transform 240ms ease;
|
transform 240ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-reveal-enter-from,
|
.header-reveal-enter-from,
|
||||||
.header-reveal-leave-to {
|
.header-reveal-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-0.75rem);
|
transform: translateY(-0.75rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.header-reveal-enter-active,
|
.header-reveal-enter-active,
|
||||||
.header-reveal-leave-active {
|
.header-reveal-leave-active {
|
||||||
transition-duration: 1ms;
|
transition-duration: 1ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-reveal-enter-from,
|
.header-reveal-enter-from,
|
||||||
.header-reveal-leave-to {
|
.header-reveal-leave-to {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,451 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { NButton, NSelect } from 'naive-ui'
|
|
||||||
|
|
||||||
export interface ContributionHeatmapCell {
|
|
||||||
date: string
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonthStat {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
totalCount: number
|
|
||||||
activeDays: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date()
|
|
||||||
const currentYear = today.getFullYear()
|
|
||||||
const currentMonth = today.getMonth() + 1
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
cells: ContributionHeatmapCell[]
|
|
||||||
totalCount: number
|
|
||||||
activeDays: number
|
|
||||||
activeDate?: string | null
|
|
||||||
title?: string
|
|
||||||
}>(), {
|
|
||||||
activeDate: null,
|
|
||||||
title: '上传活跃度',
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
selectDate: [date: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const viewMode = ref<'year' | 'month'>('year')
|
|
||||||
const selectedYear = ref(currentYear)
|
|
||||||
const selectedMonth = ref(currentMonth)
|
|
||||||
const hoveredMonthKey = ref<string | null>(null)
|
|
||||||
|
|
||||||
function parseLocalDate(dateString: string) {
|
|
||||||
const [year, month, day] = dateString.split('-').map(Number)
|
|
||||||
return new Date(year, (month || 1) - 1, day || 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayKey(date: Date) {
|
|
||||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
||||||
const day = `${date.getDate()}`.padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMonthKey(dateString: string) {
|
|
||||||
const date = parseLocalDate(dateString)
|
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function startOfDay(date: Date) {
|
|
||||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
|
||||||
}
|
|
||||||
|
|
||||||
function startOfWeek(date: Date) {
|
|
||||||
const result = startOfDay(date)
|
|
||||||
const weekday = result.getDay()
|
|
||||||
const offset = weekday === 0 ? 6 : weekday - 1
|
|
||||||
result.setDate(result.getDate() - offset)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyCounts = computed(() => {
|
|
||||||
const counts = new Map<string, number>()
|
|
||||||
for (const cell of props.cells) {
|
|
||||||
counts.set(cell.date, cell.count)
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
})
|
|
||||||
|
|
||||||
const availableYears = computed(() => {
|
|
||||||
const years = new Set<number>([currentYear])
|
|
||||||
for (const cell of props.cells) {
|
|
||||||
years.add(parseLocalDate(cell.date).getFullYear())
|
|
||||||
}
|
|
||||||
return Array.from(years).sort((a, b) => b - a)
|
|
||||||
})
|
|
||||||
|
|
||||||
const yearOptions = computed(() => {
|
|
||||||
return availableYears.value.map(year => ({
|
|
||||||
label: `${year}年`,
|
|
||||||
value: year,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const monthOptions = Array.from({ length: 12 }, (_, index) => ({
|
|
||||||
label: `${index + 1}月`,
|
|
||||||
value: index + 1,
|
|
||||||
}))
|
|
||||||
|
|
||||||
watch(availableYears, years => {
|
|
||||||
if (!years.includes(selectedYear.value)) {
|
|
||||||
selectedYear.value = years[0] || currentYear
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
const visibleCells = computed(() => {
|
|
||||||
if (viewMode.value === 'year') {
|
|
||||||
const start = new Date(selectedYear.value, 0, 1)
|
|
||||||
const end = new Date(selectedYear.value, 11, 31)
|
|
||||||
const alignedStart = startOfWeek(start)
|
|
||||||
const alignedEnd = startOfWeek(end)
|
|
||||||
alignedEnd.setDate(alignedEnd.getDate() + 6)
|
|
||||||
|
|
||||||
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
|
|
||||||
const cursor = new Date(alignedStart)
|
|
||||||
|
|
||||||
while (cursor <= alignedEnd) {
|
|
||||||
const key = formatDayKey(cursor)
|
|
||||||
const inRange = cursor >= start && cursor <= end
|
|
||||||
cells.push({
|
|
||||||
date: key,
|
|
||||||
count: dailyCounts.value.get(key) || 0,
|
|
||||||
inRange,
|
|
||||||
isToday: key === formatDayKey(today),
|
|
||||||
})
|
|
||||||
cursor.setDate(cursor.getDate() + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cells
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = new Date(selectedYear.value, selectedMonth.value - 1, 1)
|
|
||||||
const end = new Date(selectedYear.value, selectedMonth.value, 0)
|
|
||||||
const alignedStart = startOfWeek(start)
|
|
||||||
const alignedEnd = startOfWeek(end)
|
|
||||||
alignedEnd.setDate(alignedEnd.getDate() + 6)
|
|
||||||
|
|
||||||
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
|
|
||||||
const cursor = new Date(alignedStart)
|
|
||||||
|
|
||||||
while (cursor <= alignedEnd) {
|
|
||||||
const key = formatDayKey(cursor)
|
|
||||||
const inRange = cursor >= start && cursor <= end
|
|
||||||
cells.push({
|
|
||||||
date: key,
|
|
||||||
count: dailyCounts.value.get(key) || 0,
|
|
||||||
inRange,
|
|
||||||
isToday: key === formatDayKey(today),
|
|
||||||
})
|
|
||||||
cursor.setDate(cursor.getDate() + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cells
|
|
||||||
})
|
|
||||||
|
|
||||||
const contributionQuartiles = computed(() => {
|
|
||||||
const counts = visibleCells.value
|
|
||||||
.filter(cell => cell.inRange && cell.count > 0)
|
|
||||||
.map(cell => cell.count)
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
|
|
||||||
if (!counts.length) return [0, 0, 0] as const
|
|
||||||
|
|
||||||
const percentile = (value: number) => {
|
|
||||||
const index = Math.ceil(counts.length * value) - 1
|
|
||||||
return counts[Math.max(0, index)] || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return [percentile(0.25), percentile(0.5), percentile(0.75)] as const
|
|
||||||
})
|
|
||||||
|
|
||||||
function getLevel(count: number) {
|
|
||||||
if (count <= 0) return 0
|
|
||||||
|
|
||||||
const [firstQuartile, secondQuartile, thirdQuartile] = contributionQuartiles.value
|
|
||||||
|
|
||||||
if (count <= firstQuartile) return 1
|
|
||||||
if (count <= secondQuartile) return 2
|
|
||||||
if (count <= thirdQuartile) return 3
|
|
||||||
return 4
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCellClass(cell: { count: number; inRange: boolean }) {
|
|
||||||
if (!cell.inRange) return 'bg-transparent border-transparent'
|
|
||||||
|
|
||||||
const level = getLevel(cell.count)
|
|
||||||
return `oc-heatmap-cell--level-${level}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const weeks = computed(() => {
|
|
||||||
const buckets: Array<typeof visibleCells.value> = []
|
|
||||||
for (let index = 0; index < visibleCells.value.length; index += 7) {
|
|
||||||
buckets.push(visibleCells.value.slice(index, index + 7))
|
|
||||||
}
|
|
||||||
return buckets
|
|
||||||
})
|
|
||||||
|
|
||||||
const monthStats = computed(() => {
|
|
||||||
const stats = new Map<string, MonthStat>()
|
|
||||||
|
|
||||||
for (const cell of visibleCells.value) {
|
|
||||||
if (!cell.inRange) continue
|
|
||||||
const monthKey = toMonthKey(cell.date)
|
|
||||||
const date = parseLocalDate(cell.date)
|
|
||||||
const label = `${date.getMonth() + 1}月`
|
|
||||||
const existing = stats.get(monthKey)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
existing.totalCount += cell.count
|
|
||||||
if (cell.count > 0) existing.activeDays += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.set(monthKey, {
|
|
||||||
key: monthKey,
|
|
||||||
label,
|
|
||||||
totalCount: cell.count,
|
|
||||||
activeDays: cell.count > 0 ? 1 : 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
})
|
|
||||||
|
|
||||||
const monthLabels = computed(() => {
|
|
||||||
if (viewMode.value !== 'year') return []
|
|
||||||
|
|
||||||
let lastMonth = -1
|
|
||||||
return weeks.value.map(week => {
|
|
||||||
const firstInRange = week.find(cell => cell.inRange)
|
|
||||||
if (!firstInRange) return null
|
|
||||||
|
|
||||||
const date = parseLocalDate(firstInRange.date)
|
|
||||||
const month = date.getMonth()
|
|
||||||
const labelMonth = month + 1
|
|
||||||
const monthKey = toMonthKey(firstInRange.date)
|
|
||||||
|
|
||||||
if (month === lastMonth) return null
|
|
||||||
|
|
||||||
lastMonth = month
|
|
||||||
return {
|
|
||||||
key: monthKey,
|
|
||||||
label: labelMonth === 1 ? `${selectedYear.value}/1月` : `${labelMonth}月`,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const monthPreview = computed(() => {
|
|
||||||
if (!hoveredMonthKey.value) return null
|
|
||||||
return monthStats.value.get(hoveredMonthKey.value) || null
|
|
||||||
})
|
|
||||||
|
|
||||||
const panelSummary = computed(() => {
|
|
||||||
if (viewMode.value === 'year') {
|
|
||||||
return `${selectedYear.value} 年完整视图,包含尚未来到的日期。`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${selectedYear.value} 年 ${selectedMonth.value} 月完整视图。`
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatTooltip(cell: ContributionHeatmapCell & { inRange: boolean; isToday: boolean }) {
|
|
||||||
const date = parseLocalDate(cell.date)
|
|
||||||
const label = date.toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!cell.inRange) {
|
|
||||||
return `${label}:不在当前视图范围内`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cell.count === 0) {
|
|
||||||
return `${label}${cell.isToday ? '(今天)' : ''}:当天没有上传`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${label}${cell.isToday ? '(今天)' : ''}:上传 ${cell.count} 张`
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCellClick(cell: ContributionHeatmapCell & { inRange: boolean }) {
|
|
||||||
if (!cell.inRange) return
|
|
||||||
emit('selectDate', cell.date)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMonthTooltip(monthKey: string) {
|
|
||||||
const stats = monthStats.value.get(monthKey)
|
|
||||||
if (!stats) return ''
|
|
||||||
return `${stats.label}:共上传 ${stats.totalCount} 张,活跃于 ${stats.activeDays} 天`
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="oc-panel-card border border-slate-200 bg-white p-6">
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-6">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
|
|
||||||
<p class="mt-3 text-sm leading-7 text-slate-500">
|
|
||||||
共上传 {{ totalCount }} 张,活跃于 {{ activeDays }} 天。颜色越深,代表当天上传的照片越多。
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">{{ panelSummary }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-end gap-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
:class="viewMode === 'year' ? 'oc-panel-button oc-panel-button--teal' : 'oc-panel-button oc-panel-button--neutral'"
|
|
||||||
@click="viewMode = 'year'"
|
|
||||||
>
|
|
||||||
年度视图
|
|
||||||
</NButton>
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
:class="viewMode === 'month' ? 'oc-panel-button oc-panel-button--teal' : 'oc-panel-button oc-panel-button--neutral'"
|
|
||||||
@click="viewMode = 'month'"
|
|
||||||
>
|
|
||||||
月度视图
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
<NSelect
|
|
||||||
v-model:value="selectedYear"
|
|
||||||
:options="yearOptions"
|
|
||||||
class="w-28"
|
|
||||||
/>
|
|
||||||
<NSelect
|
|
||||||
v-if="viewMode === 'month'"
|
|
||||||
v-model:value="selectedMonth"
|
|
||||||
:options="monthOptions"
|
|
||||||
class="w-24"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex min-h-6 items-center justify-between gap-4 text-xs text-slate-500">
|
|
||||||
<span>点击某一天可筛选当天上传记录。</span>
|
|
||||||
<span v-if="monthPreview" class="text-right">
|
|
||||||
{{ monthPreview.label }}:{{ monthPreview.totalCount }} 张 / {{ monthPreview.activeDays }} 天
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-right">
|
|
||||||
<template v-if="viewMode === 'year'">悬停月份标签可预览当月上传摘要。</template>
|
|
||||||
<template v-else>当前显示整个月的上传情况。</template>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 overflow-x-auto">
|
|
||||||
<div :class="viewMode === 'year' ? 'min-w-[980px]' : 'min-w-[360px]'">
|
|
||||||
<div
|
|
||||||
v-if="viewMode === 'year'"
|
|
||||||
class="mb-2 grid grid-flow-col auto-cols-[14px] gap-1 pl-8 text-[11px] text-slate-400"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(month, index) in monthLabels"
|
|
||||||
:key="`${month?.key || 'empty'}-${index}`"
|
|
||||||
class="w-[14px] whitespace-nowrap"
|
|
||||||
:title="month ? formatMonthTooltip(month.key) : ''"
|
|
||||||
@mouseenter="hoveredMonthKey = month?.key || null"
|
|
||||||
@mouseleave="hoveredMonthKey = null"
|
|
||||||
>
|
|
||||||
{{ month?.label || '' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="grid grid-rows-7 gap-1 pt-[2px] text-[11px] text-slate-400">
|
|
||||||
<span>一</span>
|
|
||||||
<span></span>
|
|
||||||
<span>三</span>
|
|
||||||
<span></span>
|
|
||||||
<span>五</span>
|
|
||||||
<span></span>
|
|
||||||
<span>日</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-flow-col auto-cols-[14px] gap-1">
|
|
||||||
<div
|
|
||||||
v-for="(week, weekIndex) in weeks"
|
|
||||||
:key="weekIndex"
|
|
||||||
class="grid grid-rows-7 gap-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="cell in week"
|
|
||||||
:key="cell.date"
|
|
||||||
type="button"
|
|
||||||
class="oc-heatmap-cell relative h-3.5 w-3.5 border hover:scale-125"
|
|
||||||
:class="[
|
|
||||||
getCellClass(cell),
|
|
||||||
props.activeDate === cell.date ? 'ring-2 ring-slate-900 ring-offset-1' : '',
|
|
||||||
cell.isToday && cell.inRange ? 'outline outline-1 outline-slate-900 outline-offset-1' : '',
|
|
||||||
]"
|
|
||||||
:title="formatTooltip(cell)"
|
|
||||||
@click="handleCellClick(cell)"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
|
|
||||||
<span>少</span>
|
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-0"></span>
|
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-1"></span>
|
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-2"></span>
|
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-3"></span>
|
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-4"></span>
|
|
||||||
<span>多</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.oc-heatmap-cell,
|
|
||||||
.oc-heatmap-legend-cell {
|
|
||||||
transition:
|
|
||||||
background-color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
border-color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
transform var(--oc-duration-fast) var(--oc-ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-legend-cell {
|
|
||||||
width: 0.75rem;
|
|
||||||
height: 0.75rem;
|
|
||||||
border-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-cell--level-0 {
|
|
||||||
background: var(--oc-heatmap-empty);
|
|
||||||
border-color: var(--oc-heatmap-empty-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-cell--level-1 {
|
|
||||||
background: var(--oc-heatmap-level-1);
|
|
||||||
border-color: var(--oc-heatmap-level-1-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-cell--level-2 {
|
|
||||||
background: var(--oc-heatmap-level-2);
|
|
||||||
border-color: var(--oc-heatmap-level-2-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-cell--level-3 {
|
|
||||||
background: var(--oc-heatmap-level-3);
|
|
||||||
border-color: var(--oc-heatmap-level-3-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-cell--level-4 {
|
|
||||||
background: var(--oc-heatmap-level-4);
|
|
||||||
border-color: var(--oc-heatmap-level-4-border);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,234 +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'
|
|
||||||
import type { CloudType } from '@/types/database'
|
|
||||||
|
|
||||||
export interface UploadItem {
|
|
||||||
id: string
|
|
||||||
file: File
|
|
||||||
preview: string
|
|
||||||
cloudCategoryId: number | 'other' | null
|
|
||||||
customCloudType: string
|
|
||||||
locationName: string
|
|
||||||
description: string
|
|
||||||
isHidden: boolean
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
capturedAt: string
|
|
||||||
errors: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnlockedBadge {
|
|
||||||
cloudTypeId: number
|
|
||||||
cloudName: string
|
|
||||||
cloudNameEn: string
|
|
||||||
rarity: CloudType['rarity']
|
|
||||||
unlockedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadResult {
|
|
||||||
ok: boolean
|
|
||||||
unlockedBadges: UnlockedBadge[]
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextId = 0
|
|
||||||
|
|
||||||
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
|
||||||
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.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 | number | null) {
|
|
||||||
if (value !== null && value !== '') form.append(key, String(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpload() {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const profileStore = useProfileStore()
|
|
||||||
const items = ref<UploadItem[]>([])
|
|
||||||
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 => file.type.startsWith('image/'))
|
|
||||||
for (const file of imageFiles) {
|
|
||||||
let capturedAt = new Date().toISOString()
|
|
||||||
try {
|
|
||||||
const exifDate = extractExifDate(await readFileAsArrayBuffer(file))
|
|
||||||
if (exifDate) capturedAt = exifDate
|
|
||||||
} catch {
|
|
||||||
// 无 EXIF 时使用当前时间。
|
|
||||||
}
|
|
||||||
|
|
||||||
items.value.push({
|
|
||||||
id: String(++nextId),
|
|
||||||
file,
|
|
||||||
preview: URL.createObjectURL(file),
|
|
||||||
cloudCategoryId: null,
|
|
||||||
customCloudType: '',
|
|
||||||
locationName: '',
|
|
||||||
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<string, string> = {}
|
|
||||||
if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别'
|
|
||||||
if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) {
|
|
||||||
errors.cloudCategory = '请输入自定义云型名称'
|
|
||||||
}
|
|
||||||
if (!item.capturedAt) errors.capturedAt = '请选择拍摄时间'
|
|
||||||
|
|
||||||
const hasLat = typeof item.latitude === 'number' && !isNaN(item.latitude)
|
|
||||||
const hasLng = typeof item.longitude === 'number' && !isNaN(item.longitude)
|
|
||||||
if (hasLat && !hasLng) errors.longitude = '经纬度必须同时填写'
|
|
||||||
if (hasLng && !hasLat) errors.latitude = '经纬度必须同时填写'
|
|
||||||
item.errors = errors
|
|
||||||
return Object.keys(errors).length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAll() {
|
|
||||||
return items.value.map(validateItem).every(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadAll(): Promise<UploadResult> {
|
|
||||||
if (!validateAll() || !authStore.user) return { ok: false, unlockedBadges: [] }
|
|
||||||
|
|
||||||
uploading.value = true
|
|
||||||
totalItems.value = items.value.length
|
|
||||||
currentItemIndex.value = 0
|
|
||||||
overallProgress.value = 0
|
|
||||||
const badges = new Map<number, UnlockedBadge>()
|
|
||||||
|
|
||||||
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)
|
|
||||||
if (item.cloudCategoryId === 'other') {
|
|
||||||
form.append('custom_cloud_type', item.customCloudType.trim())
|
|
||||||
} else {
|
|
||||||
form.append('cloud_type_id', String(item.cloudCategoryId))
|
|
||||||
}
|
|
||||||
appendOptional(form, 'latitude', item.latitude)
|
|
||||||
appendOptional(form, 'longitude', item.longitude)
|
|
||||||
appendOptional(form, 'location_name', item.locationName.trim())
|
|
||||||
appendOptional(form, 'description', item.description.trim())
|
|
||||||
appendOptional(form, 'captured_at', item.capturedAt)
|
|
||||||
form.append('is_hidden', String(item.isHidden))
|
|
||||||
|
|
||||||
const result = await apiRequest<CloudCreateResponse>('/clouds', {
|
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
})
|
|
||||||
if (result.unlocked_badge) {
|
|
||||||
const badge = result.unlocked_badge
|
|
||||||
badges.set(badge.cloud_type_id, {
|
|
||||||
cloudTypeId: badge.cloud_type_id,
|
|
||||||
cloudName: badge.cloud_name,
|
|
||||||
cloudNameEn: badge.cloud_name_en,
|
|
||||||
rarity: badge.rarity,
|
|
||||||
unlockedAt: badge.unlocked_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
overallProgress.value = Math.round((index + 1) / items.value.length * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAll()
|
|
||||||
profileStore.invalidateUser(authStore.user.id)
|
|
||||||
return { ok: true, unlockedBadges: [...badges.values()] }
|
|
||||||
} catch {
|
|
||||||
return { ok: false, unlockedBadges: [] }
|
|
||||||
} finally {
|
|
||||||
uploading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
uploading,
|
|
||||||
overallProgress,
|
|
||||||
currentItemIndex,
|
|
||||||
totalItems,
|
|
||||||
addFiles,
|
|
||||||
removeItem,
|
|
||||||
clearAll,
|
|
||||||
validateItem,
|
|
||||||
validateAll,
|
|
||||||
uploadAll,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<ReturnType<typeof toAuthUser> | null>(null)
|
||||||
|
const profile = ref<Profile | null>(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<BackendUserProfile>('/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<BackendUserProfile>('/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const state = ref<'loading' | 'success' | 'failed'>('loading')
|
||||||
|
const error = ref('')
|
||||||
|
const countdown = ref(5)
|
||||||
|
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
if (route.query.token !== undefined) {
|
||||||
|
window.history.replaceState(window.history.state, '', route.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
void router.push('/login')
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!token) {
|
||||||
|
error.value = '确认链接缺少验证信息'
|
||||||
|
state.value = 'failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await authStore.confirmEmail(token)
|
||||||
|
state.value = 'success'
|
||||||
|
startCountdown()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : '确认链接无效或已过期'
|
||||||
|
state.value = 'failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div class="mx-auto max-w-3xl">
|
||||||
|
<NCard class="oc-panel-card-xl">
|
||||||
|
<NResult
|
||||||
|
v-if="state === 'success'"
|
||||||
|
status="success"
|
||||||
|
title="邮箱确认成功"
|
||||||
|
description="现在可以使用邮箱和密码登录。"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
{{ countdown }} 秒后自动前往登录页面
|
||||||
|
</p>
|
||||||
|
<RouterLink to="/login" class="no-underline"
|
||||||
|
><NButton
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
>立即登录</NButton
|
||||||
|
></RouterLink
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
<NResult
|
||||||
|
v-else-if="state === 'failed'"
|
||||||
|
status="error"
|
||||||
|
title="邮箱确认失败"
|
||||||
|
:description="error"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<RouterLink to="/resend-confirmation" class="no-underline"
|
||||||
|
><NButton type="default" class="oc-panel-button oc-panel-button--sky"
|
||||||
|
>重新发送验证邮件</NButton
|
||||||
|
></RouterLink
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
<div v-else class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<NSpin size="large" />
|
||||||
|
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在确认邮箱...</h1>
|
||||||
|
<p class="mt-2 text-slate-500">请稍候</p>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
const cooldown = ref(0)
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const buttonLabel = computed(() =>
|
||||||
|
loading.value
|
||||||
|
? '发送中...'
|
||||||
|
: cooldown.value > 0
|
||||||
|
? `重新发送(${cooldown.value}s)`
|
||||||
|
: '重新发送',
|
||||||
|
)
|
||||||
|
|
||||||
|
function startCooldown() {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
cooldown.value = 60
|
||||||
|
timer = setInterval(() => {
|
||||||
|
cooldown.value--
|
||||||
|
if (cooldown.value <= 0 && timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
const value = email.value.trim()
|
||||||
|
if (!value || value.length > 64) {
|
||||||
|
error.value = '请输入不超过 64 个字符的有效邮箱'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.sendPasswordReset(value)
|
||||||
|
email.value = value
|
||||||
|
submitted.value = true
|
||||||
|
startCooldown()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<NCard class="oc-panel-card-xl">
|
||||||
|
<NResult
|
||||||
|
v-if="submitted"
|
||||||
|
status="success"
|
||||||
|
title="请求已受理"
|
||||||
|
description="如果该邮箱符合条件,我们会发送密码重置邮件。"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
目标邮箱:<span class="font-semibold text-slate-700">{{
|
||||||
|
email
|
||||||
|
}}</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap justify-center gap-3">
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
:disabled="cooldown > 0"
|
||||||
|
:loading="loading"
|
||||||
|
@click="submit"
|
||||||
|
>{{ buttonLabel }}</NButton
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ path: '/login', query: { email } }"
|
||||||
|
class="no-underline"
|
||||||
|
><NButton
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
>返回登录</NButton
|
||||||
|
></RouterLink
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">
|
||||||
|
Password Recovery
|
||||||
|
</div>
|
||||||
|
<h1 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
输入注册邮箱,我们会发送有效期为 1 小时的重置链接。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NForm @submit.prevent="submit">
|
||||||
|
<NFormItem label="邮箱">
|
||||||
|
<NInput
|
||||||
|
v-model:value="email"
|
||||||
|
maxlength="64"
|
||||||
|
type="text"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'email',
|
||||||
|
inputmode: 'email',
|
||||||
|
name: 'email',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
||||||
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--sky"
|
||||||
|
:loading="loading"
|
||||||
|
>{{ loading ? '发送中...' : '发送重置邮件' }}</NButton
|
||||||
|
>
|
||||||
|
</NForm>
|
||||||
|
<p class="mt-6 text-sm text-slate-500">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ path: '/login', query: { email } }"
|
||||||
|
class="font-semibold text-sky-700 hover:text-sky-800"
|
||||||
|
>返回登录</RouterLink
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
|
||||||
|
import { AuthClientError } from '@/features/auth/authClient'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const needsEmailConfirmation = ref(false)
|
||||||
|
|
||||||
|
function getSafeRedirect(value: unknown) {
|
||||||
|
if (typeof value !== 'string' || !value.startsWith('/')) return '/'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = new URL(value, window.location.origin)
|
||||||
|
return target.origin === window.location.origin
|
||||||
|
? `${target.pathname}${target.search}${target.hash}`
|
||||||
|
: '/'
|
||||||
|
} catch {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
error.value = ''
|
||||||
|
needsEmailConfirmation.value = false
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.login(email.value, password.value)
|
||||||
|
void router.push(getSafeRedirect(route.query.redirect))
|
||||||
|
} catch (e: unknown) {
|
||||||
|
needsEmailConfirmation.value =
|
||||||
|
e instanceof AuthClientError && e.code === 'EMAIL_NOT_VERIFIED'
|
||||||
|
error.value = e instanceof Error ? e.message : '登录失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div
|
||||||
|
class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="oc-panel-card-xl flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#f2faf6_0%,#ffffff_48%,#eff6ff_100%)] p-6 sm:p-8"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
|
||||||
|
<h1
|
||||||
|
class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl"
|
||||||
|
>
|
||||||
|
登录后继续记录
|
||||||
|
<span class="block text-teal-700">你眼中的每一朵云</span>
|
||||||
|
</h1>
|
||||||
|
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||||
|
在地图、百科和画廊之间浏览观测记录。上传新的云图后,审核通过的作品会进入公开画廊与地图。
|
||||||
|
</p> -->
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
|
||||||
|
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="oc-stat-label">Collection</div>
|
||||||
|
<div class="oc-stat-value">10</div>
|
||||||
|
<div class="oc-field-help">基础云属资料</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="oc-stat-label">Mode</div>
|
||||||
|
<div class="oc-stat-value">Atlas</div>
|
||||||
|
<div class="oc-field-help">地图、百科、画廊一体</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">
|
||||||
|
Member Access
|
||||||
|
</div>
|
||||||
|
<h2 class="mt-3 text-3xl font-bold text-slate-900">登录 OpenCloud</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">输入邮箱和密码,继续你的天空档案。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NForm @submit.prevent="handleLogin">
|
||||||
|
<NFormItem label="邮箱">
|
||||||
|
<NInput
|
||||||
|
v-model:value="email"
|
||||||
|
type="text"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'username',
|
||||||
|
inputmode: 'email',
|
||||||
|
name: 'username',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="密码">
|
||||||
|
<NInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'current-password',
|
||||||
|
name: 'current-password',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
placeholder="输入密码"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
|
{{ error }}
|
||||||
|
<div v-if="needsEmailConfirmation" class="mt-2">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ path: '/resend-confirmation', query: { email } }"
|
||||||
|
class="font-semibold text-rose-700 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
重新发送验证邮件
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--teal"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</NButton>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
还没有账号?
|
||||||
|
<RouterLink
|
||||||
|
to="/register"
|
||||||
|
class="font-semibold text-sky-700 hover:text-sky-800"
|
||||||
|
>立即注册</RouterLink
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ path: '/forgot-password', query: { email } }"
|
||||||
|
class="font-semibold text-teal-700 hover:text-teal-800"
|
||||||
|
>
|
||||||
|
忘记密码
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||||
|
import { AuthClientError } from '@/features/auth/authClient'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const registered = ref(false)
|
||||||
|
const registerConflict = ref(false)
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
error.value = ''
|
||||||
|
registerConflict.value = false
|
||||||
|
const trimmedName = name.value.trim()
|
||||||
|
const trimmedEmail = email.value.trim()
|
||||||
|
|
||||||
|
if (!trimmedName) {
|
||||||
|
error.value = '请输入用户名'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (trimmedName.length > 16) {
|
||||||
|
error.value = '用户名不能超过 16 个字符'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!trimmedEmail || trimmedEmail.length > 64) {
|
||||||
|
error.value = '请输入不超过 64 个字符的有效邮箱'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value.length < 8) {
|
||||||
|
error.value = '密码至少需要 8 位'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
error.value = '两次输入的密码不一致'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.register(trimmedName, trimmedEmail, password.value)
|
||||||
|
email.value = trimmedEmail
|
||||||
|
registered.value = true
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (
|
||||||
|
e instanceof AuthClientError &&
|
||||||
|
(e.status === 409 || e.code === 'USER_ALREADY_EXISTS')
|
||||||
|
) {
|
||||||
|
registerConflict.value = true
|
||||||
|
error.value = '邮箱或用户名已被占用;若邮箱尚未验证,可以尝试重发验证邮件。'
|
||||||
|
} else {
|
||||||
|
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div
|
||||||
|
class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="oc-panel-card-xl flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f2faf6_100%)] p-6 sm:p-8"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
||||||
|
<h1
|
||||||
|
class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl"
|
||||||
|
>
|
||||||
|
加入天空探索者
|
||||||
|
<span class="block text-sky-700">建立你的云图档案</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
|
||||||
|
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="oc-stat-label">Step 1</div>
|
||||||
|
<div class="oc-stat-value">Join</div>
|
||||||
|
<div class="oc-field-help">填写账号资料</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="oc-stat-label">Step 2</div>
|
||||||
|
<div class="oc-stat-value">Verify</div>
|
||||||
|
<div class="oc-field-help">通过邮件确认邮箱</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
||||||
|
<NResult
|
||||||
|
v-if="registered"
|
||||||
|
status="success"
|
||||||
|
title="确认你的邮箱"
|
||||||
|
description="如该邮箱可以注册,请查收验证邮件并点击链接。验证后请重新登录。"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
目标邮箱:<span class="font-semibold text-slate-700">{{
|
||||||
|
email
|
||||||
|
}}</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap justify-center gap-3">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ path: '/resend-confirmation', query: { email } }"
|
||||||
|
class="no-underline"
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
>重发验证邮件</NButton
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ path: '/login', query: { email } }"
|
||||||
|
class="no-underline"
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
>去登录</NButton
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">
|
||||||
|
Create Account
|
||||||
|
</div>
|
||||||
|
<h2 class="mt-3 text-3xl font-bold text-slate-900">注册 OpenCloud</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
注册后,请先通过邮件确认邮箱再登录。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NForm @submit.prevent="handleRegister">
|
||||||
|
<NFormItem label="用户名">
|
||||||
|
<NInput
|
||||||
|
v-model:value="name"
|
||||||
|
maxlength="16"
|
||||||
|
show-count
|
||||||
|
placeholder="最多 16 个字符"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'username',
|
||||||
|
name: 'username',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="邮箱">
|
||||||
|
<NInput
|
||||||
|
v-model:value="email"
|
||||||
|
maxlength="64"
|
||||||
|
type="text"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'email',
|
||||||
|
inputmode: 'email',
|
||||||
|
name: 'email',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="密码">
|
||||||
|
<NInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
placeholder="至少 8 位"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'new-password',
|
||||||
|
name: 'new-password',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="确认密码">
|
||||||
|
<NInput
|
||||||
|
v-model:value="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'new-password',
|
||||||
|
name: 'confirm-new-password',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
|
{{ error }}
|
||||||
|
<div v-if="registerConflict" class="mt-2">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ path: '/resend-confirmation', query: { email } }"
|
||||||
|
class="font-semibold text-rose-700 underline underline-offset-2"
|
||||||
|
>重发验证邮件</RouterLink
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--sky"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? '注册中...' : '注册' }}
|
||||||
|
</NButton>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<p class="mt-6 text-sm text-slate-500">
|
||||||
|
已有账号?
|
||||||
|
<RouterLink
|
||||||
|
:to="{ path: '/login', query: { email } }"
|
||||||
|
class="font-semibold text-sky-700 hover:text-sky-800"
|
||||||
|
>去登录</RouterLink
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
const cooldown = ref(0)
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const buttonLabel = computed(() =>
|
||||||
|
loading.value
|
||||||
|
? '发送中...'
|
||||||
|
: cooldown.value > 0
|
||||||
|
? `重新发送(${cooldown.value}s)`
|
||||||
|
: '重新发送',
|
||||||
|
)
|
||||||
|
|
||||||
|
function startCooldown() {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
cooldown.value = 60
|
||||||
|
timer = setInterval(() => {
|
||||||
|
cooldown.value--
|
||||||
|
if (cooldown.value <= 0 && timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
const value = email.value.trim()
|
||||||
|
if (!value || value.length > 64) {
|
||||||
|
error.value = '请输入不超过 64 个字符的有效邮箱'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.resendConfirmation(value)
|
||||||
|
email.value = value
|
||||||
|
submitted.value = true
|
||||||
|
startCooldown()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : '验证邮件发送失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<NCard class="oc-panel-card-xl">
|
||||||
|
<NResult
|
||||||
|
v-if="submitted"
|
||||||
|
status="success"
|
||||||
|
title="请求已受理"
|
||||||
|
description="如果该邮箱符合条件,我们会发送新的验证邮件。"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
目标邮箱:<span class="font-semibold text-slate-700">{{
|
||||||
|
email
|
||||||
|
}}</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap justify-center gap-3">
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
:disabled="cooldown > 0"
|
||||||
|
:loading="loading"
|
||||||
|
@click="submit"
|
||||||
|
>{{ buttonLabel }}</NButton
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ path: '/login', query: { email } }"
|
||||||
|
class="no-underline"
|
||||||
|
><NButton
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
>返回登录</NButton
|
||||||
|
></RouterLink
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">
|
||||||
|
Email Verification
|
||||||
|
</div>
|
||||||
|
<h1 class="mt-3 text-3xl font-bold text-slate-900">重发验证邮件</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
输入注册邮箱。为保护账号,系统不会透露邮箱是否存在或已验证。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NForm @submit.prevent="submit">
|
||||||
|
<NFormItem label="邮箱"
|
||||||
|
><NInput
|
||||||
|
v-model:value="email"
|
||||||
|
maxlength="64"
|
||||||
|
type="text"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'email',
|
||||||
|
inputmode: 'email',
|
||||||
|
name: 'email',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
/></NFormItem>
|
||||||
|
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
||||||
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--sky"
|
||||||
|
:loading="loading"
|
||||||
|
>{{ loading ? '发送中...' : '发送验证邮件' }}</NButton
|
||||||
|
>
|
||||||
|
</NForm>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||||
|
import { AuthClientError } from '@/features/auth/authClient'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const email = typeof route.query.email === 'string' ? route.query.email : ''
|
||||||
|
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const error = ref(!token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '')
|
||||||
|
const invalid = ref(!token)
|
||||||
|
const success = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const countdown = ref(5)
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
if (route.query.token !== undefined || route.query.email !== undefined) {
|
||||||
|
window.history.replaceState(window.history.state, '', route.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = computed(
|
||||||
|
() => !invalid.value && password.value.length >= 8 && password.value === confirmPassword.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
void router.push({ path: '/login', query: { email } })
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
if (password.value.length < 8) {
|
||||||
|
error.value = '新密码至少需要 8 位'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
error.value = '两次输入的密码不一致'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.resetPassword(token, password.value)
|
||||||
|
success.value = true
|
||||||
|
startCountdown()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试'
|
||||||
|
if (e instanceof AuthClientError && (e.status === 400 || e.code === 'INVALID_TOKEN'))
|
||||||
|
invalid.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<NCard class="oc-panel-card-xl">
|
||||||
|
<NResult
|
||||||
|
v-if="success"
|
||||||
|
status="success"
|
||||||
|
title="密码已重置"
|
||||||
|
description="请使用新密码重新登录。"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
{{ countdown }} 秒后自动前往登录页面
|
||||||
|
</p>
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
@click="router.push({ path: '/login', query: { email } })"
|
||||||
|
>立即登录</NButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">
|
||||||
|
Password Recovery
|
||||||
|
</div>
|
||||||
|
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
提交成功后,此重置链接和现有登录会话都会失效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NForm @submit.prevent="submit">
|
||||||
|
<NFormItem label="新密码"
|
||||||
|
><NInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
placeholder="至少 8 位"
|
||||||
|
:disabled="invalid"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'new-password',
|
||||||
|
name: 'new-password',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
/></NFormItem>
|
||||||
|
<NFormItem label="确认新密码"
|
||||||
|
><NInput
|
||||||
|
v-model:value="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
:disabled="invalid"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'new-password',
|
||||||
|
name: 'confirm-new-password',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
/></NFormItem>
|
||||||
|
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
||||||
|
<RouterLink
|
||||||
|
v-if="invalid"
|
||||||
|
:to="{ path: '/forgot-password', query: { email } }"
|
||||||
|
class="mb-4 block text-sm font-semibold text-teal-700 hover:text-teal-800"
|
||||||
|
>重新发送密码重置邮件</RouterLink
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--teal"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:loading="loading"
|
||||||
|
>保存新密码</NButton
|
||||||
|
>
|
||||||
|
</NForm>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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 ?? '云型详情'
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { NAlert, NButton, NIcon } from 'naive-ui'
|
||||||
|
import { Map as MapIcon, X } from '@vicons/tabler'
|
||||||
|
import MapPickerModal from '@/shared/map/components/MapPickerModal.vue'
|
||||||
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
|
export interface CloudEditFormValue {
|
||||||
|
cloudCategoryId: number | null
|
||||||
|
capturedAt: string
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
description: string
|
||||||
|
isPublic: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
saving?: boolean
|
||||||
|
error?: string
|
||||||
|
cloudTypes: CloudType[]
|
||||||
|
initialValue: CloudEditFormValue | null
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
save: [value: CloudEditFormValue]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = ref<CloudEditFormValue>(createEmptyForm())
|
||||||
|
const mapPickerOpen = ref(false)
|
||||||
|
|
||||||
|
function createEmptyForm(): CloudEditFormValue {
|
||||||
|
return {
|
||||||
|
cloudCategoryId: null,
|
||||||
|
capturedAt: '',
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
description: '',
|
||||||
|
isPublic: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneForm(value: CloudEditFormValue | null) {
|
||||||
|
return value ? { ...value } : createEmptyForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCategoryChange(event: Event) {
|
||||||
|
const value = (event.target as HTMLSelectElement).value
|
||||||
|
if (!value) {
|
||||||
|
form.value.cloudCategoryId = null
|
||||||
|
} else {
|
||||||
|
form.value.cloudCategoryId = Number(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLatInput(event: Event) {
|
||||||
|
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
||||||
|
form.value.latitude = Number.isNaN(value) ? null : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLngInput(event: Event) {
|
||||||
|
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
||||||
|
form.value.longitude = Number.isNaN(value) ? null : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
||||||
|
form.value.latitude = payload.latitude
|
||||||
|
form.value.longitude = payload.longitude
|
||||||
|
mapPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
emit('save', { ...form.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
open => {
|
||||||
|
if (open) {
|
||||||
|
form.value = cloneForm(props.initialValue)
|
||||||
|
} else {
|
||||||
|
mapPickerOpen.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.initialValue,
|
||||||
|
value => {
|
||||||
|
if (props.open) {
|
||||||
|
form.value = cloneForm(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/60 px-4 py-6"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<div class="oc-modal-shell w-full max-w-2xl" @click.stop>
|
||||||
|
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-slate-900">编辑图片信息</h3>
|
||||||
|
<p class="oc-field-help">修改后会同步影响个人主页、画廊和地图中的展示。</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button"
|
||||||
|
aria-label="关闭编辑窗口"
|
||||||
|
title="关闭编辑窗口"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<NIcon size="20"><X /></NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[70vh] overflow-y-auto px-6 py-5">
|
||||||
|
<NAlert
|
||||||
|
v-if="error"
|
||||||
|
class="mb-5"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<div class="grid gap-5">
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">类别</label>
|
||||||
|
<select
|
||||||
|
:value="form.cloudCategoryId ?? ''"
|
||||||
|
class="oc-field-control"
|
||||||
|
@change="onCategoryChange"
|
||||||
|
>
|
||||||
|
<option value="" disabled>请选择</option>
|
||||||
|
<option v-for="ct in cloudTypes" :key="ct.id" :value="ct.id">
|
||||||
|
{{ ct.name }}{{ ct.genus ? '(' + ct.genus + ')' : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">拍摄时间</label>
|
||||||
|
<input
|
||||||
|
v-model="form.capturedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
class="oc-field-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label"
|
||||||
|
>经纬度 <span class="oc-field-required">*</span></label
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||||
|
<input
|
||||||
|
:value="form.latitude !== null ? form.latitude : ''"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="纬度"
|
||||||
|
class="oc-field-control"
|
||||||
|
@input="onLatInput"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
:value="form.longitude !== null ? form.longitude : ''"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="经度"
|
||||||
|
class="oc-field-control"
|
||||||
|
@input="onLngInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button oc-icon-button--sky"
|
||||||
|
title="地图选点"
|
||||||
|
aria-label="地图选点"
|
||||||
|
@click="mapPickerOpen = true"
|
||||||
|
>
|
||||||
|
<NIcon size="20"><MapIcon /></NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">图片描述</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
rows="4"
|
||||||
|
class="oc-field-control resize-none"
|
||||||
|
placeholder="描述一下这张图片..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium text-slate-800"
|
||||||
|
>公开展示</span
|
||||||
|
>
|
||||||
|
<span class="mt-1 block text-xs text-slate-500"
|
||||||
|
>关闭后不会出现在画廊、地图和公开主页中。</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model="form.isPublic"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4"
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="close"
|
||||||
|
>取消</NButton
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
:loading="saving"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
保存修改
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<MapPickerModal
|
||||||
|
:open="mapPickerOpen"
|
||||||
|
:latitude="form.latitude"
|
||||||
|
:longitude="form.longitude"
|
||||||
|
description="点击地图即可回填这张图片的经纬度。"
|
||||||
|
footer-text="保存时会继续做模糊化处理。"
|
||||||
|
@close="mapPickerOpen = false"
|
||||||
|
@confirm="confirmMapPicker"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { NButton, NIcon, useMessage } from 'naive-ui'
|
||||||
|
import { Heart } from '@vicons/tabler'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
|
import { useLikesStore } from '@/features/clouds/stores/likes'
|
||||||
|
|
||||||
|
const props = defineProps<{ cloudId: string; count: number }>()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const likesStore = useLikesStore()
|
||||||
|
const message = useMessage()
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const liked = computed(() => likesStore.isLiked(props.cloudId))
|
||||||
|
const count = computed(() => likesStore.countFor(props.cloudId, props.count))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [authStore.user?.id, props.cloudId],
|
||||||
|
async ([userId]) => {
|
||||||
|
error.value = ''
|
||||||
|
if (!userId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await likesStore.ensureLoaded()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '点赞状态加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function toggleLike() {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await likesStore.toggle(props.cloudId, props.count)
|
||||||
|
message.success(liked.value ? '已点赞' : '已取消点赞')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '点赞操作失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-5 flex flex-wrap items-center gap-3">
|
||||||
|
<NButton
|
||||||
|
v-if="authStore.isLoggedIn"
|
||||||
|
type="default"
|
||||||
|
:class="[
|
||||||
|
'oc-panel-button',
|
||||||
|
liked ? 'oc-panel-button--teal' : 'oc-panel-button--neutral',
|
||||||
|
]"
|
||||||
|
:loading="loading || likesStore.isPending(cloudId)"
|
||||||
|
@click="toggleLike"
|
||||||
|
>
|
||||||
|
<template #icon
|
||||||
|
><NIcon><Heart /></NIcon
|
||||||
|
></template>
|
||||||
|
{{ liked ? '取消点赞' : '点赞' }}
|
||||||
|
</NButton>
|
||||||
|
<RouterLink
|
||||||
|
v-else
|
||||||
|
:to="{ path: '/login', query: { redirect: route.fullPath } }"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral no-underline"
|
||||||
|
>登录后点赞</RouterLink
|
||||||
|
>
|
||||||
|
<span class="text-sm text-slate-600">{{ count }} 次点赞</span>
|
||||||
|
<span v-if="error" class="text-sm text-rose-700">{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { InfoCircle, X } from '@vicons/tabler'
|
||||||
|
import { NIcon } from 'naive-ui'
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
imageUrl: string
|
||||||
|
thumbnailUrl?: string | null
|
||||||
|
imageAlt: string
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
badgeLabel?: string
|
||||||
|
badgeClass?: string
|
||||||
|
panelWidth?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
subtitle: '',
|
||||||
|
thumbnailUrl: null,
|
||||||
|
badgeLabel: '',
|
||||||
|
badgeClass: '',
|
||||||
|
panelWidth: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const infoOpen = ref(false)
|
||||||
|
const imageLoaded = ref(false)
|
||||||
|
const previewLoaded = ref(false)
|
||||||
|
const naturalWidth = ref(0)
|
||||||
|
const naturalHeight = ref(0)
|
||||||
|
const viewportWidth = ref(0)
|
||||||
|
const viewportHeight = ref(0)
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const closing = ref(false)
|
||||||
|
let closeTimer: number | null = null
|
||||||
|
const previousBodyOverflow = ref<string | null>(null)
|
||||||
|
const previousDocumentOverflow = ref<string | null>(null)
|
||||||
|
|
||||||
|
function updateViewport() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
viewportWidth.value = window.innerWidth
|
||||||
|
viewportHeight.value = window.innerHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = computed(() => {
|
||||||
|
const aspectRatio =
|
||||||
|
naturalWidth.value > 0 && naturalHeight.value > 0
|
||||||
|
? naturalWidth.value / naturalHeight.value
|
||||||
|
: 4 / 3
|
||||||
|
const maxModalWidth =
|
||||||
|
viewportWidth.value > 0
|
||||||
|
? Math.max(Math.min(viewportWidth.value * 0.95, viewportWidth.value - 32), 280)
|
||||||
|
: 320
|
||||||
|
const maxImageHeight =
|
||||||
|
viewportHeight.value > 0 ? Math.max(viewportHeight.value * 0.9, 240) : 360
|
||||||
|
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageWidth,
|
||||||
|
imageHeight: imageWidth / aspectRatio,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageStyle = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
width: `${Math.round(layout.value.imageWidth)}px`,
|
||||||
|
height: `${Math.round(layout.value.imageHeight)}px`,
|
||||||
|
}) as Record<string, string>,
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetImageState() {
|
||||||
|
imageLoaded.value = false
|
||||||
|
previewLoaded.value = false
|
||||||
|
naturalWidth.value = 0
|
||||||
|
naturalHeight.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockPageScroll() {
|
||||||
|
if (typeof document === 'undefined' || previousBodyOverflow.value !== null) return
|
||||||
|
previousBodyOverflow.value = document.body.style.overflow
|
||||||
|
previousDocumentOverflow.value = document.documentElement.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
document.documentElement.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockPageScroll() {
|
||||||
|
if (typeof document === 'undefined' || previousBodyOverflow.value === null) return
|
||||||
|
document.body.style.overflow = previousBodyOverflow.value
|
||||||
|
document.documentElement.style.overflow = previousDocumentOverflow.value ?? ''
|
||||||
|
previousBodyOverflow.value = null
|
||||||
|
previousDocumentOverflow.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
open => {
|
||||||
|
if (open) {
|
||||||
|
if (closeTimer !== null) {
|
||||||
|
window.clearTimeout(closeTimer)
|
||||||
|
closeTimer = null
|
||||||
|
}
|
||||||
|
closing.value = false
|
||||||
|
modalVisible.value = true
|
||||||
|
lockPageScroll()
|
||||||
|
infoOpen.value = false
|
||||||
|
resetImageState()
|
||||||
|
} else {
|
||||||
|
modalVisible.value = false
|
||||||
|
unlockPageScroll()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.imageUrl,
|
||||||
|
() => {
|
||||||
|
resetImageState()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (closing.value) return
|
||||||
|
closing.value = true
|
||||||
|
modalVisible.value = false
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
unlockPageScroll()
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeTimer = window.setTimeout(() => {
|
||||||
|
closeTimer = null
|
||||||
|
unlockPageScroll()
|
||||||
|
emit('close')
|
||||||
|
}, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleInfo() {
|
||||||
|
infoOpen.value = !infoOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageLoad(event: Event) {
|
||||||
|
const target = event.target as HTMLImageElement | null
|
||||||
|
if (!target) return
|
||||||
|
naturalWidth.value = target.naturalWidth
|
||||||
|
naturalHeight.value = target.naturalHeight
|
||||||
|
imageLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreviewLoad(event: Event) {
|
||||||
|
const target = event.target as HTMLImageElement | null
|
||||||
|
if (!target) return
|
||||||
|
if (!naturalWidth.value || !naturalHeight.value) {
|
||||||
|
naturalWidth.value = target.naturalWidth
|
||||||
|
naturalHeight.value = target.naturalHeight
|
||||||
|
}
|
||||||
|
previewLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateViewport()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('resize', updateViewport)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (closeTimer !== null) {
|
||||||
|
window.clearTimeout(closeTimer)
|
||||||
|
}
|
||||||
|
unlockPageScroll()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('resize', updateViewport)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition appear name="image-detail-modal">
|
||||||
|
<div
|
||||||
|
v-if="modalVisible"
|
||||||
|
class="fixed inset-0 z-[110] flex items-center justify-center bg-black/85 px-4 py-6"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden shadow-2xl"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-overlay-button absolute right-3 top-3 z-30 h-9 w-9 opacity-0 drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)] transition-opacity duration-200 hover:opacity-100 focus-visible:opacity-100"
|
||||||
|
aria-label="关闭大图"
|
||||||
|
title="关闭大图"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<NIcon size="20">
|
||||||
|
<X />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative flex min-h-0 items-center justify-center overflow-hidden bg-slate-950"
|
||||||
|
:style="imageStyle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!previewLoaded && !imageLoaded"
|
||||||
|
class="absolute inset-0 animate-pulse bg-[linear-gradient(110deg,#0f172a_0%,#1e293b_45%,#334155_55%,#0f172a_100%)] bg-[length:220%_100%]"
|
||||||
|
></div>
|
||||||
|
<img
|
||||||
|
v-if="thumbnailUrl"
|
||||||
|
:src="thumbnailUrl"
|
||||||
|
:alt="imageAlt"
|
||||||
|
class="absolute inset-0 block h-full w-full object-contain blur-[1px] transition-opacity duration-300"
|
||||||
|
:class="imageLoaded ? 'opacity-0' : 'opacity-100'"
|
||||||
|
@load="handlePreviewLoad"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
:src="imageUrl"
|
||||||
|
:alt="imageAlt"
|
||||||
|
class="relative block h-full w-full object-contain transition-opacity duration-300"
|
||||||
|
:class="imageLoaded ? 'opacity-100' : 'opacity-0'"
|
||||||
|
@load="handleImageLoad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="image-detail-info-button oc-overlay-button absolute bottom-4 right-4 z-30 h-11 w-11 drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)]"
|
||||||
|
:aria-label="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
||||||
|
:title="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
||||||
|
@click="toggleInfo"
|
||||||
|
>
|
||||||
|
<NIcon size="30">
|
||||||
|
<InfoCircle />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="infoOpen"
|
||||||
|
class="absolute bottom-16 right-4 z-20 flex max-h-[min(70vh,560px)] w-[min(360px,calc(100%-2rem))] flex-col overflow-hidden border border-white/70 bg-white/95 shadow-2xl backdrop-blur"
|
||||||
|
>
|
||||||
|
<div class="border-b border-slate-200 px-5 py-4">
|
||||||
|
<div class="pr-8">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-xl font-bold text-slate-900">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
v-if="badgeLabel"
|
||||||
|
class="border px-3 py-1 text-xs font-medium"
|
||||||
|
:class="badgeClass"
|
||||||
|
>
|
||||||
|
{{ badgeLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="subtitle" class="mt-2 text-sm text-slate-500">
|
||||||
|
{{ subtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="image-detail-panel-close oc-icon-button oc-icon-button--small absolute right-3 top-3"
|
||||||
|
aria-label="隐藏详细信息"
|
||||||
|
title="隐藏详细信息"
|
||||||
|
@click="infoOpen = false"
|
||||||
|
>
|
||||||
|
<NIcon size="16"><X /></NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="$slots.actions" class="border-t border-slate-200 px-5 py-4">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.image-detail-modal-enter-active,
|
||||||
|
.image-detail-modal-leave-active {
|
||||||
|
transition: opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-detail-modal-enter-active .image-detail-shell,
|
||||||
|
.image-detail-modal-leave-active .image-detail-shell {
|
||||||
|
transition:
|
||||||
|
opacity 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-detail-modal-enter-from,
|
||||||
|
.image-detail-modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-detail-modal-enter-from .image-detail-shell,
|
||||||
|
.image-detail-modal-leave-to .image-detail-shell {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-detail-info-button.image-detail-info-button {
|
||||||
|
border-radius: 9999px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<CloudType[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchCloudTypes() {
|
||||||
|
if (cloudTypes.value.length > 0) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const rows = await apiRequest<BackendCloudType[]>('/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 }
|
||||||
|
})
|
||||||
@@ -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<Set<string>>(new Set())
|
||||||
|
const counts = ref<Record<string, number>>({})
|
||||||
|
const pendingIds = ref<Set<string>>(new Set())
|
||||||
|
const loadedForUser = ref<string | null>(null)
|
||||||
|
let loadPromise: Promise<void> | null = null
|
||||||
|
let loadingForUser: string | null = null
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authStore.user?.id,
|
||||||
|
() => {
|
||||||
|
likedIds.value = new Set()
|
||||||
|
counts.value = {}
|
||||||
|
pendingIds.value = new Set()
|
||||||
|
loadedForUser.value = null
|
||||||
|
loadPromise = null
|
||||||
|
loadingForUser = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function isLiked(cloudId: string) {
|
||||||
|
return likedIds.value.has(cloudId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function countFor(cloudId: string, initialCount: number) {
|
||||||
|
return counts.value[cloudId] ?? initialCount
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPending(cloudId: string) {
|
||||||
|
return pendingIds.value.has(cloudId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLoaded() {
|
||||||
|
const userId = authStore.user?.id
|
||||||
|
if (!userId || loadedForUser.value === userId) return
|
||||||
|
if (loadPromise && loadingForUser === userId) return loadPromise
|
||||||
|
|
||||||
|
loadingForUser = userId
|
||||||
|
const request = (async () => {
|
||||||
|
const nextIds = new Set<string>()
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
page_size: String(PAGE_SIZE),
|
||||||
|
})
|
||||||
|
const rows = await apiRequest<BackendCloudInfo[]>(`/profile/me/likes?${params}`)
|
||||||
|
rows.forEach(row => nextIds.add(row.id))
|
||||||
|
if (rows.length < PAGE_SIZE) break
|
||||||
|
}
|
||||||
|
if (authStore.user?.id === userId) {
|
||||||
|
likedIds.value = nextIds
|
||||||
|
loadedForUser.value = userId
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
loadPromise = request
|
||||||
|
try {
|
||||||
|
await request
|
||||||
|
} finally {
|
||||||
|
if (loadPromise === request) {
|
||||||
|
loadPromise = null
|
||||||
|
loadingForUser = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(cloudId: string, initialCount: number) {
|
||||||
|
const userId = authStore.user?.id
|
||||||
|
if (!userId) throw new Error('请先登录后点赞。')
|
||||||
|
await ensureLoaded()
|
||||||
|
if (authStore.user?.id !== userId) throw new Error('登录状态已改变,请重试。')
|
||||||
|
if (isPending(cloudId)) return
|
||||||
|
|
||||||
|
const wasLiked = isLiked(cloudId)
|
||||||
|
pendingIds.value = new Set(pendingIds.value).add(cloudId)
|
||||||
|
try {
|
||||||
|
await apiRequest<ActionResponse>(`/cloud/${cloudId}/like`, {
|
||||||
|
method: wasLiked ? 'DELETE' : 'PUT',
|
||||||
|
})
|
||||||
|
if (authStore.user?.id !== userId) return
|
||||||
|
|
||||||
|
const nextIds = new Set(likedIds.value)
|
||||||
|
if (wasLiked) nextIds.delete(cloudId)
|
||||||
|
else nextIds.add(cloudId)
|
||||||
|
likedIds.value = nextIds
|
||||||
|
counts.value[cloudId] = Math.max(
|
||||||
|
0,
|
||||||
|
countFor(cloudId, initialCount) + (wasLiked ? -1 : 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cloud = await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`, {
|
||||||
|
auth: false,
|
||||||
|
})
|
||||||
|
counts.value[cloudId] = cloud.received_like_count
|
||||||
|
if (cloud.owner.id === authStore.user?.id) await authStore.refreshProfile()
|
||||||
|
} catch {
|
||||||
|
// The mutation succeeded; keep the local count if a refresh fails.
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const nextPending = new Set(pendingIds.value)
|
||||||
|
nextPending.delete(cloudId)
|
||||||
|
pendingIds.value = nextPending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isLiked, countFor, isPending, ensureLoaded, toggle }
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f2faf6_0%,#f8fafc_100%)] px-4 py-16"
|
||||||
|
>
|
||||||
|
<section class="oc-panel-card-xl mx-auto max-w-4xl border border-slate-200 bg-white p-10">
|
||||||
|
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-teal-700">Community</p>
|
||||||
|
<h1 class="mt-4 text-4xl font-bold text-slate-900">社区</h1>
|
||||||
|
<p class="mt-5 max-w-2xl text-sm leading-7 text-slate-600">
|
||||||
|
社区模块正在规划中。后续这里会承载话题讨论、观测活动和用户互动内容。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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<CloudType[]>([])
|
||||||
|
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<BackendCloudType[]>('/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { NAlert, NButton, NEmpty, NSkeleton } from 'naive-ui'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
|
||||||
|
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
|
||||||
|
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
|
||||||
|
import { apiRequest, imageUrl, toApiCloud } from '@/lib/api'
|
||||||
|
import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia'
|
||||||
|
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24
|
||||||
|
const route = useRoute()
|
||||||
|
const encyclopediaStore = useEncyclopediaStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref('')
|
||||||
|
const gallery = ref<ApiCloud[]>([])
|
||||||
|
const selectedItem = ref<ApiCloud | null>(null)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const hasNextPage = ref(false)
|
||||||
|
|
||||||
|
const cloudTypeId = computed(() => Number(route.params.id))
|
||||||
|
const cloudType = computed(
|
||||||
|
() => encyclopediaStore.cloudTypes.find(item => item.id === cloudTypeId.value) ?? null,
|
||||||
|
)
|
||||||
|
const heroImage = computed(() =>
|
||||||
|
cloudType.value?.icon_id ? imageUrl(cloudType.value.icon_id, 'original') : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null) {
|
||||||
|
if (!iso) return '未记录拍摄时间'
|
||||||
|
return new Date(iso).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGallery(page: number) {
|
||||||
|
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
||||||
|
const rows = await apiRequest<BackendCloudInfo[]>(
|
||||||
|
`/cloud/type/${cloudTypeId.value}?${params}`,
|
||||||
|
{
|
||||||
|
auth: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
gallery.value = rows.map(toApiCloud)
|
||||||
|
currentPage.value = page
|
||||||
|
hasNextPage.value = rows.length === PAGE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage(page = 1) {
|
||||||
|
if (!Number.isInteger(cloudTypeId.value) || cloudTypeId.value <= 0) {
|
||||||
|
loadError.value = '云类型不存在'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
selectedItem.value = null
|
||||||
|
try {
|
||||||
|
await encyclopediaStore.fetchCloudTypes()
|
||||||
|
if (!cloudType.value) {
|
||||||
|
loadError.value = '云类型不存在'
|
||||||
|
gallery.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadGallery(page)
|
||||||
|
} catch (error) {
|
||||||
|
loadError.value = error instanceof Error ? error.message : '详情加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadPage())
|
||||||
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
() => loadPage(),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="oc-container oc-container--content py-8">
|
||||||
|
<RouterLink to="/encyclopedia">
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
>← 返回百科总览</NButton
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div v-if="loading" class="mt-6 space-y-6">
|
||||||
|
<NSkeleton class="h-80 w-full" />
|
||||||
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
|
<NSkeleton v-for="n in 6" :key="n" class="h-56 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NAlert
|
||||||
|
v-else-if="loadError"
|
||||||
|
class="mt-6"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
title="详情加载失败"
|
||||||
|
>
|
||||||
|
{{ loadError }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<template v-else-if="cloudType">
|
||||||
|
<section class="oc-panel-card mt-6 overflow-hidden border border-slate-200 bg-white">
|
||||||
|
<div class="grid lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
|
<div class="relative min-h-80 bg-sky-50">
|
||||||
|
<img
|
||||||
|
v-if="heroImage"
|
||||||
|
:src="heroImage"
|
||||||
|
:alt="cloudType.name"
|
||||||
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="absolute inset-0 flex items-center justify-center pb-20 text-sm font-medium text-slate-500"
|
||||||
|
>
|
||||||
|
无图片
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="heroImage"
|
||||||
|
class="absolute inset-0 bg-gradient-to-t from-slate-950/75 via-slate-950/10 to-transparent"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-7 left-7 right-7"
|
||||||
|
:class="heroImage ? 'text-white' : 'text-slate-900'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="border px-2.5 py-1 text-xs"
|
||||||
|
:class="
|
||||||
|
heroImage
|
||||||
|
? 'border-white/35 bg-slate-950/25 backdrop-blur'
|
||||||
|
: 'border-slate-200 bg-white'
|
||||||
|
"
|
||||||
|
>稀有度 {{ cloudType.rarity_value }}</span
|
||||||
|
>
|
||||||
|
<h1 class="mt-4 text-4xl font-bold">{{ cloudType.name }}</h1>
|
||||||
|
<p class="mt-2" :class="heroImage ? 'text-white/80' : 'text-slate-600'">
|
||||||
|
{{ cloudType.genus || '未提供云属名称' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-8">
|
||||||
|
<p class="oc-section-label">Meteorology Note</p>
|
||||||
|
<p class="mt-5 text-base leading-8 text-slate-700">
|
||||||
|
{{ cloudType.description || '暂未提供识别说明。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="mb-5 flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900">公开观测</h2>
|
||||||
|
<p class="oc-field-help">
|
||||||
|
第 {{ currentPage }} 页,每页最多 {{ PAGE_SIZE }} 张。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
@click="loadPage(currentPage - 1)"
|
||||||
|
>上一页</NButton
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
:disabled="!hasNextPage"
|
||||||
|
@click="loadPage(currentPage + 1)"
|
||||||
|
>下一页</NButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="gallery.length"
|
||||||
|
class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="item in gallery"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left"
|
||||||
|
@click="selectedItem = item"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="item.thumbnail_url || item.image_url"
|
||||||
|
:alt="cloudType.name"
|
||||||
|
class="h-48 w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div class="p-4">
|
||||||
|
<p class="font-medium text-slate-900">@{{ item.username }}</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
{{ formatDateTime(item.captured_at || item.created_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="oc-empty-card border border-dashed border-slate-300 bg-white p-10"
|
||||||
|
>
|
||||||
|
<NEmpty description="这一页暂无公开云图" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ImageDetailModal
|
||||||
|
v-if="selectedItem && cloudType"
|
||||||
|
:open="true"
|
||||||
|
:image-url="selectedItem.image_url"
|
||||||
|
:thumbnail-url="selectedItem.thumbnail_url"
|
||||||
|
:image-alt="cloudType.name"
|
||||||
|
:title="cloudType.name"
|
||||||
|
:subtitle="
|
||||||
|
selectedItem.captured_at
|
||||||
|
? `拍摄于 ${formatDateTime(selectedItem.captured_at)}`
|
||||||
|
: `上传于 ${formatDateTime(selectedItem.created_at)}`
|
||||||
|
"
|
||||||
|
:badge-label="`稀有度 ${cloudType.rarity_value}`"
|
||||||
|
badge-class="border-slate-200 bg-slate-50 text-slate-600"
|
||||||
|
@close="selectedItem = null"
|
||||||
|
>
|
||||||
|
<p class="text-sm leading-7 text-slate-700">
|
||||||
|
{{ selectedItem.description || '上传者没有留下额外说明。' }}
|
||||||
|
</p>
|
||||||
|
<MiniLocationMap
|
||||||
|
:latitude="selectedItem.latitude"
|
||||||
|
:longitude="selectedItem.longitude"
|
||||||
|
/>
|
||||||
|
<CloudLikeButton
|
||||||
|
:cloud-id="selectedItem.id"
|
||||||
|
:count="selectedItem.received_like_count"
|
||||||
|
/>
|
||||||
|
</ImageDetailModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { NEmpty, NSkeleton } from 'naive-ui'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { imageUrl } from '@/lib/api'
|
||||||
|
import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const encyclopediaStore = useEncyclopediaStore()
|
||||||
|
|
||||||
|
function typeImageUrl(iconId: string | null) {
|
||||||
|
return iconId ? imageUrl(iconId, 'preview') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => encyclopediaStore.fetchCloudTypes())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="oc-page-hero">
|
||||||
|
<div class="oc-container oc-container--content oc-page-hero__inner">
|
||||||
|
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
|
||||||
|
<h1 class="oc-page-title">云朵百科</h1>
|
||||||
|
<p class="oc-page-description">
|
||||||
|
浏览后端提供的云类型资料,了解云属名称、识别说明和社区公开观测记录。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oc-container oc-container--content oc-page-content">
|
||||||
|
<div
|
||||||
|
v-if="encyclopediaStore.loadingCloudTypes"
|
||||||
|
class="grid gap-5 md:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="n in 6"
|
||||||
|
:key="n"
|
||||||
|
class="oc-panel-card overflow-hidden border border-slate-200 bg-white"
|
||||||
|
>
|
||||||
|
<NSkeleton class="h-80 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="encyclopediaStore.cloudTypes.length"
|
||||||
|
class="grid gap-5 md:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="cloudType in encyclopediaStore.cloudTypes"
|
||||||
|
:key="cloudType.id"
|
||||||
|
type="button"
|
||||||
|
class="oc-interactive-card oc-panel-card relative isolate min-h-80 overflow-hidden border border-slate-200 bg-sky-50 text-left"
|
||||||
|
@click="router.push(`/encyclopedia/${cloudType.id}`)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="cloudType.icon_id"
|
||||||
|
:src="typeImageUrl(cloudType.icon_id)"
|
||||||
|
:alt="`${cloudType.name}介绍图`"
|
||||||
|
class="absolute inset-0 -z-20 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="cloudType.icon_id"
|
||||||
|
class="absolute inset-0 -z-10 bg-gradient-to-t from-slate-950/90 via-slate-950/55 to-slate-950/25"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="absolute inset-0 -z-10 flex items-center justify-center text-sm font-medium text-slate-500"
|
||||||
|
>
|
||||||
|
无图片
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-h-80 flex-col p-5">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
class="oc-section-label"
|
||||||
|
:class="cloudType.icon_id ? 'text-white/70' : 'text-slate-500'"
|
||||||
|
>
|
||||||
|
Cloud Genus
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
class="mt-2 text-xl font-bold"
|
||||||
|
:class="cloudType.icon_id ? 'text-white' : 'text-slate-900'"
|
||||||
|
>
|
||||||
|
{{ cloudType.name }}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
class="mt-1 font-mono text-xs uppercase tracking-[0.12em]"
|
||||||
|
:class="cloudType.icon_id ? 'text-white/75' : 'text-slate-500'"
|
||||||
|
>
|
||||||
|
{{ cloudType.genus || '未提供云属名称' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="shrink-0 border px-2.5 py-1 text-xs font-medium"
|
||||||
|
:class="
|
||||||
|
cloudType.icon_id
|
||||||
|
? 'border-white/35 bg-slate-950/25 text-white backdrop-blur'
|
||||||
|
: 'border-slate-200 bg-white text-slate-600'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
稀有度 {{ cloudType.rarity_value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="mt-auto line-clamp-3 pt-16 text-sm leading-6"
|
||||||
|
:class="cloudType.icon_id ? 'text-white/85' : 'text-slate-600'"
|
||||||
|
>
|
||||||
|
{{ cloudType.description || '暂未提供识别说明。' }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-4 border-t pt-4 text-xs font-semibold"
|
||||||
|
:class="
|
||||||
|
cloudType.icon_id
|
||||||
|
? 'border-white/25 text-white'
|
||||||
|
: 'border-slate-200 text-sky-700'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
查看类型资料与公开云图 →
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="oc-empty-card border border-dashed border-slate-300 bg-white px-6 py-12"
|
||||||
|
>
|
||||||
|
<NEmpty description="暂时还没有云类型资料" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||||
|
import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
|
||||||
|
import CloudEditModal, {
|
||||||
|
type CloudEditFormValue,
|
||||||
|
} from '@/features/clouds/components/CloudEditModal.vue'
|
||||||
|
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
|
||||||
|
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
|
||||||
|
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
|
||||||
|
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
|
import { useCloudsStore } from '@/features/clouds/stores/clouds'
|
||||||
|
import { useProfileStore } from '@/features/profile/stores/profile'
|
||||||
|
|
||||||
|
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
||||||
|
|
||||||
|
interface GalleryCloud {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
cloud_type_id: number | null
|
||||||
|
image_url: string
|
||||||
|
thumbnail_url: string | null
|
||||||
|
description: string | null
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
captured_at: string | null
|
||||||
|
created_at: string
|
||||||
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
|
is_hidden: boolean
|
||||||
|
cloudTypeName: string
|
||||||
|
cloudTypeRarity: number
|
||||||
|
username: string
|
||||||
|
receivedLikeCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const cloudsStore = useCloudsStore()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref('')
|
||||||
|
const galleryItems = ref<GalleryCloud[]>([])
|
||||||
|
const selectedTypeId = ref<number | 'all'>('all')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedCloud = ref<GalleryCloud | null>(null)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const hasNextPage = ref(false)
|
||||||
|
const manageError = ref('')
|
||||||
|
const editModalOpen = ref(false)
|
||||||
|
const editSaving = ref(false)
|
||||||
|
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
||||||
|
|
||||||
|
let searchTimer: number | null = null
|
||||||
|
|
||||||
|
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null) {
|
||||||
|
if (!iso) return '未知时间'
|
||||||
|
return new Date(iso).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUploadTime(cloud: GalleryCloud) {
|
||||||
|
return formatDateTime(cloud.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCapturedTime(cloud: GalleryCloud) {
|
||||||
|
return formatDateTime(cloud.captured_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value: number | null) {
|
||||||
|
return value === null ? '未记录' : value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSearch = computed(() => searchQuery.value.trim())
|
||||||
|
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
|
||||||
|
|
||||||
|
function toGalleryCloud(row: ApiCloud) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
user_id: row.user_id,
|
||||||
|
cloud_type_id: row.cloud_type_id,
|
||||||
|
image_url: row.image_url,
|
||||||
|
thumbnail_url: row.thumbnail_url,
|
||||||
|
description: row.description,
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude,
|
||||||
|
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,
|
||||||
|
username: row.username || '匿名',
|
||||||
|
receivedLikeCount: row.received_like_count,
|
||||||
|
} satisfies GalleryCloud
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage(page: number) {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
||||||
|
if (normalizedSearch.value) {
|
||||||
|
params.set('filter', isUserSearch.value ? 'owner' : 'type')
|
||||||
|
params.set('value', normalizedSearch.value)
|
||||||
|
} else if (selectedTypeId.value !== 'all') {
|
||||||
|
const typeName = cloudsStore.cloudTypes.find(
|
||||||
|
type => type.id === selectedTypeId.value,
|
||||||
|
)?.name
|
||||||
|
if (typeName) {
|
||||||
|
params.set('filter', 'type')
|
||||||
|
params.set('value', typeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud?${params}`, { auth: false })
|
||||||
|
galleryItems.value = rows.map(toApiCloud).map(toGalleryCloud)
|
||||||
|
hasNextPage.value = rows.length === PAGE_SIZE
|
||||||
|
currentPage.value = page
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 404 && isUserSearch.value) {
|
||||||
|
galleryItems.value = []
|
||||||
|
hasNextPage.value = false
|
||||||
|
currentPage.value = page
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToPage(page: number) {
|
||||||
|
if (page < 1 || page === currentPage.value || (page > currentPage.value && !hasNextPage.value))
|
||||||
|
return
|
||||||
|
await loadPage(page)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(cloud: GalleryCloud) {
|
||||||
|
selectedCloud.value = cloud
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
selectedCloud.value = null
|
||||||
|
editModalOpen.value = false
|
||||||
|
manageError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCloudIsMine = computed(() => {
|
||||||
|
return !!selectedCloud.value && selectedCloud.value.user_id === authStore.user?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const cloudActionOptions = computed(() => [
|
||||||
|
{ label: '编辑图片信息', key: 'edit' },
|
||||||
|
{ label: '删除', key: 'delete' },
|
||||||
|
{ label: '更改为私密', key: 'toggle-privacy' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function formatDatetimeLocal(iso: string | null) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const date = new Date(iso)
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurCoordinate(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal() {
|
||||||
|
if (!selectedCloud.value) return
|
||||||
|
|
||||||
|
manageError.value = ''
|
||||||
|
editInitialValue.value = {
|
||||||
|
cloudCategoryId: selectedCloud.value.cloud_type_id,
|
||||||
|
capturedAt: formatDatetimeLocal(selectedCloud.value.captured_at),
|
||||||
|
latitude: selectedCloud.value.latitude,
|
||||||
|
longitude: selectedCloud.value.longitude,
|
||||||
|
description: selectedCloud.value.description || '',
|
||||||
|
isPublic: !selectedCloud.value.is_hidden,
|
||||||
|
}
|
||||||
|
editModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
editModalOpen.value = false
|
||||||
|
editSaving.value = false
|
||||||
|
manageError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchGalleryCloud(updated: GalleryCloud) {
|
||||||
|
galleryItems.value = galleryItems.value.map(item => (item.id === updated.id ? updated : item))
|
||||||
|
if (selectedCloud.value?.id === updated.id) {
|
||||||
|
selectedCloud.value = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditModal(value: CloudEditFormValue) {
|
||||||
|
if (!selectedCloud.value || !authStore.user?.id) return
|
||||||
|
|
||||||
|
if (!value.cloudCategoryId) {
|
||||||
|
manageError.value = '请选择云型类别。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
||||||
|
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
||||||
|
if (!hasLat || !hasLng) {
|
||||||
|
manageError.value = '经纬度为必填项。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (value.description.trim().length > 128) {
|
||||||
|
manageError.value = '图片描述不能超过 128 个字符。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = selectedCloud.value
|
||||||
|
editSaving.value = true
|
||||||
|
manageError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await profileStore.updateCloud(authStore.user.id, current.id, {
|
||||||
|
type_id: value.cloudCategoryId,
|
||||||
|
latitude: blurCoordinate(value.latitude as number),
|
||||||
|
longitude: blurCoordinate(value.longitude as number),
|
||||||
|
description: value.description.trim(),
|
||||||
|
...(value.capturedAt ? { captured_at: new Date(value.capturedAt).toISOString() } : {}),
|
||||||
|
is_hidden: !value.isPublic,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (updated.is_hidden || updated.status !== 'approved') {
|
||||||
|
removeGalleryCloud(updated.id)
|
||||||
|
} else {
|
||||||
|
patchGalleryCloud({
|
||||||
|
...current,
|
||||||
|
...updated,
|
||||||
|
user_id: current.user_id,
|
||||||
|
username: current.username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
closeEditModal()
|
||||||
|
message.success('图片信息已更新')
|
||||||
|
} catch (error) {
|
||||||
|
manageError.value = error instanceof Error ? error.message : '图片信息更新失败'
|
||||||
|
message.error(manageError.value)
|
||||||
|
} finally {
|
||||||
|
editSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGalleryCloud(cloudId: string) {
|
||||||
|
galleryItems.value = galleryItems.value.filter(item => item.id !== cloudId)
|
||||||
|
if (selectedCloud.value?.id === cloudId) {
|
||||||
|
selectedCloud.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedCloud() {
|
||||||
|
if (!selectedCloud.value || !authStore.user?.id) return
|
||||||
|
const cloudId = selectedCloud.value.id
|
||||||
|
const confirmed = window.confirm('确定删除这张图片吗?删除后无法在页面中恢复。')
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
manageError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletedCount = await profileStore.deleteClouds(authStore.user.id, [cloudId])
|
||||||
|
removeGalleryCloud(cloudId)
|
||||||
|
message.success(`已成功删除 ${deletedCount} 张照片`)
|
||||||
|
} catch (error) {
|
||||||
|
manageError.value = error instanceof Error ? error.message : '图片删除失败'
|
||||||
|
message.error(manageError.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hideSelectedCloud() {
|
||||||
|
if (!selectedCloud.value || !authStore.user?.id) return
|
||||||
|
const cloudId = selectedCloud.value.id
|
||||||
|
|
||||||
|
manageError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await profileStore.updateCloudVisibility(authStore.user.id, cloudId, true)
|
||||||
|
removeGalleryCloud(cloudId)
|
||||||
|
message.success('已更改为私密图片')
|
||||||
|
} catch (error) {
|
||||||
|
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
||||||
|
message.error(manageError.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloudAction(key: string | number) {
|
||||||
|
if (key === 'edit') openEditModal()
|
||||||
|
if (key === 'delete') deleteSelectedCloud()
|
||||||
|
if (key === 'toggle-privacy') hideSelectedCloud()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
searchQuery.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterTabs = computed(() => [
|
||||||
|
{ id: 'all' as const, label: '全部' },
|
||||||
|
...cloudsStore.cloudTypes.map(type => ({ id: type.id, label: type.name })),
|
||||||
|
])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await cloudsStore.fetchCloudTypes()
|
||||||
|
await loadPage(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedTypeId, async () => {
|
||||||
|
selectedCloud.value = null
|
||||||
|
await loadPage(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(searchQuery, () => {
|
||||||
|
if (searchTimer !== null) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
searchTimer = window.setTimeout(async () => {
|
||||||
|
selectedCloud.value = null
|
||||||
|
await loadPage(1)
|
||||||
|
searchTimer = null
|
||||||
|
}, 250)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (searchTimer !== null) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section class="oc-page-hero">
|
||||||
|
<div class="oc-container oc-container--content oc-page-hero__inner">
|
||||||
|
<p class="oc-page-eyebrow">Community Gallery</p>
|
||||||
|
<h1 class="oc-page-title">云图画廊</h1>
|
||||||
|
<p class="oc-page-description">
|
||||||
|
按上传时间倒序浏览社区云图。瀑布流会尽量保留图片原始比例,悬停即可快速查看基本信息,点开可看大图和详细记录。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="oc-container oc-container--content oc-page-content">
|
||||||
|
<section>
|
||||||
|
<div class="oc-panel-card mb-4 border border-slate-200 bg-white p-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<NIcon size="20" class="shrink-0 text-slate-400">
|
||||||
|
<Search />
|
||||||
|
</NIcon>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
class="min-w-0 flex-1 bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400"
|
||||||
|
placeholder="输入完整云类型名称,或 @用户名进行精确搜索"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="searchQuery"
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button oc-icon-button--small"
|
||||||
|
title="清空搜索"
|
||||||
|
aria-label="清空搜索"
|
||||||
|
@click="clearSearch"
|
||||||
|
>
|
||||||
|
<NIcon size="18">
|
||||||
|
<X />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="normalizedSearch" class="mt-2 text-xs text-slate-500">
|
||||||
|
<template v-if="isUserSearch">
|
||||||
|
正在按上传者搜索:{{ normalizedSearch.slice(1) || '请输入用户名' }}
|
||||||
|
</template>
|
||||||
|
<template v-else> 正在精确匹配云型名称:{{ normalizedSearch }} </template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 overflow-x-auto pb-2">
|
||||||
|
<NButton
|
||||||
|
v-for="tab in filterTabs"
|
||||||
|
:key="tab.id"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="shrink-0"
|
||||||
|
:class="
|
||||||
|
selectedTypeId === tab.id
|
||||||
|
? 'oc-panel-button oc-panel-button--sky'
|
||||||
|
: 'oc-panel-button oc-panel-button--neutral'
|
||||||
|
"
|
||||||
|
type="default"
|
||||||
|
@click="selectedTypeId = tab.id"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NAlert
|
||||||
|
v-if="loadError"
|
||||||
|
class="mt-6"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
title="画廊加载失败"
|
||||||
|
>
|
||||||
|
{{ loadError }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NAlert
|
||||||
|
v-if="manageError"
|
||||||
|
class="mt-6"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
title="操作失败"
|
||||||
|
>
|
||||||
|
{{ manageError }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="loading"
|
||||||
|
class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="n in 8"
|
||||||
|
:key="n"
|
||||||
|
class="oc-panel-card mb-3 break-inside-avoid border border-slate-200 bg-white p-3"
|
||||||
|
>
|
||||||
|
<NSkeleton class="h-44 w-full" />
|
||||||
|
<NSkeleton class="mt-4 h-4 w-3/5" />
|
||||||
|
<NSkeleton class="mt-3 h-3 w-2/5" />
|
||||||
|
<NSkeleton class="mt-2 h-3 w-4/5" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-else-if="galleryItems.length"
|
||||||
|
class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="cloud in galleryItems"
|
||||||
|
:key="cloud.id"
|
||||||
|
type="button"
|
||||||
|
class="oc-interactive-card group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left transition-transform duration-300 hover:-translate-y-1"
|
||||||
|
@click="openDetail(cloud)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="cloud.thumbnail_url || cloud.image_url"
|
||||||
|
:alt="cloud.cloudTypeName"
|
||||||
|
class="block h-auto w-full object-cover transition duration-500 group-hover:scale-[1.04]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/88 via-slate-950/45 to-transparent opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 bottom-0 p-4 text-white opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="truncate text-sm font-semibold">
|
||||||
|
{{ cloud.cloudTypeName }}
|
||||||
|
</p>
|
||||||
|
<NTag
|
||||||
|
size="small"
|
||||||
|
:bordered="false"
|
||||||
|
class="shrink-0 bg-white/12 text-white backdrop-blur"
|
||||||
|
>
|
||||||
|
稀有度 {{ cloud.cloudTypeRarity }}
|
||||||
|
</NTag>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="mt-2 flex items-center gap-1.5 truncate text-xs text-white/82"
|
||||||
|
>
|
||||||
|
<NIcon size="14">
|
||||||
|
<User />
|
||||||
|
</NIcon>
|
||||||
|
<span class="truncate">{{ cloud.username }}</span>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82"
|
||||||
|
>
|
||||||
|
<NIcon size="14">
|
||||||
|
<Clock />
|
||||||
|
</NIcon>
|
||||||
|
<span class="truncate">{{ formatUploadTime(cloud) }}</span>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82"
|
||||||
|
>
|
||||||
|
<NIcon size="14">
|
||||||
|
<Location />
|
||||||
|
</NIcon>
|
||||||
|
<span class="truncate"
|
||||||
|
>{{ formatCoordinate(cloud.latitude) }},
|
||||||
|
{{ formatCoordinate(cloud.longitude) }}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-else
|
||||||
|
class="oc-empty-card mt-6 border border-dashed border-slate-300 bg-white px-6 py-12"
|
||||||
|
>
|
||||||
|
<NEmpty description="还没有符合条件的云图">
|
||||||
|
<template #extra>
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
换个云型筛选试试,或者等社区上传更多作品。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</NEmpty>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="currentPage > 1 || hasNextPage"
|
||||||
|
class="mt-8 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="goToPage(currentPage - 1)"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</NButton>
|
||||||
|
|
||||||
|
<span class="border border-slate-200 bg-white px-4 py-2 text-sm text-slate-600"
|
||||||
|
>第 {{ currentPage }} 页</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
:disabled="!hasNextPage"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="goToPage(currentPage + 1)"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageDetailModal
|
||||||
|
v-if="selectedCloud"
|
||||||
|
:open="!!selectedCloud"
|
||||||
|
:image-url="selectedCloud.image_url"
|
||||||
|
:thumbnail-url="selectedCloud.thumbnail_url"
|
||||||
|
:image-alt="selectedCloud.cloudTypeName"
|
||||||
|
:title="selectedCloud.cloudTypeName"
|
||||||
|
:subtitle="`上传者:${selectedCloud.username}`"
|
||||||
|
:badge-label="`稀有度 ${selectedCloud.cloudTypeRarity}`"
|
||||||
|
:badge-class="rarityChip"
|
||||||
|
@close="closeDetail"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">上传时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
{{ formatUploadTime(selectedCloud) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">拍摄时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
{{ formatCapturedTime(selectedCloud) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">位置</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">模糊化经纬度</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度
|
||||||
|
{{ formatCoordinate(selectedCloud.longitude) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 border border-slate-200 bg-white p-5">
|
||||||
|
<p class="oc-section-label">图片说明</p>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'user-profile', params: { id: selectedCloud.username } }"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral mt-5 inline-flex"
|
||||||
|
>
|
||||||
|
查看 @{{ selectedCloud.username }} 的公开主页
|
||||||
|
</RouterLink>
|
||||||
|
<MiniLocationMap
|
||||||
|
:latitude="selectedCloud.latitude"
|
||||||
|
:longitude="selectedCloud.longitude"
|
||||||
|
/>
|
||||||
|
<CloudLikeButton
|
||||||
|
:cloud-id="selectedCloud.id"
|
||||||
|
:count="selectedCloud.receivedLikeCount"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="selectedCloudIsMine" #actions>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<NDropdown
|
||||||
|
trigger="click"
|
||||||
|
placement="top-start"
|
||||||
|
:options="cloudActionOptions"
|
||||||
|
@select="handleCloudAction"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button"
|
||||||
|
title="图片管理"
|
||||||
|
aria-label="图片管理"
|
||||||
|
>
|
||||||
|
<NIcon size="20">
|
||||||
|
<Settings />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</NDropdown>
|
||||||
|
<!-- <span class="text-xs text-slate-500">这是你上传的公开图片</span> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ImageDetailModal>
|
||||||
|
|
||||||
|
<CloudEditModal
|
||||||
|
:open="editModalOpen && !!selectedCloud"
|
||||||
|
:saving="editSaving"
|
||||||
|
:error="manageError"
|
||||||
|
:cloud-types="cloudsStore.cloudTypes"
|
||||||
|
:initial-value="editInitialValue"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@save="submitEditModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { NButton, NSelect } from 'naive-ui'
|
||||||
|
|
||||||
|
export interface ContributionHeatmapCell {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthStat {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
totalCount: number
|
||||||
|
activeDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const currentYear = today.getFullYear()
|
||||||
|
const currentMonth = today.getMonth() + 1
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
cells: ContributionHeatmapCell[]
|
||||||
|
totalCount: number
|
||||||
|
activeDays: number
|
||||||
|
activeDate?: string | null
|
||||||
|
title?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
activeDate: null,
|
||||||
|
title: '上传活跃度',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
selectDate: [date: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const viewMode = ref<'year' | 'month'>('year')
|
||||||
|
const selectedYear = ref(currentYear)
|
||||||
|
const selectedMonth = ref(currentMonth)
|
||||||
|
const hoveredMonthKey = ref<string | null>(null)
|
||||||
|
|
||||||
|
function parseLocalDate(dateString: string) {
|
||||||
|
const [year, month, day] = dateString.split('-').map(Number)
|
||||||
|
return new Date(year, (month || 1) - 1, day || 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayKey(date: Date) {
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMonthKey(dateString: string) {
|
||||||
|
const date = parseLocalDate(dateString)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date: Date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfWeek(date: Date) {
|
||||||
|
const result = startOfDay(date)
|
||||||
|
const weekday = result.getDay()
|
||||||
|
const offset = weekday === 0 ? 6 : weekday - 1
|
||||||
|
result.setDate(result.getDate() - offset)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyCounts = computed(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const cell of props.cells) {
|
||||||
|
counts.set(cell.date, cell.count)
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableYears = computed(() => {
|
||||||
|
const years = new Set<number>([currentYear])
|
||||||
|
for (const cell of props.cells) {
|
||||||
|
years.add(parseLocalDate(cell.date).getFullYear())
|
||||||
|
}
|
||||||
|
return Array.from(years).sort((a, b) => b - a)
|
||||||
|
})
|
||||||
|
|
||||||
|
const yearOptions = computed(() => {
|
||||||
|
return availableYears.value.map(year => ({
|
||||||
|
label: `${year}年`,
|
||||||
|
value: year,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthOptions = Array.from({ length: 12 }, (_, index) => ({
|
||||||
|
label: `${index + 1}月`,
|
||||||
|
value: index + 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
availableYears,
|
||||||
|
years => {
|
||||||
|
if (!years.includes(selectedYear.value)) {
|
||||||
|
selectedYear.value = years[0] || currentYear
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleCells = computed(() => {
|
||||||
|
if (viewMode.value === 'year') {
|
||||||
|
const start = new Date(selectedYear.value, 0, 1)
|
||||||
|
const end = new Date(selectedYear.value, 11, 31)
|
||||||
|
const alignedStart = startOfWeek(start)
|
||||||
|
const alignedEnd = startOfWeek(end)
|
||||||
|
alignedEnd.setDate(alignedEnd.getDate() + 6)
|
||||||
|
|
||||||
|
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
|
||||||
|
const cursor = new Date(alignedStart)
|
||||||
|
|
||||||
|
while (cursor <= alignedEnd) {
|
||||||
|
const key = formatDayKey(cursor)
|
||||||
|
const inRange = cursor >= start && cursor <= end
|
||||||
|
cells.push({
|
||||||
|
date: key,
|
||||||
|
count: dailyCounts.value.get(key) || 0,
|
||||||
|
inRange,
|
||||||
|
isToday: key === formatDayKey(today),
|
||||||
|
})
|
||||||
|
cursor.setDate(cursor.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(selectedYear.value, selectedMonth.value - 1, 1)
|
||||||
|
const end = new Date(selectedYear.value, selectedMonth.value, 0)
|
||||||
|
const alignedStart = startOfWeek(start)
|
||||||
|
const alignedEnd = startOfWeek(end)
|
||||||
|
alignedEnd.setDate(alignedEnd.getDate() + 6)
|
||||||
|
|
||||||
|
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
|
||||||
|
const cursor = new Date(alignedStart)
|
||||||
|
|
||||||
|
while (cursor <= alignedEnd) {
|
||||||
|
const key = formatDayKey(cursor)
|
||||||
|
const inRange = cursor >= start && cursor <= end
|
||||||
|
cells.push({
|
||||||
|
date: key,
|
||||||
|
count: dailyCounts.value.get(key) || 0,
|
||||||
|
inRange,
|
||||||
|
isToday: key === formatDayKey(today),
|
||||||
|
})
|
||||||
|
cursor.setDate(cursor.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
})
|
||||||
|
|
||||||
|
const contributionQuartiles = computed(() => {
|
||||||
|
const counts = visibleCells.value
|
||||||
|
.filter(cell => cell.inRange && cell.count > 0)
|
||||||
|
.map(cell => cell.count)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
|
||||||
|
if (!counts.length) return [0, 0, 0] as const
|
||||||
|
|
||||||
|
const percentile = (value: number) => {
|
||||||
|
const index = Math.ceil(counts.length * value) - 1
|
||||||
|
return counts[Math.max(0, index)] || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return [percentile(0.25), percentile(0.5), percentile(0.75)] as const
|
||||||
|
})
|
||||||
|
|
||||||
|
function getLevel(count: number) {
|
||||||
|
if (count <= 0) return 0
|
||||||
|
|
||||||
|
const [firstQuartile, secondQuartile, thirdQuartile] = contributionQuartiles.value
|
||||||
|
|
||||||
|
if (count <= firstQuartile) return 1
|
||||||
|
if (count <= secondQuartile) return 2
|
||||||
|
if (count <= thirdQuartile) return 3
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellClass(cell: { count: number; inRange: boolean }) {
|
||||||
|
if (!cell.inRange) return 'bg-transparent border-transparent'
|
||||||
|
|
||||||
|
const level = getLevel(cell.count)
|
||||||
|
return `oc-heatmap-cell--level-${level}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeks = computed(() => {
|
||||||
|
const buckets: Array<typeof visibleCells.value> = []
|
||||||
|
for (let index = 0; index < visibleCells.value.length; index += 7) {
|
||||||
|
buckets.push(visibleCells.value.slice(index, index + 7))
|
||||||
|
}
|
||||||
|
return buckets
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthStats = computed(() => {
|
||||||
|
const stats = new Map<string, MonthStat>()
|
||||||
|
|
||||||
|
for (const cell of visibleCells.value) {
|
||||||
|
if (!cell.inRange) continue
|
||||||
|
const monthKey = toMonthKey(cell.date)
|
||||||
|
const date = parseLocalDate(cell.date)
|
||||||
|
const label = `${date.getMonth() + 1}月`
|
||||||
|
const existing = stats.get(monthKey)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.totalCount += cell.count
|
||||||
|
if (cell.count > 0) existing.activeDays += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.set(monthKey, {
|
||||||
|
key: monthKey,
|
||||||
|
label,
|
||||||
|
totalCount: cell.count,
|
||||||
|
activeDays: cell.count > 0 ? 1 : 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthLabels = computed(() => {
|
||||||
|
if (viewMode.value !== 'year') return []
|
||||||
|
|
||||||
|
let lastMonth = -1
|
||||||
|
return weeks.value.map(week => {
|
||||||
|
const firstInRange = week.find(cell => cell.inRange)
|
||||||
|
if (!firstInRange) return null
|
||||||
|
|
||||||
|
const date = parseLocalDate(firstInRange.date)
|
||||||
|
const month = date.getMonth()
|
||||||
|
const labelMonth = month + 1
|
||||||
|
const monthKey = toMonthKey(firstInRange.date)
|
||||||
|
|
||||||
|
if (month === lastMonth) return null
|
||||||
|
|
||||||
|
lastMonth = month
|
||||||
|
return {
|
||||||
|
key: monthKey,
|
||||||
|
label: labelMonth === 1 ? `${selectedYear.value}/1月` : `${labelMonth}月`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthPreview = computed(() => {
|
||||||
|
if (!hoveredMonthKey.value) return null
|
||||||
|
return monthStats.value.get(hoveredMonthKey.value) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const panelSummary = computed(() => {
|
||||||
|
if (viewMode.value === 'year') {
|
||||||
|
return `${selectedYear.value} 年完整视图,包含尚未来到的日期。`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${selectedYear.value} 年 ${selectedMonth.value} 月完整视图。`
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTooltip(cell: ContributionHeatmapCell & { inRange: boolean; isToday: boolean }) {
|
||||||
|
const date = parseLocalDate(cell.date)
|
||||||
|
const label = date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!cell.inRange) {
|
||||||
|
return `${label}:不在当前视图范围内`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.count === 0) {
|
||||||
|
return `${label}${cell.isToday ? '(今天)' : ''}:当天没有上传`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${label}${cell.isToday ? '(今天)' : ''}:上传 ${cell.count} 张`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCellClick(cell: ContributionHeatmapCell & { inRange: boolean }) {
|
||||||
|
if (!cell.inRange) return
|
||||||
|
emit('selectDate', cell.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthTooltip(monthKey: string) {
|
||||||
|
const stats = monthStats.value.get(monthKey)
|
||||||
|
if (!stats) return ''
|
||||||
|
return `${stats.label}:共上传 ${stats.totalCount} 张,活跃于 ${stats.activeDays} 天`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="oc-panel-card border border-slate-200 bg-white p-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-6">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-slate-500">
|
||||||
|
共上传 {{ totalCount }} 张,活跃于
|
||||||
|
{{ activeDays }} 天。颜色越深,代表当天上传的照片越多。
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">{{ panelSummary }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
:class="
|
||||||
|
viewMode === 'year'
|
||||||
|
? 'oc-panel-button oc-panel-button--teal'
|
||||||
|
: 'oc-panel-button oc-panel-button--neutral'
|
||||||
|
"
|
||||||
|
@click="viewMode = 'year'"
|
||||||
|
>
|
||||||
|
年度视图
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
:class="
|
||||||
|
viewMode === 'month'
|
||||||
|
? 'oc-panel-button oc-panel-button--teal'
|
||||||
|
: 'oc-panel-button oc-panel-button--neutral'
|
||||||
|
"
|
||||||
|
@click="viewMode = 'month'"
|
||||||
|
>
|
||||||
|
月度视图
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<NSelect v-model:value="selectedYear" :options="yearOptions" class="w-28" />
|
||||||
|
<NSelect
|
||||||
|
v-if="viewMode === 'month'"
|
||||||
|
v-model:value="selectedMonth"
|
||||||
|
:options="monthOptions"
|
||||||
|
class="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex min-h-6 items-center justify-between gap-4 text-xs text-slate-500">
|
||||||
|
<span>点击某一天可筛选当天上传记录。</span>
|
||||||
|
<span v-if="monthPreview" class="text-right">
|
||||||
|
{{ monthPreview.label }}:{{ monthPreview.totalCount }} 张 /
|
||||||
|
{{ monthPreview.activeDays }} 天
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-right">
|
||||||
|
<template v-if="viewMode === 'year'">悬停月份标签可预览当月上传摘要。</template>
|
||||||
|
<template v-else>当前显示整个月的上传情况。</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 overflow-x-auto">
|
||||||
|
<div :class="viewMode === 'year' ? 'min-w-[980px]' : 'min-w-[360px]'">
|
||||||
|
<div
|
||||||
|
v-if="viewMode === 'year'"
|
||||||
|
class="mb-2 grid grid-flow-col auto-cols-[14px] gap-1 pl-8 text-[11px] text-slate-400"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(month, index) in monthLabels"
|
||||||
|
:key="`${month?.key || 'empty'}-${index}`"
|
||||||
|
class="w-[14px] whitespace-nowrap"
|
||||||
|
:title="month ? formatMonthTooltip(month.key) : ''"
|
||||||
|
@mouseenter="hoveredMonthKey = month?.key || null"
|
||||||
|
@mouseleave="hoveredMonthKey = null"
|
||||||
|
>
|
||||||
|
{{ month?.label || '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="grid grid-rows-7 gap-1 pt-[2px] text-[11px] text-slate-400">
|
||||||
|
<span>一</span>
|
||||||
|
<span></span>
|
||||||
|
<span>三</span>
|
||||||
|
<span></span>
|
||||||
|
<span>五</span>
|
||||||
|
<span></span>
|
||||||
|
<span>日</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-flow-col auto-cols-[14px] gap-1">
|
||||||
|
<div
|
||||||
|
v-for="(week, weekIndex) in weeks"
|
||||||
|
:key="weekIndex"
|
||||||
|
class="grid grid-rows-7 gap-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="cell in week"
|
||||||
|
:key="cell.date"
|
||||||
|
type="button"
|
||||||
|
class="oc-heatmap-cell relative h-3.5 w-3.5 border hover:scale-125"
|
||||||
|
:class="[
|
||||||
|
getCellClass(cell),
|
||||||
|
props.activeDate === cell.date
|
||||||
|
? 'ring-2 ring-slate-900 ring-offset-1'
|
||||||
|
: '',
|
||||||
|
cell.isToday && cell.inRange
|
||||||
|
? 'outline outline-1 outline-slate-900 outline-offset-1'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
:title="formatTooltip(cell)"
|
||||||
|
@click="handleCellClick(cell)"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
|
||||||
|
<span>少</span>
|
||||||
|
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-0"></span>
|
||||||
|
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-1"></span>
|
||||||
|
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-2"></span>
|
||||||
|
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-3"></span>
|
||||||
|
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-4"></span>
|
||||||
|
<span>多</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.oc-heatmap-cell,
|
||||||
|
.oc-heatmap-legend-cell {
|
||||||
|
transition:
|
||||||
|
background-color var(--oc-duration-fast) var(--oc-ease-standard),
|
||||||
|
border-color var(--oc-duration-fast) var(--oc-ease-standard),
|
||||||
|
transform var(--oc-duration-fast) var(--oc-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-heatmap-legend-cell {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-heatmap-cell--level-0 {
|
||||||
|
background: var(--oc-heatmap-empty);
|
||||||
|
border-color: var(--oc-heatmap-empty-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-heatmap-cell--level-1 {
|
||||||
|
background: var(--oc-heatmap-level-1);
|
||||||
|
border-color: var(--oc-heatmap-level-1-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-heatmap-cell--level-2 {
|
||||||
|
background: var(--oc-heatmap-level-2);
|
||||||
|
border-color: var(--oc-heatmap-level-2-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-heatmap-cell--level-3 {
|
||||||
|
background: var(--oc-heatmap-level-3);
|
||||||
|
border-color: var(--oc-heatmap-level-3-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-heatmap-cell--level-4 {
|
||||||
|
background: var(--oc-heatmap-level-4);
|
||||||
|
border-color: var(--oc-heatmap-level-4-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<Record<string, Profile>>({})
|
||||||
|
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
||||||
|
const loadingKeys = ref<Record<string, boolean>>({})
|
||||||
|
const errorByKey = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
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<BackendCloudInfo[]>(`/cloud?${params}`, { auth: false })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 404) return rows
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
rows.push(...current)
|
||||||
|
if (current.length < 100) return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProfilePage(identifier: string, isOwnProfile: boolean, force = false) {
|
||||||
|
const key = makeKey(identifier, isOwnProfile)
|
||||||
|
if (!force && key in cloudsByKey.value && profilesByKey.value[identifier]) return
|
||||||
|
|
||||||
|
loadingKeys.value[key] = true
|
||||||
|
errorByKey.value[key] = ''
|
||||||
|
try {
|
||||||
|
const backendRows = isOwnProfile
|
||||||
|
? await apiRequest<BackendCloudInfo[]>('/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<BackendUserProfile>(`/profile/${owner.id}`)
|
||||||
|
} catch {
|
||||||
|
// Public clouds can still be displayed if the profile lookup fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profilesByKey.value[identifier] = {
|
||||||
|
id: owner?.id || identifier,
|
||||||
|
username: owner?.name || identifier,
|
||||||
|
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<ProfileCloudItem>,
|
||||||
|
) {
|
||||||
|
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<ActionResponse>(`/cloud/${cloudId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: patch,
|
||||||
|
})
|
||||||
|
const updated = toProfileCloud(
|
||||||
|
toApiCloud(await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`)),
|
||||||
|
)
|
||||||
|
patchCachedCloud(identifier, cloudId, updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) {
|
||||||
|
await apiRequest<ActionResponse>(`/cloud/${cloudId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { is_hidden: isHidden },
|
||||||
|
})
|
||||||
|
patchCachedCloud(identifier, cloudId, { is_hidden: isHidden })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClouds(identifier: string, cloudIds: string[]) {
|
||||||
|
if (!cloudIds.length) return 0
|
||||||
|
|
||||||
|
if (cloudIds.length === 1) {
|
||||||
|
await apiRequest<ActionResponse>(`/cloud/${cloudIds[0]}`, { method: 'DELETE' })
|
||||||
|
} else {
|
||||||
|
for (let offset = 0; offset < cloudIds.length; offset += 100) {
|
||||||
|
await apiRequest<ActionResponse>('/cloud', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: {
|
||||||
|
cloud_ids: cloudIds
|
||||||
|
.slice(offset, offset + 100)
|
||||||
|
.map(cloudId => ({ cloud_id: cloudId })),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeCachedClouds(identifier, cloudIds)
|
||||||
|
return cloudIds.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getProfile,
|
||||||
|
getClouds,
|
||||||
|
getError,
|
||||||
|
isLoaded,
|
||||||
|
isLoading,
|
||||||
|
fetchProfilePage,
|
||||||
|
invalidateUser,
|
||||||
|
updateCloud,
|
||||||
|
updateCloudVisibility,
|
||||||
|
deleteClouds,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const usernameDraft = ref('')
|
||||||
|
const currentPassword = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const confirmNewPassword = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
const usernameSaving = ref(false)
|
||||||
|
const passwordSaving = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
usernameDraft.value = authStore.profile?.username || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveUsername() {
|
||||||
|
const nextUsername = usernameDraft.value.trim()
|
||||||
|
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
|
||||||
|
if (!nextUsername) {
|
||||||
|
error.value = '昵称不能为空。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextUsername.length > 16) {
|
||||||
|
error.value = '昵称不能超过 16 个字符。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextUsername === authStore.profile?.username) {
|
||||||
|
success.value = '昵称没有变化。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameSaving.value = true
|
||||||
|
try {
|
||||||
|
await authStore.updateUsername(nextUsername)
|
||||||
|
success.value = '昵称已更新。'
|
||||||
|
message.success('昵称已更新')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '昵称更新失败'
|
||||||
|
} finally {
|
||||||
|
usernameSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePassword() {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
|
||||||
|
if (newPassword.value.length < 8) {
|
||||||
|
error.value = '新密码至少需要 8 位。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword.value !== confirmNewPassword.value) {
|
||||||
|
error.value = '两次输入的新密码不一致。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!currentPassword.value) {
|
||||||
|
error.value = '请输入当前密码。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordSaving.value = true
|
||||||
|
try {
|
||||||
|
await authStore.updatePassword(currentPassword.value, newPassword.value)
|
||||||
|
currentPassword.value = ''
|
||||||
|
newPassword.value = ''
|
||||||
|
confirmNewPassword.value = ''
|
||||||
|
success.value = '密码已更新。其他设备上的会话已退出。'
|
||||||
|
message.success('密码已更新')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '密码更新失败'
|
||||||
|
} finally {
|
||||||
|
passwordSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] py-10"
|
||||||
|
>
|
||||||
|
<div class="oc-container oc-container--content">
|
||||||
|
<RouterLink to="/profile">
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
>← 返回个人主页</NButton
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<p class="oc-page-eyebrow">Account Settings</p>
|
||||||
|
<h1 class="oc-page-title">个人资料设置</h1>
|
||||||
|
<p class="oc-page-description">
|
||||||
|
修改公开昵称和登录密码。昵称会显示在画廊、百科和地图中,且不能与其他用户重复。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-6">
|
||||||
|
<NAlert v-if="error" type="error" :show-icon="false" :bordered="false">
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
<NAlert v-if="success" type="success" :show-icon="false" :bordered="false">
|
||||||
|
{{ success }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
|
||||||
|
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900">公开昵称</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
用于公开展示你的上传记录。保存前会检查是否与其他用户重复。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<NInput
|
||||||
|
v-model:value="usernameDraft"
|
||||||
|
placeholder="输入新的昵称"
|
||||||
|
maxlength="16"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
:loading="usernameSaving"
|
||||||
|
@click="saveUsername"
|
||||||
|
>
|
||||||
|
保存昵称
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
|
||||||
|
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NForm class="grid gap-3" @submit.prevent="savePassword">
|
||||||
|
<input
|
||||||
|
:value="authStore.user?.email || ''"
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
autocomplete="username"
|
||||||
|
class="sr-only"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label="账户邮箱"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<NInput
|
||||||
|
v-model:value="currentPassword"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'current-password',
|
||||||
|
name: 'current-password',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
placeholder="当前密码"
|
||||||
|
/>
|
||||||
|
<NInput
|
||||||
|
v-model:value="newPassword"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'new-password',
|
||||||
|
name: 'new-password',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
placeholder="新密码"
|
||||||
|
/>
|
||||||
|
<NInput
|
||||||
|
v-model:value="confirmNewPassword"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
:input-props="{
|
||||||
|
autocomplete: 'new-password',
|
||||||
|
name: 'confirm-new-password',
|
||||||
|
required: true,
|
||||||
|
}"
|
||||||
|
placeholder="确认新密码"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
attr-type="submit"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
:loading="passwordSaving"
|
||||||
|
>
|
||||||
|
保存密码
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</NForm>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,937 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
|
||||||
|
import { Settings } from '@vicons/tabler'
|
||||||
|
import CloudEditModal, {
|
||||||
|
type CloudEditFormValue,
|
||||||
|
} from '@/features/clouds/components/CloudEditModal.vue'
|
||||||
|
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
|
||||||
|
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
|
||||||
|
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
|
||||||
|
import ContributionHeatmap from '@/features/profile/components/ContributionHeatmap.vue'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
|
import { useCloudsStore } from '@/features/clouds/stores/clouds'
|
||||||
|
import { useProfileStore, type ProfileCloudItem } from '@/features/profile/stores/profile'
|
||||||
|
import type { Profile } from '@/types/database'
|
||||||
|
|
||||||
|
interface TimelineGroup {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
items: ProfileCloudItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const cloudsStore = useCloudsStore()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const selectedCloud = ref<ProfileCloudItem | null>(null)
|
||||||
|
const selectedUploadDate = ref<string | null>(null)
|
||||||
|
const timelineSectionRef = ref<HTMLElement | null>(null)
|
||||||
|
const selectionMode = ref(false)
|
||||||
|
const selectedCloudIds = ref<Set<string>>(new Set())
|
||||||
|
const editModalOpen = ref(false)
|
||||||
|
const editSaving = ref(false)
|
||||||
|
const manageError = ref('')
|
||||||
|
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
||||||
|
|
||||||
|
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
|
||||||
|
|
||||||
|
const statusMeta = {
|
||||||
|
approved: { label: '已通过', chip: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
|
||||||
|
pending: { label: '待审核', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
|
||||||
|
rejected: { label: '未通过', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
||||||
|
} satisfies Record<ProfileCloudItem['status'], { label: string; chip: string }>
|
||||||
|
|
||||||
|
const viewedIdentifier = computed(() => {
|
||||||
|
const routeUsername = typeof route.params.id === 'string' ? route.params.id : null
|
||||||
|
return routeUsername || authStore.user?.id || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOwnProfile = computed(() => typeof route.params.id !== 'string')
|
||||||
|
|
||||||
|
const loading = computed(() => profileStore.isLoading(viewedIdentifier.value, isOwnProfile.value))
|
||||||
|
const errorMsg = computed(() => {
|
||||||
|
if (!viewedIdentifier.value) return '未找到要查看的用户。'
|
||||||
|
return profileStore.getError(viewedIdentifier.value, isOwnProfile.value)
|
||||||
|
})
|
||||||
|
const profileData = computed<Profile | null>(() =>
|
||||||
|
isOwnProfile.value ? authStore.profile : profileStore.getProfile(viewedIdentifier.value),
|
||||||
|
)
|
||||||
|
const profileImageFailed = ref(false)
|
||||||
|
watch(
|
||||||
|
() => profileData.value?.image,
|
||||||
|
() => {
|
||||||
|
profileImageFailed.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const clouds = computed<ProfileCloudItem[]>(() =>
|
||||||
|
profileStore.getClouds(viewedIdentifier.value, isOwnProfile.value),
|
||||||
|
)
|
||||||
|
const receivedLikes = computed(() =>
|
||||||
|
isOwnProfile.value ? authStore.user?.received_like_count || 0 : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
return isOwnProfile.value
|
||||||
|
? '我的天空日志'
|
||||||
|
: `${profileData.value?.username || '这位用户'}的天空日志`
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalShots = computed(() => clouds.value.length)
|
||||||
|
|
||||||
|
const approvedShots = computed(() => {
|
||||||
|
return clouds.value.filter(item => item.status === 'approved').length
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedCloudCount = computed(() => selectedCloudIds.value.size)
|
||||||
|
|
||||||
|
const cloudActionOptions = computed(() => {
|
||||||
|
if (!selectedCloud.value) return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: '编辑', key: 'edit' },
|
||||||
|
{ label: '删除', key: 'delete' },
|
||||||
|
{
|
||||||
|
label: selectedCloud.value.is_hidden ? '更改为公开' : '更改为私密',
|
||||||
|
key: 'toggle-privacy',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const shootingDays = computed(() => {
|
||||||
|
return new Set(clouds.value.map(item => toDayKey(item.captured_at || item.created_at))).size
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadDayCounts = computed(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const item of clouds.value) {
|
||||||
|
const key = toDayKey(item.created_at)
|
||||||
|
counts.set(key, (counts.get(key) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
const mostCommonCloudType = computed(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const item of clouds.value) {
|
||||||
|
counts.set(item.cloudTypeName, (counts.get(item.cloudTypeName) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let winner = '还没有记录'
|
||||||
|
let maxCount = 0
|
||||||
|
for (const [name, count] of counts.entries()) {
|
||||||
|
if (count > maxCount) {
|
||||||
|
winner = name
|
||||||
|
maxCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: winner,
|
||||||
|
count: maxCount,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredClouds = computed(() => {
|
||||||
|
if (!selectedUploadDate.value) return clouds.value
|
||||||
|
|
||||||
|
return clouds.value.filter(item => toDayKey(item.created_at) === selectedUploadDate.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const timelineGroups = computed<TimelineGroup[]>(() => {
|
||||||
|
const groups = new Map<string, TimelineGroup>()
|
||||||
|
|
||||||
|
for (const item of filteredClouds.value) {
|
||||||
|
const date = new Date(item.captured_at || item.created_at)
|
||||||
|
const key = `${date.getFullYear()}-${date.getMonth() + 1}`
|
||||||
|
const existing = groups.get(key)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.items.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.set(key, {
|
||||||
|
key,
|
||||||
|
label: `${date.getFullYear()}年${date.getMonth() + 1}月`,
|
||||||
|
items: [item],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.values())
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedUploadCount = computed(() => {
|
||||||
|
if (!selectedUploadDate.value) return 0
|
||||||
|
return uploadDayCounts.value.get(selectedUploadDate.value) || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const contributionCells = computed(() => {
|
||||||
|
return Array.from(uploadDayCounts.value.entries())
|
||||||
|
.map(([date, count]) => ({ date, count }))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeUploadDays = computed(() => {
|
||||||
|
return contributionCells.value.filter(cell => cell.count > 0).length
|
||||||
|
})
|
||||||
|
|
||||||
|
function toDayKey(iso: string) {
|
||||||
|
const date = new Date(iso)
|
||||||
|
return toLocalDayKey(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDayKey(date: Date) {
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null) {
|
||||||
|
if (!iso) return '未知时间'
|
||||||
|
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayKey(dayKey: string | null) {
|
||||||
|
if (!dayKey) return ''
|
||||||
|
const [year, month, day] = dayKey.split('-').map(Number)
|
||||||
|
return new Date(year, (month || 1) - 1, day || 1).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null) {
|
||||||
|
if (!iso) return '未知时间'
|
||||||
|
return new Date(iso).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value: number | null) {
|
||||||
|
return value === null ? '未记录' : value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCloudTime(item: ProfileCloudItem) {
|
||||||
|
return item.captured_at
|
||||||
|
? `拍摄于 ${formatDateTime(item.captured_at)}`
|
||||||
|
: `上传于 ${formatDateTime(item.created_at)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfileInitial(profile: Profile | null) {
|
||||||
|
return profile?.username?.slice(0, 1).toUpperCase() || '云'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectedUploadDate(date: string) {
|
||||||
|
selectedUploadDate.value = selectedUploadDate.value === date ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelectedUploadDate() {
|
||||||
|
selectedUploadDate.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedCloudIds(nextIds: Iterable<string>) {
|
||||||
|
selectedCloudIds.value = new Set(nextIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCloudSelection(cloudId: string, checked: boolean) {
|
||||||
|
const nextIds = new Set(selectedCloudIds.value)
|
||||||
|
if (checked) {
|
||||||
|
nextIds.add(cloudId)
|
||||||
|
} else {
|
||||||
|
nextIds.delete(cloudId)
|
||||||
|
}
|
||||||
|
setSelectedCloudIds(nextIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCloudSelection() {
|
||||||
|
setSelectedCloudIds([])
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectionMode() {
|
||||||
|
selectionMode.value = !selectionMode.value
|
||||||
|
if (!selectionMode.value) {
|
||||||
|
clearCloudSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDatetimeLocal(iso: string | null) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const date = new Date(iso)
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(cloud: ProfileCloudItem) {
|
||||||
|
manageError.value = ''
|
||||||
|
editInitialValue.value = {
|
||||||
|
cloudCategoryId: cloud.cloud_type_id,
|
||||||
|
capturedAt: formatDatetimeLocal(cloud.captured_at),
|
||||||
|
latitude: cloud.latitude,
|
||||||
|
longitude: cloud.longitude,
|
||||||
|
description: cloud.description || '',
|
||||||
|
isPublic: !cloud.is_hidden,
|
||||||
|
}
|
||||||
|
editModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
editModalOpen.value = false
|
||||||
|
editSaving.value = false
|
||||||
|
manageError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurCoordinate(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditModal(value: CloudEditFormValue) {
|
||||||
|
if (!selectedCloud.value || !viewedIdentifier.value) return
|
||||||
|
|
||||||
|
if (!value.cloudCategoryId) {
|
||||||
|
manageError.value = '请选择云型类别。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
||||||
|
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
||||||
|
if (!hasLat || !hasLng) {
|
||||||
|
manageError.value = '经纬度为必填项。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (value.description.trim().length > 128) {
|
||||||
|
manageError.value = '图片描述不能超过 128 个字符。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editSaving.value = true
|
||||||
|
manageError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await profileStore.updateCloud(
|
||||||
|
viewedIdentifier.value,
|
||||||
|
selectedCloud.value.id,
|
||||||
|
{
|
||||||
|
type_id: value.cloudCategoryId,
|
||||||
|
latitude: blurCoordinate(value.latitude as number),
|
||||||
|
longitude: blurCoordinate(value.longitude as number),
|
||||||
|
description: value.description.trim(),
|
||||||
|
...(value.capturedAt
|
||||||
|
? { captured_at: new Date(value.capturedAt).toISOString() }
|
||||||
|
: {}),
|
||||||
|
is_hidden: !value.isPublic,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
selectedCloud.value = updated
|
||||||
|
closeEditModal()
|
||||||
|
} catch (error) {
|
||||||
|
manageError.value = error instanceof Error ? error.message : '图片信息更新失败'
|
||||||
|
} finally {
|
||||||
|
editSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSelectedCloudPrivacy() {
|
||||||
|
if (!selectedCloud.value || !viewedIdentifier.value) return
|
||||||
|
|
||||||
|
manageError.value = ''
|
||||||
|
const nextHidden = !selectedCloud.value.is_hidden
|
||||||
|
|
||||||
|
try {
|
||||||
|
await profileStore.updateCloudVisibility(
|
||||||
|
viewedIdentifier.value,
|
||||||
|
selectedCloud.value.id,
|
||||||
|
nextHidden,
|
||||||
|
)
|
||||||
|
selectedCloud.value = { ...selectedCloud.value, is_hidden: nextHidden }
|
||||||
|
} catch (error) {
|
||||||
|
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCloudByIds(cloudIds: string[]) {
|
||||||
|
if (!viewedIdentifier.value || !cloudIds.length) return
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`确定删除 ${cloudIds.length} 张图片吗?删除后无法在页面中恢复。`,
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
manageError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletedCount = await profileStore.deleteClouds(viewedIdentifier.value, cloudIds)
|
||||||
|
if (selectedCloud.value && cloudIds.includes(selectedCloud.value.id)) {
|
||||||
|
selectedCloud.value = null
|
||||||
|
closeEditModal()
|
||||||
|
}
|
||||||
|
const nextSelected = new Set(selectedCloudIds.value)
|
||||||
|
for (const cloudId of cloudIds) nextSelected.delete(cloudId)
|
||||||
|
setSelectedCloudIds(nextSelected)
|
||||||
|
message.success(`已成功删除 ${deletedCount} 张照片`)
|
||||||
|
} catch (error) {
|
||||||
|
manageError.value = error instanceof Error ? error.message : '图片删除失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloudAction(key: string | number) {
|
||||||
|
if (!selectedCloud.value) return
|
||||||
|
if (key === 'edit') openEditModal(selectedCloud.value)
|
||||||
|
if (key === 'delete') deleteCloudByIds([selectedCloud.value.id])
|
||||||
|
if (key === 'toggle-privacy') toggleSelectedCloudPrivacy()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfilePage(force = false) {
|
||||||
|
selectedCloud.value = null
|
||||||
|
selectedUploadDate.value = null
|
||||||
|
clearCloudSelection()
|
||||||
|
selectionMode.value = false
|
||||||
|
|
||||||
|
const targetIdentifier = viewedIdentifier.value
|
||||||
|
|
||||||
|
if (!targetIdentifier) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await profileStore.fetchProfilePage(targetIdentifier, isOwnProfile.value, force)
|
||||||
|
|
||||||
|
if (isOwnProfile.value) {
|
||||||
|
await cloudsStore.fetchCloudTypes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.params.id, authStore.user?.id],
|
||||||
|
() => {
|
||||||
|
loadProfilePage()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(selectedUploadDate, async newValue => {
|
||||||
|
if (!newValue) return
|
||||||
|
await nextTick()
|
||||||
|
timelineSectionRef.value?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="oc-page-hero">
|
||||||
|
<div class="oc-container oc-container--content oc-page-hero__inner">
|
||||||
|
<div v-if="loading" class="oc-page-hero__split">
|
||||||
|
<NCard class="oc-panel-card h-full border border-slate-200" :bordered="false">
|
||||||
|
<NSkeleton class="h-6 w-1/3" />
|
||||||
|
<NSkeleton class="mt-4 h-10 w-2/3" />
|
||||||
|
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
|
||||||
|
</NCard>
|
||||||
|
<div
|
||||||
|
v-if="isOwnProfile"
|
||||||
|
class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2"
|
||||||
|
>
|
||||||
|
<NCard
|
||||||
|
v-for="n in 4"
|
||||||
|
:key="n"
|
||||||
|
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<NSkeleton class="h-3 w-2/3" />
|
||||||
|
<NSkeleton class="mt-3 h-7 w-1/2" />
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NCard
|
||||||
|
v-else
|
||||||
|
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<NSkeleton class="h-5 w-1/3" />
|
||||||
|
<NSkeleton class="mt-4 h-8 w-1/2" />
|
||||||
|
<NSkeleton class="mt-5 h-3 w-full" />
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="oc-page-hero__split oc-page-hero__split--stretch">
|
||||||
|
<NCard
|
||||||
|
class="oc-panel-card h-full border border-slate-200 bg-white/90 backdrop-blur"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<div class="flex gap-5">
|
||||||
|
<div
|
||||||
|
class="profile-avatar flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-3xl font-bold text-slate-900"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="profileData?.image && !profileImageFailed"
|
||||||
|
:src="profileData.image"
|
||||||
|
:alt="profileData.username"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
@error="profileImageFailed = true"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
{{ getProfileInitial(profileData) }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="oc-page-eyebrow">Sky Journal</p>
|
||||||
|
<h1 class="oc-page-title">
|
||||||
|
{{ pageTitle }}
|
||||||
|
</h1>
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<p class="text-lg font-medium text-slate-700">
|
||||||
|
@{{ profileData?.username || '未知用户' }}
|
||||||
|
</p>
|
||||||
|
<RouterLink v-if="isOwnProfile" to="/profile/settings">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button oc-icon-button--small"
|
||||||
|
title="个人资料设置"
|
||||||
|
aria-label="个人资料设置"
|
||||||
|
>
|
||||||
|
<NIcon size="17">
|
||||||
|
<Settings />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
<span v-if="isOwnProfile"
|
||||||
|
>注册于
|
||||||
|
{{ formatDate(profileData?.created_at || null) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isOwnProfile"
|
||||||
|
class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2"
|
||||||
|
>
|
||||||
|
<NCard
|
||||||
|
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<p class="oc-stat-label">总拍摄数</p>
|
||||||
|
<p class="oc-stat-value">{{ totalShots }}</p>
|
||||||
|
</NCard>
|
||||||
|
<NCard
|
||||||
|
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<p class="oc-stat-label">最常拍云型</p>
|
||||||
|
<p class="mt-2 truncate text-xl font-bold text-slate-900">
|
||||||
|
{{ mostCommonCloudType.name }}
|
||||||
|
</p>
|
||||||
|
</NCard>
|
||||||
|
<NCard
|
||||||
|
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<p class="oc-stat-label">拍摄天数</p>
|
||||||
|
<p class="oc-stat-value">{{ shootingDays }}</p>
|
||||||
|
</NCard>
|
||||||
|
<NCard
|
||||||
|
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<p class="oc-stat-label">被点赞数</p>
|
||||||
|
<p class="oc-stat-value">{{ receivedLikes }}</p>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NCard
|
||||||
|
v-if="!isOwnProfile"
|
||||||
|
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-slate-500">公开贡献</p>
|
||||||
|
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oc-container oc-container--content oc-page-content">
|
||||||
|
<NAlert
|
||||||
|
v-if="errorMsg"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
title="个人主页加载失败"
|
||||||
|
>
|
||||||
|
{{ errorMsg }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NAlert
|
||||||
|
v-if="manageError"
|
||||||
|
class="mb-5"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
title="操作失败"
|
||||||
|
>
|
||||||
|
{{ manageError }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section ref="timelineSectionRef">
|
||||||
|
<ContributionHeatmap
|
||||||
|
:cells="contributionCells"
|
||||||
|
:total-count="totalShots"
|
||||||
|
:active-days="activeUploadDays"
|
||||||
|
:active-date="selectedUploadDate"
|
||||||
|
:title="isOwnProfile ? '上传活跃度' : '公开活跃度'"
|
||||||
|
@select-date="toggleSelectedUploadDate"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="mb-5 flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900">天空日志</h2>
|
||||||
|
<p class="oc-field-help">
|
||||||
|
按拍摄时间展示;未记录拍摄时间时回退为上传时间。热力图按上传日期计算。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<NButton
|
||||||
|
v-if="isOwnProfile && selectedCloudCount"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--danger"
|
||||||
|
@click="deleteCloudByIds(Array.from(selectedCloudIds))"
|
||||||
|
>
|
||||||
|
删除已选 {{ selectedCloudCount }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
v-if="isOwnProfile"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="toggleSelectionMode"
|
||||||
|
>
|
||||||
|
{{ selectionMode ? '取消选择' : '选择图片' }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="loadProfilePage(true)"
|
||||||
|
>
|
||||||
|
刷新数据
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
v-if="selectedUploadDate"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="clearSelectedUploadDate"
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</NButton>
|
||||||
|
<RouterLink
|
||||||
|
v-if="isOwnProfile"
|
||||||
|
to="/upload"
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
>
|
||||||
|
继续上传
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedUploadDate"
|
||||||
|
class="mb-5 border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800"
|
||||||
|
>
|
||||||
|
当前正在查看
|
||||||
|
<span class="font-semibold">{{ formatDayKey(selectedUploadDate) }}</span>
|
||||||
|
上传的图片, 共
|
||||||
|
<span class="font-semibold">{{ selectedUploadCount }}</span> 张。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="space-y-8">
|
||||||
|
<div v-for="n in 2" :key="n">
|
||||||
|
<NSkeleton class="h-6 w-40" />
|
||||||
|
<div class="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<NCard
|
||||||
|
v-for="m in 3"
|
||||||
|
:key="m"
|
||||||
|
class="oc-panel-card border border-slate-200"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<NSkeleton class="h-52 w-full" />
|
||||||
|
<NSkeleton class="mt-4 h-5 w-2/3" />
|
||||||
|
<NSkeleton class="mt-3 h-4 w-1/2" />
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="timelineGroups.length" class="space-y-10">
|
||||||
|
<section v-for="group in timelineGroups" :key="group.key">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<h3 class="text-xl font-bold text-slate-900">{{ group.label }}</h3>
|
||||||
|
<div class="h-px flex-1 bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<button
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="oc-interactive-card group overflow-hidden border bg-white text-left transition-all hover:-translate-y-1"
|
||||||
|
:class="
|
||||||
|
selectedUploadDate &&
|
||||||
|
toDayKey(item.created_at) === selectedUploadDate
|
||||||
|
? 'oc-interactive-card--selected'
|
||||||
|
: 'border-slate-200'
|
||||||
|
"
|
||||||
|
@click="selectedCloud = item"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<img
|
||||||
|
:src="item.thumbnail_url || item.image_url"
|
||||||
|
:alt="item.cloudTypeName"
|
||||||
|
class="h-56 w-full object-cover transition duration-500 group-hover:scale-[1.03]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/82 via-slate-950/32 to-transparent"
|
||||||
|
></div>
|
||||||
|
<div class="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-if="isOwnProfile && selectionMode"
|
||||||
|
class="inline-flex items-center border border-white/60 bg-white/90 px-2 py-1 text-xs font-medium text-slate-700"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mr-2 h-3.5 w-3.5 border-slate-300 text-sky-500 focus:ring-sky-500"
|
||||||
|
:checked="selectedCloudIds.has(item.id)"
|
||||||
|
@change="
|
||||||
|
toggleCloudSelection(
|
||||||
|
item.id,
|
||||||
|
($event.target as HTMLInputElement)
|
||||||
|
.checked,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
选择
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
class="inline-flex border px-3 py-1 text-xs font-medium"
|
||||||
|
:class="rarityChip"
|
||||||
|
>
|
||||||
|
稀有度 {{ item.cloudTypeRarity }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="isOwnProfile"
|
||||||
|
class="inline-flex border px-3 py-1 text-xs font-medium"
|
||||||
|
:class="statusMeta[item.status].chip"
|
||||||
|
>
|
||||||
|
{{ statusMeta[item.status].label }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="isOwnProfile"
|
||||||
|
class="inline-flex border px-3 py-1 text-xs font-medium"
|
||||||
|
:class="
|
||||||
|
item.is_hidden
|
||||||
|
? 'border-slate-200 bg-slate-100 text-slate-600'
|
||||||
|
: 'border-emerald-200 bg-emerald-100 text-emerald-700'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ item.is_hidden ? '私密' : '公开' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-4 left-4 right-4 text-white">
|
||||||
|
<p class="truncate text-lg font-semibold">
|
||||||
|
{{ item.cloudTypeName }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-white/80">
|
||||||
|
{{ formatCloudTime(item) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
<p class="truncate text-sm font-medium text-slate-900">
|
||||||
|
纬度 {{ formatCoordinate(item.latitude) }} / 经度
|
||||||
|
{{ formatCoordinate(item.longitude) }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500"
|
||||||
|
>
|
||||||
|
{{ item.description || '没有留下额外说明。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="oc-empty-card border border-dashed border-slate-300 bg-white p-10"
|
||||||
|
>
|
||||||
|
<NEmpty
|
||||||
|
:description="
|
||||||
|
selectedUploadDate
|
||||||
|
? '这一天没有上传记录'
|
||||||
|
: isOwnProfile
|
||||||
|
? '还没有上传任何云图'
|
||||||
|
: '该用户暂无公开云图'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<RouterLink
|
||||||
|
v-if="isOwnProfile && !selectedUploadDate"
|
||||||
|
to="/upload"
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
>
|
||||||
|
去上传第一张
|
||||||
|
</RouterLink>
|
||||||
|
<NButton
|
||||||
|
v-else-if="selectedUploadDate"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="clearSelectedUploadDate"
|
||||||
|
>返回全部记录</NButton
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</NEmpty>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageDetailModal
|
||||||
|
v-if="selectedCloud"
|
||||||
|
:open="!!selectedCloud"
|
||||||
|
:image-url="selectedCloud.image_url"
|
||||||
|
:thumbnail-url="selectedCloud.thumbnail_url"
|
||||||
|
:image-alt="selectedCloud.cloudTypeName"
|
||||||
|
:title="selectedCloud.cloudTypeName"
|
||||||
|
:subtitle="formatCloudTime(selectedCloud)"
|
||||||
|
:badge-label="`稀有度 ${selectedCloud.cloudTypeRarity}`"
|
||||||
|
:badge-class="rarityChip"
|
||||||
|
@close="selectedCloud = null"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">上传时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
{{ formatDateTime(selectedCloud.created_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">拍摄时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
{{ formatDateTime(selectedCloud.captured_at) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">位置</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">模糊化经纬度</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度
|
||||||
|
{{ formatCoordinate(selectedCloud.longitude) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">{{ isOwnProfile ? '审核状态' : '可见性' }}</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
<template v-if="isOwnProfile">
|
||||||
|
{{ statusMeta[selectedCloud.status].label }} /
|
||||||
|
{{ selectedCloud.is_hidden ? '私密' : '公开' }}
|
||||||
|
</template>
|
||||||
|
<template v-else>公开可见</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 border border-slate-200 bg-white p-5">
|
||||||
|
<p class="oc-section-label">图片说明</p>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<MiniLocationMap
|
||||||
|
:latitude="selectedCloud.latitude"
|
||||||
|
:longitude="selectedCloud.longitude"
|
||||||
|
/>
|
||||||
|
<CloudLikeButton
|
||||||
|
v-if="selectedCloud.status === 'approved' && !selectedCloud.is_hidden"
|
||||||
|
:cloud-id="selectedCloud.id"
|
||||||
|
:count="selectedCloud.receivedLikeCount"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="isOwnProfile" #actions>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<NDropdown
|
||||||
|
trigger="click"
|
||||||
|
placement="top-start"
|
||||||
|
:options="cloudActionOptions"
|
||||||
|
@select="handleCloudAction"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button"
|
||||||
|
title="图片管理"
|
||||||
|
aria-label="图片管理"
|
||||||
|
>
|
||||||
|
<NIcon size="20">
|
||||||
|
<Settings />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</NDropdown>
|
||||||
|
<!-- <span class="text-xs text-slate-500">
|
||||||
|
{{ selectedCloud.is_hidden ? '当前为私密图片' : '当前会公开展示' }}
|
||||||
|
</span> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ImageDetailModal>
|
||||||
|
|
||||||
|
<CloudEditModal
|
||||||
|
:open="editModalOpen && !!selectedCloud"
|
||||||
|
:saving="editSaving"
|
||||||
|
:error="manageError"
|
||||||
|
:cloud-types="cloudsStore.cloudTypes"
|
||||||
|
:initial-value="editInitialValue"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@save="submitEditModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-avatar {
|
||||||
|
box-shadow: var(--oc-shadow-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NButton, NIcon } from 'naive-ui'
|
||||||
|
import { Lock } from '@vicons/tabler'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const redirect = computed(() => {
|
||||||
|
const value = route.query.redirect
|
||||||
|
return typeof value === 'string' && value.startsWith('/') ? value : '/'
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (redirect.value === '/upload') {
|
||||||
|
return '上传前先登录'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '需要先登录'
|
||||||
|
})
|
||||||
|
|
||||||
|
const description = computed(() => {
|
||||||
|
if (redirect.value === '/upload') {
|
||||||
|
return '上传云图、使用地图快捷上传都需要先登录账号。登录后再回来,这里的上传入口就会开放。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '这个页面需要先登录才能继续访问。登录成功后可以返回刚才的目标页面继续操作。'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="oc-system-page">
|
||||||
|
<div class="oc-system-card">
|
||||||
|
<NIcon size="72" class="text-sky-600"><Lock /></NIcon>
|
||||||
|
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-sky-600">
|
||||||
|
401 Login Required
|
||||||
|
</p>
|
||||||
|
<h1 class="oc-page-title">{{ title }}</h1>
|
||||||
|
<p class="oc-page-description mx-auto max-w-xl">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ path: '/login', query: redirect !== '/' ? { redirect } : undefined }"
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
>前往登录</NButton
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/">
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
>返回地图</NButton
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton, NIcon } from 'naive-ui'
|
||||||
|
import { HandStop } from '@vicons/tabler'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="oc-system-page">
|
||||||
|
<div class="oc-system-card">
|
||||||
|
<NIcon size="72" class="text-rose-600"><HandStop /></NIcon>
|
||||||
|
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-rose-600">
|
||||||
|
403 Forbidden
|
||||||
|
</p>
|
||||||
|
<h1 class="oc-page-title">这里不让进</h1>
|
||||||
|
<p class="oc-page-description mx-auto max-w-xl">
|
||||||
|
你当前没有访问这个页面的权限。通常是因为这个区域只对管理员开放。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<RouterLink to="/">
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
>返回地图</NButton
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/profile">
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
>前往个人主页</NButton
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton, NIcon } from 'naive-ui'
|
||||||
|
import { Help } from '@vicons/tabler'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="oc-system-page">
|
||||||
|
<div class="oc-system-card">
|
||||||
|
<NIcon size="72" class="text-amber-600"><Help /></NIcon>
|
||||||
|
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-amber-600">
|
||||||
|
404 Not Found
|
||||||
|
</p>
|
||||||
|
<h1 class="oc-page-title">我也没想明白</h1>
|
||||||
|
<p class="oc-page-description mx-auto max-w-xl">
|
||||||
|
这个页面不存在,或者它已经被移动到了别的地方。换个入口再试一次。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<RouterLink to="/">
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
>返回地图</NButton
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/gallery">
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
>去画廊看看</NButton
|
||||||
|
>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { NAlert, NButton, NIcon, NProgress } from 'naive-ui'
|
||||||
|
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
|
||||||
|
import MapPickerModal from '@/shared/map/components/MapPickerModal.vue'
|
||||||
|
import { useCloudsStore } from '@/features/clouds/stores/clouds'
|
||||||
|
import { useUpload } from '@/features/upload/composables/useUpload'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
uploaded: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const cloudsStore = useCloudsStore()
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
uploading,
|
||||||
|
overallProgress,
|
||||||
|
currentItemIndex,
|
||||||
|
totalItems,
|
||||||
|
addFiles,
|
||||||
|
removeItem,
|
||||||
|
clearAll,
|
||||||
|
validateAll,
|
||||||
|
uploadAll,
|
||||||
|
} = useUpload()
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const activeId = ref<string | null>(null)
|
||||||
|
const dragOver = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const successMsg = ref(false)
|
||||||
|
const mapPickerOpen = ref(false)
|
||||||
|
|
||||||
|
const activeItem = computed(() => {
|
||||||
|
if (!activeId.value) return null
|
||||||
|
return items.value.find(item => item.id === activeId.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
open => {
|
||||||
|
if (open) {
|
||||||
|
cloudsStore.fetchCloudTypes()
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => items.value.length,
|
||||||
|
() => {
|
||||||
|
if (items.value.length > 0 && !activeId.value) {
|
||||||
|
activeId.value = items.value[0].id
|
||||||
|
}
|
||||||
|
if (items.value.length === 0) {
|
||||||
|
activeId.value = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function addSelectedFiles(files: File[]) {
|
||||||
|
const previousLength = items.value.length
|
||||||
|
await addFiles(files)
|
||||||
|
if (items.value.length > previousLength) {
|
||||||
|
activeId.value = items.value[previousLength].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event: DragEvent) {
|
||||||
|
dragOver.value = false
|
||||||
|
if (event.dataTransfer?.files) {
|
||||||
|
addSelectedFiles(Array.from(event.dataTransfer.files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
if (target.files?.length) {
|
||||||
|
addSelectedFiles(Array.from(target.files))
|
||||||
|
}
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(id: string) {
|
||||||
|
removeItem(id)
|
||||||
|
if (activeId.value === id) {
|
||||||
|
activeId.value = items.value[0]?.id ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCategoryChange(event: Event) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
const value = (event.target as HTMLSelectElement).value
|
||||||
|
if (value === '') {
|
||||||
|
activeItem.value.cloudCategoryId = null
|
||||||
|
} else {
|
||||||
|
activeItem.value.cloudCategoryId = Number(value)
|
||||||
|
}
|
||||||
|
activeItem.value.errors = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDatetimeLocal(iso: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const date = new Date(iso)
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCapturedAtChange(event: Event) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
const value = (event.target as HTMLInputElement).value
|
||||||
|
activeItem.value.capturedAt = value ? new Date(value).toISOString() : ''
|
||||||
|
delete activeItem.value.errors.capturedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLatInput(event: Event) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
||||||
|
activeItem.value.latitude = Number.isNaN(value) ? null : value
|
||||||
|
delete activeItem.value.errors.latitude
|
||||||
|
delete activeItem.value.errors.longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLngInput(event: Event) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
||||||
|
activeItem.value.longitude = Number.isNaN(value) ? null : value
|
||||||
|
delete activeItem.value.errors.latitude
|
||||||
|
delete activeItem.value.errors.longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePublicState(isPublic: boolean) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
activeItem.value.isHidden = !isPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMapPicker() {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
mapPickerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMapPicker() {
|
||||||
|
mapPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
activeItem.value.latitude = payload.latitude
|
||||||
|
activeItem.value.longitude = payload.longitude
|
||||||
|
delete activeItem.value.errors.latitude
|
||||||
|
delete activeItem.value.errors.longitude
|
||||||
|
closeMapPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = false
|
||||||
|
|
||||||
|
if (!validateAll()) {
|
||||||
|
const firstInvalid = items.value.find(item => Object.keys(item.errors).length > 0)
|
||||||
|
if (firstInvalid) {
|
||||||
|
activeId.value = firstInvalid.id
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await uploadAll()
|
||||||
|
if (!result.ok) {
|
||||||
|
errorMsg.value = result.errorMessage || '上传失败,请稍后重试'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
successMsg.value = true
|
||||||
|
emit('uploaded')
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (uploading.value) return
|
||||||
|
clearAll()
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = false
|
||||||
|
mapPickerOpen.value = false
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearAll()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/55 px-4 py-6 backdrop-blur-sm"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
|
||||||
|
<div class="flex items-start justify-between border-b border-slate-200 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-sky-700">
|
||||||
|
Quick Upload
|
||||||
|
</p>
|
||||||
|
<h2 class="mt-2 text-xl font-bold text-slate-950">快速上传云图</h2>
|
||||||
|
<p class="oc-field-help">从地图当前位置带入经纬度,提交后进入审核队列。</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button"
|
||||||
|
aria-label="关闭快捷上传"
|
||||||
|
title="关闭快捷上传"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<NIcon size="20"><X /></NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[72vh] overflow-y-auto px-5 py-5">
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NAlert
|
||||||
|
v-if="successMsg"
|
||||||
|
type="success"
|
||||||
|
:bordered="false"
|
||||||
|
title="已提交审核"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
图片审核通过后会出现在画廊和地图中。
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<div v-if="uploading" class="mb-4 border border-sky-100 bg-sky-50 p-4">
|
||||||
|
<div class="mb-2 flex items-center justify-between text-sm">
|
||||||
|
<span class="font-medium text-slate-700"
|
||||||
|
>正在上传 {{ currentItemIndex }} / {{ totalItems }}</span
|
||||||
|
>
|
||||||
|
<span class="font-medium text-sky-700">{{ overallProgress }}%</span>
|
||||||
|
</div>
|
||||||
|
<NProgress
|
||||||
|
type="line"
|
||||||
|
:percentage="overallProgress"
|
||||||
|
:show-indicator="false"
|
||||||
|
color="var(--oc-color-info)"
|
||||||
|
rail-color="var(--oc-color-border)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="items.length === 0"
|
||||||
|
class="flex cursor-pointer flex-col items-center justify-center border-2 border-dashed px-6 py-12 text-center transition-colors"
|
||||||
|
:class="
|
||||||
|
dragOver
|
||||||
|
? 'border-sky-400 bg-sky-50'
|
||||||
|
: 'border-slate-300 bg-slate-50 hover:border-sky-300 hover:bg-sky-50'
|
||||||
|
"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
@dragover.prevent="dragOver = true"
|
||||||
|
@dragleave.prevent="dragOver = false"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<NIcon size="42" class="text-sky-600"><CloudUpload /></NIcon>
|
||||||
|
<p class="mt-3 text-base font-semibold text-slate-800">选择一张云图</p>
|
||||||
|
<p class="oc-field-help">支持 JPEG、PNG、WebP,图片不超过 20MB。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="activeItem">
|
||||||
|
<div class="grid gap-5 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
|
<div>
|
||||||
|
<div class="overflow-hidden border border-slate-900 bg-slate-950">
|
||||||
|
<img
|
||||||
|
:src="activeItem.preview"
|
||||||
|
alt="图片预览"
|
||||||
|
class="h-56 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral flex-1"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
>
|
||||||
|
重新选择
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-panel-button oc-panel-button--danger flex-1"
|
||||||
|
@click="handleRemove(activeItem.id)"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label"
|
||||||
|
>类别 <span class="oc-field-required">*</span></label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
:value="activeItem.cloudCategoryId ?? ''"
|
||||||
|
class="oc-field-control"
|
||||||
|
@change="onCategoryChange"
|
||||||
|
>
|
||||||
|
<option value="" disabled>请选择</option>
|
||||||
|
<option
|
||||||
|
v-for="cloudType in cloudsStore.cloudTypes"
|
||||||
|
:key="cloudType.id"
|
||||||
|
:value="cloudType.id"
|
||||||
|
>
|
||||||
|
{{ cloudType.name
|
||||||
|
}}{{
|
||||||
|
cloudType.genus ? '(' + cloudType.genus + ')' : ''
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p
|
||||||
|
v-if="activeItem.errors.cloudCategory"
|
||||||
|
class="oc-field-error"
|
||||||
|
>
|
||||||
|
{{ activeItem.errors.cloudCategory }}
|
||||||
|
</p>
|
||||||
|
<p v-if="activeItem.errors.file" class="oc-field-error">
|
||||||
|
{{ activeItem.errors.file }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">拍摄时间</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
||||||
|
class="oc-field-control"
|
||||||
|
@change="onCapturedAtChange"
|
||||||
|
/>
|
||||||
|
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">
|
||||||
|
{{ activeItem.errors.capturedAt }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label"
|
||||||
|
>经纬度 <span class="oc-field-required">*</span></label
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_40px] gap-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
:value="activeItem.latitude ?? ''"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="纬度"
|
||||||
|
class="oc-field-control"
|
||||||
|
@input="onLatInput"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="activeItem.errors.latitude"
|
||||||
|
class="oc-field-error"
|
||||||
|
>
|
||||||
|
{{ activeItem.errors.latitude }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
:value="activeItem.longitude ?? ''"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="经度"
|
||||||
|
class="oc-field-control"
|
||||||
|
@input="onLngInput"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="activeItem.errors.longitude"
|
||||||
|
class="oc-field-error"
|
||||||
|
>
|
||||||
|
{{ activeItem.errors.longitude }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button oc-icon-button--sky"
|
||||||
|
title="地图选点"
|
||||||
|
aria-label="地图选点"
|
||||||
|
@click="openMapPicker"
|
||||||
|
>
|
||||||
|
<NIcon size="20">
|
||||||
|
<MapIcon />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-400">
|
||||||
|
必填,可手动输入,也可以点击右侧地图按钮选点。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">图片说明</label>
|
||||||
|
<textarea
|
||||||
|
v-model="activeItem.description"
|
||||||
|
rows="3"
|
||||||
|
maxlength="128"
|
||||||
|
placeholder="描述一下这张图片..."
|
||||||
|
class="oc-field-control resize-none"
|
||||||
|
></textarea>
|
||||||
|
<p v-if="activeItem.errors.description" class="oc-field-error">
|
||||||
|
{{ activeItem.errors.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium text-slate-800"
|
||||||
|
>公开展示</span
|
||||||
|
>
|
||||||
|
<span class="mt-1 block text-xs text-slate-500"
|
||||||
|
>关闭后不会出现在公开地图和画廊中。</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
:checked="!activeItem.isHidden"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-5 w-5 border-slate-300 text-sky-600 focus:ring-sky-500"
|
||||||
|
@change="
|
||||||
|
updatePublicState(
|
||||||
|
($event.target as HTMLInputElement).checked,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NAlert
|
||||||
|
v-if="errorMsg"
|
||||||
|
class="mt-4"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
title="上传失败"
|
||||||
|
>
|
||||||
|
{{ errorMsg }}
|
||||||
|
</NAlert>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-3 border-t border-slate-200 bg-slate-50 px-5 py-4"
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
:disabled="uploading"
|
||||||
|
@click="close"
|
||||||
|
>关闭</NButton
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
:disabled="uploading || items.length === 0"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
{{ uploading ? '上传中...' : '提交审核' }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapPickerModal
|
||||||
|
:open="mapPickerOpen"
|
||||||
|
:latitude="activeItem?.latitude ?? null"
|
||||||
|
:longitude="activeItem?.longitude ?? null"
|
||||||
|
description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
|
||||||
|
footer-text="建议点选大致拍摄位置,上传时会继续做模糊化处理。"
|
||||||
|
@close="closeMapPicker"
|
||||||
|
@confirm="confirmMapPicker"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -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<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
ok: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||||
|
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<UploadItem[]>([])
|
||||||
|
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<string, string> = {}
|
||||||
|
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<UploadResult> {
|
||||||
|
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<CloudCreateResponse>('/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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
|
import { NAlert, NButton, NCard, NIcon, NProgress, NResult } from 'naive-ui'
|
||||||
|
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useCloudsStore } from '@/features/clouds/stores/clouds'
|
||||||
|
import { useUpload } from '@/features/upload/composables/useUpload'
|
||||||
|
import MapPickerModal from '@/shared/map/components/MapPickerModal.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const cloudsStore = useCloudsStore()
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
uploading,
|
||||||
|
overallProgress,
|
||||||
|
currentItemIndex,
|
||||||
|
totalItems,
|
||||||
|
addFiles,
|
||||||
|
removeItem,
|
||||||
|
clearAll,
|
||||||
|
validateAll,
|
||||||
|
uploadAll,
|
||||||
|
} = useUpload()
|
||||||
|
|
||||||
|
const activeId = ref<string | null>(null)
|
||||||
|
const dragOver = ref(false)
|
||||||
|
const successMsg = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const mapPickerOpen = ref(false)
|
||||||
|
|
||||||
|
const activeItem = computed(() => {
|
||||||
|
if (!activeId.value) return null
|
||||||
|
return items.value.find(i => i.id === activeId.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => items.value.length,
|
||||||
|
() => {
|
||||||
|
if (items.value.length > 0 && !activeId.value) {
|
||||||
|
activeId.value = items.value[0].id
|
||||||
|
}
|
||||||
|
if (items.value.length === 0) {
|
||||||
|
activeId.value = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function selectItem(id: string) {
|
||||||
|
activeId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
dragOver.value = false
|
||||||
|
if (e.dataTransfer?.files) {
|
||||||
|
addFiles(Array.from(e.dataTransfer.files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target.files && target.files.length > 0) {
|
||||||
|
addFiles(Array.from(target.files))
|
||||||
|
}
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(id: string) {
|
||||||
|
removeItem(id)
|
||||||
|
if (activeId.value === id) {
|
||||||
|
activeId.value = items.value[0]?.id ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveCoordinates(latitude: number | null, longitude: number | null) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
|
||||||
|
activeItem.value.latitude = latitude
|
||||||
|
activeItem.value.longitude = longitude
|
||||||
|
|
||||||
|
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
||||||
|
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMapPicker() {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
|
||||||
|
mapPickerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMapPicker() {
|
||||||
|
mapPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
||||||
|
updateActiveCoordinates(payload.latitude, payload.longitude)
|
||||||
|
closeMapPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActivePublicState(isPublic: boolean) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
activeItem.value.isHidden = !isPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
errorMsg.value = ''
|
||||||
|
const allValid = validateAll()
|
||||||
|
if (!allValid) {
|
||||||
|
const firstInvalid = items.value.find(i => Object.keys(i.errors).length > 0)
|
||||||
|
if (firstInvalid) {
|
||||||
|
activeId.value = firstInvalid.id
|
||||||
|
await nextTick()
|
||||||
|
const el = document.getElementById(`field-${firstInvalid.id}-cloudCategory`)
|
||||||
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await uploadAll()
|
||||||
|
if (result.ok) {
|
||||||
|
successMsg.value = true
|
||||||
|
} else {
|
||||||
|
errorMsg.value = result.errorMessage || '上传失败,请稍后重试'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAfterSuccess() {
|
||||||
|
successMsg.value = false
|
||||||
|
errorMsg.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLatInput(e: Event) {
|
||||||
|
const val = parseFloat((e.target as HTMLInputElement).value)
|
||||||
|
if (activeItem.value) {
|
||||||
|
activeItem.value.latitude = isNaN(val) ? null : val
|
||||||
|
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
||||||
|
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLngInput(e: Event) {
|
||||||
|
const val = parseFloat((e.target as HTMLInputElement).value)
|
||||||
|
if (activeItem.value) {
|
||||||
|
activeItem.value.longitude = isNaN(val) ? null : val
|
||||||
|
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
||||||
|
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDatetimeLocal(iso: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCapturedAtChange(e: Event) {
|
||||||
|
const val = (e.target as HTMLInputElement).value
|
||||||
|
if (activeItem.value) {
|
||||||
|
activeItem.value.capturedAt = val ? new Date(val).toISOString() : ''
|
||||||
|
if (activeItem.value.errors.capturedAt) {
|
||||||
|
delete activeItem.value.errors.capturedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCategoryChange(e: Event) {
|
||||||
|
const val = (e.target as HTMLSelectElement).value
|
||||||
|
if (!activeItem.value) return
|
||||||
|
if (val === '') {
|
||||||
|
activeItem.value.cloudCategoryId = null
|
||||||
|
} else {
|
||||||
|
activeItem.value.cloudCategoryId = Number(val)
|
||||||
|
}
|
||||||
|
activeItem.value.errors = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
cloudsStore.fetchCloudTypes()
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
for (const item of items.value) URL.revokeObjectURL(item.preview)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section class="oc-page-hero">
|
||||||
|
<div class="oc-container oc-container--content oc-page-hero__inner">
|
||||||
|
<p class="oc-page-eyebrow">Cloud Upload</p>
|
||||||
|
<h1 class="oc-page-title">上传云图</h1>
|
||||||
|
<p class="oc-page-description">
|
||||||
|
批量整理你的云图记录。类别和经纬度必填;拍摄时间可留空,也可以从图片 EXIF
|
||||||
|
自动读取。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="oc-container oc-container--content oc-page-content">
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div class="w-full max-w-5xl">
|
||||||
|
<NResult status="success" title="已提交审核" />
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="resetAfterSuccess"
|
||||||
|
>继续上传</NButton
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
@click="router.push('/encyclopedia')"
|
||||||
|
>前往百科</NButton
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="router.push('/profile')"
|
||||||
|
>返回个人主页</NButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-if="uploading"
|
||||||
|
class="oc-panel-card mb-6 border border-slate-200 bg-white p-5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm font-medium text-slate-700"
|
||||||
|
>正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
|
||||||
|
</div>
|
||||||
|
<NProgress
|
||||||
|
type="line"
|
||||||
|
:show-indicator="false"
|
||||||
|
:percentage="overallProgress"
|
||||||
|
color="var(--oc-color-info)"
|
||||||
|
rail-color="var(--oc-color-border)"
|
||||||
|
:height="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="items.length === 0"
|
||||||
|
class="oc-panel-card flex cursor-pointer flex-col items-center justify-center border-2 border-dashed bg-white py-20 transition-colors"
|
||||||
|
:class="
|
||||||
|
dragOver
|
||||||
|
? 'border-sky-400 bg-sky-50'
|
||||||
|
: 'border-slate-300 hover:border-sky-400 hover:bg-slate-50'
|
||||||
|
"
|
||||||
|
@dragover.prevent="dragOver = true"
|
||||||
|
@dragleave.prevent="dragOver = false"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
>
|
||||||
|
<NIcon size="48" class="mb-4 text-sky-600"><CloudUpload /></NIcon>
|
||||||
|
<p class="mb-1 text-lg font-medium text-slate-700">点击或拖拽图片到此处</p>
|
||||||
|
<p class="text-sm text-slate-400">
|
||||||
|
支持 JPEG、PNG、WebP,单张不超过 20MB,可一次选择多张
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex min-w-0 flex-col gap-6 lg:flex-row">
|
||||||
|
<div class="w-full min-w-0 lg:w-[480px] lg:flex-shrink-0">
|
||||||
|
<div
|
||||||
|
v-if="activeItem"
|
||||||
|
class="mb-3 overflow-hidden border border-slate-900 bg-slate-900"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="activeItem.preview"
|
||||||
|
alt="预览"
|
||||||
|
class="h-64 w-full object-contain sm:h-[360px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="relative flex-shrink-0 w-16 h-16 overflow-hidden cursor-pointer border-2 transition-colors bg-white"
|
||||||
|
:class="
|
||||||
|
activeId === item.id
|
||||||
|
? 'border-sky-500'
|
||||||
|
: 'border-transparent hover:border-slate-300'
|
||||||
|
"
|
||||||
|
@click="selectItem(item.id)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="item.preview"
|
||||||
|
alt=""
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="Object.keys(item.errors).length > 0"
|
||||||
|
class="absolute top-0 right-0 w-2.5 h-2.5 bg-red-500"
|
||||||
|
></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button oc-icon-button--danger oc-icon-button--tiny absolute -right-1 -top-1"
|
||||||
|
title="移除图片"
|
||||||
|
aria-label="移除图片"
|
||||||
|
@click.stop="handleRemove(item.id)"
|
||||||
|
>
|
||||||
|
<NIcon size="12"><X /></NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex h-16 w-16 flex-shrink-0 cursor-pointer items-center justify-center border-2 border-dashed border-slate-300 bg-white transition-colors hover:border-sky-400 hover:bg-slate-50"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
>
|
||||||
|
<span class="text-xl text-slate-400">+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeItem" class="flex-1 min-w-0">
|
||||||
|
<NCard class="oc-panel-card border border-slate-200">
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">
|
||||||
|
图片信息
|
||||||
|
</h2>
|
||||||
|
<span class="text-sm text-slate-400"
|
||||||
|
>{{ items.findIndex(i => i.id === activeId) + 1 }} /
|
||||||
|
{{ items.length }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 类别 * -->
|
||||||
|
<div :id="`field-${activeItem.id}-cloudCategory`">
|
||||||
|
<label class="oc-field-label">
|
||||||
|
类别 <span class="oc-field-required">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
:value="activeItem.cloudCategoryId ?? ''"
|
||||||
|
class="oc-field-control"
|
||||||
|
@change="onCategoryChange"
|
||||||
|
>
|
||||||
|
<option value="" disabled>请选择</option>
|
||||||
|
<option
|
||||||
|
v-for="ct in cloudsStore.cloudTypes"
|
||||||
|
:key="ct.id"
|
||||||
|
:value="ct.id"
|
||||||
|
>
|
||||||
|
{{ ct.name
|
||||||
|
}}{{ ct.genus ? '(' + ct.genus + ')' : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p
|
||||||
|
v-if="activeItem.errors.cloudCategory"
|
||||||
|
class="oc-field-error"
|
||||||
|
>
|
||||||
|
{{ activeItem.errors.cloudCategory }}
|
||||||
|
</p>
|
||||||
|
<p v-if="activeItem.errors.file" class="oc-field-error">
|
||||||
|
{{ activeItem.errors.file }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 拍摄时间 -->
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">拍摄时间</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
||||||
|
class="oc-field-control"
|
||||||
|
@change="onCapturedAtChange"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="activeItem.errors.capturedAt"
|
||||||
|
class="oc-field-error"
|
||||||
|
>
|
||||||
|
{{ activeItem.errors.capturedAt }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 经纬度 * -->
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label"
|
||||||
|
>经纬度 <span class="oc-field-required">*</span></label
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
:value="
|
||||||
|
activeItem.latitude !== null
|
||||||
|
? activeItem.latitude
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="纬度(如 39.90)"
|
||||||
|
class="oc-field-control"
|
||||||
|
@input="onLatInput"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="activeItem.errors.latitude"
|
||||||
|
class="oc-field-error"
|
||||||
|
>
|
||||||
|
{{ activeItem.errors.latitude }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
:value="
|
||||||
|
activeItem.longitude !== null
|
||||||
|
? activeItem.longitude
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="经度(如 116.40)"
|
||||||
|
class="oc-field-control"
|
||||||
|
@input="onLngInput"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="activeItem.errors.longitude"
|
||||||
|
class="oc-field-error"
|
||||||
|
>
|
||||||
|
{{ activeItem.errors.longitude }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="地图选点"
|
||||||
|
aria-label="地图选点"
|
||||||
|
class="oc-icon-button oc-icon-button--sky"
|
||||||
|
@click="openMapPicker"
|
||||||
|
>
|
||||||
|
<NIcon size="20"><MapIcon /></NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-400">
|
||||||
|
必填,可手动输入,或点击右侧地图图标选择
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片描述 -->
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">图片描述</label>
|
||||||
|
<textarea
|
||||||
|
v-model="activeItem.description"
|
||||||
|
rows="3"
|
||||||
|
maxlength="128"
|
||||||
|
placeholder="描述一下这张图片..."
|
||||||
|
class="oc-field-control resize-none"
|
||||||
|
></textarea>
|
||||||
|
<p
|
||||||
|
v-if="activeItem.errors.description"
|
||||||
|
class="oc-field-error"
|
||||||
|
>
|
||||||
|
{{ activeItem.errors.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 公开状态 -->
|
||||||
|
<div class="border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-slate-800">
|
||||||
|
公开展示
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
关闭后不会出现在画廊、地图和公开主页中。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
:id="'isPublic-' + activeItem.id"
|
||||||
|
:checked="!activeItem.isHidden"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500"
|
||||||
|
@change="
|
||||||
|
updateActivePublicState(
|
||||||
|
($event.target as HTMLInputElement).checked,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-slate-500">共 {{ items.length }} 张图片</p>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
:disabled="uploading"
|
||||||
|
@click="clearAll()"
|
||||||
|
>清空</NButton
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
:disabled="uploading"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
{{ uploading ? '上传中...' : '提交上传' }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NAlert
|
||||||
|
v-if="errorMsg"
|
||||||
|
class="mt-3"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
title="上传失败"
|
||||||
|
>
|
||||||
|
{{ errorMsg }}
|
||||||
|
</NAlert>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapPickerModal
|
||||||
|
:open="mapPickerOpen"
|
||||||
|
:latitude="activeItem?.latitude ?? null"
|
||||||
|
:longitude="activeItem?.longitude ?? null"
|
||||||
|
description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
|
||||||
|
footer-text="建议点选大致拍摄位置,上传时会继续做模糊化处理。"
|
||||||
|
@close="closeMapPicker"
|
||||||
|
@confirm="confirmMapPicker"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -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',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+171
-102
@@ -1,134 +1,203 @@
|
|||||||
import type { AuthResponse } from '@/types/api'
|
import type {
|
||||||
|
ApiCloud,
|
||||||
|
AuthUser,
|
||||||
|
BackendCloudInfo,
|
||||||
|
BackendCloudType,
|
||||||
|
BackendUserProfile,
|
||||||
|
} from '@/types/api'
|
||||||
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
const DEFAULT_API_URL = 'http://localhost:8000/api/v1'
|
const DEFAULT_API_URL = 'http://localhost:3000'
|
||||||
|
|
||||||
export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
|
export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
|
||||||
export const AUTH_UPDATED_EVENT = 'opencloud:auth-updated'
|
|
||||||
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
|
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
|
||||||
|
|
||||||
let accessToken: string | null = null
|
|
||||||
let refreshPromise: Promise<AuthResponse> | null = null
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number
|
status: number
|
||||||
detail: unknown
|
detail: unknown
|
||||||
|
|
||||||
constructor(message: string, status: number, detail?: unknown) {
|
constructor(message: string, status: number, detail?: unknown) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'ApiError'
|
this.name = 'ApiError'
|
||||||
this.status = status
|
this.status = status
|
||||||
this.detail = detail
|
this.detail = detail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||||||
body?: BodyInit | Record<string, unknown> | null
|
body?: BodyInit | Record<string, unknown> | null
|
||||||
auth?: boolean
|
auth?: boolean
|
||||||
retry?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
|
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
|
||||||
return !!body
|
return (
|
||||||
&& typeof body === 'object'
|
!!body &&
|
||||||
&& !(body instanceof FormData)
|
typeof body === 'object' &&
|
||||||
&& !(body instanceof Blob)
|
!(body instanceof FormData) &&
|
||||||
&& !(body instanceof URLSearchParams)
|
!(body instanceof Blob) &&
|
||||||
&& !(body instanceof ArrayBuffer)
|
!(body instanceof URLSearchParams) &&
|
||||||
|
!(body instanceof ArrayBuffer)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailToMessage(detail: unknown, fallback: string) {
|
function validationMessage(value: unknown) {
|
||||||
if (typeof detail === 'string') return detail
|
if (typeof value !== 'string') return ''
|
||||||
if (Array.isArray(detail)) {
|
|
||||||
const messages = detail
|
try {
|
||||||
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
|
const issues = JSON.parse(value) as unknown
|
||||||
.filter(Boolean)
|
if (!Array.isArray(issues)) return value
|
||||||
if (messages.length) return messages.join(';')
|
const messages = issues
|
||||||
}
|
.map(issue =>
|
||||||
return fallback
|
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 ('detail' in payload) {
|
||||||
|
const detail = payload.detail
|
||||||
|
if (typeof detail === 'string') return detail
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
const messages = detail
|
||||||
|
.map(item =>
|
||||||
|
item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '',
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
if (messages.length) return messages.join(';')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('error' in payload) {
|
||||||
|
const error = payload.error
|
||||||
|
if (typeof error === 'string') return error
|
||||||
|
if (error && typeof error === 'object' && 'message' in error) {
|
||||||
|
return validationMessage(error.message) || fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPayload(response: Response) {
|
||||||
|
const text = await response.text()
|
||||||
|
if (!text) return undefined
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
if (!contentType.includes('json')) return text
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as unknown
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toApiError(response: Response) {
|
async function toApiError(response: Response) {
|
||||||
let detail: unknown
|
const payload = await readPayload(response)
|
||||||
try {
|
const fallback =
|
||||||
const payload = await response.json() as { detail?: unknown }
|
typeof payload === 'string' && payload.trim()
|
||||||
detail = payload.detail
|
? payload.trim()
|
||||||
} catch {
|
: `请求失败(${response.status})`
|
||||||
detail = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ApiError(
|
return new ApiError(errorMessage(payload, fallback), response.status, payload)
|
||||||
detailToMessage(detail, `请求失败(${response.status})`),
|
|
||||||
response.status,
|
|
||||||
detail,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setAccessToken(token: string | null) {
|
|
||||||
accessToken = token
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearAccessToken() {
|
|
||||||
accessToken = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||||
const { body, auth = true, retry = true, ...requestOptions } = options
|
const { body, auth = true, ...requestOptions } = options
|
||||||
const headers = new Headers(requestOptions.headers)
|
const headers = new Headers(requestOptions.headers)
|
||||||
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
||||||
|
|
||||||
if (isJsonBody(body)) {
|
if (isJsonBody(body)) {
|
||||||
headers.set('Content-Type', 'application/json')
|
headers.set('Content-Type', 'application/json')
|
||||||
requestBody = JSON.stringify(body)
|
requestBody = JSON.stringify(body)
|
||||||
}
|
|
||||||
|
|
||||||
if (auth && accessToken) {
|
|
||||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
|
||||||
...requestOptions,
|
|
||||||
body: requestBody,
|
|
||||||
headers,
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 401 && auth && accessToken && retry) {
|
|
||||||
try {
|
|
||||||
const authResponse = await refreshSession()
|
|
||||||
window.dispatchEvent(new CustomEvent<AuthResponse>(AUTH_UPDATED_EVENT, { detail: authResponse }))
|
|
||||||
return request<T>(path, { ...options, retry: false })
|
|
||||||
} catch {
|
|
||||||
clearAccessToken()
|
|
||||||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) throw await toApiError(response)
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
if (response.status === 204) return undefined as T
|
...requestOptions,
|
||||||
|
body: requestBody,
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
return response.json() as Promise<T>
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
|
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
|
||||||
return request<T>(path, options)
|
return request<T>(path, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refreshSession() {
|
export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') {
|
||||||
if (!refreshPromise) {
|
return `${API_URL}/image/${encodeURIComponent(id)}/${variant}`
|
||||||
refreshPromise = (async () => {
|
}
|
||||||
const response = await fetch(`${API_URL}/auth/refresh`, {
|
|
||||||
method: 'POST',
|
export function toAuthUser(profile: BackendUserProfile): AuthUser {
|
||||||
credentials: 'include',
|
return {
|
||||||
})
|
id: profile.id,
|
||||||
if (!response.ok) throw await toApiError(response)
|
email: profile.email,
|
||||||
|
username: profile.name,
|
||||||
const authResponse = await response.json() as AuthResponse
|
image: profile.image,
|
||||||
setAccessToken(authResponse.access_token)
|
role: profile.role,
|
||||||
return authResponse
|
is_disabled: profile.is_disabled,
|
||||||
})().finally(() => {
|
cloud_count: profile.cloud_count,
|
||||||
refreshPromise = null
|
received_like_count: profile.received_like_count,
|
||||||
})
|
created_at: profile.created_at,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return refreshPromise
|
|
||||||
|
export function toCloudType(row: BackendCloudType): CloudType {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
genus: row.genus || '',
|
||||||
|
icon_id: row.icon_id,
|
||||||
|
// Compatibility value for the excluded legacy admin view. Migrated pages
|
||||||
|
// display rarity_value directly because API.md defines no semantic mapping.
|
||||||
|
rarity: 'common',
|
||||||
|
rarity_value: row.rarity,
|
||||||
|
description: row.description,
|
||||||
|
created_at: row.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toApiCloud(row: BackendCloudInfo): ApiCloud {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
user_id: row.owner.id,
|
||||||
|
cloud_type_id: row.type.id,
|
||||||
|
image_url: imageUrl(row.id, 'original'),
|
||||||
|
thumbnail_url: imageUrl(row.id, 'preview'),
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude,
|
||||||
|
description: row.description,
|
||||||
|
captured_at: row.captured_at,
|
||||||
|
status: row.status,
|
||||||
|
is_hidden: row.is_hidden,
|
||||||
|
created_at: row.uploaded_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
cloud_type: {
|
||||||
|
id: row.type.id,
|
||||||
|
name: row.type.name,
|
||||||
|
rarity: 'common',
|
||||||
|
rarity_value: row.type.rarity,
|
||||||
|
},
|
||||||
|
owner: { id: row.owner.id, username: row.owner.name },
|
||||||
|
cloud_type_name: row.type.name,
|
||||||
|
cloud_type_rarity: 'common',
|
||||||
|
cloud_type_rarity_value: row.type.rarity,
|
||||||
|
username: row.owner.name,
|
||||||
|
received_like_count: row.received_like_count,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
const PATCH_FLAG = '__openCloudWillReadFrequentlyPatched__'
|
|
||||||
|
|
||||||
type CanvasLikePrototype = {
|
|
||||||
getContext?: (contextId: string, options?: unknown) => unknown
|
|
||||||
[PATCH_FLAG]?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function patch2DContextOptions(ctor: typeof HTMLCanvasElement | typeof OffscreenCanvas | undefined) {
|
|
||||||
const prototype = ctor?.prototype as CanvasLikePrototype | undefined
|
|
||||||
if (!prototype?.getContext || prototype[PATCH_FLAG]) return
|
|
||||||
|
|
||||||
const originalGetContext = prototype.getContext
|
|
||||||
|
|
||||||
prototype.getContext = function getContextWithReadHint(contextId: string, options?: unknown) {
|
|
||||||
if (contextId === '2d') {
|
|
||||||
const nextOptions =
|
|
||||||
options && typeof options === 'object'
|
|
||||||
? { ...options as Record<string, unknown>, willReadFrequently: true }
|
|
||||||
: { willReadFrequently: true }
|
|
||||||
|
|
||||||
return originalGetContext.call(this, contextId, nextOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalGetContext.call(this, contextId, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
prototype[PATCH_FLAG] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function enableCanvasReadbackHint() {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
|
|
||||||
patch2DContextOptions(window.HTMLCanvasElement)
|
|
||||||
if (typeof window.OffscreenCanvas !== 'undefined') {
|
|
||||||
patch2DContextOptions(window.OffscreenCanvas)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
export type BadgeRarity = 'common' | 'uncommon' | 'rare'
|
|
||||||
|
|
||||||
export interface CloudBadgeCardInput {
|
|
||||||
cloudName: string
|
|
||||||
cloudNameEn: string
|
|
||||||
unlockedAt: string
|
|
||||||
username: string
|
|
||||||
rarity: BadgeRarity
|
|
||||||
}
|
|
||||||
|
|
||||||
const rarityTheme = {
|
|
||||||
common: {
|
|
||||||
from: '#dbeafe',
|
|
||||||
to: '#f8fafc',
|
|
||||||
accent: '#0284c7',
|
|
||||||
shadow: 'rgba(2, 132, 199, 0.18)',
|
|
||||||
},
|
|
||||||
uncommon: {
|
|
||||||
from: '#fef3c7',
|
|
||||||
to: '#fff7ed',
|
|
||||||
accent: '#d97706',
|
|
||||||
shadow: 'rgba(217, 119, 6, 0.22)',
|
|
||||||
},
|
|
||||||
rare: {
|
|
||||||
from: '#fee2e2',
|
|
||||||
to: '#fff7ed',
|
|
||||||
accent: '#dc2626',
|
|
||||||
shadow: 'rgba(220, 38, 38, 0.22)',
|
|
||||||
},
|
|
||||||
} satisfies Record<BadgeRarity, { from: string; to: string; accent: string; shadow: string }>
|
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
|
||||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDownloadName(name: string) {
|
|
||||||
return `opencloud-badge-${name}.png`
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadCloudBadgeCard(input: CloudBadgeCardInput) {
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
canvas.width = 1200
|
|
||||||
canvas.height = 630
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
if (!ctx) return
|
|
||||||
|
|
||||||
const theme = rarityTheme[input.rarity]
|
|
||||||
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
|
||||||
gradient.addColorStop(0, theme.from)
|
|
||||||
gradient.addColorStop(1, theme.to)
|
|
||||||
|
|
||||||
ctx.fillStyle = gradient
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.72)'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(1010, 120, 140, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.45)'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(160, 520, 180, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
ctx.fillStyle = '#0f172a'
|
|
||||||
ctx.font = '700 44px sans-serif'
|
|
||||||
ctx.fillText('OpenCloud 云朵图鉴', 72, 88)
|
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.62)'
|
|
||||||
ctx.font = '400 24px sans-serif'
|
|
||||||
ctx.fillText('新徽章已解锁', 72, 126)
|
|
||||||
|
|
||||||
ctx.shadowColor = theme.shadow
|
|
||||||
ctx.shadowBlur = 28
|
|
||||||
ctx.fillStyle = '#ffffff'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.roundRect(72, 170, 1056, 380, 32)
|
|
||||||
ctx.fill()
|
|
||||||
ctx.shadowBlur = 0
|
|
||||||
|
|
||||||
ctx.fillStyle = theme.accent
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(210, 360, 92, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
ctx.fillStyle = '#ffffff'
|
|
||||||
ctx.font = '700 82px sans-serif'
|
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.textBaseline = 'middle'
|
|
||||||
ctx.fillText(input.cloudName.slice(0, 1), 210, 360)
|
|
||||||
|
|
||||||
ctx.textAlign = 'left'
|
|
||||||
ctx.textBaseline = 'alphabetic'
|
|
||||||
ctx.fillStyle = '#0f172a'
|
|
||||||
ctx.font = '700 64px sans-serif'
|
|
||||||
ctx.fillText(input.cloudName, 340, 300)
|
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.62)'
|
|
||||||
ctx.font = '400 30px sans-serif'
|
|
||||||
ctx.fillText(input.cloudNameEn, 340, 346)
|
|
||||||
|
|
||||||
ctx.fillStyle = theme.accent
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.roundRect(340, 382, 168, 48, 24)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
ctx.fillStyle = '#ffffff'
|
|
||||||
ctx.font = '600 24px sans-serif'
|
|
||||||
ctx.fillText(input.rarity === 'common' ? '常见' : input.rarity === 'uncommon' ? '少见' : '罕见', 384, 414)
|
|
||||||
|
|
||||||
ctx.fillStyle = '#334155'
|
|
||||||
ctx.font = '400 28px sans-serif'
|
|
||||||
ctx.fillText(`解锁者:${input.username}`, 340, 476)
|
|
||||||
ctx.fillText(`解锁日期:${formatDate(input.unlockedAt)}`, 340, 518)
|
|
||||||
|
|
||||||
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/png'))
|
|
||||||
if (!blob) return
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = makeDownloadName(input.cloudNameEn.toLowerCase())
|
|
||||||
link.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export const CLOUD_TYPES = [
|
|
||||||
{ id: 1, name: '积云', nameEn: 'Cumulus' },
|
|
||||||
{ id: 2, name: '层云', nameEn: 'Stratus' },
|
|
||||||
{ id: 3, name: '卷云', nameEn: 'Cirrus' },
|
|
||||||
{ id: 4, name: '积雨云', nameEn: 'Cumulonimbus' },
|
|
||||||
{ id: 5, name: '层积云', nameEn: 'Stratocumulus' },
|
|
||||||
{ id: 6, name: '高积云', nameEn: 'Altocumulus' },
|
|
||||||
{ id: 7, name: '高层云', nameEn: 'Altostratus' },
|
|
||||||
{ id: 8, name: '雨层云', nameEn: 'Nimbostratus' },
|
|
||||||
{ id: 9, name: '卷层云', nameEn: 'Cirrostratus' },
|
|
||||||
{ id: 10, name: '卷积云', nameEn: 'Cirrocumulus' },
|
|
||||||
] 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 ?? '云型详情'
|
|
||||||
}
|
|
||||||
+45
-41
@@ -4,60 +4,64 @@ const defaultTitle = 'OpenCloud'
|
|||||||
const defaultDescription = 'OpenCloud - 活的天空地图,拍云、识别、收藏'
|
const defaultDescription = 'OpenCloud - 活的天空地图,拍云、识别、收藏'
|
||||||
|
|
||||||
function resolveSiteUrl() {
|
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) {
|
function upsertMeta(name: string, content: string) {
|
||||||
let element = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
|
let element = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
|
||||||
if (!element) {
|
if (!element) {
|
||||||
element = document.createElement('meta')
|
element = document.createElement('meta')
|
||||||
element.name = name
|
element.name = name
|
||||||
document.head.appendChild(element)
|
document.head.appendChild(element)
|
||||||
}
|
}
|
||||||
element.content = content
|
element.content = content
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertPropertyMeta(property: string, content: string) {
|
function upsertPropertyMeta(property: string, content: string) {
|
||||||
let element = document.querySelector<HTMLMetaElement>(`meta[property="${property}"]`)
|
let element = document.querySelector<HTMLMetaElement>(`meta[property="${property}"]`)
|
||||||
if (!element) {
|
if (!element) {
|
||||||
element = document.createElement('meta')
|
element = document.createElement('meta')
|
||||||
element.setAttribute('property', property)
|
element.setAttribute('property', property)
|
||||||
document.head.appendChild(element)
|
document.head.appendChild(element)
|
||||||
}
|
}
|
||||||
element.content = content
|
element.content = content
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertCanonical(href: string) {
|
function upsertCanonical(href: string) {
|
||||||
let element = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
let element = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
||||||
if (!element) {
|
if (!element) {
|
||||||
element = document.createElement('link')
|
element = document.createElement('link')
|
||||||
element.rel = 'canonical'
|
element.rel = 'canonical'
|
||||||
document.head.appendChild(element)
|
document.head.appendChild(element)
|
||||||
}
|
}
|
||||||
element.href = href
|
element.href = href
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyRouteSeo(route: RouteLocationNormalizedLoaded) {
|
export function applyRouteSeo(route: RouteLocationNormalizedLoaded) {
|
||||||
const title = typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title
|
const title =
|
||||||
const description = typeof route.meta.description === 'function' ? route.meta.description(route) : route.meta.description
|
typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title
|
||||||
const robots = route.meta.noindex ? 'noindex, nofollow' : 'index, follow'
|
const description =
|
||||||
const canonicalPath = route.meta.canonicalPath || route.path
|
typeof route.meta.description === 'function'
|
||||||
const canonical = `${resolveSiteUrl()}${canonicalPath}`
|
? route.meta.description(route)
|
||||||
const pageTitle = title ? `${title} | OpenCloud` : defaultTitle
|
: route.meta.description
|
||||||
const pageDescription = description || defaultDescription
|
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
|
document.title = pageTitle
|
||||||
upsertMeta('description', pageDescription)
|
upsertMeta('description', pageDescription)
|
||||||
upsertMeta('robots', robots)
|
upsertMeta('robots', robots)
|
||||||
upsertCanonical(canonical)
|
upsertCanonical(canonical)
|
||||||
|
|
||||||
upsertPropertyMeta('og:site_name', 'OpenCloud')
|
upsertPropertyMeta('og:site_name', 'OpenCloud')
|
||||||
upsertPropertyMeta('og:type', 'website')
|
upsertPropertyMeta('og:type', 'website')
|
||||||
upsertPropertyMeta('og:title', pageTitle)
|
upsertPropertyMeta('og:title', pageTitle)
|
||||||
upsertPropertyMeta('og:description', pageDescription)
|
upsertPropertyMeta('og:description', pageDescription)
|
||||||
upsertPropertyMeta('og:url', canonical)
|
upsertPropertyMeta('og:url', canonical)
|
||||||
|
|
||||||
upsertMeta('twitter:card', 'summary')
|
upsertMeta('twitter:card', 'summary')
|
||||||
upsertMeta('twitter:title', pageTitle)
|
upsertMeta('twitter:title', pageTitle)
|
||||||
upsertMeta('twitter:description', pageDescription)
|
upsertMeta('twitter:description', pageDescription)
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-52
@@ -2,57 +2,57 @@ import type { GlobalThemeOverrides } from 'naive-ui'
|
|||||||
|
|
||||||
/** Keep these values aligned with the semantic tokens in styles/tokens.css. */
|
/** Keep these values aligned with the semantic tokens in styles/tokens.css. */
|
||||||
export const openCloudThemeOverrides: GlobalThemeOverrides = {
|
export const openCloudThemeOverrides: GlobalThemeOverrides = {
|
||||||
common: {
|
common: {
|
||||||
primaryColor: '#34745d',
|
primaryColor: '#34745d',
|
||||||
primaryColorHover: '#2d5f4e',
|
primaryColorHover: '#2d5f4e',
|
||||||
primaryColorPressed: '#274c40',
|
primaryColorPressed: '#274c40',
|
||||||
primaryColorSuppl: '#34745d',
|
primaryColorSuppl: '#34745d',
|
||||||
infoColor: '#0369a1',
|
infoColor: '#0369a1',
|
||||||
successColor: '#4d7f3b',
|
successColor: '#4d7f3b',
|
||||||
successColorHover: '#3f6633',
|
successColorHover: '#3f6633',
|
||||||
successColorPressed: '#35522c',
|
successColorPressed: '#35522c',
|
||||||
successColorSuppl: '#4d7f3b',
|
successColorSuppl: '#4d7f3b',
|
||||||
warningColor: '#d97706',
|
warningColor: '#d97706',
|
||||||
errorColor: '#dc2626',
|
errorColor: '#dc2626',
|
||||||
borderRadius: '0px',
|
borderRadius: '0px',
|
||||||
borderRadiusSmall: '0px',
|
borderRadiusSmall: '0px',
|
||||||
fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif',
|
fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif',
|
||||||
fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace',
|
fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace',
|
||||||
bodyColor: '#eef4f8',
|
bodyColor: '#eef4f8',
|
||||||
cardColor: '#ffffff',
|
cardColor: '#ffffff',
|
||||||
modalColor: '#ffffff',
|
modalColor: '#ffffff',
|
||||||
popoverColor: '#ffffff',
|
popoverColor: '#ffffff',
|
||||||
tableColor: '#ffffff',
|
tableColor: '#ffffff',
|
||||||
textColorBase: '#0f172a',
|
textColorBase: '#0f172a',
|
||||||
textColor1: '#0f172a',
|
textColor1: '#0f172a',
|
||||||
textColor2: '#334155',
|
textColor2: '#334155',
|
||||||
textColor3: '#64748b',
|
textColor3: '#64748b',
|
||||||
textColorDisabled: 'var(--oc-color-text-disabled)',
|
textColorDisabled: 'var(--oc-color-text-disabled)',
|
||||||
dividerColor: '#d9e3ee',
|
dividerColor: '#d9e3ee',
|
||||||
borderColor: '#d9e3ee',
|
borderColor: '#d9e3ee',
|
||||||
inputColor: '#ffffff',
|
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' },
|
|
||||||
},
|
},
|
||||||
},
|
Button: {
|
||||||
Tabs: { tabBorderRadius: '0px' },
|
borderRadiusTiny: '0px',
|
||||||
Tag: { borderRadius: '0px' },
|
borderRadiusSmall: '0px',
|
||||||
Alert: { borderRadius: '0px' },
|
borderRadiusMedium: '0px',
|
||||||
Modal: { borderRadius: '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' },
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-6
@@ -2,12 +2,9 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
import { enableCanvasReadbackHint } from './lib/canvas'
|
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
enableCanvasReadbackHint()
|
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
@@ -15,6 +12,6 @@ app.use(pinia)
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
authStore.initialize().then(() => {
|
authStore.initialize().then(() => {
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
})
|
})
|
||||||
|
|||||||
+162
-153
@@ -1,168 +1,177 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { getCloudTypeName } from '@/lib/cloudTypes'
|
import { getCloudTypeName } from '@/features/clouds/cloudTypes'
|
||||||
import { applyRouteSeo } from '@/lib/seo'
|
import { applyRouteSeo } from '@/lib/seo'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'map',
|
name: 'map',
|
||||||
component: () => import('@/views/map/MapView.vue'),
|
component: () => import('@/features/map/views/MapView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '实时云图地图',
|
title: '实时云图地图',
|
||||||
description: '在 OpenCloud 地图上浏览社区拍摄的云图,按位置查看天空观测记录。',
|
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: '/forgot-password',
|
|
||||||
name: 'forgot-password',
|
|
||||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
|
||||||
meta: { title: '忘记密码', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/auth/confirm',
|
|
||||||
name: 'auth-confirm',
|
|
||||||
component: () => import('@/views/auth/AuthConfirmView.vue'),
|
|
||||||
meta: { title: '确认邮箱', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/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: '浏览 10 种基础云属,了解云型特征,并通过拍摄云图点亮个人图鉴。',
|
|
||||||
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) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||||
next({
|
next({
|
||||||
name: 'auth-required',
|
name: 'auth-required',
|
||||||
query: {
|
query: {
|
||||||
redirect: to.fullPath,
|
redirect: to.fullPath,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||||
next({ name: 'forbidden' })
|
next({ name: 'forbidden' })
|
||||||
} else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) {
|
} else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) {
|
||||||
next({ name: 'map' })
|
next({ name: 'map' })
|
||||||
} else {
|
} else {
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.afterEach(to => {
|
router.afterEach(to => {
|
||||||
applyRouteSeo(to)
|
applyRouteSeo(to)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -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',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { NButton, NIcon } from 'naive-ui'
|
||||||
|
import { X } from '@vicons/tabler'
|
||||||
|
import { loadAMap } from '@/shared/map/amap'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
footerText?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: '地图选点',
|
||||||
|
description: '点击地图即可回填经纬度,也可以继续手动修改。',
|
||||||
|
footerText: '建议点选大致拍摄位置,保存时会继续做模糊化处理。',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
confirm: [payload: { latitude: number; longitude: number }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const pickedLat = ref<number | null>(null)
|
||||||
|
const pickedLng = ref<number | null>(null)
|
||||||
|
const mapEl = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
let mapPickerAMap: typeof AMap | null = null
|
||||||
|
let mapPickerInstance: AMap.Map | null = null
|
||||||
|
let mapPickerMarker: AMap.Marker | null = null
|
||||||
|
|
||||||
|
function formatCoordinate(value: number | null) {
|
||||||
|
return value === null ? '未选择' : value.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyMapPicker() {
|
||||||
|
mapPickerMarker?.setMap(null)
|
||||||
|
mapPickerMarker = null
|
||||||
|
mapPickerInstance?.destroy()
|
||||||
|
mapPickerInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeMarker(longitude: number, latitude: number) {
|
||||||
|
if (!mapPickerAMap || !mapPickerInstance) return
|
||||||
|
|
||||||
|
if (!mapPickerMarker) {
|
||||||
|
mapPickerMarker = new mapPickerAMap.Marker({
|
||||||
|
position: [longitude, latitude],
|
||||||
|
title: '拍摄位置',
|
||||||
|
content:
|
||||||
|
'<div aria-hidden="true" class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(52,116,93,0.24)]"></div>',
|
||||||
|
offset: new mapPickerAMap.Pixel(-8, -8),
|
||||||
|
} as AMap.MarkerOptions)
|
||||||
|
mapPickerMarker.setMap(mapPickerInstance)
|
||||||
|
} else {
|
||||||
|
mapPickerMarker.setPosition([longitude, latitude])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLngLat(event: unknown) {
|
||||||
|
const payload = event as { lnglat?: { lng?: number; lat?: number } } | undefined
|
||||||
|
const lng = payload?.lnglat?.lng
|
||||||
|
const lat = payload?.lnglat?.lat
|
||||||
|
|
||||||
|
if (typeof lng !== 'number' || typeof lat !== 'number') return null
|
||||||
|
return { lng, lat }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMapClick(event: unknown) {
|
||||||
|
const picked = extractLngLat(event)
|
||||||
|
if (!picked) return
|
||||||
|
|
||||||
|
pickedLng.value = picked.lng
|
||||||
|
pickedLat.value = picked.lat
|
||||||
|
placeMarker(picked.lng, picked.lat)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeMap() {
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
pickedLat.value = props.latitude
|
||||||
|
pickedLng.value = props.longitude
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
destroyMapPicker()
|
||||||
|
|
||||||
|
try {
|
||||||
|
mapPickerAMap = await loadAMap()
|
||||||
|
mapPickerAMap.getConfig().appname = 'amap-jsapi-skill'
|
||||||
|
|
||||||
|
const hasCoordinates =
|
||||||
|
typeof props.latitude === 'number' && typeof props.longitude === 'number'
|
||||||
|
|
||||||
|
const center: [number, number] = hasCoordinates
|
||||||
|
? [props.longitude as number, props.latitude as number]
|
||||||
|
: [104.07, 30.67]
|
||||||
|
|
||||||
|
mapPickerInstance = new mapPickerAMap.Map(mapEl.value!, {
|
||||||
|
viewMode: '2D',
|
||||||
|
pitch: 0,
|
||||||
|
rotation: 0,
|
||||||
|
zoom: hasCoordinates ? 11 : 4,
|
||||||
|
center,
|
||||||
|
mapStyle: 'amap://styles/normal',
|
||||||
|
resizeEnable: true,
|
||||||
|
} as AMap.MapOptions)
|
||||||
|
|
||||||
|
mapPickerInstance.on('click', handleMapClick)
|
||||||
|
|
||||||
|
if (hasCoordinates) {
|
||||||
|
placeMarker(props.longitude as number, props.latitude as number)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (pickedLat.value === null || pickedLng.value === null) return
|
||||||
|
emit('confirm', {
|
||||||
|
latitude: pickedLat.value,
|
||||||
|
longitude: pickedLng.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
open => {
|
||||||
|
if (open) {
|
||||||
|
initializeMap()
|
||||||
|
} else {
|
||||||
|
loading.value = false
|
||||||
|
errorMsg.value = ''
|
||||||
|
destroyMapPicker()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
destroyMapPicker()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
|
||||||
|
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-slate-900">{{ title }}</h3>
|
||||||
|
<p class="oc-field-help">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button"
|
||||||
|
aria-label="关闭地图选点窗口"
|
||||||
|
title="关闭地图选点窗口"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<NIcon size="20"><X /></NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-5">
|
||||||
|
<div
|
||||||
|
class="mb-4 flex flex-wrap items-center gap-3 border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600"
|
||||||
|
>
|
||||||
|
<span>当前选择:</span>
|
||||||
|
<span class="font-medium text-slate-900"
|
||||||
|
>纬度 {{ formatCoordinate(pickedLat) }}</span
|
||||||
|
>
|
||||||
|
<span class="font-medium text-slate-900"
|
||||||
|
>经度 {{ formatCoordinate(pickedLng) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative overflow-hidden border border-slate-200 bg-slate-100">
|
||||||
|
<div ref="mapEl" class="h-[420px] w-full"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-white/80 text-sm font-medium text-slate-600"
|
||||||
|
>
|
||||||
|
正在加载地图...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="errorMsg"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-white/90 px-6 text-center text-sm text-red-600"
|
||||||
|
>
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4">
|
||||||
|
<p class="text-sm text-slate-500">{{ footerText }}</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="close"
|
||||||
|
>取消</NButton
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
:disabled="pickedLat === null || pickedLng === null"
|
||||||
|
@click="confirm"
|
||||||
|
>
|
||||||
|
使用这个位置
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { loadAMap } from '@/shared/map/amap'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
locationName?: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapEl = ref<HTMLDivElement | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
let amapLib: typeof AMap | null = null
|
||||||
|
let mapInstance: AMap.Map | null = null
|
||||||
|
let marker: AMap.Marker | null = null
|
||||||
|
|
||||||
|
const hasCoordinates = computed(() => {
|
||||||
|
return Number.isFinite(props.latitude) && Number.isFinite(props.longitude)
|
||||||
|
})
|
||||||
|
|
||||||
|
const center = computed<[number, number] | null>(() => {
|
||||||
|
if (!hasCoordinates.value) return null
|
||||||
|
return [props.longitude as number, props.latitude as number]
|
||||||
|
})
|
||||||
|
|
||||||
|
function destroyMap() {
|
||||||
|
marker?.setMap(null)
|
||||||
|
marker = null
|
||||||
|
mapInstance?.destroy()
|
||||||
|
mapInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeMap() {
|
||||||
|
if (!center.value) {
|
||||||
|
destroyMap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
destroyMap()
|
||||||
|
|
||||||
|
if (!mapEl.value) {
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
amapLib = await loadAMap()
|
||||||
|
if ('getConfig' in amapLib) {
|
||||||
|
amapLib.getConfig().appname = 'amap-jsapi-skill'
|
||||||
|
}
|
||||||
|
|
||||||
|
mapInstance = new amapLib.Map(mapEl.value, {
|
||||||
|
viewMode: '2D',
|
||||||
|
zoom: 11,
|
||||||
|
center: center.value,
|
||||||
|
mapStyle: 'amap://styles/normal',
|
||||||
|
features: ['bg', 'road', 'building', 'point'],
|
||||||
|
resizeEnable: true,
|
||||||
|
dragEnable: true,
|
||||||
|
zoomEnable: true,
|
||||||
|
keyboardEnable: false,
|
||||||
|
doubleClickZoom: true,
|
||||||
|
scrollWheel: true,
|
||||||
|
} as AMap.MapOptions)
|
||||||
|
|
||||||
|
mapInstance.addControl(
|
||||||
|
new amapLib.ToolBar({
|
||||||
|
position: 'RT',
|
||||||
|
offset: [8, 8],
|
||||||
|
} as Record<string, unknown>),
|
||||||
|
)
|
||||||
|
|
||||||
|
marker = new amapLib.Marker({
|
||||||
|
position: center.value,
|
||||||
|
title: props.locationName || '拍摄位置',
|
||||||
|
content:
|
||||||
|
'<div class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(52,116,93,0.24)]"></div>',
|
||||||
|
offset: new amapLib.Pixel(-8, -8),
|
||||||
|
} as AMap.MarkerOptions)
|
||||||
|
marker.setMap(mapInstance)
|
||||||
|
} catch (error) {
|
||||||
|
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
center,
|
||||||
|
() => {
|
||||||
|
initializeMap()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
destroyMap()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="hasCoordinates" class="mt-5 overflow-hidden border border-slate-200 bg-white">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p class="oc-section-label">位置地图</p>
|
||||||
|
<p class="mt-1 text-sm font-medium text-slate-900">
|
||||||
|
{{ locationName || '拍摄位置' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-right text-[11px] leading-5 text-slate-500">
|
||||||
|
{{ latitude?.toFixed(4) }}<br />
|
||||||
|
{{ longitude?.toFixed(4) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative h-40 bg-sky-50">
|
||||||
|
<div ref="mapEl" class="h-full w-full"></div>
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-white/70 text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
地图加载中...
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="errorMsg"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-white/85 px-4 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Vendored
+126
@@ -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<string, unknown>)
|
||||||
|
}
|
||||||
|
class ControlBar {
|
||||||
|
constructor(opts?: Record<string, unknown>)
|
||||||
|
}
|
||||||
|
class Geolocation {
|
||||||
|
constructor(opts?: Record<string, unknown>)
|
||||||
|
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<string, unknown>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare function AMapLoader_load(opts: {
|
||||||
|
key: string
|
||||||
|
version: string
|
||||||
|
plugins?: string[]
|
||||||
|
}): Promise<typeof AMap>
|
||||||
|
|
||||||
|
declare namespace AMap {
|
||||||
|
function getConfig(): { appname?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@amap/amap-jsapi-loader' {
|
||||||
|
const AMapLoader: {
|
||||||
|
load: typeof AMapLoader_load
|
||||||
|
}
|
||||||
|
export default AMapLoader
|
||||||
|
}
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { computed, ref } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import {
|
|
||||||
AUTH_EXPIRED_EVENT,
|
|
||||||
AUTH_UPDATED_EVENT,
|
|
||||||
apiRequest,
|
|
||||||
clearAccessToken,
|
|
||||||
refreshSession,
|
|
||||||
setAccessToken,
|
|
||||||
} from '@/lib/api'
|
|
||||||
import type { AuthResponse, AuthUser } from '@/types/api'
|
|
||||||
import type { Profile } from '@/types/database'
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
|
||||||
const user = ref<AuthUser | null>(null)
|
|
||||||
const profile = ref<Profile | null>(null)
|
|
||||||
const loading = ref(true)
|
|
||||||
let listenersAttached = false
|
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!user.value)
|
|
||||||
const isAdmin = computed(() => profile.value?.role === 'admin')
|
|
||||||
|
|
||||||
function applyAuth(auth: AuthResponse) {
|
|
||||||
setAccessToken(auth.access_token)
|
|
||||||
user.value = auth.user
|
|
||||||
profile.value = {
|
|
||||||
id: auth.user.id,
|
|
||||||
username: auth.user.username,
|
|
||||||
avatar_url: auth.user.avatar_url,
|
|
||||||
role: auth.user.role,
|
|
||||||
is_disabled: auth.user.is_disabled,
|
|
||||||
created_at: auth.user.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAuth() {
|
|
||||||
clearAccessToken()
|
|
||||||
user.value = null
|
|
||||||
profile.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login(email: string, password: string) {
|
|
||||||
const auth = await apiRequest<AuthResponse>('/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
auth: false,
|
|
||||||
body: { email, password },
|
|
||||||
})
|
|
||||||
applyAuth(auth)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function register(email: string, password: string, username: string) {
|
|
||||||
await apiRequest('/auth/register', {
|
|
||||||
method: 'POST',
|
|
||||||
auth: false,
|
|
||||||
body: { email, password, username },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
try {
|
|
||||||
await apiRequest('/auth/logout', { method: 'POST', retry: false })
|
|
||||||
} finally {
|
|
||||||
clearAuth()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateUsername(username: string) {
|
|
||||||
const updatedUser = await apiRequest<AuthUser>('/profiles/me', {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { username },
|
|
||||||
})
|
|
||||||
user.value = updatedUser
|
|
||||||
profile.value = {
|
|
||||||
id: updatedUser.id,
|
|
||||||
username: updatedUser.username,
|
|
||||||
avatar_url: updatedUser.avatar_url,
|
|
||||||
role: updatedUser.role,
|
|
||||||
is_disabled: updatedUser.is_disabled,
|
|
||||||
created_at: updatedUser.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updatePassword(password: string) {
|
|
||||||
const auth = await apiRequest<AuthResponse>('/auth/password', {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { password },
|
|
||||||
})
|
|
||||||
applyAuth(auth)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendPasswordReset(email: string) {
|
|
||||||
await apiRequest('/auth/forgot-password', {
|
|
||||||
method: 'POST',
|
|
||||||
auth: false,
|
|
||||||
body: { email },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initialize() {
|
|
||||||
if (!listenersAttached) {
|
|
||||||
window.addEventListener(AUTH_UPDATED_EVENT, event => {
|
|
||||||
applyAuth((event as CustomEvent<AuthResponse>).detail)
|
|
||||||
})
|
|
||||||
window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
|
|
||||||
listenersAttached = true
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
applyAuth(await refreshSession())
|
|
||||||
} catch {
|
|
||||||
clearAuth()
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
profile,
|
|
||||||
loading,
|
|
||||||
isLoggedIn,
|
|
||||||
isAdmin,
|
|
||||||
login,
|
|
||||||
register,
|
|
||||||
logout,
|
|
||||||
updateUsername,
|
|
||||||
updatePassword,
|
|
||||||
sendPasswordReset,
|
|
||||||
initialize,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { apiRequest } from '@/lib/api'
|
|
||||||
import type { CloudType } from '@/types/database'
|
|
||||||
|
|
||||||
export const useCloudsStore = defineStore('clouds', () => {
|
|
||||||
const cloudTypes = ref<CloudType[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
async function fetchCloudTypes() {
|
|
||||||
if (cloudTypes.value.length > 0) return
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { cloudTypes, loading, fetchCloudTypes }
|
|
||||||
})
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { computed, ref } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { apiRequest } from '@/lib/api'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import type { ApiCollection } from '@/types/api'
|
|
||||||
import type { CloudType, UserCollection } from '@/types/database'
|
|
||||||
|
|
||||||
export interface CollectionPreviewCloud {
|
|
||||||
id: string
|
|
||||||
image_url: string
|
|
||||||
thumbnail_url: string | null
|
|
||||||
captured_at: string | null
|
|
||||||
created_at: string
|
|
||||||
location_name: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CollectionEntry extends UserCollection {
|
|
||||||
firstCloud: CollectionPreviewCloud | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
|
||||||
return error instanceof Error ? error.message : '图鉴收藏加载失败'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const cloudTypes = ref<CloudType[]>([])
|
|
||||||
const myCollection = ref<CollectionEntry[]>([])
|
|
||||||
const loadingCloudTypes = ref(false)
|
|
||||||
const loadingCollection = ref(false)
|
|
||||||
const cloudTypesLoaded = ref(false)
|
|
||||||
const collectionLoadedForUserId = ref<string | null>(null)
|
|
||||||
const collectionError = ref('')
|
|
||||||
|
|
||||||
const collectionMap = computed(() => new Map(myCollection.value.map(item => [item.cloud_type_id, item])))
|
|
||||||
const unlockedCount = computed(() => myCollection.value.length)
|
|
||||||
const unlockProgress = computed(() => `${unlockedCount.value}/${cloudTypes.value.length || 10}`)
|
|
||||||
const unlockPercent = computed(() => cloudTypes.value.length
|
|
||||||
? Math.round(unlockedCount.value / cloudTypes.value.length * 100)
|
|
||||||
: 0)
|
|
||||||
|
|
||||||
async function fetchCloudTypes(force = false) {
|
|
||||||
if (cloudTypesLoaded.value && !force) return
|
|
||||||
loadingCloudTypes.value = true
|
|
||||||
try {
|
|
||||||
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
|
|
||||||
cloudTypesLoaded.value = true
|
|
||||||
} finally {
|
|
||||||
loadingCloudTypes.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchMyCollection(force = false) {
|
|
||||||
const userId = authStore.user?.id ?? null
|
|
||||||
if (!userId) {
|
|
||||||
myCollection.value = []
|
|
||||||
collectionLoadedForUserId.value = null
|
|
||||||
collectionError.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!force && collectionLoadedForUserId.value === userId) return
|
|
||||||
|
|
||||||
loadingCollection.value = true
|
|
||||||
collectionError.value = ''
|
|
||||||
try {
|
|
||||||
const rows = await apiRequest<ApiCollection[]>('/collections/me')
|
|
||||||
myCollection.value = rows.map(({ first_cloud, cloud_type: _cloudType, ...collection }) => ({
|
|
||||||
...collection,
|
|
||||||
firstCloud: first_cloud,
|
|
||||||
}))
|
|
||||||
collectionLoadedForUserId.value = userId
|
|
||||||
} catch (error) {
|
|
||||||
myCollection.value = []
|
|
||||||
collectionLoadedForUserId.value = userId
|
|
||||||
collectionError.value = getErrorMessage(error)
|
|
||||||
} finally {
|
|
||||||
loadingCollection.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUnlocked(cloudTypeId: number) {
|
|
||||||
return collectionMap.value.has(cloudTypeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCollectionEntry(cloudTypeId: number) {
|
|
||||||
return collectionMap.value.get(cloudTypeId) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cloudTypes,
|
|
||||||
myCollection,
|
|
||||||
loadingCloudTypes,
|
|
||||||
loadingCollection,
|
|
||||||
collectionError,
|
|
||||||
unlockedCount,
|
|
||||||
unlockProgress,
|
|
||||||
unlockPercent,
|
|
||||||
fetchCloudTypes,
|
|
||||||
fetchMyCollection,
|
|
||||||
isUnlocked,
|
|
||||||
getCollectionEntry,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { apiRequest } from '@/lib/api'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import type { ApiCloud, ApiPage, LikeStats, PublicProfile } from '@/types/api'
|
|
||||||
import type { CloudType, Profile } from '@/types/database'
|
|
||||||
|
|
||||||
export interface ProfileCloudItem {
|
|
||||||
id: string
|
|
||||||
cloud_type_id: number | null
|
|
||||||
custom_cloud_type: string | null
|
|
||||||
image_url: string
|
|
||||||
thumbnail_url: string | null
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
location_name: string | null
|
|
||||||
description: string | null
|
|
||||||
captured_at: string | null
|
|
||||||
created_at: string
|
|
||||||
status: 'pending' | 'approved' | 'rejected'
|
|
||||||
is_hidden: boolean
|
|
||||||
cloudTypeName: string
|
|
||||||
cloudTypeRarity: CloudType['rarity']
|
|
||||||
favoriteCount: number
|
|
||||||
isFavorited: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
cloud_type_id: row.cloud_type_id,
|
|
||||||
custom_cloud_type: row.custom_cloud_type,
|
|
||||||
image_url: row.image_url,
|
|
||||||
thumbnail_url: row.thumbnail_url,
|
|
||||||
latitude: row.latitude,
|
|
||||||
longitude: row.longitude,
|
|
||||||
location_name: row.location_name,
|
|
||||||
description: row.description,
|
|
||||||
captured_at: row.captured_at,
|
|
||||||
created_at: row.created_at,
|
|
||||||
status: row.status,
|
|
||||||
is_hidden: row.is_hidden,
|
|
||||||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
|
||||||
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
|
||||||
favoriteCount: row.favorite_count,
|
|
||||||
isFavorited: row.is_favorited,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toProfile(profile: PublicProfile): Profile {
|
|
||||||
return {
|
|
||||||
...profile,
|
|
||||||
role: 'user',
|
|
||||||
is_disabled: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useProfileStore = defineStore('profile-page', () => {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const profilesById = ref<Record<string, Profile>>({})
|
|
||||||
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
|
||||||
const likeStatsByUserId = ref<Record<string, LikeStats>>({})
|
|
||||||
const loadingKeys = ref<Record<string, boolean>>({})
|
|
||||||
const errorByKey = ref<Record<string, string>>({})
|
|
||||||
|
|
||||||
const makeKey = (userId: string, isOwnProfile: boolean) => `${userId}:${isOwnProfile ? 'own' : 'public'}`
|
|
||||||
|
|
||||||
function getProfile(userId: string | null) {
|
|
||||||
return userId ? profilesById.value[userId] ?? null : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClouds(userId: string | null, isOwnProfile: boolean) {
|
|
||||||
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLikeStats(userId: string | null) {
|
|
||||||
return userId ? likeStatsByUserId.value[userId] ?? null : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getError(userId: string | null, isOwnProfile: boolean) {
|
|
||||||
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoaded(userId: string | null, isOwnProfile: boolean) {
|
|
||||||
return !!userId && makeKey(userId, isOwnProfile) in cloudsByKey.value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAllClouds(userId: string, isOwnProfile: boolean) {
|
|
||||||
const requestOptions = { auth: isOwnProfile }
|
|
||||||
const first = await apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=1&page_size=100`, requestOptions)
|
|
||||||
if (first.total_pages <= 1) return first.items
|
|
||||||
|
|
||||||
const remaining = await Promise.all(
|
|
||||||
Array.from({ length: first.total_pages - 1 }, (_, index) =>
|
|
||||||
apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=${index + 2}&page_size=100`, requestOptions),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return [first, ...remaining].flatMap(page => page.items)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchProfilePage(userId: string, isOwnProfile: boolean, force = false) {
|
|
||||||
const key = makeKey(userId, isOwnProfile)
|
|
||||||
if (!force && key in cloudsByKey.value && profilesById.value[userId]) return
|
|
||||||
|
|
||||||
loadingKeys.value[key] = true
|
|
||||||
errorByKey.value[key] = ''
|
|
||||||
try {
|
|
||||||
const profilePromise = isOwnProfile && authStore.profile?.id === userId
|
|
||||||
? Promise.resolve(authStore.profile)
|
|
||||||
: apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
|
|
||||||
const likeStatsPromise = isOwnProfile
|
|
||||||
? apiRequest<LikeStats>('/profiles/me/like-stats')
|
|
||||||
: Promise.resolve(null)
|
|
||||||
const [profile, cloudRows, likeStats] = await Promise.all([
|
|
||||||
profilePromise,
|
|
||||||
fetchAllClouds(userId, isOwnProfile),
|
|
||||||
likeStatsPromise,
|
|
||||||
])
|
|
||||||
profilesById.value[userId] = profile
|
|
||||||
cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
|
|
||||||
if (likeStats) likeStatsByUserId.value[userId] = likeStats
|
|
||||||
} catch (error) {
|
|
||||||
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
|
|
||||||
cloudsByKey.value[key] = []
|
|
||||||
} finally {
|
|
||||||
loadingKeys.value[key] = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoading(userId: string | null, isOwnProfile: boolean) {
|
|
||||||
return !!userId && !!loadingKeys.value[makeKey(userId, isOwnProfile)]
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchCachedCloud(userId: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
|
|
||||||
for (const isOwnProfile of [true, false]) {
|
|
||||||
const key = makeKey(userId, 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(userId: string, cloudIds: string[]) {
|
|
||||||
const idSet = new Set(cloudIds)
|
|
||||||
for (const isOwnProfile of [true, false]) {
|
|
||||||
const key = makeKey(userId, isOwnProfile)
|
|
||||||
const current = cloudsByKey.value[key]
|
|
||||||
if (current) cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidateUser(userId: string) {
|
|
||||||
delete cloudsByKey.value[makeKey(userId, true)]
|
|
||||||
delete cloudsByKey.value[makeKey(userId, false)]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateCloud(userId: string, cloudId: string, patch: {
|
|
||||||
cloud_type_id: number | null
|
|
||||||
custom_cloud_type: string | null
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
location_name: string | null
|
|
||||||
description: string | null
|
|
||||||
captured_at: string | null
|
|
||||||
is_hidden: boolean
|
|
||||||
}) {
|
|
||||||
const updated = toProfileCloud(await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: patch,
|
|
||||||
}))
|
|
||||||
patchCachedCloud(userId, cloudId, updated)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) {
|
|
||||||
await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { is_hidden: isHidden },
|
|
||||||
})
|
|
||||||
patchCachedCloud(userId, cloudId, { is_hidden: isHidden })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteClouds(userId: string, cloudIds: string[]) {
|
|
||||||
if (!cloudIds.length) return 0
|
|
||||||
|
|
||||||
if (cloudIds.length === 1) {
|
|
||||||
await apiRequest(`/clouds/${cloudIds[0]}`, { method: 'DELETE' })
|
|
||||||
} else {
|
|
||||||
for (let offset = 0; offset < cloudIds.length; offset += 100) {
|
|
||||||
await apiRequest('/clouds/batch-delete', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { ids: cloudIds.slice(offset, offset + 100) },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeCachedClouds(userId, cloudIds)
|
|
||||||
return cloudIds.length
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
getProfile,
|
|
||||||
getClouds,
|
|
||||||
getLikeStats,
|
|
||||||
getError,
|
|
||||||
isLoaded,
|
|
||||||
isLoading,
|
|
||||||
fetchProfilePage,
|
|
||||||
invalidateUser,
|
|
||||||
updateCloud,
|
|
||||||
updateCloudVisibility,
|
|
||||||
deleteClouds,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
+19
-20
@@ -1,27 +1,26 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--oc-color-text);
|
color: var(--oc-color-text);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgb(15 23 42 / 2%), rgb(15 23 42 / 0%)),
|
linear-gradient(180deg, rgb(15 23 42 / 2%), rgb(15 23 42 / 0%)), var(--oc-color-canvas);
|
||||||
var(--oc-color-canvas);
|
font-family: var(--oc-font-sans);
|
||||||
font-family: var(--oc-font-sans);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--oc-color-selection);
|
background: var(--oc-color-selection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
/* The atlas language is square by default. Documented exceptions may opt out. */
|
/* The atlas language is square by default. Documented exceptions may opt out. */
|
||||||
[class~='rounded'],
|
[class~='rounded'],
|
||||||
[class*='rounded-'] {
|
[class*='rounded-'] {
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+596
-583
File diff suppressed because it is too large
Load Diff
+71
-71
@@ -5,87 +5,87 @@
|
|||||||
* values live here so the Vue and Naive UI layers share one visual contract.
|
* values live here so the Vue and Naive UI layers share one visual contract.
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Tailwind compatibility palettes.
|
* Tailwind compatibility palettes.
|
||||||
* Existing teal/emerald utilities intentionally resolve to OpenCloud's
|
* Existing teal/emerald utilities intentionally resolve to OpenCloud's
|
||||||
* fresh eucalyptus and leaf colors instead of Tailwind's vivid defaults.
|
* fresh eucalyptus and leaf colors instead of Tailwind's vivid defaults.
|
||||||
*/
|
*/
|
||||||
--color-teal-50: #f2faf6;
|
--color-teal-50: #f2faf6;
|
||||||
--color-teal-100: #ddf2e6;
|
--color-teal-100: #ddf2e6;
|
||||||
--color-teal-200: #bce4cf;
|
--color-teal-200: #bce4cf;
|
||||||
--color-teal-300: #8fceb0;
|
--color-teal-300: #8fceb0;
|
||||||
--color-teal-400: #60b18d;
|
--color-teal-400: #60b18d;
|
||||||
--color-teal-500: #438f71;
|
--color-teal-500: #438f71;
|
||||||
--color-teal-600: #34745d;
|
--color-teal-600: #34745d;
|
||||||
--color-teal-700: #2d5f4e;
|
--color-teal-700: #2d5f4e;
|
||||||
--color-teal-800: #274c40;
|
--color-teal-800: #274c40;
|
||||||
--color-teal-900: #213e35;
|
--color-teal-900: #213e35;
|
||||||
|
|
||||||
--color-emerald-50: #f4faef;
|
--color-emerald-50: #f4faef;
|
||||||
--color-emerald-100: #e5f3d9;
|
--color-emerald-100: #e5f3d9;
|
||||||
--color-emerald-200: #cce7b7;
|
--color-emerald-200: #cce7b7;
|
||||||
--color-emerald-300: #a8d58d;
|
--color-emerald-300: #a8d58d;
|
||||||
--color-emerald-400: #7fbc63;
|
--color-emerald-400: #7fbc63;
|
||||||
--color-emerald-500: #609d48;
|
--color-emerald-500: #609d48;
|
||||||
--color-emerald-600: #4d7f3b;
|
--color-emerald-600: #4d7f3b;
|
||||||
--color-emerald-700: #3f6633;
|
--color-emerald-700: #3f6633;
|
||||||
--color-emerald-800: #35522c;
|
--color-emerald-800: #35522c;
|
||||||
--color-emerald-900: #2d4427;
|
--color-emerald-900: #2d4427;
|
||||||
|
|
||||||
--oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif;
|
--oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif;
|
||||||
--oc-font-mono: 'IBM Plex Mono', 'SFMono-Regular', monospace;
|
--oc-font-mono: 'IBM Plex Mono', 'SFMono-Regular', monospace;
|
||||||
|
|
||||||
--oc-color-canvas: #eef4f8;
|
--oc-color-canvas: #eef4f8;
|
||||||
--oc-color-surface: #ffffff;
|
--oc-color-surface: #ffffff;
|
||||||
--oc-color-surface-muted: #f8fafc;
|
--oc-color-surface-muted: #f8fafc;
|
||||||
--oc-color-text: #0f172a;
|
--oc-color-text: #0f172a;
|
||||||
--oc-color-text-secondary: #334155;
|
--oc-color-text-secondary: #334155;
|
||||||
--oc-color-text-muted: #64748b;
|
--oc-color-text-muted: #64748b;
|
||||||
--oc-color-text-disabled: #94a3b8;
|
--oc-color-text-disabled: #94a3b8;
|
||||||
--oc-color-border: #d9e3ee;
|
--oc-color-border: #d9e3ee;
|
||||||
--oc-color-primary: #34745d;
|
--oc-color-primary: #34745d;
|
||||||
--oc-color-primary-hover: #2d5f4e;
|
--oc-color-primary-hover: #2d5f4e;
|
||||||
--oc-color-primary-pressed: #274c40;
|
--oc-color-primary-pressed: #274c40;
|
||||||
--oc-color-info: #0369a1;
|
--oc-color-info: #0369a1;
|
||||||
--oc-color-success: #4d7f3b;
|
--oc-color-success: #4d7f3b;
|
||||||
--oc-color-warning: #d97706;
|
--oc-color-warning: #d97706;
|
||||||
--oc-color-danger: #dc2626;
|
--oc-color-danger: #dc2626;
|
||||||
--oc-color-selection: rgb(52 116 93 / 20%);
|
--oc-color-selection: rgb(52 116 93 / 20%);
|
||||||
|
|
||||||
/* Contribution heatmap: no activity + GitHub-style contribution quartiles. */
|
/* Contribution heatmap: no activity + GitHub-style contribution quartiles. */
|
||||||
--oc-heatmap-empty: #f1f5f9;
|
--oc-heatmap-empty: #f1f5f9;
|
||||||
--oc-heatmap-empty-border: #d9e3ee;
|
--oc-heatmap-empty-border: #d9e3ee;
|
||||||
--oc-heatmap-level-1: #ddf2e6;
|
--oc-heatmap-level-1: #ddf2e6;
|
||||||
--oc-heatmap-level-1-border: #bce4cf;
|
--oc-heatmap-level-1-border: #bce4cf;
|
||||||
--oc-heatmap-level-2: #8fceb0;
|
--oc-heatmap-level-2: #8fceb0;
|
||||||
--oc-heatmap-level-2-border: #60b18d;
|
--oc-heatmap-level-2-border: #60b18d;
|
||||||
--oc-heatmap-level-3: #438f71;
|
--oc-heatmap-level-3: #438f71;
|
||||||
--oc-heatmap-level-3-border: #34745d;
|
--oc-heatmap-level-3-border: #34745d;
|
||||||
--oc-heatmap-level-4: #274c40;
|
--oc-heatmap-level-4: #274c40;
|
||||||
--oc-heatmap-level-4-border: #213e35;
|
--oc-heatmap-level-4-border: #213e35;
|
||||||
|
|
||||||
--oc-gradient-canvas:
|
--oc-gradient-canvas:
|
||||||
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
|
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
|
||||||
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
|
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
|
||||||
--oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%);
|
--oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%);
|
||||||
--oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%);
|
--oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%);
|
||||||
--oc-gradient-grid:
|
--oc-gradient-grid:
|
||||||
linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px),
|
linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, 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-xs: 3px 3px 0 0 rgb(15 23 42 / 6%);
|
||||||
--oc-shadow-sm: 4px 4px 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-md: 6px 6px 0 0 rgb(15 23 42 / 5%);
|
||||||
--oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%);
|
--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-xl: 12px 12px 0 0 rgb(15 23 42 / 8%);
|
||||||
--oc-shadow-sky: 4px 4px 0 0 rgb(14 165 233 / 14%);
|
--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: 4px 4px 0 0 rgb(52 116 93 / 14%);
|
||||||
--oc-shadow-primary-hover: 5px 5px 0 0 rgb(52 116 93 / 20%);
|
--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-amber: 5px 5px 0 0 rgb(245 158 11 / 12%);
|
||||||
--oc-shadow-teal: var(--oc-shadow-primary);
|
--oc-shadow-teal: var(--oc-shadow-primary);
|
||||||
|
|
||||||
--oc-duration-fast: 160ms;
|
--oc-duration-fast: 160ms;
|
||||||
--oc-ease-standard: ease;
|
--oc-ease-standard: ease;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-126
@@ -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<string, unknown>)
|
|
||||||
}
|
|
||||||
class ControlBar {
|
|
||||||
constructor(opts?: Record<string, unknown>)
|
|
||||||
}
|
|
||||||
class Geolocation {
|
|
||||||
constructor(opts?: Record<string, unknown>)
|
|
||||||
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<string, unknown>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare function AMapLoader_load(opts: {
|
|
||||||
key: string
|
|
||||||
version: string
|
|
||||||
plugins?: string[]
|
|
||||||
}): Promise<typeof AMap>
|
|
||||||
|
|
||||||
declare namespace AMap {
|
|
||||||
function getConfig(): { appname?: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '@amap/amap-jsapi-loader' {
|
|
||||||
const AMapLoader: {
|
|
||||||
load: typeof AMapLoader_load
|
|
||||||
}
|
|
||||||
export default AMapLoader
|
|
||||||
}
|
|
||||||
+82
-103
@@ -1,115 +1,94 @@
|
|||||||
import type { Cloud, CloudType, Profile, UserCollection } from '@/types/database'
|
import type { Cloud, CloudType, Profile } from '@/types/database'
|
||||||
|
|
||||||
|
export interface BackendUserProfile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
image: string | null
|
||||||
|
cloud_count: number
|
||||||
|
received_like_count: number
|
||||||
|
last_online: string | null
|
||||||
|
role: Profile['role']
|
||||||
|
is_disabled: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendCloudType {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
genus: string | null
|
||||||
|
icon_id: string | null
|
||||||
|
rarity: number
|
||||||
|
description: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendCloudOwner {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendCloudInfo {
|
||||||
|
id: string
|
||||||
|
owner: BackendCloudOwner
|
||||||
|
type: BackendCloudType
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
description: string | null
|
||||||
|
captured_at: string | null
|
||||||
|
uploaded_at: string
|
||||||
|
updated_at: string
|
||||||
|
received_like_count: number
|
||||||
|
status: Cloud['status']
|
||||||
|
is_hidden: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionResponse {
|
||||||
|
status_code: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudCreateResponse extends ActionResponse {
|
||||||
|
cloud_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudTypeCreateResponse extends ActionResponse {
|
||||||
|
cloud_type_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal view model. The backend profile is normalized here so existing
|
||||||
|
// layout/admin consumers do not need to understand the wire representation.
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
email_verified: boolean
|
username: string
|
||||||
username: string
|
image: string | null
|
||||||
avatar_url: string | null
|
role: Profile['role']
|
||||||
role: Profile['role']
|
is_disabled: boolean
|
||||||
is_disabled: boolean
|
cloud_count: number
|
||||||
cloud_count: number
|
received_like_count: number
|
||||||
public_cloud_count: number
|
created_at: string
|
||||||
collection_count: number
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthResponse {
|
|
||||||
access_token: string
|
|
||||||
token_type: 'bearer'
|
|
||||||
expires_in: number
|
|
||||||
user: AuthUser
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MeResponse {
|
|
||||||
user: AuthUser
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PublicProfile {
|
|
||||||
id: string
|
|
||||||
username: string
|
|
||||||
avatar_url: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiPage<T> {
|
|
||||||
items: T[]
|
|
||||||
page: number
|
|
||||||
page_size: number
|
|
||||||
total: number
|
|
||||||
total_pages: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiCloudTypeSummary {
|
export interface ApiCloudTypeSummary {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
name_en: string
|
rarity: CloudType['rarity']
|
||||||
rarity: CloudType['rarity']
|
rarity_value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiCloudOwner {
|
export interface ApiCloudOwner {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiCloud extends Cloud {
|
export interface ApiCloud extends Cloud {
|
||||||
updated_at: string
|
updated_at: string
|
||||||
cloud_type: ApiCloudTypeSummary | null
|
cloud_type: ApiCloudTypeSummary | null
|
||||||
owner: ApiCloudOwner | null
|
owner: ApiCloudOwner | null
|
||||||
cloud_type_name: string | null
|
cloud_type_name: string | null
|
||||||
cloud_type_rarity: CloudType['rarity'] | null
|
cloud_type_rarity: CloudType['rarity'] | null
|
||||||
username: string | null
|
cloud_type_rarity_value: number | null
|
||||||
favorite_count: number
|
username: string | null
|
||||||
is_favorited: boolean
|
received_like_count: number
|
||||||
}
|
|
||||||
|
|
||||||
export interface LikeStatsPhoto {
|
|
||||||
cloud_id: string
|
|
||||||
image_url: string
|
|
||||||
thumbnail_url: string
|
|
||||||
favorite_count: number
|
|
||||||
captured_at: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LikeStats {
|
|
||||||
user_id: string
|
|
||||||
username: string
|
|
||||||
total_likes: number
|
|
||||||
photos: LikeStatsPhoto[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiCollection extends UserCollection {
|
|
||||||
cloud_type: ApiCloudTypeSummary
|
|
||||||
first_cloud: Pick<
|
|
||||||
ApiCloud,
|
|
||||||
'id' | 'image_url' | 'thumbnail_url' | 'captured_at' | 'created_at' | 'location_name'
|
|
||||||
> | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnlockedBadge {
|
|
||||||
cloud_type_id: number
|
|
||||||
cloud_name: string
|
|
||||||
cloud_name_en: string
|
|
||||||
rarity: CloudType['rarity']
|
|
||||||
unlocked_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CloudCreateResponse {
|
|
||||||
cloud: ApiCloud
|
|
||||||
unlocked_badge: UnlockedBadge | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminStats {
|
|
||||||
users: number
|
|
||||||
images: number
|
|
||||||
today_uploads: number
|
|
||||||
pending: number
|
|
||||||
approved: number
|
|
||||||
rejected: number
|
|
||||||
hidden: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUserItem {
|
|
||||||
user: AuthUser
|
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-44
@@ -1,52 +1,34 @@
|
|||||||
export interface CloudType {
|
export interface CloudType {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
name_en: string
|
genus: string
|
||||||
genus: string
|
icon_id: string | null
|
||||||
rarity: 'common' | 'uncommon' | 'rare'
|
rarity: 'common' | 'uncommon' | 'rare'
|
||||||
description: string | null
|
rarity_value: number
|
||||||
icon_url: string | null
|
description: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Cloud {
|
export interface Cloud {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
cloud_type_id: number | null
|
cloud_type_id: number | null
|
||||||
custom_cloud_type: string | null
|
image_url: string
|
||||||
image_url: string
|
thumbnail_url: string | null
|
||||||
thumbnail_url: string | null
|
latitude: number | null
|
||||||
latitude: number | null
|
longitude: number | null
|
||||||
longitude: number | null
|
description: string | null
|
||||||
location_name: string | null
|
captured_at: string | null
|
||||||
description: string | null
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
captured_at: string | null
|
is_hidden: boolean
|
||||||
status: 'pending' | 'approved' | 'rejected'
|
created_at: string
|
||||||
is_hidden: boolean
|
|
||||||
created_at: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
avatar_url: string | null
|
image: string | null
|
||||||
role: 'user' | 'admin'
|
role: 'user' | 'admin'
|
||||||
is_disabled: boolean
|
is_disabled: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserCollection {
|
|
||||||
id: string
|
|
||||||
user_id: string
|
|
||||||
cloud_type_id: number
|
|
||||||
first_cloud_id: string | null
|
|
||||||
unlocked_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Reaction {
|
|
||||||
id: string
|
|
||||||
user_id: string
|
|
||||||
cloud_id: string
|
|
||||||
emoji: string
|
|
||||||
created_at: string
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+8
-8
@@ -1,12 +1,12 @@
|
|||||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||||
|
|
||||||
declare module 'vue-router' {
|
declare module 'vue-router' {
|
||||||
interface RouteMeta {
|
interface RouteMeta {
|
||||||
title?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
title?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||||
description?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
description?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||||
canonicalPath?: string
|
canonicalPath?: string
|
||||||
noindex?: boolean
|
noindex?: boolean
|
||||||
requiresAuth?: boolean
|
requiresAuth?: boolean
|
||||||
requiresAdmin?: boolean
|
requiresAdmin?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,90 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
|
||||||
import { apiRequest } from '@/lib/api'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const state = ref<'loading' | 'success' | 'failed'>('loading')
|
|
||||||
const countdown = ref(5)
|
|
||||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
const confirmationToken = typeof route.query.token === 'string' ? route.query.token : ''
|
|
||||||
|
|
||||||
if (route.query.token !== undefined) {
|
|
||||||
window.history.replaceState(window.history.state, '', window.location.pathname)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCountdown() {
|
|
||||||
countdownTimer = setInterval(() => {
|
|
||||||
countdown.value--
|
|
||||||
if (countdown.value <= 0) {
|
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
if (!confirmationToken) {
|
|
||||||
state.value = 'failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await apiRequest('/auth/confirm-email', {
|
|
||||||
method: 'POST',
|
|
||||||
auth: false,
|
|
||||||
body: { token: confirmationToken },
|
|
||||||
})
|
|
||||||
state.value = 'success'
|
|
||||||
startCountdown()
|
|
||||||
} catch {
|
|
||||||
state.value = 'failed'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
|
||||||
<div class="mx-auto max-w-3xl">
|
|
||||||
<NCard class="oc-panel-card-xl">
|
|
||||||
<template v-if="state === 'success'">
|
|
||||||
<NResult status="success" title="邮箱认证成功" description="你的邮箱已确认,现在可以登录了。">
|
|
||||||
<template #footer>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
|
|
||||||
<RouterLink to="/login" class="no-underline">
|
|
||||||
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">立即登录</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="state === 'failed'">
|
|
||||||
<NResult status="error" title="认证失败" description="邮箱确认链接无效或已过期,请重新注册。">
|
|
||||||
<template #footer>
|
|
||||||
<RouterLink to="/register" class="no-underline">
|
|
||||||
<NButton type="primary" class="oc-primary-button oc-primary-button--sky">重新注册</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<NSpin size="large" />
|
|
||||||
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在验证...</h1>
|
|
||||||
<p class="mt-2 text-slate-500">请稍候</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const submitted = ref(false)
|
|
||||||
const cooldown = ref(0)
|
|
||||||
let cooldownTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
const resendButtonLabel = computed(() => {
|
|
||||||
if (loading.value) return '发送中...'
|
|
||||||
if (cooldown.value > 0) return `重新发送(${cooldown.value}s)`
|
|
||||||
return '重新发送'
|
|
||||||
})
|
|
||||||
|
|
||||||
function clearCooldownTimer() {
|
|
||||||
if (!cooldownTimer) return
|
|
||||||
clearInterval(cooldownTimer)
|
|
||||||
cooldownTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCooldown() {
|
|
||||||
clearCooldownTimer()
|
|
||||||
cooldown.value = 60
|
|
||||||
cooldownTimer = setInterval(() => {
|
|
||||||
cooldown.value--
|
|
||||||
if (cooldown.value <= 0) {
|
|
||||||
cooldown.value = 0
|
|
||||||
clearCooldownTimer()
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSendResetEmail() {
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
if (!email.value.trim()) {
|
|
||||||
error.value = '请输入需要找回密码的邮箱。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await authStore.sendPasswordReset(email.value.trim())
|
|
||||||
submitted.value = true
|
|
||||||
startCooldown()
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试。'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resendResetEmail() {
|
|
||||||
if (cooldown.value > 0 || loading.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleSendResetEmail()
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
clearCooldownTimer()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
|
||||||
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
|
||||||
<section class="oc-panel-card-xl min-w-0 overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_52%,#f2faf6_100%)] p-6 sm:p-8">
|
|
||||||
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Password Recovery</p>
|
|
||||||
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
|
||||||
重新拿回你的
|
|
||||||
<span class="block text-sky-700">OpenCloud 账号</span>
|
|
||||||
</h1>
|
|
||||||
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
|
||||||
输入注册邮箱后,我们会发送一封密码重置邮件。打开邮件中的链接,即可进入新的密码设置页面。
|
|
||||||
</p>
|
|
||||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
|
||||||
<div class="border border-slate-200 bg-white px-4 py-3">
|
|
||||||
<div class="oc-stat-label">Step 1</div>
|
|
||||||
<div class="mt-2 text-lg font-bold text-slate-900">发送邮件</div>
|
|
||||||
<div class="oc-field-help">使用注册邮箱请求重置链接</div>
|
|
||||||
</div>
|
|
||||||
<div class="border border-slate-200 bg-white px-4 py-3">
|
|
||||||
<div class="oc-stat-label">Step 2</div>
|
|
||||||
<div class="mt-2 text-lg font-bold text-slate-900">设置新密码</div>
|
|
||||||
<div class="oc-field-help">在专用页面完成新密码更新</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<NCard class="oc-panel-card-xl min-w-0">
|
|
||||||
<NResult
|
|
||||||
v-if="submitted"
|
|
||||||
status="success"
|
|
||||||
title="重置邮件已发送"
|
|
||||||
description="如果该邮箱已注册,请查收邮件中的重置链接。"
|
|
||||||
>
|
|
||||||
<template #footer>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-sm text-slate-500">
|
|
||||||
目标邮箱:
|
|
||||||
<span class="font-semibold text-slate-700">{{ email }}</span>
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap justify-center gap-3">
|
|
||||||
<NButton
|
|
||||||
type="primary"
|
|
||||||
class="oc-primary-button oc-primary-button--teal"
|
|
||||||
:disabled="cooldown > 0"
|
|
||||||
:loading="loading"
|
|
||||||
@click="resendResetEmail"
|
|
||||||
>
|
|
||||||
{{ resendButtonLabel }}
|
|
||||||
</NButton>
|
|
||||||
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline">
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">返回登录</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Reset Request</div>
|
|
||||||
<h2 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">输入你的注册邮箱,我们会发送一封密码重置邮件。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NForm @submit.prevent="handleSendResetEmail">
|
|
||||||
<NFormItem label="邮箱">
|
|
||||||
<NInput
|
|
||||||
v-model:value="email"
|
|
||||||
type="text"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'email',
|
|
||||||
inputmode: 'email',
|
|
||||||
name: 'email',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">
|
|
||||||
{{ error }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NButton
|
|
||||||
attr-type="submit"
|
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
class="oc-primary-button oc-primary-button--sky"
|
|
||||||
:loading="loading"
|
|
||||||
>
|
|
||||||
{{ loading ? '发送中...' : '发送重置邮件' }}
|
|
||||||
</NButton>
|
|
||||||
</NForm>
|
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
|
||||||
<p>如果你已经记起密码,可以直接返回登录。</p>
|
|
||||||
<RouterLink :to="{ path: '/login', query: { email } }" class="font-semibold text-sky-700 hover:text-sky-800">
|
|
||||||
返回登录
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
|
||||||
const password = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
function getSafeRedirect(value: unknown) {
|
|
||||||
if (typeof value !== 'string' || !value.startsWith('/')) return '/'
|
|
||||||
|
|
||||||
try {
|
|
||||||
const target = new URL(value, window.location.origin)
|
|
||||||
return target.origin === window.location.origin
|
|
||||||
? `${target.pathname}${target.search}${target.hash}`
|
|
||||||
: '/'
|
|
||||||
} catch {
|
|
||||||
return '/'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogin() {
|
|
||||||
error.value = ''
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await authStore.login(email.value, password.value)
|
|
||||||
void router.push(getSafeRedirect(route.query.redirect))
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error.value = e instanceof Error ? e.message : '登录失败,请稍后重试'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
|
||||||
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
|
|
||||||
<section class="oc-panel-card-xl flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#f2faf6_0%,#ffffff_48%,#eff6ff_100%)] p-6 sm:p-8">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
|
|
||||||
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
|
||||||
登录后继续记录
|
|
||||||
<span class="block text-teal-700">你眼中的每一朵云</span>
|
|
||||||
</h1>
|
|
||||||
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
|
||||||
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
|
|
||||||
</p> -->
|
|
||||||
</div>
|
|
||||||
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
|
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
|
||||||
<div class="oc-stat-label">Collection</div>
|
|
||||||
<div class="oc-stat-value">10</div>
|
|
||||||
<div class="oc-field-help">基础云属待收集</div>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
|
||||||
<div class="oc-stat-label">Mode</div>
|
|
||||||
<div class="oc-stat-value">Atlas</div>
|
|
||||||
<div class="oc-field-help">地图、图鉴、画廊一体</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Member Access</div>
|
|
||||||
<h2 class="mt-3 text-3xl font-bold text-slate-900">登录 OpenCloud</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">输入邮箱和密码,继续你的天空档案。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NForm @submit.prevent="handleLogin">
|
|
||||||
<NFormItem label="邮箱">
|
|
||||||
<NInput
|
|
||||||
v-model:value="email"
|
|
||||||
type="text"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'username',
|
|
||||||
inputmode: 'email',
|
|
||||||
name: 'username',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NFormItem label="密码">
|
|
||||||
<NInput
|
|
||||||
v-model:value="password"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'current-password',
|
|
||||||
name: 'current-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="输入密码"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">
|
|
||||||
{{ error }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NButton
|
|
||||||
attr-type="submit"
|
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
class="oc-primary-button oc-primary-button--teal"
|
|
||||||
:loading="loading"
|
|
||||||
>
|
|
||||||
{{ loading ? '登录中...' : '登录' }}
|
|
||||||
</NButton>
|
|
||||||
</NForm>
|
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
|
||||||
<p>
|
|
||||||
没有账号?
|
|
||||||
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
|
|
||||||
</p>
|
|
||||||
<RouterLink
|
|
||||||
:to="email ? { path: '/forgot-password', query: { email } } : { path: '/forgot-password' }"
|
|
||||||
class="font-semibold text-teal-700 transition-colors hover:text-teal-800"
|
|
||||||
>
|
|
||||||
忘记密码?
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const email = ref('')
|
|
||||||
const password = ref('')
|
|
||||||
const confirmPassword = ref('')
|
|
||||||
const username = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const emailSent = ref(false)
|
|
||||||
|
|
||||||
async function handleRegister() {
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
if (!username.value.trim()) {
|
|
||||||
error.value = '请输入用户名'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (username.value.length > 20) {
|
|
||||||
error.value = '用户名不能超过20个字符'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password.value.length < 8) {
|
|
||||||
error.value = '密码至少8位'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password.value !== confirmPassword.value) {
|
|
||||||
error.value = '两次密码不一致'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await authStore.register(email.value, password.value, username.value.trim())
|
|
||||||
emailSent.value = true
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
|
||||||
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
|
|
||||||
<section class="oc-panel-card-xl flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f2faf6_100%)] p-6 sm:p-8">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
|
||||||
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
|
||||||
加入天空探索者
|
|
||||||
<span class="block text-sky-700">建立你的云图档案</span>
|
|
||||||
</h1>
|
|
||||||
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
|
||||||
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
|
|
||||||
</p> -->
|
|
||||||
</div>
|
|
||||||
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
|
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
|
||||||
<div class="oc-stat-label">Review</div>
|
|
||||||
<div class="oc-stat-value">Queue</div>
|
|
||||||
<div class="oc-field-help">上传后进入审核队列</div>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
|
||||||
<div class="oc-stat-label">Profile</div>
|
|
||||||
<div class="oc-stat-value">Journal</div>
|
|
||||||
<div class="oc-field-help">自动生成个人天空日志</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
|
||||||
<NResult
|
|
||||||
v-if="emailSent"
|
|
||||||
status="success"
|
|
||||||
title="确认你的邮箱"
|
|
||||||
description="确认邮件已经发送,请查收并点击链接完成注册。"
|
|
||||||
>
|
|
||||||
<template #footer>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-sm text-slate-500">
|
|
||||||
目标邮箱:
|
|
||||||
<span class="font-semibold text-slate-700">{{ email }}</span>
|
|
||||||
</p>
|
|
||||||
<RouterLink to="/login" class="no-underline">
|
|
||||||
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">去登录</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Create Account</div>
|
|
||||||
<h2 class="mt-3 text-3xl font-bold text-slate-900">注册 OpenCloud</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">加入社区后即可开始记录、收集与展示云图。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NForm @submit.prevent="handleRegister">
|
|
||||||
<NFormItem label="用户名">
|
|
||||||
<NInput
|
|
||||||
v-model:value="username"
|
|
||||||
type="text"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'username',
|
|
||||||
name: 'username',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="你的昵称"
|
|
||||||
maxlength="20"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NFormItem label="邮箱">
|
|
||||||
<NInput
|
|
||||||
v-model:value="email"
|
|
||||||
type="text"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'email',
|
|
||||||
inputmode: 'email',
|
|
||||||
name: 'email',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NFormItem label="密码">
|
|
||||||
<NInput
|
|
||||||
v-model:value="password"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="至少8位"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NFormItem label="确认密码">
|
|
||||||
<NInput
|
|
||||||
v-model:value="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'confirm-new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="再次输入密码"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">
|
|
||||||
{{ error }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NButton
|
|
||||||
attr-type="submit"
|
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
class="oc-primary-button oc-primary-button--sky"
|
|
||||||
:loading="loading"
|
|
||||||
>
|
|
||||||
{{ loading ? '注册中...' : '注册' }}
|
|
||||||
</NButton>
|
|
||||||
</NForm>
|
|
||||||
|
|
||||||
<p class="mt-6 text-sm text-slate-500">
|
|
||||||
已有账号?
|
|
||||||
<RouterLink to="/login" class="font-semibold text-sky-700 hover:text-sky-800">去登录</RouterLink>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
|
|
||||||
import { apiRequest } from '@/lib/api'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const password = ref('')
|
|
||||||
const confirmPassword = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
|
|
||||||
const loading = ref(false)
|
|
||||||
const countdown = ref(5)
|
|
||||||
const initialResetToken = typeof route.query.token === 'string' ? route.query.token : ''
|
|
||||||
const resetToken = ref(initialResetToken)
|
|
||||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
if (route.query.token !== undefined) {
|
|
||||||
window.history.replaceState(window.history.state, '', window.location.pathname)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
|
||||||
state.value === 'ready' && password.value.length >= 8 && password.value === confirmPassword.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
function showInvalidRecoveryLink() {
|
|
||||||
error.value = '密码重置链接无效或已过期,请重新发送邮件。'
|
|
||||||
state.value = 'invalid'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResetErrorMessage(value: unknown) {
|
|
||||||
const message = value instanceof Error ? value.message : ''
|
|
||||||
if (message.includes('无效') || message.includes('过期') || message.includes('expired')) {
|
|
||||||
return '密码重置链接无效或已过期,请重新发送邮件。'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('8')) {
|
|
||||||
return '新密码至少需要 8 位。'
|
|
||||||
}
|
|
||||||
|
|
||||||
return message || '密码重置失败,请稍后重试。'
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCountdown() {
|
|
||||||
countdownTimer = setInterval(() => {
|
|
||||||
countdown.value--
|
|
||||||
if (countdown.value <= 0) {
|
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeRecoverySession() {
|
|
||||||
if (!resetToken.value) {
|
|
||||||
showInvalidRecoveryLink()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
state.value = 'ready'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResetPassword() {
|
|
||||||
error.value = ''
|
|
||||||
|
|
||||||
if (password.value.length < 8) {
|
|
||||||
error.value = '新密码至少需要 8 位。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password.value !== confirmPassword.value) {
|
|
||||||
error.value = '两次输入的密码不一致。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await apiRequest('/auth/reset-password', {
|
|
||||||
method: 'POST',
|
|
||||||
auth: false,
|
|
||||||
body: { token: resetToken.value, password: password.value },
|
|
||||||
})
|
|
||||||
countdown.value = 5
|
|
||||||
state.value = 'success'
|
|
||||||
startCountdown()
|
|
||||||
} catch (e) {
|
|
||||||
error.value = getResetErrorMessage(e)
|
|
||||||
if (error.value === '密码重置链接无效或已过期,请重新发送邮件。') {
|
|
||||||
state.value = 'invalid'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void initializeRecoverySession()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
|
||||||
<div class="mx-auto max-w-2xl">
|
|
||||||
<NCard class="oc-panel-card-xl">
|
|
||||||
<NResult
|
|
||||||
v-if="state === 'success'"
|
|
||||||
status="success"
|
|
||||||
title="密码已重置"
|
|
||||||
description="你已使用新密码完成更新,现在可以重新登录。"
|
|
||||||
>
|
|
||||||
<template #footer>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
|
|
||||||
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">立即登录</NButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
|
|
||||||
<template v-else-if="state === 'checking'">
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<NSpin size="large" />
|
|
||||||
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在验证重置链接...</h1>
|
|
||||||
<p class="mt-2 text-slate-500">请稍候</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
|
||||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">
|
|
||||||
{{
|
|
||||||
state === 'invalid'
|
|
||||||
? '当前重置链接不可用,请重新发送密码重置邮件。'
|
|
||||||
: '请输入新的登录密码,提交后当前重置链接会失效。'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NForm @submit.prevent="handleResetPassword">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
autocomplete="username"
|
|
||||||
class="sr-only"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-label="账户用户名"
|
|
||||||
/>
|
|
||||||
<NFormItem label="新密码">
|
|
||||||
<NInput
|
|
||||||
v-model:value="password"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="至少 8 位"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NFormItem label="确认新密码">
|
|
||||||
<NInput
|
|
||||||
v-model:value="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'confirm-new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="再次输入新密码"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">
|
|
||||||
{{ error }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<RouterLink
|
|
||||||
v-if="state === 'invalid'"
|
|
||||||
to="/forgot-password"
|
|
||||||
class="mb-4 block text-sm font-semibold text-teal-700 hover:text-teal-800"
|
|
||||||
>
|
|
||||||
返回重新发送重置邮件
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<NButton
|
|
||||||
attr-type="submit"
|
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
class="oc-primary-button oc-primary-button--teal"
|
|
||||||
:disabled="!canSubmit"
|
|
||||||
:loading="loading"
|
|
||||||
>
|
|
||||||
保存新密码
|
|
||||||
</NButton>
|
|
||||||
</NForm>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f2faf6_0%,#f8fafc_100%)] px-4 py-16">
|
|
||||||
<section class="oc-panel-card-xl mx-auto max-w-4xl border border-slate-200 bg-white p-10">
|
|
||||||
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-teal-700">Community</p>
|
|
||||||
<h1 class="mt-4 text-4xl font-bold text-slate-900">社区</h1>
|
|
||||||
<p class="mt-5 max-w-2xl text-sm leading-7 text-slate-600">
|
|
||||||
社区模块正在规划中。后续这里会承载话题讨论、观测活动和用户互动内容。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
|
||||||
import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
|
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
|
||||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
|
||||||
import { apiRequest } from '@/lib/api'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
|
||||||
import type { ApiCloud, ApiPage } from '@/types/api'
|
|
||||||
import type { CloudType } from '@/types/database'
|
|
||||||
|
|
||||||
interface CloudGalleryItem {
|
|
||||||
id: string
|
|
||||||
image_url: string
|
|
||||||
thumbnail_url: string | null
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
location_name: string | null
|
|
||||||
description: string | null
|
|
||||||
captured_at: string | null
|
|
||||||
created_at: string
|
|
||||||
profiles: {
|
|
||||||
username: string
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const encyclopediaStore = useEncyclopediaStore()
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const loadError = ref('')
|
|
||||||
const gallery = ref<CloudGalleryItem[]>([])
|
|
||||||
const selectedGalleryItem = ref<CloudGalleryItem | null>(null)
|
|
||||||
const publicCount = ref(0)
|
|
||||||
|
|
||||||
const rarityMeta = {
|
|
||||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200', glow: 'from-sky-100 via-white to-cyan-50' },
|
|
||||||
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200', glow: 'from-amber-100 via-white to-orange-50' },
|
|
||||||
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200', glow: 'from-rose-100 via-white to-orange-50' },
|
|
||||||
} satisfies Record<CloudType['rarity'], { label: string; chip: string; glow: string }>
|
|
||||||
|
|
||||||
const cloudTypeId = computed(() => Number(route.params.id))
|
|
||||||
const cloudType = computed(() => encyclopediaStore.cloudTypes.find(item => item.id === cloudTypeId.value) ?? null)
|
|
||||||
const collectionEntry = computed(() => {
|
|
||||||
if (!authStore.isLoggedIn || !cloudType.value) return null
|
|
||||||
return encyclopediaStore.getCollectionEntry(cloudType.value.id)
|
|
||||||
})
|
|
||||||
const isUnlocked = computed(() => !!collectionEntry.value)
|
|
||||||
const heroImage = computed(() => collectionEntry.value?.firstCloud?.image_url || gallery.value[0]?.image_url || null)
|
|
||||||
|
|
||||||
function formatDate(iso: string | null) {
|
|
||||||
if (!iso) return '未知时间'
|
|
||||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatGalleryTime(item: CloudGalleryItem) {
|
|
||||||
return formatDate(item.captured_at || item.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(iso: string | null) {
|
|
||||||
if (!iso) return '未知时间'
|
|
||||||
return new Date(iso).toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'numeric',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCoordinate(value: number | null) {
|
|
||||||
return value === null ? '未记录' : value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openGalleryDetail(item: CloudGalleryItem) {
|
|
||||||
selectedGalleryItem.value = item
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGallery(typeId: number) {
|
|
||||||
const result = await apiRequest<ApiPage<ApiCloud>>(`/cloud-types/${typeId}/clouds?page=1&page_size=24`, { auth: false })
|
|
||||||
gallery.value = result.items.map(row => ({
|
|
||||||
id: row.id,
|
|
||||||
image_url: row.image_url,
|
|
||||||
thumbnail_url: row.thumbnail_url,
|
|
||||||
latitude: row.latitude,
|
|
||||||
longitude: row.longitude,
|
|
||||||
location_name: row.location_name,
|
|
||||||
description: row.description,
|
|
||||||
captured_at: row.captured_at,
|
|
||||||
created_at: row.created_at,
|
|
||||||
profiles: row.username ? { username: row.username } : null,
|
|
||||||
}))
|
|
||||||
publicCount.value = result.total
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPage() {
|
|
||||||
if (!Number.isFinite(cloudTypeId.value) || cloudTypeId.value <= 0) {
|
|
||||||
loadError.value = '云型不存在'
|
|
||||||
loading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
loadError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await encyclopediaStore.fetchCloudTypes()
|
|
||||||
await encyclopediaStore.fetchMyCollection()
|
|
||||||
|
|
||||||
if (!cloudType.value) {
|
|
||||||
loadError.value = '云型不存在'
|
|
||||||
gallery.value = []
|
|
||||||
publicCount.value = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadGallery(cloudType.value.id)
|
|
||||||
} catch (error) {
|
|
||||||
loadError.value = error instanceof Error ? error.message : '详情加载失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadPage)
|
|
||||||
watch(() => route.params.id, loadPage)
|
|
||||||
|
|
||||||
watch(() => route.params.id, () => {
|
|
||||||
selectedGalleryItem.value = null
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
|
||||||
<div class="mb-6">
|
|
||||||
<RouterLink to="/encyclopedia">
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回图鉴总览</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="space-y-6">
|
|
||||||
<NSkeleton class="h-80 w-full" />
|
|
||||||
<div class="grid gap-4 lg:grid-cols-3">
|
|
||||||
<NCard v-for="n in 3" :key="n" class="oc-panel-card border border-slate-200">
|
|
||||||
<NSkeleton class="h-4 w-1/3" />
|
|
||||||
<NSkeleton class="mt-4 h-7 w-1/2" />
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NAlert
|
|
||||||
v-else-if="loadError"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
title="详情加载失败"
|
|
||||||
>
|
|
||||||
{{ loadError }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<template v-else-if="cloudType">
|
|
||||||
<section class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
|
|
||||||
<div class="grid lg:grid-cols-[1.15fr_0.85fr]">
|
|
||||||
<div class="relative min-h-[360px] overflow-hidden">
|
|
||||||
<img
|
|
||||||
v-if="heroImage"
|
|
||||||
:src="heroImage"
|
|
||||||
:alt="cloudType.name"
|
|
||||||
class="absolute inset-0 h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="absolute inset-0 bg-gradient-to-br"
|
|
||||||
:class="rarityMeta[cloudType.rarity].glow"
|
|
||||||
></div>
|
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/78 via-slate-950/18 to-transparent"></div>
|
|
||||||
<div class="absolute bottom-8 left-8 right-8 text-white">
|
|
||||||
<NTag :bordered="false" class="border border-white/20 bg-white/12 text-white backdrop-blur">
|
|
||||||
{{ rarityMeta[cloudType.rarity].label }}
|
|
||||||
</NTag>
|
|
||||||
<h1 class="mt-4 text-4xl font-bold">{{ cloudType.name }}</h1>
|
|
||||||
<p class="mt-2 text-lg text-white/82">{{ cloudType.name_en }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col justify-between bg-slate-50 p-8">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.22em] text-slate-500">Meteorology Note</p>
|
|
||||||
<p class="mt-5 text-base leading-8 text-slate-700">
|
|
||||||
{{ cloudType.description || '这类云的图像、形成条件和观测要点会随着图鉴完善逐步补充。' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="oc-inset-panel mt-8">
|
|
||||||
<p class="text-sm text-slate-500">你的收集状态</p>
|
|
||||||
<template v-if="isUnlocked && collectionEntry">
|
|
||||||
<p class="oc-stat-value">已解锁</p>
|
|
||||||
<p class="mt-2 text-sm text-slate-600">首次解锁于 {{ formatDate(collectionEntry.unlocked_at) }}</p>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<p class="oc-stat-value">尚未解锁</p>
|
|
||||||
<p class="mt-2 text-sm text-slate-600">拍到并上传这种云朵后,这枚徽章就会被点亮。</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="mt-6 grid gap-4 md:grid-cols-3">
|
|
||||||
<NCard class="oc-panel-card border border-slate-200">
|
|
||||||
<p class="text-sm text-slate-500">稀有度</p>
|
|
||||||
<p class="oc-stat-value">{{ rarityMeta[cloudType.rarity].label }}</p>
|
|
||||||
</NCard>
|
|
||||||
<NCard class="oc-panel-card border border-slate-200">
|
|
||||||
<p class="text-sm text-slate-500">公开云图</p>
|
|
||||||
<p class="oc-stat-value">{{ publicCount }}</p>
|
|
||||||
</NCard>
|
|
||||||
<NCard class="oc-panel-card border border-slate-200">
|
|
||||||
<p class="text-sm text-slate-500">首次解锁</p>
|
|
||||||
<p class="mt-2 text-xl font-bold text-slate-900">
|
|
||||||
{{ collectionEntry ? formatDate(collectionEntry.unlocked_at) : '等待记录' }}
|
|
||||||
</p>
|
|
||||||
</NCard>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="mt-8">
|
|
||||||
<div class="mb-4 flex items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-slate-900">该类型云图画廊</h2>
|
|
||||||
<p class="oc-field-help">来自社区公开可见的已通过云图。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="gallery.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<button
|
|
||||||
v-for="item in gallery"
|
|
||||||
:key="item.id"
|
|
||||||
type="button"
|
|
||||||
class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left transition-transform duration-300 hover:-translate-y-1"
|
|
||||||
@click="openGalleryDetail(item)"
|
|
||||||
>
|
|
||||||
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-56 w-full object-cover" />
|
|
||||||
<div class="p-5">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<p class="text-sm font-medium text-slate-900">{{ item.profiles?.username || '匿名' }}</p>
|
|
||||||
<p class="text-xs text-slate-400">{{ formatGalleryTime(item) }}</p>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ item.location_name || '未填写位置' }}</p>
|
|
||||||
<p v-if="item.description" class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500">
|
|
||||||
{{ item.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="border border-dashed border-slate-300 bg-white p-8">
|
|
||||||
<NEmpty description="还没有公开作品">
|
|
||||||
<template #extra>
|
|
||||||
<p class="text-sm text-slate-500">等第一位观测者上传这类云图后,这里就会展示出来。</p>
|
|
||||||
</template>
|
|
||||||
</NEmpty>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<ImageDetailModal
|
|
||||||
v-if="selectedGalleryItem"
|
|
||||||
:open="!!selectedGalleryItem"
|
|
||||||
:image-url="selectedGalleryItem.image_url"
|
|
||||||
:thumbnail-url="selectedGalleryItem.thumbnail_url"
|
|
||||||
:image-alt="cloudType.name"
|
|
||||||
:title="cloudType.name"
|
|
||||||
:subtitle="`上传者:${selectedGalleryItem.profiles?.username || '匿名'}`"
|
|
||||||
:badge-label="rarityMeta[cloudType.rarity].label"
|
|
||||||
:badge-class="rarityMeta[cloudType.rarity].chip"
|
|
||||||
@close="selectedGalleryItem = null"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">上传时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedGalleryItem.created_at) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">拍摄时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedGalleryItem.captured_at) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">位置</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedGalleryItem.location_name || '未填写位置名称' }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">模糊化经纬度</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
纬度 {{ formatCoordinate(selectedGalleryItem.latitude) }} / 经度 {{ formatCoordinate(selectedGalleryItem.longitude) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
|
||||||
<p class="oc-section-label">图片说明</p>
|
|
||||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
|
||||||
{{ selectedGalleryItem.description || '上传者没有留下额外说明。' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<MiniLocationMap
|
|
||||||
:latitude="selectedGalleryItem.latitude"
|
|
||||||
:longitude="selectedGalleryItem.longitude"
|
|
||||||
:location-name="selectedGalleryItem.location_name"
|
|
||||||
/>
|
|
||||||
</ImageDetailModal>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { NAlert, NEmpty, NIcon, NSkeleton } from 'naive-ui'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
|
||||||
import EncyclopediaProgressCard from '@/components/cloud/EncyclopediaProgressCard.vue'
|
|
||||||
import { Lock } from '@vicons/tabler'
|
|
||||||
import type { CloudType } from '@/types/database'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const encyclopediaStore = useEncyclopediaStore()
|
|
||||||
|
|
||||||
const lockHint = ref('')
|
|
||||||
let lockHintTimer: number | null = null
|
|
||||||
|
|
||||||
const rarityMeta = {
|
|
||||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200', glow: 'from-sky-100 to-white' },
|
|
||||||
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200', glow: 'from-amber-100 to-white' },
|
|
||||||
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200', glow: 'from-rose-100 to-white' },
|
|
||||||
} satisfies Record<CloudType['rarity'], { label: string; chip: string; glow: string }>
|
|
||||||
|
|
||||||
function isUnlocked(cloudTypeId: number) {
|
|
||||||
return authStore.isLoggedIn && encyclopediaStore.isUnlocked(cloudTypeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCollectionEntry(cloudTypeId: number) {
|
|
||||||
if (!authStore.isLoggedIn) return null
|
|
||||||
return encyclopediaStore.getCollectionEntry(cloudTypeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUnlockedAt(iso: string) {
|
|
||||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
|
||||||
month: 'numeric',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLockHint(name: string) {
|
|
||||||
lockHint.value = `拍到 ${name} 后,这枚徽章就会亮起来。`
|
|
||||||
if (lockHintTimer) window.clearTimeout(lockHintTimer)
|
|
||||||
lockHintTimer = window.setTimeout(() => {
|
|
||||||
lockHint.value = ''
|
|
||||||
lockHintTimer = null
|
|
||||||
}, 2200)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCard(cloudType: CloudType) {
|
|
||||||
if (isUnlocked(cloudType.id)) {
|
|
||||||
router.push(`/encyclopedia/${cloudType.id}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showLockHint(cloudType.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await encyclopediaStore.fetchCloudTypes()
|
|
||||||
await encyclopediaStore.fetchMyCollection()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="oc-page-hero">
|
|
||||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
|
||||||
<div class="oc-page-hero__split">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
|
|
||||||
<h1 class="oc-page-title">云朵图鉴</h1>
|
|
||||||
<p class="oc-page-description">
|
|
||||||
收集 10 种基础云属。每拍到一种新的云型,图鉴里就会点亮一枚徽章,并记录你第一次遇见它的时间。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EncyclopediaProgressCard
|
|
||||||
:unlocked-count="authStore.isLoggedIn ? encyclopediaStore.unlockedCount : 0"
|
|
||||||
:total-count="encyclopediaStore.cloudTypes.length || 10"
|
|
||||||
:is-logged-in="authStore.isLoggedIn"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="oc-container oc-container--content oc-page-content">
|
|
||||||
<NAlert
|
|
||||||
v-if="encyclopediaStore.collectionError && authStore.isLoggedIn"
|
|
||||||
class="mb-6"
|
|
||||||
type="warning"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
title="图鉴收藏数据暂时不可用"
|
|
||||||
>
|
|
||||||
图鉴收藏数据暂时不可用:{{ encyclopediaStore.collectionError }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div v-for="n in 6" :key="n" class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
|
|
||||||
<NSkeleton class="h-44 w-full border-b border-slate-200" />
|
|
||||||
<div class="p-5">
|
|
||||||
<NSkeleton class="h-3 w-1/3" />
|
|
||||||
<NSkeleton class="mt-3 h-6 w-2/5" />
|
|
||||||
<NSkeleton class="mt-2 h-3 w-1/2" />
|
|
||||||
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
|
|
||||||
<NSkeleton class="mt-5 h-10 w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<button
|
|
||||||
v-for="cloudType in encyclopediaStore.cloudTypes"
|
|
||||||
:key="cloudType.id"
|
|
||||||
type="button"
|
|
||||||
@click="openCard(cloudType)"
|
|
||||||
class="encyclopedia-type-card oc-interactive-card oc-panel-card flex h-full flex-col overflow-hidden border bg-white text-left"
|
|
||||||
:class="isUnlocked(cloudType.id) ? 'border-sky-200' : 'border-slate-200'"
|
|
||||||
>
|
|
||||||
<div class="relative h-44 shrink-0 overflow-hidden border-b border-slate-200 bg-sky-50">
|
|
||||||
<img
|
|
||||||
v-if="getCollectionEntry(cloudType.id)?.firstCloud?.image_url"
|
|
||||||
:src="getCollectionEntry(cloudType.id)?.firstCloud?.thumbnail_url || getCollectionEntry(cloudType.id)?.firstCloud?.image_url || ''"
|
|
||||||
:alt="cloudType.name"
|
|
||||||
class="encyclopedia-type-card__image h-full w-full object-cover"
|
|
||||||
:class="isUnlocked(cloudType.id) ? '' : 'grayscale contrast-75'"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex h-full w-full items-center justify-center bg-gradient-to-br"
|
|
||||||
:class="isUnlocked(cloudType.id) ? rarityMeta[cloudType.rarity].glow : 'from-slate-200 to-slate-50'"
|
|
||||||
>
|
|
||||||
<span class="font-mono text-5xl font-bold text-slate-500/75">{{ cloudType.name.slice(0, 1) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/12">
|
|
||||||
<span class="encyclopedia-lock-badge inline-flex items-center gap-2 border border-slate-300 bg-white/92 px-3 py-2 text-xs font-semibold text-slate-700 backdrop-blur">
|
|
||||||
<NIcon size="15"><Lock /></NIcon>
|
|
||||||
尚未收录
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute left-4 top-4">
|
|
||||||
<span class="inline-flex border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em]" :class="rarityMeta[cloudType.rarity].chip">
|
|
||||||
{{ rarityMeta[cloudType.rarity].label }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col p-5">
|
|
||||||
<p class="oc-section-label">Cloud Genus</p>
|
|
||||||
<h2 class="mt-2 text-xl font-bold text-slate-900">{{ cloudType.name }}</h2>
|
|
||||||
<p class="mt-1 font-mono text-xs uppercase tracking-[0.12em] text-slate-500">{{ cloudType.name_en }}</p>
|
|
||||||
|
|
||||||
<p class="mt-4 line-clamp-2 min-h-[3.25rem] text-sm leading-6 text-slate-600">
|
|
||||||
{{ cloudType.description || '云层特征和识别要点会显示在这里。' }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-5 flex items-center justify-between gap-3 border-t border-slate-200 pt-4 text-xs">
|
|
||||||
<template v-if="isUnlocked(cloudType.id) && getCollectionEntry(cloudType.id)">
|
|
||||||
<span class="font-semibold uppercase tracking-[0.12em] text-sky-800">已收录</span>
|
|
||||||
<span class="text-right text-slate-500">首次记录 {{ formatUnlockedAt(getCollectionEntry(cloudType.id)!.unlocked_at) }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="font-semibold uppercase tracking-[0.12em] text-slate-600">等待发现</span>
|
|
||||||
<span class="text-right text-slate-400">拍到后点亮</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white px-6 py-12">
|
|
||||||
<NEmpty description="暂时还没有图鉴数据" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="lockHint"
|
|
||||||
class="encyclopedia-lock-hint fixed bottom-6 left-1/2 z-50 -translate-x-1/2 border border-slate-900 bg-slate-900 px-4 py-2 text-sm text-white"
|
|
||||||
>
|
|
||||||
{{ lockHint }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.encyclopedia-lock-badge {
|
|
||||||
box-shadow: var(--oc-shadow-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-lock-hint {
|
|
||||||
box-shadow: var(--oc-shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-type-card:hover {
|
|
||||||
border-color: rgb(125 211 252);
|
|
||||||
box-shadow: var(--oc-shadow-lg) !important;
|
|
||||||
transform: translate(-1px, -1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-type-card:active {
|
|
||||||
box-shadow: var(--oc-shadow-md) !important;
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-type-card__image {
|
|
||||||
transition: transform 500ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-type-card:hover .encyclopedia-type-card__image {
|
|
||||||
transform: scale(1.035);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.encyclopedia-type-card,
|
|
||||||
.encyclopedia-type-card__image {
|
|
||||||
transition-duration: 1ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.encyclopedia-type-card:hover,
|
|
||||||
.encyclopedia-type-card:hover .encyclopedia-type-card__image {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,606 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
||||||
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
|
||||||
import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
|
|
||||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
|
||||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
|
||||||
import { apiRequest } from '@/lib/api'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { useCloudsStore } from '@/stores/clouds'
|
|
||||||
import { useProfileStore } from '@/stores/profile'
|
|
||||||
|
|
||||||
import type { ApiCloud, ApiPage } from '@/types/api'
|
|
||||||
import type { CloudType } from '@/types/database'
|
|
||||||
|
|
||||||
interface GalleryCloud {
|
|
||||||
id: string
|
|
||||||
user_id: string
|
|
||||||
cloud_type_id: number | null
|
|
||||||
custom_cloud_type: string | null
|
|
||||||
image_url: string
|
|
||||||
thumbnail_url: string | null
|
|
||||||
location_name: string | null
|
|
||||||
description: string | null
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
captured_at: string | null
|
|
||||||
created_at: string
|
|
||||||
status: 'pending' | 'approved' | 'rejected'
|
|
||||||
is_hidden: boolean
|
|
||||||
cloudTypeName: string
|
|
||||||
cloudTypeRarity: CloudType['rarity']
|
|
||||||
username: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const cloudsStore = useCloudsStore()
|
|
||||||
const profileStore = useProfileStore()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const loadError = ref('')
|
|
||||||
const galleryItems = ref<GalleryCloud[]>([])
|
|
||||||
const selectedTypeId = ref<number | 'all'>('all')
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const selectedCloud = ref<GalleryCloud | null>(null)
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const totalCount = ref(0)
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / PAGE_SIZE)))
|
|
||||||
const manageError = ref('')
|
|
||||||
const editModalOpen = ref(false)
|
|
||||||
const editSaving = ref(false)
|
|
||||||
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
|
||||||
|
|
||||||
let searchTimer: number | null = null
|
|
||||||
|
|
||||||
const rarityMeta = {
|
|
||||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
|
|
||||||
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
|
|
||||||
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
|
||||||
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
|
|
||||||
|
|
||||||
function formatDateTime(iso: string | null) {
|
|
||||||
if (!iso) return '未知时间'
|
|
||||||
return new Date(iso).toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'numeric',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUploadTime(cloud: GalleryCloud) {
|
|
||||||
return formatDateTime(cloud.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCapturedTime(cloud: GalleryCloud) {
|
|
||||||
return formatDateTime(cloud.captured_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCoordinate(value: number | null) {
|
|
||||||
return value === null ? '未记录' : value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedSearch = computed(() => searchQuery.value.trim())
|
|
||||||
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
|
|
||||||
|
|
||||||
function toGalleryCloud(row: ApiCloud) {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
user_id: row.user_id,
|
|
||||||
cloud_type_id: row.cloud_type_id,
|
|
||||||
custom_cloud_type: row.custom_cloud_type,
|
|
||||||
image_url: row.image_url,
|
|
||||||
thumbnail_url: row.thumbnail_url,
|
|
||||||
location_name: row.location_name,
|
|
||||||
description: row.description,
|
|
||||||
latitude: row.latitude,
|
|
||||||
longitude: row.longitude,
|
|
||||||
captured_at: row.captured_at,
|
|
||||||
created_at: row.created_at,
|
|
||||||
status: row.status,
|
|
||||||
is_hidden: row.is_hidden,
|
|
||||||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
|
||||||
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
|
||||||
username: row.username || '匿名',
|
|
||||||
} satisfies GalleryCloud
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPage(page: number) {
|
|
||||||
loading.value = true
|
|
||||||
loadError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
|
||||||
if (selectedTypeId.value !== 'all') params.set('type_id', String(selectedTypeId.value))
|
|
||||||
if (normalizedSearch.value) params.set('search', normalizedSearch.value)
|
|
||||||
|
|
||||||
const result = await apiRequest<ApiPage<ApiCloud>>(`/clouds?${params}`, { auth: false })
|
|
||||||
totalCount.value = result.total
|
|
||||||
galleryItems.value = result.items.map(toGalleryCloud)
|
|
||||||
currentPage.value = page
|
|
||||||
} catch (error) {
|
|
||||||
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goToPage(page: number) {
|
|
||||||
if (page < 1 || page > totalPages.value || page === currentPage.value) return
|
|
||||||
await loadPage(page)
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDetail(cloud: GalleryCloud) {
|
|
||||||
selectedCloud.value = cloud
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDetail() {
|
|
||||||
selectedCloud.value = null
|
|
||||||
editModalOpen.value = false
|
|
||||||
manageError.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedCloudIsMine = computed(() => {
|
|
||||||
return !!selectedCloud.value && selectedCloud.value.user_id === authStore.user?.id
|
|
||||||
})
|
|
||||||
|
|
||||||
const cloudActionOptions = computed(() => [
|
|
||||||
{ label: '编辑图片信息', key: 'edit' },
|
|
||||||
{ label: '删除', key: 'delete' },
|
|
||||||
{ label: '更改为私密', key: 'toggle-privacy' },
|
|
||||||
])
|
|
||||||
|
|
||||||
function formatDatetimeLocal(iso: string | null) {
|
|
||||||
if (!iso) return ''
|
|
||||||
const date = new Date(iso)
|
|
||||||
const pad = (value: number) => String(value).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function blurCoordinate(value: number): number {
|
|
||||||
return Math.round(value * 100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditModal() {
|
|
||||||
if (!selectedCloud.value) return
|
|
||||||
|
|
||||||
manageError.value = ''
|
|
||||||
editInitialValue.value = {
|
|
||||||
cloudCategoryId: selectedCloud.value.cloud_type_id ?? (selectedCloud.value.custom_cloud_type ? 'other' : null),
|
|
||||||
customCloudType: selectedCloud.value.custom_cloud_type || '',
|
|
||||||
capturedAt: formatDatetimeLocal(selectedCloud.value.captured_at),
|
|
||||||
latitude: selectedCloud.value.latitude,
|
|
||||||
longitude: selectedCloud.value.longitude,
|
|
||||||
locationName: selectedCloud.value.location_name || '',
|
|
||||||
description: selectedCloud.value.description || '',
|
|
||||||
isPublic: !selectedCloud.value.is_hidden,
|
|
||||||
}
|
|
||||||
editModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditModal() {
|
|
||||||
editModalOpen.value = false
|
|
||||||
editSaving.value = false
|
|
||||||
manageError.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchGalleryCloud(updated: GalleryCloud) {
|
|
||||||
galleryItems.value = galleryItems.value.map(item => (item.id === updated.id ? updated : item))
|
|
||||||
if (selectedCloud.value?.id === updated.id) {
|
|
||||||
selectedCloud.value = updated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitEditModal(value: CloudEditFormValue) {
|
|
||||||
if (!selectedCloud.value || !authStore.user?.id) return
|
|
||||||
|
|
||||||
if (!value.cloudCategoryId) {
|
|
||||||
manageError.value = '请选择云型类别。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (value.cloudCategoryId === 'other' && !value.customCloudType.trim()) {
|
|
||||||
manageError.value = '请输入自定义云型名称。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!value.capturedAt) {
|
|
||||||
manageError.value = '请选择拍摄时间。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
|
||||||
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
|
||||||
if (hasLat !== hasLng) {
|
|
||||||
manageError.value = '经纬度必须同时填写。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = selectedCloud.value
|
|
||||||
editSaving.value = true
|
|
||||||
manageError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await profileStore.updateCloud(authStore.user.id, current.id, {
|
|
||||||
cloud_type_id: value.cloudCategoryId === 'other' ? null : value.cloudCategoryId,
|
|
||||||
custom_cloud_type: value.cloudCategoryId === 'other' ? value.customCloudType.trim() : null,
|
|
||||||
latitude: hasLat ? blurCoordinate(value.latitude as number) : null,
|
|
||||||
longitude: hasLng ? blurCoordinate(value.longitude as number) : null,
|
|
||||||
location_name: value.locationName.trim() || null,
|
|
||||||
description: value.description.trim() || null,
|
|
||||||
captured_at: new Date(value.capturedAt).toISOString(),
|
|
||||||
is_hidden: !value.isPublic,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (updated.is_hidden || updated.status !== 'approved') {
|
|
||||||
removeGalleryCloud(updated.id)
|
|
||||||
} else {
|
|
||||||
patchGalleryCloud({
|
|
||||||
...current,
|
|
||||||
...updated,
|
|
||||||
user_id: current.user_id,
|
|
||||||
username: current.username,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
closeEditModal()
|
|
||||||
message.success('图片信息已更新')
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '图片信息更新失败'
|
|
||||||
message.error(manageError.value)
|
|
||||||
} finally {
|
|
||||||
editSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeGalleryCloud(cloudId: string) {
|
|
||||||
galleryItems.value = galleryItems.value.filter(item => item.id !== cloudId)
|
|
||||||
if (selectedCloud.value?.id === cloudId) {
|
|
||||||
selectedCloud.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSelectedCloud() {
|
|
||||||
if (!selectedCloud.value || !authStore.user?.id) return
|
|
||||||
const cloudId = selectedCloud.value.id
|
|
||||||
const confirmed = window.confirm('确定删除这张图片吗?删除后无法在页面中恢复。')
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
manageError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deletedCount = await profileStore.deleteClouds(authStore.user.id, [cloudId])
|
|
||||||
removeGalleryCloud(cloudId)
|
|
||||||
message.success(`已成功删除 ${deletedCount} 张照片`)
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '图片删除失败'
|
|
||||||
message.error(manageError.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hideSelectedCloud() {
|
|
||||||
if (!selectedCloud.value || !authStore.user?.id) return
|
|
||||||
const cloudId = selectedCloud.value.id
|
|
||||||
|
|
||||||
manageError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await profileStore.updateCloudVisibility(authStore.user.id, cloudId, true)
|
|
||||||
removeGalleryCloud(cloudId)
|
|
||||||
message.success('已更改为私密图片')
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
|
||||||
message.error(manageError.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCloudAction(key: string | number) {
|
|
||||||
if (key === 'edit') openEditModal()
|
|
||||||
if (key === 'delete') deleteSelectedCloud()
|
|
||||||
if (key === 'toggle-privacy') hideSelectedCloud()
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSearch() {
|
|
||||||
searchQuery.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterTabs = computed(() => [
|
|
||||||
{ id: 'all' as const, label: '全部' },
|
|
||||||
...cloudsStore.cloudTypes.map(type => ({ id: type.id, label: type.name })),
|
|
||||||
])
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await cloudsStore.fetchCloudTypes()
|
|
||||||
await loadPage(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(selectedTypeId, async () => {
|
|
||||||
selectedCloud.value = null
|
|
||||||
await loadPage(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(searchQuery, () => {
|
|
||||||
if (searchTimer !== null) {
|
|
||||||
window.clearTimeout(searchTimer)
|
|
||||||
}
|
|
||||||
searchTimer = window.setTimeout(async () => {
|
|
||||||
selectedCloud.value = null
|
|
||||||
await loadPage(1)
|
|
||||||
searchTimer = null
|
|
||||||
}, 250)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (searchTimer !== null) {
|
|
||||||
window.clearTimeout(searchTimer)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<section class="oc-page-hero">
|
|
||||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
|
||||||
<p class="oc-page-eyebrow">Community Gallery</p>
|
|
||||||
<h1 class="oc-page-title">云图画廊</h1>
|
|
||||||
<p class="oc-page-description">
|
|
||||||
按上传时间倒序浏览社区云图。瀑布流会尽量保留图片原始比例,悬停即可快速查看基本信息,点开可看大图和详细记录。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="oc-container oc-container--content oc-page-content">
|
|
||||||
<section>
|
|
||||||
<div class="oc-panel-card mb-4 border border-slate-200 bg-white p-3">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<NIcon size="20" class="shrink-0 text-slate-400">
|
|
||||||
<Search />
|
|
||||||
</NIcon>
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="text"
|
|
||||||
class="min-w-0 flex-1 bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400"
|
|
||||||
placeholder="搜索云型名称,或输入 @用户名 查看某个用户的图片"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="searchQuery"
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button oc-icon-button--small"
|
|
||||||
title="清空搜索"
|
|
||||||
aria-label="清空搜索"
|
|
||||||
@click="clearSearch"
|
|
||||||
>
|
|
||||||
<NIcon size="18">
|
|
||||||
<X />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="normalizedSearch" class="mt-2 text-xs text-slate-500">
|
|
||||||
<template v-if="isUserSearch">
|
|
||||||
正在按上传者搜索:{{ normalizedSearch.slice(1) || '请输入用户名' }}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
正在匹配云型名称和自定义云型:{{ normalizedSearch }}
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 overflow-x-auto pb-2">
|
|
||||||
<NButton
|
|
||||||
v-for="tab in filterTabs"
|
|
||||||
:key="tab.id"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
@click="selectedTypeId = tab.id"
|
|
||||||
class="shrink-0"
|
|
||||||
:class="selectedTypeId === tab.id ? 'oc-panel-button oc-panel-button--sky' : 'oc-panel-button oc-panel-button--neutral'"
|
|
||||||
type="default"
|
|
||||||
>
|
|
||||||
{{ tab.label }}
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<NAlert v-if="loadError" class="mt-6" type="error" :show-icon="false" :bordered="false" title="画廊加载失败">
|
|
||||||
{{ loadError }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NAlert v-if="manageError" class="mt-6" type="error" :show-icon="false" :bordered="false" title="操作失败">
|
|
||||||
{{ manageError }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<section v-if="loading" class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]">
|
|
||||||
<div
|
|
||||||
v-for="n in 8"
|
|
||||||
:key="n"
|
|
||||||
class="oc-panel-card mb-3 break-inside-avoid border border-slate-200 bg-white p-3"
|
|
||||||
>
|
|
||||||
<NSkeleton class="h-44 w-full" />
|
|
||||||
<NSkeleton class="mt-4 h-4 w-3/5" />
|
|
||||||
<NSkeleton class="mt-3 h-3 w-2/5" />
|
|
||||||
<NSkeleton class="mt-2 h-3 w-4/5" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="galleryItems.length" class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]">
|
|
||||||
<button
|
|
||||||
v-for="cloud in galleryItems"
|
|
||||||
:key="cloud.id"
|
|
||||||
type="button"
|
|
||||||
@click="openDetail(cloud)"
|
|
||||||
class="oc-interactive-card group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left transition-transform duration-300 hover:-translate-y-1"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="cloud.thumbnail_url || cloud.image_url"
|
|
||||||
:alt="cloud.cloudTypeName"
|
|
||||||
class="block h-auto w-full object-cover transition duration-500 group-hover:scale-[1.04]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/88 via-slate-950/45 to-transparent opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"></div>
|
|
||||||
<div class="absolute inset-x-0 bottom-0 p-4 text-white opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<p class="truncate text-sm font-semibold">{{ cloud.cloudTypeName }}</p>
|
|
||||||
<NTag size="small" :bordered="false" class="shrink-0 bg-white/12 text-white backdrop-blur">
|
|
||||||
{{ rarityMeta[cloud.cloudTypeRarity].label }}
|
|
||||||
</NTag>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 flex items-center gap-1.5 truncate text-xs text-white/82">
|
|
||||||
<NIcon size="14">
|
|
||||||
<User />
|
|
||||||
</NIcon>
|
|
||||||
<span class="truncate">{{ cloud.username }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82">
|
|
||||||
<NIcon size="14">
|
|
||||||
<Clock />
|
|
||||||
</NIcon>
|
|
||||||
<span class="truncate">{{ formatUploadTime(cloud) }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82">
|
|
||||||
<NIcon size="14">
|
|
||||||
<Location />
|
|
||||||
</NIcon>
|
|
||||||
<span class="truncate">{{ cloud.location_name || '未填写位置' }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else class="oc-empty-card mt-6 border border-dashed border-slate-300 bg-white px-6 py-12">
|
|
||||||
<NEmpty description="还没有符合条件的云图">
|
|
||||||
<template #extra>
|
|
||||||
<p class="text-sm text-slate-500">换个云型筛选试试,或者等社区上传更多作品。</p>
|
|
||||||
</template>
|
|
||||||
</NEmpty>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div v-if="totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
:disabled="currentPage <= 1"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="goToPage(currentPage - 1)"
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</NButton>
|
|
||||||
|
|
||||||
<template v-for="page in totalPages" :key="page">
|
|
||||||
<NButton
|
|
||||||
v-if="page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
:class="page === currentPage ? 'oc-panel-button oc-panel-button--sky' : 'oc-panel-button oc-panel-button--neutral'"
|
|
||||||
type="default"
|
|
||||||
@click="goToPage(page)"
|
|
||||||
>
|
|
||||||
{{ page }}
|
|
||||||
</NButton>
|
|
||||||
<span
|
|
||||||
v-else-if="page === 2 || page === totalPages - 1"
|
|
||||||
class="px-1 text-sm text-slate-400"
|
|
||||||
>...</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
:disabled="currentPage >= totalPages"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="goToPage(currentPage + 1)"
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="totalCount > 0" class="mt-2 text-center text-xs text-slate-400">
|
|
||||||
共 {{ totalCount }} 张照片,第 {{ currentPage }} / {{ totalPages }} 页
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ImageDetailModal
|
|
||||||
v-if="selectedCloud"
|
|
||||||
:open="!!selectedCloud"
|
|
||||||
:image-url="selectedCloud.image_url"
|
|
||||||
:thumbnail-url="selectedCloud.thumbnail_url"
|
|
||||||
:image-alt="selectedCloud.cloudTypeName"
|
|
||||||
:title="selectedCloud.cloudTypeName"
|
|
||||||
:subtitle="`上传者:${selectedCloud.username}`"
|
|
||||||
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
|
|
||||||
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
|
|
||||||
@close="closeDetail"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">上传时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatUploadTime(selectedCloud) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">拍摄时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatCapturedTime(selectedCloud) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">位置</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">模糊化经纬度</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度 {{ formatCoordinate(selectedCloud.longitude) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
|
||||||
<p class="oc-section-label">图片说明</p>
|
|
||||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
|
||||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<MiniLocationMap
|
|
||||||
:latitude="selectedCloud.latitude"
|
|
||||||
:longitude="selectedCloud.longitude"
|
|
||||||
:location-name="selectedCloud.location_name"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-if="selectedCloudIsMine" #actions>
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<NDropdown
|
|
||||||
trigger="click"
|
|
||||||
placement="top-start"
|
|
||||||
:options="cloudActionOptions"
|
|
||||||
@select="handleCloudAction"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button"
|
|
||||||
title="图片管理"
|
|
||||||
aria-label="图片管理"
|
|
||||||
>
|
|
||||||
<NIcon size="20">
|
|
||||||
<Settings />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
</NDropdown>
|
|
||||||
<!-- <span class="text-xs text-slate-500">这是你上传的公开图片</span> -->
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ImageDetailModal>
|
|
||||||
|
|
||||||
<CloudEditModal
|
|
||||||
:open="editModalOpen && !!selectedCloud"
|
|
||||||
:saving="editSaving"
|
|
||||||
:error="manageError"
|
|
||||||
:cloud-types="cloudsStore.cloudTypes"
|
|
||||||
:initial-value="editInitialValue"
|
|
||||||
@close="closeEditModal"
|
|
||||||
@save="submitEditModal"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,971 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
|
||||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
|
||||||
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
|
|
||||||
import { apiRequest } from '@/lib/api'
|
|
||||||
import { loadAMap } from '@/lib/amap'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import type { ApiCloud } from '@/types/api'
|
|
||||||
import { NIcon } from 'naive-ui'
|
|
||||||
import { Adjustments, Calendar, CloudUpload, Minus, Plus, Refresh, Map, Satellite, X } from '@vicons/tabler'
|
|
||||||
|
|
||||||
interface CloudMarkerData {
|
|
||||||
id: string
|
|
||||||
latitude: number
|
|
||||||
longitude: number
|
|
||||||
imageUrl: string
|
|
||||||
thumbnailUrl: string | null
|
|
||||||
locationName: string | null
|
|
||||||
description: string | null
|
|
||||||
cloudTypeName: string
|
|
||||||
rarity: 'common' | 'uncommon' | 'rare'
|
|
||||||
username: string
|
|
||||||
capturedAt: string
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapEl = ref<HTMLDivElement>()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const previewCloud = ref<CloudMarkerData | null>(null)
|
|
||||||
const satelliteOn = ref(false)
|
|
||||||
const statusText = ref('加载中...')
|
|
||||||
const quickUploadOpen = ref(false)
|
|
||||||
const mapMode = ref<'realtime' | 'archive'>('realtime')
|
|
||||||
const timelineControlsOpen = ref(false)
|
|
||||||
const archivePanelOpen = ref(false)
|
|
||||||
const archiveKind = ref<'day' | 'month'>('day')
|
|
||||||
const archiveDay = ref(formatDateInput(new Date()))
|
|
||||||
const archiveMonth = ref(formatMonthInput(new Date()))
|
|
||||||
const currentMinuteOfDay = ref(getMinuteOfDay(new Date()))
|
|
||||||
const selectedMinuteOfDay = ref(currentMinuteOfDay.value)
|
|
||||||
const sliderDragging = ref(false)
|
|
||||||
const currentZoom = ref(5)
|
|
||||||
|
|
||||||
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
|
|
||||||
const MIN_MARKER_OPACITY = 0.3
|
|
||||||
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
|
||||||
const MIN_MAP_ZOOM = 4
|
|
||||||
const MAX_MAP_ZOOM = 18
|
|
||||||
const ZOOM_HOLD_DELAY_MS = 180
|
|
||||||
const ZOOM_CONTINUOUS_SPEED = 2.2
|
|
||||||
|
|
||||||
let AMapLib: typeof AMap | null = null
|
|
||||||
let mapInst: AMap.Map | null = null
|
|
||||||
let satLayer: AMap.TileLayer | null = null
|
|
||||||
let roadLayer: AMap.TileLayer | null = null
|
|
||||||
let mks: AMap.Marker[] = []
|
|
||||||
let hoverIW: AMap.InfoWindow | null = null
|
|
||||||
let allClouds: CloudMarkerData[] = []
|
|
||||||
let redrawTimer: number | null = null
|
|
||||||
let zoomHoldDelayTimer: number | null = null
|
|
||||||
let zoomAnimationFrame: number | null = null
|
|
||||||
let activeZoomPointerId: number | null = null
|
|
||||||
let zoomContinuousDirection: -1 | 1 | null = null
|
|
||||||
let zoomAnimationValue = currentZoom.value
|
|
||||||
let zoomLastFrameTime = 0
|
|
||||||
let zoomHoldStarted = false
|
|
||||||
|
|
||||||
const rarityColors: Record<string, string> = {
|
|
||||||
common: '#cbd5e1',
|
|
||||||
uncommon: '#f59e0b',
|
|
||||||
rare: '#f43f5e',
|
|
||||||
}
|
|
||||||
|
|
||||||
const rarityMeta = {
|
|
||||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
|
|
||||||
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
|
|
||||||
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const selectedTimeLabel = computed(() => formatMinuteOfDay(selectedMinuteOfDay.value))
|
|
||||||
const currentMinutePercent = computed(() => Math.min(100, Math.max(0, currentMinuteOfDay.value / 1439 * 100)))
|
|
||||||
const selectedMinutePercent = computed(() => Math.min(100, Math.max(0, selectedMinuteOfDay.value / 1439 * 100)))
|
|
||||||
const archiveTitle = computed(() => {
|
|
||||||
if (mapMode.value === 'realtime') return '实时'
|
|
||||||
return archiveKind.value === 'day' ? archiveDay.value : archiveMonth.value
|
|
||||||
})
|
|
||||||
function getMinuteOfDay(date: Date) {
|
|
||||||
return date.getHours() * 60 + date.getMinutes()
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMinuteOfDay(minute: number) {
|
|
||||||
const hours = Math.floor(minute / 60)
|
|
||||||
const minutes = minute % 60
|
|
||||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateInput(date: Date) {
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMonthInput(date: Date) {
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${month}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalDateRange(dateValue: string) {
|
|
||||||
const [year, month, day] = dateValue.split('-').map(Number)
|
|
||||||
const start = new Date(year, month - 1, day, 0, 0, 0, 0)
|
|
||||||
const end = new Date(year, month - 1, day + 1, 0, 0, 0, 0)
|
|
||||||
return { start, end }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalMonthRange(monthValue: string) {
|
|
||||||
const [year, month] = monthValue.split('-').map(Number)
|
|
||||||
const start = new Date(year, month - 1, 1, 0, 0, 0, 0)
|
|
||||||
const end = new Date(year, month, 1, 0, 0, 0, 0)
|
|
||||||
return { start, end }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSelectedRealtimeDate() {
|
|
||||||
const { start } = getLocalDateRange(formatDateInput(new Date()))
|
|
||||||
return new Date(start.getTime() + selectedMinuteOfDay.value * 60 * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncCurrentMinute() {
|
|
||||||
currentMinuteOfDay.value = getMinuteOfDay(new Date())
|
|
||||||
if (selectedMinuteOfDay.value > currentMinuteOfDay.value) {
|
|
||||||
selectedMinuteOfDay.value = currentMinuteOfDay.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(iso: string): string {
|
|
||||||
const diff = Date.now() - new Date(iso).getTime()
|
|
||||||
const m = Math.floor(diff / 60000)
|
|
||||||
if (m < 1) return '刚刚'
|
|
||||||
if (m < 60) return `${m} 分钟前`
|
|
||||||
const h = Math.floor(m / 60)
|
|
||||||
if (h < 24) return `${h} 小时前`
|
|
||||||
const d = Math.floor(h / 24)
|
|
||||||
if (d < 30) return `${d} 天前`
|
|
||||||
return new Date(iso).toLocaleDateString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(iso: string | null) {
|
|
||||||
if (!iso) return '未知时间'
|
|
||||||
return new Date(iso).toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'numeric',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCoordinate(value: number | null) {
|
|
||||||
return value === null ? '未记录' : value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCloudAgeMs(cloud: CloudMarkerData) {
|
|
||||||
const capturedTime = new Date(cloud.capturedAt).getTime()
|
|
||||||
const fallbackTime = new Date(cloud.createdAt).getTime()
|
|
||||||
const targetTime = Number.isNaN(capturedTime) ? fallbackTime : capturedTime
|
|
||||||
return getSelectedRealtimeDate().getTime() - targetTime
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMarkerOpacity(cloud: CloudMarkerData) {
|
|
||||||
if (mapMode.value === 'archive') return 1
|
|
||||||
|
|
||||||
const ageMs = getCloudAgeMs(cloud)
|
|
||||||
if (ageMs < 0) return null
|
|
||||||
if (ageMs >= VISIBLE_WINDOW_MS) return null
|
|
||||||
|
|
||||||
const progress = ageMs / VISIBLE_WINDOW_MS
|
|
||||||
return 1 - progress * (1 - MIN_MARKER_OPACITY)
|
|
||||||
}
|
|
||||||
|
|
||||||
function bubbleContent(cloud: CloudMarkerData): HTMLElement {
|
|
||||||
const border = rarityColors[cloud.rarity] || rarityColors.common
|
|
||||||
const bubbleImage = cloud.thumbnailUrl || cloud.imageUrl
|
|
||||||
const opacity = getMarkerOpacity(cloud) ?? MIN_MARKER_OPACITY
|
|
||||||
|
|
||||||
const container = document.createElement('div')
|
|
||||||
container.style.cssText = `opacity:${opacity};width:46px;height:46px;cursor:pointer;line-height:0`
|
|
||||||
|
|
||||||
const bubble = document.createElement('div')
|
|
||||||
bubble.style.cssText = `box-sizing:border-box;width:46px;height:46px;border-radius:50%;background:linear-gradient(135deg,#ffffff 0%,#e8ecf1 100%);border:3px solid ${border};box-shadow:0 4px 16px rgba(0,0,0,0.3),inset 0 2px 4px rgba(255,255,255,0.6),0 0 0 2px rgba(0,0,0,0.06);overflow:hidden`
|
|
||||||
|
|
||||||
const image = document.createElement('img')
|
|
||||||
image.src = bubbleImage
|
|
||||||
image.alt = ''
|
|
||||||
image.style.cssText = 'display:block;width:100%;height:100%;object-fit:cover'
|
|
||||||
|
|
||||||
bubble.append(image)
|
|
||||||
container.append(bubble)
|
|
||||||
return container
|
|
||||||
}
|
|
||||||
|
|
||||||
function hoverCardContent(cloud: CloudMarkerData): HTMLElement {
|
|
||||||
const border = rarityColors[cloud.rarity] || rarityColors.common
|
|
||||||
const cardImage = cloud.thumbnailUrl || cloud.imageUrl
|
|
||||||
const label = cloud.rarity === 'common' ? '常见' : cloud.rarity === 'uncommon' ? '少见' : '罕见'
|
|
||||||
|
|
||||||
const card = document.createElement('div')
|
|
||||||
card.style.cssText = 'background:#fff;border-radius:14px;box-shadow:0 10px 30px rgba(0,0,0,0.18);overflow:hidden;width:200px;font-family:system-ui,-apple-system,sans-serif'
|
|
||||||
|
|
||||||
const image = document.createElement('img')
|
|
||||||
image.src = cardImage
|
|
||||||
image.alt = ''
|
|
||||||
image.style.cssText = 'display:block;width:100%;height:110px;object-fit:cover'
|
|
||||||
|
|
||||||
const body = document.createElement('div')
|
|
||||||
body.style.cssText = 'padding:8px 10px'
|
|
||||||
const title = document.createElement('div')
|
|
||||||
title.style.cssText = 'display:flex;align-items:center;gap:6px'
|
|
||||||
|
|
||||||
const name = document.createElement('span')
|
|
||||||
name.textContent = cloud.cloudTypeName
|
|
||||||
name.style.cssText = 'font-size:13px;font-weight:600;color:#1e293b'
|
|
||||||
|
|
||||||
const rarity = document.createElement('span')
|
|
||||||
rarity.textContent = label
|
|
||||||
rarity.style.cssText = `font-size:10px;padding:1px 5px;border-radius:3px;color:${border};background:${border}22`
|
|
||||||
|
|
||||||
const meta = document.createElement('div')
|
|
||||||
meta.textContent = `${cloud.username} · ${timeAgo(cloud.capturedAt)}`
|
|
||||||
meta.style.cssText = 'font-size:11px;color:#94a3b8;margin-top:3px'
|
|
||||||
|
|
||||||
title.append(name, rarity)
|
|
||||||
body.append(title, meta)
|
|
||||||
card.append(image, body)
|
|
||||||
return card
|
|
||||||
}
|
|
||||||
|
|
||||||
function showHoverCard(cloud: CloudMarkerData, pos: [number, number]) {
|
|
||||||
if (!AMapLib || !mapInst) return
|
|
||||||
hoverIW?.close()
|
|
||||||
const iw = new AMapLib.InfoWindow({
|
|
||||||
content: hoverCardContent(cloud),
|
|
||||||
offset: new AMapLib.Pixel(-100, -60),
|
|
||||||
isCustom: true,
|
|
||||||
} as AMap.InfoWindowOptions)
|
|
||||||
iw.open(mapInst, pos)
|
|
||||||
hoverIW = iw
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideHoverCard() {
|
|
||||||
hoverIW?.close()
|
|
||||||
hoverIW = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideHeader() {
|
|
||||||
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncCurrentZoom() {
|
|
||||||
if (!mapInst) return
|
|
||||||
currentZoom.value = mapInst.getZoom(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeZoom(direction: -1 | 1, step = 1) {
|
|
||||||
if (!mapInst) return false
|
|
||||||
|
|
||||||
const zoom = mapInst.getZoom(2)
|
|
||||||
const nextZoom = Math.min(MAX_MAP_ZOOM, Math.max(MIN_MAP_ZOOM, zoom + direction * step))
|
|
||||||
if (Math.abs(nextZoom - zoom) < 0.001) return false
|
|
||||||
|
|
||||||
mapInst.setZoom(nextZoom, true)
|
|
||||||
currentZoom.value = nextZoom
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopZoomAnimation() {
|
|
||||||
if (zoomAnimationFrame !== null) {
|
|
||||||
window.cancelAnimationFrame(zoomAnimationFrame)
|
|
||||||
zoomAnimationFrame = null
|
|
||||||
}
|
|
||||||
zoomContinuousDirection = null
|
|
||||||
zoomLastFrameTime = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearZoomHoldDelay() {
|
|
||||||
if (zoomHoldDelayTimer !== null) {
|
|
||||||
window.clearTimeout(zoomHoldDelayTimer)
|
|
||||||
zoomHoldDelayTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearZoomHold() {
|
|
||||||
clearZoomHoldDelay()
|
|
||||||
stopZoomAnimation()
|
|
||||||
activeZoomPointerId = null
|
|
||||||
zoomHoldStarted = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function runContinuousZoomFrame(timestamp: number) {
|
|
||||||
if (!mapInst || zoomContinuousDirection === null) {
|
|
||||||
zoomAnimationFrame = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zoomLastFrameTime === 0) {
|
|
||||||
zoomLastFrameTime = timestamp
|
|
||||||
zoomAnimationFrame = window.requestAnimationFrame(runContinuousZoomFrame)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsedSeconds = Math.min((timestamp - zoomLastFrameTime) / 1000, 0.05)
|
|
||||||
zoomLastFrameTime = timestamp
|
|
||||||
const nextZoom = Math.min(
|
|
||||||
MAX_MAP_ZOOM,
|
|
||||||
Math.max(
|
|
||||||
MIN_MAP_ZOOM,
|
|
||||||
zoomAnimationValue + zoomContinuousDirection * ZOOM_CONTINUOUS_SPEED * elapsedSeconds,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (Math.abs(nextZoom - zoomAnimationValue) < 0.0001) {
|
|
||||||
stopZoomAnimation()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
zoomAnimationValue = nextZoom
|
|
||||||
mapInst.setZoom(nextZoom, true)
|
|
||||||
currentZoom.value = nextZoom
|
|
||||||
|
|
||||||
if (nextZoom === MIN_MAP_ZOOM || nextZoom === MAX_MAP_ZOOM) {
|
|
||||||
stopZoomAnimation()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
zoomAnimationFrame = window.requestAnimationFrame(runContinuousZoomFrame)
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginContinuousZoom(direction: -1 | 1) {
|
|
||||||
if (!mapInst) return
|
|
||||||
|
|
||||||
zoomHoldStarted = true
|
|
||||||
zoomContinuousDirection = direction
|
|
||||||
zoomAnimationValue = mapInst.getZoom(4)
|
|
||||||
zoomLastFrameTime = 0
|
|
||||||
zoomAnimationFrame = window.requestAnimationFrame(runContinuousZoomFrame)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startZoomHold(direction: -1 | 1, event: PointerEvent) {
|
|
||||||
if (event.pointerType === 'mouse' && event.button !== 0) return
|
|
||||||
if (activeZoomPointerId !== null) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
clearZoomHold()
|
|
||||||
activeZoomPointerId = event.pointerId
|
|
||||||
|
|
||||||
const button = event.currentTarget as HTMLButtonElement
|
|
||||||
button.setPointerCapture?.(event.pointerId)
|
|
||||||
|
|
||||||
const zoom = mapInst?.getZoom(4)
|
|
||||||
if (
|
|
||||||
zoom === undefined
|
|
||||||
|| (direction === 1 && zoom >= MAX_MAP_ZOOM)
|
|
||||||
|| (direction === -1 && zoom <= MIN_MAP_ZOOM)
|
|
||||||
) {
|
|
||||||
activeZoomPointerId = null
|
|
||||||
if (button.hasPointerCapture?.(event.pointerId)) {
|
|
||||||
button.releasePointerCapture(event.pointerId)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
zoomHoldDelayTimer = window.setTimeout(() => {
|
|
||||||
zoomHoldDelayTimer = null
|
|
||||||
beginContinuousZoom(direction)
|
|
||||||
}, ZOOM_HOLD_DELAY_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
function finishZoomHold(direction: -1 | 1, event: PointerEvent) {
|
|
||||||
if (activeZoomPointerId === null || event.pointerId !== activeZoomPointerId) return
|
|
||||||
|
|
||||||
const pointerId = activeZoomPointerId
|
|
||||||
const wasContinuous = zoomHoldStarted
|
|
||||||
activeZoomPointerId = null
|
|
||||||
clearZoomHoldDelay()
|
|
||||||
stopZoomAnimation()
|
|
||||||
zoomHoldStarted = false
|
|
||||||
|
|
||||||
const button = event.currentTarget as HTMLButtonElement
|
|
||||||
if (button.hasPointerCapture?.(pointerId)) {
|
|
||||||
button.releasePointerCapture(pointerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!wasContinuous) {
|
|
||||||
changeZoom(direction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelZoomHold(event: PointerEvent) {
|
|
||||||
if (activeZoomPointerId === null || event.pointerId !== activeZoomPointerId) return
|
|
||||||
|
|
||||||
const pointerId = activeZoomPointerId
|
|
||||||
activeZoomPointerId = null
|
|
||||||
clearZoomHoldDelay()
|
|
||||||
stopZoomAnimation()
|
|
||||||
zoomHoldStarted = false
|
|
||||||
|
|
||||||
const button = event.currentTarget as HTMLButtonElement
|
|
||||||
if (button.hasPointerCapture?.(pointerId)) {
|
|
||||||
button.releasePointerCapture(pointerId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleZoomClick(direction: -1 | 1, event: MouseEvent) {
|
|
||||||
if (event.detail === 0) {
|
|
||||||
changeZoom(direction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCloudMarker(row: ApiCloud): CloudMarkerData {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
latitude: row.latitude as number,
|
|
||||||
longitude: row.longitude as number,
|
|
||||||
imageUrl: row.image_url,
|
|
||||||
thumbnailUrl: row.thumbnail_url,
|
|
||||||
locationName: row.location_name,
|
|
||||||
description: row.description,
|
|
||||||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
|
||||||
rarity: row.cloud_type_rarity || 'common',
|
|
||||||
username: row.username || '匿名',
|
|
||||||
capturedAt: row.captured_at || row.created_at,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCloudsByRange(field: 'captured_at' | 'created_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
start: start.toISOString(),
|
|
||||||
end: end.toISOString(),
|
|
||||||
time_field: field,
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
const rows = await apiRequest<ApiCloud[]>(`/clouds/map?${params}`, { auth: false })
|
|
||||||
return rows.map(toCloudMarker)
|
|
||||||
} catch (error) {
|
|
||||||
statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRealtimeClouds() {
|
|
||||||
const { start, end } = getLocalDateRange(formatDateInput(new Date()))
|
|
||||||
return fetchCloudsByRange('captured_at', start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadArchiveClouds() {
|
|
||||||
const range = archiveKind.value === 'day'
|
|
||||||
? getLocalDateRange(archiveDay.value)
|
|
||||||
: getLocalMonthRange(archiveMonth.value)
|
|
||||||
return fetchCloudsByRange('captured_at', range.start, range.end)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVisibleClouds(clouds: CloudMarkerData[]) {
|
|
||||||
if (mapMode.value === 'archive') return clouds
|
|
||||||
|
|
||||||
return clouds.filter(cloud => {
|
|
||||||
const opacity = getMarkerOpacity(cloud)
|
|
||||||
return opacity !== null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearMarkers() {
|
|
||||||
hideHoverCard()
|
|
||||||
for (const m of mks) m.setMap(null)
|
|
||||||
mks = []
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawMarkers(clouds: CloudMarkerData[]) {
|
|
||||||
if (!AMapLib) return
|
|
||||||
clearMarkers()
|
|
||||||
const visibleClouds = getVisibleClouds(clouds)
|
|
||||||
statusText.value = mapMode.value === 'realtime'
|
|
||||||
? `${visibleClouds.length} 朵云(${selectedTimeLabel.value} 前 2 小时)`
|
|
||||||
: `${visibleClouds.length} 朵云(${archiveTitle.value} 拍摄)`
|
|
||||||
|
|
||||||
if (previewCloud.value && !visibleClouds.some(item => item.id === previewCloud.value?.id)) {
|
|
||||||
previewCloud.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const c of visibleClouds) {
|
|
||||||
const m = new AMapLib.Marker({
|
|
||||||
position: [c.longitude, c.latitude],
|
|
||||||
content: bubbleContent(c),
|
|
||||||
offset: new AMapLib.Pixel(-23, -23),
|
|
||||||
zIndex: 200,
|
|
||||||
} as AMap.MarkerOptions)
|
|
||||||
m.on('mouseover', () => showHoverCard(c, [c.longitude, c.latitude]))
|
|
||||||
m.on('mouseout', () => hideHoverCard())
|
|
||||||
m.on('click', () => {
|
|
||||||
previewCloud.value = c
|
|
||||||
})
|
|
||||||
m.setMap(mapInst!)
|
|
||||||
mks.push(m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
if (mapMode.value === 'realtime') {
|
|
||||||
syncCurrentMinute()
|
|
||||||
}
|
|
||||||
statusText.value = '加载中...'
|
|
||||||
allClouds = mapMode.value === 'archive'
|
|
||||||
? await loadArchiveClouds()
|
|
||||||
: await loadRealtimeClouds()
|
|
||||||
drawMarkers(allClouds)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startMarkerDecayTimer() {
|
|
||||||
if (redrawTimer !== null) window.clearInterval(redrawTimer)
|
|
||||||
redrawTimer = window.setInterval(() => {
|
|
||||||
if (mapMode.value === 'realtime') {
|
|
||||||
syncCurrentMinute()
|
|
||||||
}
|
|
||||||
drawMarkers(allClouds)
|
|
||||||
}, 60 * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSat() {
|
|
||||||
if (!AMapLib || !mapInst) return
|
|
||||||
if (satelliteOn.value) {
|
|
||||||
if (satLayer) mapInst.remove(satLayer as unknown as Parameters<typeof mapInst.remove>[0])
|
|
||||||
if (roadLayer) mapInst.remove(roadLayer as unknown as Parameters<typeof mapInst.remove>[0])
|
|
||||||
satelliteOn.value = false
|
|
||||||
} else {
|
|
||||||
satLayer = satLayer || new AMapLib.TileLayer.Satellite()
|
|
||||||
roadLayer = roadLayer || new AMapLib.TileLayer.RoadNet()
|
|
||||||
mapInst.add(satLayer as unknown as Parameters<typeof mapInst.add>[0])
|
|
||||||
mapInst.add(roadLayer as unknown as Parameters<typeof mapInst.add>[0])
|
|
||||||
satelliteOn.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openQuickUpload() {
|
|
||||||
if (authStore.loading || !authStore.isLoggedIn) return
|
|
||||||
quickUploadOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeQuickUpload() {
|
|
||||||
quickUploadOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleQuickUploaded() {
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTimeSliderInput(event: Event) {
|
|
||||||
syncCurrentMinute()
|
|
||||||
selectedMinuteOfDay.value = Math.min(Number((event.target as HTMLInputElement).value), currentMinuteOfDay.value)
|
|
||||||
mapMode.value = 'realtime'
|
|
||||||
archivePanelOpen.value = false
|
|
||||||
drawMarkers(allClouds)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSliderPointerDown() {
|
|
||||||
sliderDragging.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSliderPointerUp() {
|
|
||||||
sliderDragging.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleTimelineControls() {
|
|
||||||
timelineControlsOpen.value = !timelineControlsOpen.value
|
|
||||||
if (timelineControlsOpen.value) {
|
|
||||||
syncCurrentMinute()
|
|
||||||
selectedMinuteOfDay.value = currentMinuteOfDay.value
|
|
||||||
} else {
|
|
||||||
archivePanelOpen.value = false
|
|
||||||
sliderDragging.value = false
|
|
||||||
if (mapMode.value === 'archive') {
|
|
||||||
await returnRealtime()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyArchiveFilter() {
|
|
||||||
mapMode.value = 'archive'
|
|
||||||
archivePanelOpen.value = false
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function returnRealtime() {
|
|
||||||
mapMode.value = 'realtime'
|
|
||||||
archivePanelOpen.value = false
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
window.addEventListener('blur', clearZoomHold)
|
|
||||||
try {
|
|
||||||
AMapLib = await loadAMap()
|
|
||||||
AMapLib.getConfig().appname = 'amap-jsapi-skill'
|
|
||||||
mapInst = new AMapLib.Map(mapEl.value!, {
|
|
||||||
viewMode: '3D',
|
|
||||||
pitch: 0,
|
|
||||||
rotation: 0,
|
|
||||||
zoom: currentZoom.value,
|
|
||||||
zooms: [MIN_MAP_ZOOM, MAX_MAP_ZOOM],
|
|
||||||
center: [104.07, 30.67],
|
|
||||||
mapStyle: 'amap://styles/normal',
|
|
||||||
features: ['bg', 'road', 'building', 'point'],
|
|
||||||
resizeEnable: true,
|
|
||||||
skyColor: '#c8dce8',
|
|
||||||
maxPitch: 70,
|
|
||||||
} as AMap.MapOptions)
|
|
||||||
|
|
||||||
mapInst.addControl(new AMapLib.Scale())
|
|
||||||
mapInst.addControl(new AMapLib.ControlBar({ position: { right: '50px', bottom: '224px' } } as Record<string, unknown>))
|
|
||||||
mapInst.on('click', () => { previewCloud.value = null; hideHoverCard() })
|
|
||||||
mapInst.on('zoomstart', hideHeader)
|
|
||||||
mapInst.on('zoomchange', syncCurrentZoom)
|
|
||||||
mapInst.on('movestart', hideHeader)
|
|
||||||
mapInst.on('dragstart', hideHeader)
|
|
||||||
syncCurrentZoom()
|
|
||||||
await refresh()
|
|
||||||
startMarkerDecayTimer()
|
|
||||||
} catch (e) {
|
|
||||||
statusText.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('blur', clearZoomHold)
|
|
||||||
clearZoomHold()
|
|
||||||
if (redrawTimer !== null) {
|
|
||||||
window.clearInterval(redrawTimer)
|
|
||||||
redrawTimer = null
|
|
||||||
}
|
|
||||||
mapInst?.off('zoomchange', syncCurrentZoom)
|
|
||||||
mapInst?.destroy()
|
|
||||||
mapInst = null
|
|
||||||
AMapLib = null
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative h-[100dvh] min-h-screen">
|
|
||||||
<div ref="mapEl" class="w-full h-full"></div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="absolute left-4 top-32 z-20 flex w-10 flex-col gap-2 md:top-20"
|
|
||||||
role="group"
|
|
||||||
:aria-label="`地图缩放控制,当前级别 ${currentZoom.toFixed(2)}`"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-map-button touch-none select-none"
|
|
||||||
title="放大地图(按住可连续放大)"
|
|
||||||
aria-label="放大地图,按住可连续放大"
|
|
||||||
:disabled="currentZoom >= MAX_MAP_ZOOM"
|
|
||||||
@pointerdown="startZoomHold(1, $event)"
|
|
||||||
@pointerup="finishZoomHold(1, $event)"
|
|
||||||
@pointercancel="cancelZoomHold"
|
|
||||||
@lostpointercapture="cancelZoomHold"
|
|
||||||
@click="handleZoomClick(1, $event)"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><Plus /></NIcon>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-map-button touch-none select-none"
|
|
||||||
title="缩小地图(按住可连续缩小)"
|
|
||||||
aria-label="缩小地图,按住可连续缩小"
|
|
||||||
:disabled="currentZoom <= MIN_MAP_ZOOM"
|
|
||||||
@pointerdown="startZoomHold(-1, $event)"
|
|
||||||
@pointerup="finishZoomHold(-1, $event)"
|
|
||||||
@pointercancel="cancelZoomHold"
|
|
||||||
@lostpointercapture="cancelZoomHold"
|
|
||||||
@click="handleZoomClick(-1, $event)"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><Minus /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute bottom-6 right-7 z-20 flex w-10 flex-col items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-map-button"
|
|
||||||
:title="authStore.isLoggedIn ? '快速上传图片' : '请先登录后上传图片'"
|
|
||||||
aria-label="快速上传图片"
|
|
||||||
:disabled="authStore.loading || !authStore.isLoggedIn"
|
|
||||||
@click="openQuickUpload"
|
|
||||||
>
|
|
||||||
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
|
||||||
<CloudUpload />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
<button type="button" @click="refresh" class="oc-map-button" title="刷新" aria-label="刷新地图">
|
|
||||||
<span class="text-lg">
|
|
||||||
<NIcon>
|
|
||||||
<Refresh/>
|
|
||||||
</NIcon>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" @click="toggleSat" class="oc-map-button" :title="satelliteOn ? '切换普通视图' : '切换卫星视图'" :aria-label="satelliteOn ? '切换普通视图' : '切换卫星视图'">
|
|
||||||
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
|
||||||
<Map v-if="satelliteOn" />
|
|
||||||
<Satellite v-else />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-map-button"
|
|
||||||
:title="timelineControlsOpen ? '收起时间控件' : '展开时间控件'"
|
|
||||||
:aria-label="timelineControlsOpen ? '收起时间控件' : '展开时间控件'"
|
|
||||||
@click="toggleTimelineControls"
|
|
||||||
>
|
|
||||||
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
|
||||||
<X v-if="timelineControlsOpen" />
|
|
||||||
<Adjustments v-else />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute bottom-6 right-19 z-10 flex justify-end pointer-events-none">
|
|
||||||
<div class="pointer-events-auto flex items-center justify-end gap-3">
|
|
||||||
<Transition name="map-timeline-controls">
|
|
||||||
<div v-if="timelineControlsOpen" class="flex items-center gap-3">
|
|
||||||
<div class="relative w-[min(42vw,420px)] min-w-[220px]">
|
|
||||||
<div
|
|
||||||
v-if="sliderDragging"
|
|
||||||
class="oc-panel-card absolute bottom-8 border border-slate-200 bg-white/95 px-3 py-1.5 text-xs font-medium text-slate-800 backdrop-blur"
|
|
||||||
:style="{ left: `${selectedMinutePercent}%`, transform: 'translateX(-50%)' }"
|
|
||||||
>
|
|
||||||
{{ selectedTimeLabel }}
|
|
||||||
</div>
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1439"
|
|
||||||
step="10"
|
|
||||||
:value="selectedMinuteOfDay"
|
|
||||||
class="map-time-slider block w-full accent-sky-500"
|
|
||||||
:disabled="mapMode === 'archive'"
|
|
||||||
@pointerdown="handleSliderPointerDown"
|
|
||||||
@pointerup="handleSliderPointerUp"
|
|
||||||
@pointercancel="handleSliderPointerUp"
|
|
||||||
@blur="handleSliderPointerUp"
|
|
||||||
@input="handleTimeSliderInput"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="mapMode === 'realtime'"
|
|
||||||
class="absolute bottom-0 top-0 z-10"
|
|
||||||
:style="{ left: `${currentMinutePercent}%`, right: '0' }"
|
|
||||||
@pointerdown.prevent
|
|
||||||
@click.prevent
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-map-button"
|
|
||||||
title="按日期或月份查看"
|
|
||||||
aria-label="按日期或月份查看"
|
|
||||||
@click="archivePanelOpen = !archivePanelOpen"
|
|
||||||
>
|
|
||||||
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
|
||||||
<Calendar />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="archivePanelOpen"
|
|
||||||
class="oc-panel-card absolute bottom-16 right-0 w-72 border border-slate-200 bg-white p-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<p class="text-sm font-semibold text-slate-900">历史位置</p>
|
|
||||||
<button type="button" class="oc-text-button text-xs" @click="returnRealtime">
|
|
||||||
返回实时
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-choice-button"
|
|
||||||
:class="archiveKind === 'day' ? 'oc-choice-button--active' : 'oc-choice-button--inactive'"
|
|
||||||
:aria-pressed="archiveKind === 'day'"
|
|
||||||
@click="archiveKind = 'day'"
|
|
||||||
>
|
|
||||||
某天
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-choice-button"
|
|
||||||
:class="archiveKind === 'month' ? 'oc-choice-button--active' : 'oc-choice-button--inactive'"
|
|
||||||
:aria-pressed="archiveKind === 'month'"
|
|
||||||
@click="archiveKind = 'month'"
|
|
||||||
>
|
|
||||||
某月
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
v-if="archiveKind === 'day'"
|
|
||||||
v-model="archiveDay"
|
|
||||||
type="date"
|
|
||||||
class="oc-field-control mt-3"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-else
|
|
||||||
v-model="archiveMonth"
|
|
||||||
type="month"
|
|
||||||
class="oc-field-control mt-3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-panel-button oc-panel-button--sky mt-3 w-full"
|
|
||||||
@click="applyArchiveFilter"
|
|
||||||
>
|
|
||||||
查看拍摄位置
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ImageDetailModal
|
|
||||||
v-if="previewCloud"
|
|
||||||
:open="!!previewCloud"
|
|
||||||
:image-url="previewCloud.imageUrl"
|
|
||||||
:thumbnail-url="previewCloud.thumbnailUrl"
|
|
||||||
:image-alt="previewCloud.cloudTypeName"
|
|
||||||
:title="previewCloud.cloudTypeName"
|
|
||||||
:subtitle="`上传者:${previewCloud.username}`"
|
|
||||||
:badge-label="rarityMeta[previewCloud.rarity].label"
|
|
||||||
:badge-class="rarityMeta[previewCloud.rarity].chip"
|
|
||||||
@close="previewCloud = null"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">上传时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.createdAt) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">拍摄时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.capturedAt) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">位置</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ previewCloud.locationName || '未填写位置名称' }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">模糊化经纬度</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
纬度 {{ formatCoordinate(previewCloud.latitude) }} / 经度 {{ formatCoordinate(previewCloud.longitude) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
|
||||||
<p class="oc-section-label">图片说明</p>
|
|
||||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
|
||||||
{{ previewCloud.description || '上传者没有留下额外说明。' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<MiniLocationMap
|
|
||||||
:latitude="previewCloud.latitude"
|
|
||||||
:longitude="previewCloud.longitude"
|
|
||||||
:location-name="previewCloud.locationName"
|
|
||||||
/>
|
|
||||||
</ImageDetailModal>
|
|
||||||
|
|
||||||
<QuickUploadModal
|
|
||||||
:open="quickUploadOpen"
|
|
||||||
@close="closeQuickUpload"
|
|
||||||
@uploaded="handleQuickUploaded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.amap-controlbar) {
|
|
||||||
transform: translateX(50%) scale(1);
|
|
||||||
transform-origin: bottom right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-archive-button.map-archive-button {
|
|
||||||
border-radius: 9999px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-time-slider:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-time-slider {
|
|
||||||
appearance: none;
|
|
||||||
background: transparent;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-time-slider::-webkit-slider-runnable-track {
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: 1px solid rgba(186, 230, 253, 0.85);
|
|
||||||
background: rgba(255, 255, 255, 0.82);
|
|
||||||
box-shadow: 0 3px 12px rgba(15, 23, 42, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-time-slider::-webkit-slider-thumb {
|
|
||||||
appearance: none;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-top: -10px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.95);
|
|
||||||
border-radius: 9999px;
|
|
||||||
background-color: #e0f2fe;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%230ea5e9' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6.5 18a4.5 4.5 0 1 1 .5-8.97A6 6 0 0 1 18.35 11.5A3.5 3.5 0 1 1 18 18Z'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center;
|
|
||||||
background-size: 18px 18px;
|
|
||||||
box-shadow: 0 2px 10px rgba(14, 165, 233, 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-time-slider::-moz-range-track {
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: 1px solid rgba(186, 230, 253, 0.85);
|
|
||||||
background: rgba(255, 255, 255, 0.82);
|
|
||||||
box-shadow: 0 3px 12px rgba(15, 23, 42, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-time-slider::-moz-range-thumb {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.95);
|
|
||||||
border-radius: 9999px;
|
|
||||||
background-color: #e0f2fe;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%230ea5e9' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6.5 18a4.5 4.5 0 1 1 .5-8.97A6 6 0 0 1 18.35 11.5A3.5 3.5 0 1 1 18 18Z'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center;
|
|
||||||
background-size: 18px 18px;
|
|
||||||
box-shadow: 0 2px 10px rgba(14, 165, 233, 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-timeline-controls-enter-active,
|
|
||||||
.map-timeline-controls-leave-active {
|
|
||||||
overflow: hidden;
|
|
||||||
transition:
|
|
||||||
opacity 180ms ease,
|
|
||||||
transform 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
||||||
max-width 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-timeline-controls-enter-from,
|
|
||||||
.map-timeline-controls-leave-to {
|
|
||||||
max-width: 0;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-timeline-controls-enter-to,
|
|
||||||
.map-timeline-controls-leave-from {
|
|
||||||
max-width: 560px;
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { RouterLink } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const usernameDraft = ref('')
|
|
||||||
const newPassword = ref('')
|
|
||||||
const confirmNewPassword = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const success = ref('')
|
|
||||||
const usernameSaving = ref(false)
|
|
||||||
const passwordSaving = ref(false)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
usernameDraft.value = authStore.profile?.username || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
async function saveUsername() {
|
|
||||||
const nextUsername = usernameDraft.value.trim()
|
|
||||||
|
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
|
||||||
|
|
||||||
if (!nextUsername) {
|
|
||||||
error.value = '昵称不能为空。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (nextUsername.length < 2) {
|
|
||||||
error.value = '昵称至少需要 2 个字符。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (nextUsername === authStore.profile?.username) {
|
|
||||||
success.value = '昵称没有变化。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
usernameSaving.value = true
|
|
||||||
try {
|
|
||||||
await authStore.updateUsername(nextUsername)
|
|
||||||
success.value = '昵称已更新。'
|
|
||||||
message.success('昵称已更新')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : '昵称更新失败'
|
|
||||||
} finally {
|
|
||||||
usernameSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePassword() {
|
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
|
||||||
|
|
||||||
if (newPassword.value.length < 8) {
|
|
||||||
error.value = '新密码至少需要 8 位。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (newPassword.value !== confirmNewPassword.value) {
|
|
||||||
error.value = '两次输入的新密码不一致。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordSaving.value = true
|
|
||||||
try {
|
|
||||||
await authStore.updatePassword(newPassword.value)
|
|
||||||
newPassword.value = ''
|
|
||||||
confirmNewPassword.value = ''
|
|
||||||
success.value = '密码已更新。'
|
|
||||||
message.success('密码已更新')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : '密码更新失败'
|
|
||||||
} finally {
|
|
||||||
passwordSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] py-10">
|
|
||||||
<div class="oc-container oc-container--content">
|
|
||||||
<RouterLink to="/profile">
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回个人主页</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<p class="oc-page-eyebrow">Account Settings</p>
|
|
||||||
<h1 class="oc-page-title">个人资料设置</h1>
|
|
||||||
<p class="oc-page-description">
|
|
||||||
修改公开昵称和登录密码。昵称会显示在画廊、图鉴和地图中,且不能与其他用户重复。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 grid gap-6">
|
|
||||||
<NAlert
|
|
||||||
v-if="error"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</NAlert>
|
|
||||||
<NAlert
|
|
||||||
v-if="success"
|
|
||||||
type="success"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
{{ success }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
|
|
||||||
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-slate-900">公开昵称</h2>
|
|
||||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
|
||||||
用于公开展示你的上传记录。保存前会检查是否与其他用户重复。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
|
|
||||||
<NInput
|
|
||||||
v-model:value="usernameDraft"
|
|
||||||
placeholder="输入新的昵称"
|
|
||||||
maxlength="32"
|
|
||||||
show-count
|
|
||||||
/>
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--teal"
|
|
||||||
:loading="usernameSaving"
|
|
||||||
@click="saveUsername"
|
|
||||||
>
|
|
||||||
保存昵称
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
|
|
||||||
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
|
|
||||||
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
|
|
||||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
|
||||||
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<NForm class="grid gap-3" @submit.prevent="savePassword">
|
|
||||||
<input
|
|
||||||
:value="authStore.user?.email || ''"
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
autocomplete="username"
|
|
||||||
class="sr-only"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-label="账户邮箱"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
<NInput
|
|
||||||
v-model:value="newPassword"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="新密码"
|
|
||||||
/>
|
|
||||||
<NInput
|
|
||||||
v-model:value="confirmNewPassword"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'confirm-new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="确认新密码"
|
|
||||||
/>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
attr-type="submit"
|
|
||||||
class="oc-panel-button oc-panel-button--teal"
|
|
||||||
:loading="passwordSaving"
|
|
||||||
>
|
|
||||||
保存密码
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</NForm>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,823 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
|
|
||||||
import { Settings } from '@vicons/tabler'
|
|
||||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
|
||||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
|
||||||
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { useCloudsStore } from '@/stores/clouds'
|
|
||||||
import { useProfileStore, type ProfileCloudItem } from '@/stores/profile'
|
|
||||||
import type { CloudType, Profile } from '@/types/database'
|
|
||||||
|
|
||||||
interface TimelineGroup {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
items: ProfileCloudItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const cloudsStore = useCloudsStore()
|
|
||||||
const profileStore = useProfileStore()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const selectedCloud = ref<ProfileCloudItem | null>(null)
|
|
||||||
const selectedUploadDate = ref<string | null>(null)
|
|
||||||
const timelineSectionRef = ref<HTMLElement | null>(null)
|
|
||||||
const selectionMode = ref(false)
|
|
||||||
const selectedCloudIds = ref<Set<string>>(new Set())
|
|
||||||
const editModalOpen = ref(false)
|
|
||||||
const editSaving = ref(false)
|
|
||||||
const manageError = ref('')
|
|
||||||
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
|
||||||
|
|
||||||
const rarityMeta = {
|
|
||||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
|
|
||||||
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
|
|
||||||
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
|
||||||
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
|
|
||||||
|
|
||||||
const statusMeta = {
|
|
||||||
approved: { label: '已通过', chip: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
|
|
||||||
pending: { label: '待审核', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
|
|
||||||
rejected: { label: '未通过', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
|
||||||
} satisfies Record<ProfileCloudItem['status'], { label: string; chip: string }>
|
|
||||||
|
|
||||||
const viewedUserId = computed(() => {
|
|
||||||
const routeUserId = typeof route.params.id === 'string' ? route.params.id : null
|
|
||||||
return routeUserId || authStore.user?.id || null
|
|
||||||
})
|
|
||||||
|
|
||||||
const isOwnProfile = computed(() => {
|
|
||||||
return !!viewedUserId.value && viewedUserId.value === authStore.user?.id
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = computed(() => profileStore.isLoading(viewedUserId.value, isOwnProfile.value))
|
|
||||||
const errorMsg = computed(() => {
|
|
||||||
if (!viewedUserId.value) return '未找到要查看的用户。'
|
|
||||||
return profileStore.getError(viewedUserId.value, isOwnProfile.value)
|
|
||||||
})
|
|
||||||
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedUserId.value))
|
|
||||||
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedUserId.value, isOwnProfile.value))
|
|
||||||
const receivedLikes = computed(() => profileStore.getLikeStats(viewedUserId.value)?.total_likes || 0)
|
|
||||||
|
|
||||||
const pageTitle = computed(() => {
|
|
||||||
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
|
|
||||||
})
|
|
||||||
|
|
||||||
// const profileSubtitle = computed(() => {
|
|
||||||
// if (isOwnProfile.value) {
|
|
||||||
// return '回看你的云图记录、收集进度和拍摄节奏。'
|
|
||||||
// }
|
|
||||||
// return '公开展示的云图记录会按时间顺序陈列在这里。'
|
|
||||||
// })
|
|
||||||
|
|
||||||
const totalShots = computed(() => clouds.value.length)
|
|
||||||
|
|
||||||
const approvedShots = computed(() => {
|
|
||||||
return clouds.value.filter(item => item.status === 'approved').length
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedCloudCount = computed(() => selectedCloudIds.value.size)
|
|
||||||
|
|
||||||
const cloudActionOptions = computed(() => {
|
|
||||||
if (!selectedCloud.value) return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ label: '编辑', key: 'edit' },
|
|
||||||
{ label: '删除', key: 'delete' },
|
|
||||||
{
|
|
||||||
label: selectedCloud.value.is_hidden ? '更改为公开' : '更改为私密',
|
|
||||||
key: 'toggle-privacy',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const shootingDays = computed(() => {
|
|
||||||
return new Set(
|
|
||||||
clouds.value.map(item => toDayKey(item.captured_at || item.created_at)),
|
|
||||||
).size
|
|
||||||
})
|
|
||||||
|
|
||||||
const uploadDayCounts = computed(() => {
|
|
||||||
const counts = new Map<string, number>()
|
|
||||||
|
|
||||||
for (const item of clouds.value) {
|
|
||||||
const key = toDayKey(item.created_at)
|
|
||||||
counts.set(key, (counts.get(key) || 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return counts
|
|
||||||
})
|
|
||||||
|
|
||||||
const mostCommonCloudType = computed(() => {
|
|
||||||
const counts = new Map<string, number>()
|
|
||||||
for (const item of clouds.value) {
|
|
||||||
counts.set(item.cloudTypeName, (counts.get(item.cloudTypeName) || 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
let winner = '还没有记录'
|
|
||||||
let maxCount = 0
|
|
||||||
for (const [name, count] of counts.entries()) {
|
|
||||||
if (count > maxCount) {
|
|
||||||
winner = name
|
|
||||||
maxCount = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: winner,
|
|
||||||
count: maxCount,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredClouds = computed(() => {
|
|
||||||
if (!selectedUploadDate.value) return clouds.value
|
|
||||||
|
|
||||||
return clouds.value.filter(item => toDayKey(item.created_at) === selectedUploadDate.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const timelineGroups = computed<TimelineGroup[]>(() => {
|
|
||||||
const groups = new Map<string, TimelineGroup>()
|
|
||||||
|
|
||||||
for (const item of filteredClouds.value) {
|
|
||||||
const date = new Date(item.captured_at || item.created_at)
|
|
||||||
const key = `${date.getFullYear()}-${date.getMonth() + 1}`
|
|
||||||
const existing = groups.get(key)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
existing.items.push(item)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
groups.set(key, {
|
|
||||||
key,
|
|
||||||
label: `${date.getFullYear()}年${date.getMonth() + 1}月`,
|
|
||||||
items: [item],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(groups.values())
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedUploadCount = computed(() => {
|
|
||||||
if (!selectedUploadDate.value) return 0
|
|
||||||
return uploadDayCounts.value.get(selectedUploadDate.value) || 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const contributionCells = computed(() => {
|
|
||||||
return Array.from(uploadDayCounts.value.entries())
|
|
||||||
.map(([date, count]) => ({ date, count }))
|
|
||||||
.sort((a, b) => a.date.localeCompare(b.date))
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeUploadDays = computed(() => {
|
|
||||||
return contributionCells.value.filter(cell => cell.count > 0).length
|
|
||||||
})
|
|
||||||
|
|
||||||
function toDayKey(iso: string) {
|
|
||||||
const date = new Date(iso)
|
|
||||||
return toLocalDayKey(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toLocalDayKey(date: Date) {
|
|
||||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
||||||
const day = `${date.getDate()}`.padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string | null) {
|
|
||||||
if (!iso) return '未知时间'
|
|
||||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayKey(dayKey: string | null) {
|
|
||||||
if (!dayKey) return ''
|
|
||||||
const [year, month, day] = dayKey.split('-').map(Number)
|
|
||||||
return new Date(year, (month || 1) - 1, day || 1).toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(iso: string | null) {
|
|
||||||
if (!iso) return '未知时间'
|
|
||||||
return new Date(iso).toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'numeric',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCoordinate(value: number | null) {
|
|
||||||
return value === null ? '未记录' : value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCloudTime(item: ProfileCloudItem) {
|
|
||||||
return formatDateTime(item.captured_at || item.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProfileInitial(profile: Profile | null) {
|
|
||||||
return profile?.username?.slice(0, 1).toUpperCase() || '云'
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectedUploadDate(date: string) {
|
|
||||||
selectedUploadDate.value = selectedUploadDate.value === date ? null : date
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelectedUploadDate() {
|
|
||||||
selectedUploadDate.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSelectedCloudIds(nextIds: Iterable<string>) {
|
|
||||||
selectedCloudIds.value = new Set(nextIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCloudSelection(cloudId: string, checked: boolean) {
|
|
||||||
const nextIds = new Set(selectedCloudIds.value)
|
|
||||||
if (checked) {
|
|
||||||
nextIds.add(cloudId)
|
|
||||||
} else {
|
|
||||||
nextIds.delete(cloudId)
|
|
||||||
}
|
|
||||||
setSelectedCloudIds(nextIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCloudSelection() {
|
|
||||||
setSelectedCloudIds([])
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectionMode() {
|
|
||||||
selectionMode.value = !selectionMode.value
|
|
||||||
if (!selectionMode.value) {
|
|
||||||
clearCloudSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDatetimeLocal(iso: string | null) {
|
|
||||||
if (!iso) return ''
|
|
||||||
const date = new Date(iso)
|
|
||||||
const pad = (value: number) => String(value).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditModal(cloud: ProfileCloudItem) {
|
|
||||||
manageError.value = ''
|
|
||||||
editInitialValue.value = {
|
|
||||||
cloudCategoryId: cloud.cloud_type_id ?? (cloud.custom_cloud_type ? 'other' : null),
|
|
||||||
customCloudType: cloud.custom_cloud_type || '',
|
|
||||||
capturedAt: formatDatetimeLocal(cloud.captured_at),
|
|
||||||
latitude: cloud.latitude,
|
|
||||||
longitude: cloud.longitude,
|
|
||||||
locationName: cloud.location_name || '',
|
|
||||||
description: cloud.description || '',
|
|
||||||
isPublic: !cloud.is_hidden,
|
|
||||||
}
|
|
||||||
editModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditModal() {
|
|
||||||
editModalOpen.value = false
|
|
||||||
editSaving.value = false
|
|
||||||
manageError.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function blurCoordinate(value: number): number {
|
|
||||||
return Math.round(value * 100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitEditModal(value: CloudEditFormValue) {
|
|
||||||
if (!selectedCloud.value || !viewedUserId.value) return
|
|
||||||
|
|
||||||
if (!value.cloudCategoryId) {
|
|
||||||
manageError.value = '请选择云型类别。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (value.cloudCategoryId === 'other' && !value.customCloudType.trim()) {
|
|
||||||
manageError.value = '请输入自定义云型名称。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!value.capturedAt) {
|
|
||||||
manageError.value = '请选择拍摄时间。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
|
||||||
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
|
||||||
if (hasLat !== hasLng) {
|
|
||||||
manageError.value = '经纬度必须同时填写。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
editSaving.value = true
|
|
||||||
manageError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await profileStore.updateCloud(viewedUserId.value, selectedCloud.value.id, {
|
|
||||||
cloud_type_id: value.cloudCategoryId === 'other' ? null : value.cloudCategoryId,
|
|
||||||
custom_cloud_type: value.cloudCategoryId === 'other' ? value.customCloudType.trim() : null,
|
|
||||||
latitude: hasLat ? blurCoordinate(value.latitude as number) : null,
|
|
||||||
longitude: hasLng ? blurCoordinate(value.longitude as number) : null,
|
|
||||||
location_name: value.locationName.trim() || null,
|
|
||||||
description: value.description.trim() || null,
|
|
||||||
captured_at: new Date(value.capturedAt).toISOString(),
|
|
||||||
is_hidden: !value.isPublic,
|
|
||||||
})
|
|
||||||
selectedCloud.value = updated
|
|
||||||
closeEditModal()
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '图片信息更新失败'
|
|
||||||
} finally {
|
|
||||||
editSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleSelectedCloudPrivacy() {
|
|
||||||
if (!selectedCloud.value || !viewedUserId.value) return
|
|
||||||
|
|
||||||
manageError.value = ''
|
|
||||||
const nextHidden = !selectedCloud.value.is_hidden
|
|
||||||
|
|
||||||
try {
|
|
||||||
await profileStore.updateCloudVisibility(viewedUserId.value, selectedCloud.value.id, nextHidden)
|
|
||||||
selectedCloud.value = { ...selectedCloud.value, is_hidden: nextHidden }
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteCloudByIds(cloudIds: string[]) {
|
|
||||||
if (!viewedUserId.value || !cloudIds.length) return
|
|
||||||
const confirmed = window.confirm(`确定删除 ${cloudIds.length} 张图片吗?删除后无法在页面中恢复。`)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
manageError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deletedCount = await profileStore.deleteClouds(viewedUserId.value, cloudIds)
|
|
||||||
if (selectedCloud.value && cloudIds.includes(selectedCloud.value.id)) {
|
|
||||||
selectedCloud.value = null
|
|
||||||
closeEditModal()
|
|
||||||
}
|
|
||||||
const nextSelected = new Set(selectedCloudIds.value)
|
|
||||||
for (const cloudId of cloudIds) nextSelected.delete(cloudId)
|
|
||||||
setSelectedCloudIds(nextSelected)
|
|
||||||
message.success(`已成功删除 ${deletedCount} 张照片`)
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '图片删除失败'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCloudAction(key: string | number) {
|
|
||||||
if (!selectedCloud.value) return
|
|
||||||
if (key === 'edit') openEditModal(selectedCloud.value)
|
|
||||||
if (key === 'delete') deleteCloudByIds([selectedCloud.value.id])
|
|
||||||
if (key === 'toggle-privacy') toggleSelectedCloudPrivacy()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProfilePage(force = false) {
|
|
||||||
selectedCloud.value = null
|
|
||||||
selectedUploadDate.value = null
|
|
||||||
clearCloudSelection()
|
|
||||||
selectionMode.value = false
|
|
||||||
|
|
||||||
const targetUserId = viewedUserId.value
|
|
||||||
|
|
||||||
if (!targetUserId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await profileStore.fetchProfilePage(targetUserId, isOwnProfile.value, force)
|
|
||||||
|
|
||||||
if (isOwnProfile.value) {
|
|
||||||
await cloudsStore.fetchCloudTypes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [route.params.id, authStore.user?.id],
|
|
||||||
() => {
|
|
||||||
loadProfilePage()
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(selectedUploadDate, async newValue => {
|
|
||||||
if (!newValue) return
|
|
||||||
await nextTick()
|
|
||||||
timelineSectionRef.value?.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'start',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="oc-page-hero">
|
|
||||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="oc-page-hero__split"
|
|
||||||
>
|
|
||||||
<NCard class="oc-panel-card h-full border border-slate-200" :bordered="false">
|
|
||||||
<NSkeleton class="h-6 w-1/3" />
|
|
||||||
<NSkeleton class="mt-4 h-10 w-2/3" />
|
|
||||||
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
|
|
||||||
</NCard>
|
|
||||||
<div v-if="isOwnProfile" class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2">
|
|
||||||
<NCard v-for="n in 4" :key="n" class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
|
|
||||||
<NSkeleton class="h-3 w-2/3" />
|
|
||||||
<NSkeleton class="mt-3 h-7 w-1/2" />
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NCard v-else class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
|
|
||||||
<NSkeleton class="h-5 w-1/3" />
|
|
||||||
<NSkeleton class="mt-4 h-8 w-1/2" />
|
|
||||||
<NSkeleton class="mt-5 h-3 w-full" />
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="oc-page-hero__split oc-page-hero__split--stretch"
|
|
||||||
>
|
|
||||||
<NCard class="oc-panel-card h-full border border-slate-200 bg-white/90 backdrop-blur" :bordered="false">
|
|
||||||
<div class="flex gap-5">
|
|
||||||
<div
|
|
||||||
class="profile-avatar flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-3xl font-bold text-slate-900"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="profileData?.avatar_url"
|
|
||||||
:src="profileData.avatar_url"
|
|
||||||
:alt="profileData.username"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<template v-else>
|
|
||||||
{{ getProfileInitial(profileData) }}
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="oc-page-eyebrow">Sky Journal</p>
|
|
||||||
<h1 class="oc-page-title">
|
|
||||||
{{ pageTitle }}
|
|
||||||
</h1>
|
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
|
||||||
<p class="text-lg font-medium text-slate-700">
|
|
||||||
@{{ profileData?.username || '未知用户' }}
|
|
||||||
</p>
|
|
||||||
<RouterLink v-if="isOwnProfile" to="/profile/settings">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button oc-icon-button--small"
|
|
||||||
title="个人资料设置"
|
|
||||||
aria-label="个人资料设置"
|
|
||||||
>
|
|
||||||
<NIcon size="17">
|
|
||||||
<Settings />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
|
|
||||||
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
|
|
||||||
<div v-if="isOwnProfile" class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2">
|
|
||||||
<NCard class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
|
|
||||||
<p class="oc-stat-label">总拍摄数</p>
|
|
||||||
<p class="oc-stat-value">{{ totalShots }}</p>
|
|
||||||
</NCard>
|
|
||||||
<NCard class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
|
|
||||||
<p class="oc-stat-label">最常拍云型</p>
|
|
||||||
<p class="mt-2 truncate text-xl font-bold text-slate-900">{{ mostCommonCloudType.name }}</p>
|
|
||||||
</NCard>
|
|
||||||
<NCard class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
|
|
||||||
<p class="oc-stat-label">拍摄天数</p>
|
|
||||||
<p class="oc-stat-value">{{ shootingDays }}</p>
|
|
||||||
</NCard>
|
|
||||||
<NCard class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
|
|
||||||
<p class="oc-stat-label">被点赞数</p>
|
|
||||||
<p class="oc-stat-value">{{ receivedLikes }}</p>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NCard v-if="!isOwnProfile" class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
|
|
||||||
<p class="text-sm text-slate-500">公开贡献</p>
|
|
||||||
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="oc-container oc-container--content oc-page-content">
|
|
||||||
<NAlert
|
|
||||||
v-if="errorMsg"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
title="个人主页加载失败"
|
|
||||||
>
|
|
||||||
{{ errorMsg }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NAlert
|
|
||||||
v-if="manageError"
|
|
||||||
class="mb-5"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
title="操作失败"
|
|
||||||
>
|
|
||||||
{{ manageError }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<section ref="timelineSectionRef">
|
|
||||||
<ContributionHeatmap
|
|
||||||
:cells="contributionCells"
|
|
||||||
:total-count="totalShots"
|
|
||||||
:active-days="activeUploadDays"
|
|
||||||
:active-date="selectedUploadDate"
|
|
||||||
:title="isOwnProfile ? '上传活跃度' : '公开活跃度'"
|
|
||||||
@select-date="toggleSelectedUploadDate"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="mt-8">
|
|
||||||
<div class="mb-5 flex items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-slate-900">天空日志</h2>
|
|
||||||
<p class="oc-field-help">
|
|
||||||
按拍摄时间倒序展示,并按月份分组。热力图统计按上传日期计算。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<NButton
|
|
||||||
v-if="isOwnProfile && selectedCloudCount"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--danger"
|
|
||||||
@click="deleteCloudByIds(Array.from(selectedCloudIds))"
|
|
||||||
>
|
|
||||||
删除已选 {{ selectedCloudCount }}
|
|
||||||
</NButton>
|
|
||||||
<NButton
|
|
||||||
v-if="isOwnProfile"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="toggleSelectionMode"
|
|
||||||
>
|
|
||||||
{{ selectionMode ? '取消选择' : '选择图片' }}
|
|
||||||
</NButton>
|
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="loadProfilePage(true)">
|
|
||||||
刷新数据
|
|
||||||
</NButton>
|
|
||||||
<NButton
|
|
||||||
v-if="selectedUploadDate"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="clearSelectedUploadDate"
|
|
||||||
>
|
|
||||||
清除筛选
|
|
||||||
</NButton>
|
|
||||||
<RouterLink
|
|
||||||
v-if="isOwnProfile"
|
|
||||||
to="/upload"
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
>
|
|
||||||
继续上传
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="selectedUploadDate"
|
|
||||||
class="mb-5 border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800"
|
|
||||||
>
|
|
||||||
当前正在查看 <span class="font-semibold">{{ formatDayKey(selectedUploadDate) }}</span> 上传的图片,
|
|
||||||
共 <span class="font-semibold">{{ selectedUploadCount }}</span> 张。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="space-y-8">
|
|
||||||
<div v-for="n in 2" :key="n">
|
|
||||||
<NSkeleton class="h-6 w-40" />
|
|
||||||
<div class="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<NCard v-for="m in 3" :key="m" class="oc-panel-card border border-slate-200" :bordered="false">
|
|
||||||
<NSkeleton class="h-52 w-full" />
|
|
||||||
<NSkeleton class="mt-4 h-5 w-2/3" />
|
|
||||||
<NSkeleton class="mt-3 h-4 w-1/2" />
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="timelineGroups.length" class="space-y-10">
|
|
||||||
<section v-for="group in timelineGroups" :key="group.key">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<h3 class="text-xl font-bold text-slate-900">{{ group.label }}</h3>
|
|
||||||
<div class="h-px flex-1 bg-slate-200"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<button
|
|
||||||
v-for="item in group.items"
|
|
||||||
:key="item.id"
|
|
||||||
type="button"
|
|
||||||
@click="selectedCloud = item"
|
|
||||||
class="oc-interactive-card group overflow-hidden border bg-white text-left transition-all hover:-translate-y-1"
|
|
||||||
:class="selectedUploadDate && toDayKey(item.created_at) === selectedUploadDate ? 'oc-interactive-card--selected' : 'border-slate-200'"
|
|
||||||
>
|
|
||||||
<div class="relative">
|
|
||||||
<img
|
|
||||||
:src="item.thumbnail_url || item.image_url"
|
|
||||||
:alt="item.cloudTypeName"
|
|
||||||
class="h-56 w-full object-cover transition duration-500 group-hover:scale-[1.03]"
|
|
||||||
/>
|
|
||||||
<div class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/82 via-slate-950/32 to-transparent"></div>
|
|
||||||
<div class="absolute left-4 top-4 flex flex-wrap gap-2">
|
|
||||||
<label
|
|
||||||
v-if="isOwnProfile && selectionMode"
|
|
||||||
class="inline-flex items-center border border-white/60 bg-white/90 px-2 py-1 text-xs font-medium text-slate-700"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="mr-2 h-3.5 w-3.5 border-slate-300 text-sky-500 focus:ring-sky-500"
|
|
||||||
:checked="selectedCloudIds.has(item.id)"
|
|
||||||
@change="toggleCloudSelection(item.id, ($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
选择
|
|
||||||
</label>
|
|
||||||
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[item.cloudTypeRarity].chip">
|
|
||||||
{{ rarityMeta[item.cloudTypeRarity].label }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="isOwnProfile"
|
|
||||||
class="inline-flex border px-3 py-1 text-xs font-medium"
|
|
||||||
:class="statusMeta[item.status].chip"
|
|
||||||
>
|
|
||||||
{{ statusMeta[item.status].label }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="isOwnProfile"
|
|
||||||
class="inline-flex border px-3 py-1 text-xs font-medium"
|
|
||||||
:class="item.is_hidden ? 'border-slate-200 bg-slate-100 text-slate-600' : 'border-emerald-200 bg-emerald-100 text-emerald-700'"
|
|
||||||
>
|
|
||||||
{{ item.is_hidden ? '私密' : '公开' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="absolute bottom-4 left-4 right-4 text-white">
|
|
||||||
<p class="truncate text-lg font-semibold">{{ item.cloudTypeName }}</p>
|
|
||||||
<p class="mt-1 truncate text-xs text-white/80">{{ formatCloudTime(item) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-5">
|
|
||||||
<p class="truncate text-sm font-medium text-slate-900">
|
|
||||||
{{ item.location_name || '未填写位置名称' }}
|
|
||||||
</p>
|
|
||||||
<p class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500">
|
|
||||||
{{ item.description || '没有留下额外说明。' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10">
|
|
||||||
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '这位用户还没有公开云图')">
|
|
||||||
<template #extra>
|
|
||||||
<RouterLink
|
|
||||||
v-if="isOwnProfile && !selectedUploadDate"
|
|
||||||
to="/upload"
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
>
|
|
||||||
去上传第一张
|
|
||||||
</RouterLink>
|
|
||||||
<NButton v-else-if="selectedUploadDate" secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearSelectedUploadDate">返回全部记录</NButton>
|
|
||||||
</template>
|
|
||||||
</NEmpty>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ImageDetailModal
|
|
||||||
v-if="selectedCloud"
|
|
||||||
:open="!!selectedCloud"
|
|
||||||
:image-url="selectedCloud.image_url"
|
|
||||||
:thumbnail-url="selectedCloud.thumbnail_url"
|
|
||||||
:image-alt="selectedCloud.cloudTypeName"
|
|
||||||
:title="selectedCloud.cloudTypeName"
|
|
||||||
:subtitle="`拍摄时间:${formatCloudTime(selectedCloud)}`"
|
|
||||||
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
|
|
||||||
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
|
|
||||||
@close="selectedCloud = null"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">上传时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedCloud.created_at) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">拍摄时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedCloud.captured_at) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">位置</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">模糊化经纬度</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度 {{ formatCoordinate(selectedCloud.longitude) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">{{ isOwnProfile ? '审核状态' : '可见性' }}</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
<template v-if="isOwnProfile">
|
|
||||||
{{ statusMeta[selectedCloud.status].label }} / {{ selectedCloud.is_hidden ? '私密' : '公开' }}
|
|
||||||
</template>
|
|
||||||
<template v-else>公开可见</template>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
|
||||||
<p class="oc-section-label">图片说明</p>
|
|
||||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
|
||||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<MiniLocationMap
|
|
||||||
:latitude="selectedCloud.latitude"
|
|
||||||
:longitude="selectedCloud.longitude"
|
|
||||||
:location-name="selectedCloud.location_name"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-if="isOwnProfile" #actions>
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<NDropdown
|
|
||||||
trigger="click"
|
|
||||||
placement="top-start"
|
|
||||||
:options="cloudActionOptions"
|
|
||||||
@select="handleCloudAction"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button"
|
|
||||||
title="图片管理"
|
|
||||||
aria-label="图片管理"
|
|
||||||
>
|
|
||||||
<NIcon size="20">
|
|
||||||
<Settings />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
</NDropdown>
|
|
||||||
<!-- <span class="text-xs text-slate-500">
|
|
||||||
{{ selectedCloud.is_hidden ? '当前为私密图片' : '当前会公开展示' }}
|
|
||||||
</span> -->
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ImageDetailModal>
|
|
||||||
|
|
||||||
<CloudEditModal
|
|
||||||
:open="editModalOpen && !!selectedCloud"
|
|
||||||
:saving="editSaving"
|
|
||||||
:error="manageError"
|
|
||||||
:cloud-types="cloudsStore.cloudTypes"
|
|
||||||
:initial-value="editInitialValue"
|
|
||||||
@close="closeEditModal"
|
|
||||||
@save="submitEditModal"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.profile-avatar {
|
|
||||||
box-shadow: var(--oc-shadow-sm);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
|
||||||
import { Lock } from '@vicons/tabler'
|
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const redirect = computed(() => {
|
|
||||||
const value = route.query.redirect
|
|
||||||
return typeof value === 'string' && value.startsWith('/') ? value : '/'
|
|
||||||
})
|
|
||||||
|
|
||||||
const title = computed(() => {
|
|
||||||
if (redirect.value === '/upload') {
|
|
||||||
return '上传前先登录'
|
|
||||||
}
|
|
||||||
|
|
||||||
return '需要先登录'
|
|
||||||
})
|
|
||||||
|
|
||||||
const description = computed(() => {
|
|
||||||
if (redirect.value === '/upload') {
|
|
||||||
return '上传云图、使用地图快捷上传都需要先登录账号。登录后再回来,这里的上传入口就会开放。'
|
|
||||||
}
|
|
||||||
|
|
||||||
return '这个页面需要先登录才能继续访问。登录成功后可以返回刚才的目标页面继续操作。'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="oc-system-page">
|
|
||||||
<div class="oc-system-card">
|
|
||||||
<NIcon size="72" class="text-sky-600"><Lock /></NIcon>
|
|
||||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-sky-600">401 Login Required</p>
|
|
||||||
<h1 class="oc-page-title">{{ title }}</h1>
|
|
||||||
<p class="oc-page-description mx-auto max-w-xl">
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<RouterLink :to="{ path: '/login', query: redirect !== '/' ? { redirect } : undefined }">
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky">前往登录</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/">
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">返回地图</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
|
||||||
import { HandStop } from '@vicons/tabler'
|
|
||||||
import { RouterLink } from 'vue-router'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="oc-system-page">
|
|
||||||
<div class="oc-system-card">
|
|
||||||
<NIcon size="72" class="text-rose-600"><HandStop /></NIcon>
|
|
||||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-rose-600">403 Forbidden</p>
|
|
||||||
<h1 class="oc-page-title">这里不让进</h1>
|
|
||||||
<p class="oc-page-description mx-auto max-w-xl">
|
|
||||||
你当前没有访问这个页面的权限。通常是因为这个区域只对管理员开放。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<RouterLink to="/">
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky">返回地图</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/profile">
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">前往个人主页</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
|
||||||
import { Help } from '@vicons/tabler'
|
|
||||||
import { RouterLink } from 'vue-router'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="oc-system-page">
|
|
||||||
<div class="oc-system-card">
|
|
||||||
<NIcon size="72" class="text-amber-600"><Help /></NIcon>
|
|
||||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-amber-600">404 Not Found</p>
|
|
||||||
<h1 class="oc-page-title">我也没想明白</h1>
|
|
||||||
<p class="oc-page-description mx-auto max-w-xl">
|
|
||||||
这个页面不存在,或者它已经被移动到了别的地方。换个入口再试一次。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<RouterLink to="/">
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky">返回地图</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/gallery">
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">去画廊看看</NButton>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
|
||||||
import { NAlert, NButton, NCard, NIcon, NProgress, NResult, NTag } from 'naive-ui'
|
|
||||||
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useCloudsStore } from '@/stores/clouds'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { downloadCloudBadgeCard } from '@/lib/cloudBadges'
|
|
||||||
import { useUpload } from '@/composables/useUpload'
|
|
||||||
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const cloudsStore = useCloudsStore()
|
|
||||||
const { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateAll, uploadAll } = useUpload()
|
|
||||||
|
|
||||||
const activeId = ref<string | null>(null)
|
|
||||||
const dragOver = ref(false)
|
|
||||||
const successMsg = ref(false)
|
|
||||||
const unlockedBadges = ref<Awaited<ReturnType<typeof uploadAll>>['unlockedBadges']>([])
|
|
||||||
const errorMsg = ref('')
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const mapPickerOpen = ref(false)
|
|
||||||
|
|
||||||
const activeItem = computed(() => {
|
|
||||||
if (!activeId.value) return null
|
|
||||||
return items.value.find(i => i.id === activeId.value) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => items.value.length, () => {
|
|
||||||
if (items.value.length > 0 && !activeId.value) {
|
|
||||||
activeId.value = items.value[0].id
|
|
||||||
}
|
|
||||||
if (items.value.length === 0) {
|
|
||||||
activeId.value = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function selectItem(id: string) {
|
|
||||||
activeId.value = id
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
|
||||||
dragOver.value = false
|
|
||||||
if (e.dataTransfer?.files) {
|
|
||||||
addFiles(Array.from(e.dataTransfer.files))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileSelect(e: Event) {
|
|
||||||
const target = e.target as HTMLInputElement
|
|
||||||
if (target.files && target.files.length > 0) {
|
|
||||||
addFiles(Array.from(target.files))
|
|
||||||
}
|
|
||||||
target.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemove(id: string) {
|
|
||||||
removeItem(id)
|
|
||||||
if (activeId.value === id) {
|
|
||||||
activeId.value = items.value[0]?.id ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateActiveCoordinates(latitude: number | null, longitude: number | null) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
|
|
||||||
activeItem.value.latitude = latitude
|
|
||||||
activeItem.value.longitude = longitude
|
|
||||||
|
|
||||||
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
|
||||||
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openMapPicker() {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
|
|
||||||
mapPickerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMapPicker() {
|
|
||||||
mapPickerOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
|
||||||
updateActiveCoordinates(payload.latitude, payload.longitude)
|
|
||||||
closeMapPicker()
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCoordinates() {
|
|
||||||
updateActiveCoordinates(null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateActivePublicState(isPublic: boolean) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
activeItem.value.isHidden = !isPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
errorMsg.value = ''
|
|
||||||
const allValid = validateAll()
|
|
||||||
if (!allValid) {
|
|
||||||
const firstInvalid = items.value.find(i => Object.keys(i.errors).length > 0)
|
|
||||||
if (firstInvalid) {
|
|
||||||
activeId.value = firstInvalid.id
|
|
||||||
await nextTick()
|
|
||||||
const el = document.getElementById(`field-${firstInvalid.id}-cloudCategory`)
|
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await uploadAll()
|
|
||||||
if (result.ok) {
|
|
||||||
unlockedBadges.value = result.unlockedBadges
|
|
||||||
successMsg.value = true
|
|
||||||
} else {
|
|
||||||
errorMsg.value = '上传失败,请稍后重试'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAfterSuccess() {
|
|
||||||
successMsg.value = false
|
|
||||||
unlockedBadges.value = []
|
|
||||||
errorMsg.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveBadge(badge: NonNullable<typeof unlockedBadges.value[number]>) {
|
|
||||||
await downloadCloudBadgeCard({
|
|
||||||
cloudName: badge.cloudName,
|
|
||||||
cloudNameEn: badge.cloudNameEn,
|
|
||||||
unlockedAt: badge.unlockedAt,
|
|
||||||
username: authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户',
|
|
||||||
rarity: badge.rarity,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function rarityLabel(rarity: NonNullable<typeof unlockedBadges.value[number]>['rarity']) {
|
|
||||||
if (rarity === 'common') return '常见'
|
|
||||||
if (rarity === 'uncommon') return '少见'
|
|
||||||
return '罕见'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUnlockedAt(iso: string) {
|
|
||||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLatInput(e: Event) {
|
|
||||||
const val = parseFloat((e.target as HTMLInputElement).value)
|
|
||||||
if (activeItem.value) {
|
|
||||||
activeItem.value.latitude = isNaN(val) ? null : val
|
|
||||||
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
|
||||||
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLngInput(e: Event) {
|
|
||||||
const val = parseFloat((e.target as HTMLInputElement).value)
|
|
||||||
if (activeItem.value) {
|
|
||||||
activeItem.value.longitude = isNaN(val) ? null : val
|
|
||||||
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
|
||||||
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDatetimeLocal(iso: string): string {
|
|
||||||
const d = new Date(iso)
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCapturedAtChange(e: Event) {
|
|
||||||
const val = (e.target as HTMLInputElement).value
|
|
||||||
if (activeItem.value && val) {
|
|
||||||
activeItem.value.capturedAt = new Date(val).toISOString()
|
|
||||||
if (activeItem.value.errors.capturedAt) {
|
|
||||||
delete activeItem.value.errors.capturedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCategoryChange(e: Event) {
|
|
||||||
const val = (e.target as HTMLSelectElement).value
|
|
||||||
if (!activeItem.value) return
|
|
||||||
if (val === 'other') {
|
|
||||||
activeItem.value.cloudCategoryId = 'other'
|
|
||||||
} else if (val === '') {
|
|
||||||
activeItem.value.cloudCategoryId = null
|
|
||||||
} else {
|
|
||||||
activeItem.value.cloudCategoryId = Number(val)
|
|
||||||
}
|
|
||||||
activeItem.value.errors = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => { cloudsStore.fetchCloudTypes() })
|
|
||||||
onUnmounted(() => {
|
|
||||||
for (const item of items.value) URL.revokeObjectURL(item.preview)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<section class="oc-page-hero">
|
|
||||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
|
||||||
<p class="oc-page-eyebrow">Cloud Upload</p>
|
|
||||||
<h1 class="oc-page-title">上传云图</h1>
|
|
||||||
<p class="oc-page-description">
|
|
||||||
批量整理你的云图记录。类别和拍摄时间必填,经纬度既可以手动输入,也可以在迷你地图里点选回填。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="oc-container oc-container--content oc-page-content">
|
|
||||||
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
|
|
||||||
|
|
||||||
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
|
||||||
<div class="w-full max-w-5xl">
|
|
||||||
<NResult status="success" title="已提交审核" />
|
|
||||||
|
|
||||||
<div v-if="unlockedBadges.length" class="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<NCard
|
|
||||||
v-for="badge in unlockedBadges"
|
|
||||||
:key="badge.cloudTypeId"
|
|
||||||
class="oc-panel-card border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)]"
|
|
||||||
>
|
|
||||||
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white">
|
|
||||||
{{ badge.cloudName.slice(0, 1) }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-5">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<h3 class="text-2xl font-bold text-slate-900">{{ badge.cloudName }}</h3>
|
|
||||||
<NTag :bordered="false" type="warning">
|
|
||||||
{{ rarityLabel(badge.rarity) }}
|
|
||||||
</NTag>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-slate-500">{{ badge.cloudNameEn }}</p>
|
|
||||||
<p class="mt-4 text-sm text-slate-600">解锁时间:{{ formatUnlockedAt(badge.unlockedAt) }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex gap-3">
|
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky flex-1" @click="saveBadge(badge)">保存分享卡片</NButton>
|
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral flex-1" @click="router.push(`/encyclopedia/${badge.cloudTypeId}`)">查看详情</NButton>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="resetAfterSuccess">继续上传</NButton>
|
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky" @click="router.push('/encyclopedia')">前往图鉴</NButton>
|
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="router.push('/profile')">返回个人主页</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="uploading" class="oc-panel-card mb-6 border border-slate-200 bg-white p-5">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span class="text-sm font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
|
|
||||||
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
|
|
||||||
</div>
|
|
||||||
<NProgress
|
|
||||||
type="line"
|
|
||||||
:show-indicator="false"
|
|
||||||
:percentage="overallProgress"
|
|
||||||
color="var(--oc-color-info)"
|
|
||||||
rail-color="var(--oc-color-border)"
|
|
||||||
:height="10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="items.length === 0"
|
|
||||||
@dragover.prevent="dragOver = true"
|
|
||||||
@dragleave.prevent="dragOver = false"
|
|
||||||
@drop.prevent="handleDrop"
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
class="oc-panel-card flex cursor-pointer flex-col items-center justify-center border-2 border-dashed bg-white py-20 transition-colors"
|
|
||||||
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-slate-300 hover:border-sky-400 hover:bg-slate-50'"
|
|
||||||
>
|
|
||||||
<NIcon size="48" class="mb-4 text-sky-600"><CloudUpload /></NIcon>
|
|
||||||
<p class="mb-1 text-lg font-medium text-slate-700">点击或拖拽图片到此处</p>
|
|
||||||
<p class="text-sm text-slate-400">支持 JPG、PNG 格式,可一次选择多张</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="flex min-w-0 flex-col gap-6 lg:flex-row">
|
|
||||||
<div class="w-full min-w-0 lg:w-[480px] lg:flex-shrink-0">
|
|
||||||
<div v-if="activeItem" class="mb-3 overflow-hidden border border-slate-900 bg-slate-900">
|
|
||||||
<img :src="activeItem.preview" alt="预览" class="h-64 w-full object-contain sm:h-[360px]" />
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
|
||||||
<div
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.id"
|
|
||||||
@click="selectItem(item.id)"
|
|
||||||
class="relative flex-shrink-0 w-16 h-16 overflow-hidden cursor-pointer border-2 transition-colors bg-white"
|
|
||||||
:class="activeId === item.id ? 'border-sky-500' : 'border-transparent hover:border-slate-300'"
|
|
||||||
>
|
|
||||||
<img :src="item.preview" alt="" class="w-full h-full object-cover" />
|
|
||||||
<div v-if="Object.keys(item.errors).length > 0" class="absolute top-0 right-0 w-2.5 h-2.5 bg-red-500"></div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click.stop="handleRemove(item.id)"
|
|
||||||
class="oc-icon-button oc-icon-button--danger oc-icon-button--tiny absolute -right-1 -top-1"
|
|
||||||
title="移除图片"
|
|
||||||
aria-label="移除图片"
|
|
||||||
>
|
|
||||||
<NIcon size="12"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
class="flex h-16 w-16 flex-shrink-0 cursor-pointer items-center justify-center border-2 border-dashed border-slate-300 bg-white transition-colors hover:border-sky-400 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
<span class="text-xl text-slate-400">+</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 min-w-0" v-if="activeItem">
|
|
||||||
<NCard class="oc-panel-card border border-slate-200">
|
|
||||||
<div class="space-y-5">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-semibold text-slate-900">图片信息</h2>
|
|
||||||
<span class="text-sm text-slate-400">{{ items.findIndex(i => i.id === activeId) + 1 }} / {{ items.length }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 类别 * -->
|
|
||||||
<div :id="`field-${activeItem.id}-cloudCategory`">
|
|
||||||
<label class="oc-field-label">
|
|
||||||
类别 <span class="oc-field-required">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
:value="activeItem.cloudCategoryId ?? ''"
|
|
||||||
@change="onCategoryChange"
|
|
||||||
class="oc-field-control"
|
|
||||||
>
|
|
||||||
<option value="" disabled>请选择</option>
|
|
||||||
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
|
|
||||||
{{ ct.name }}({{ ct.name_en }})
|
|
||||||
</option>
|
|
||||||
<option value="other">其他</option>
|
|
||||||
</select>
|
|
||||||
<div v-if="activeItem.cloudCategoryId === 'other'" class="mt-2">
|
|
||||||
<input
|
|
||||||
v-model="activeItem.customCloudType"
|
|
||||||
type="text"
|
|
||||||
placeholder="输入云的类型"
|
|
||||||
@input="activeItem.errors = {}"
|
|
||||||
class="oc-field-control"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="activeItem.errors.cloudCategory" class="oc-field-error">{{ activeItem.errors.cloudCategory }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 拍摄时间 * -->
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">
|
|
||||||
拍摄时间 <span class="oc-field-required">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
|
||||||
@change="onCapturedAtChange"
|
|
||||||
class="oc-field-control"
|
|
||||||
/>
|
|
||||||
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">{{ activeItem.errors.capturedAt }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 经纬度 -->
|
|
||||||
<div>
|
|
||||||
<div class="mb-1 flex items-center justify-between gap-3">
|
|
||||||
<label class="oc-field-label mb-0">经纬度</label>
|
|
||||||
<button
|
|
||||||
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
|
|
||||||
type="button"
|
|
||||||
@click="clearCoordinates"
|
|
||||||
class="oc-text-button oc-text-button--muted text-xs"
|
|
||||||
>
|
|
||||||
清空
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
:value="activeItem.latitude !== null ? activeItem.latitude : ''"
|
|
||||||
@input="onLatInput"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="纬度(如 39.90)"
|
|
||||||
class="oc-field-control"
|
|
||||||
/>
|
|
||||||
<p v-if="activeItem.errors.latitude" class="oc-field-error">{{ activeItem.errors.latitude }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
:value="activeItem.longitude !== null ? activeItem.longitude : ''"
|
|
||||||
@input="onLngInput"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="经度(如 116.40)"
|
|
||||||
class="oc-field-control"
|
|
||||||
/>
|
|
||||||
<p v-if="activeItem.errors.longitude" class="oc-field-error">{{ activeItem.errors.longitude }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="openMapPicker"
|
|
||||||
title="地图选点"
|
|
||||||
aria-label="地图选点"
|
|
||||||
class="oc-icon-button oc-icon-button--sky"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><MapIcon /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-slate-400">选填,可手动输入,或点击右侧小地图图标选择</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 位置名称 -->
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">位置名称</label>
|
|
||||||
<input
|
|
||||||
v-model="activeItem.locationName"
|
|
||||||
type="text"
|
|
||||||
placeholder="如:北京、成都"
|
|
||||||
class="oc-field-control"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 图片描述 -->
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">图片描述</label>
|
|
||||||
<textarea
|
|
||||||
v-model="activeItem.description"
|
|
||||||
rows="3"
|
|
||||||
placeholder="描述一下这张图片..."
|
|
||||||
class="oc-field-control resize-none"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 公开状态 -->
|
|
||||||
<div class="border border-slate-200 bg-slate-50 px-4 py-3">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-slate-800">公开展示</p>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">关闭后不会出现在画廊、地图和公开主页中。</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
:checked="!activeItem.isHidden"
|
|
||||||
type="checkbox"
|
|
||||||
:id="'isPublic-' + activeItem.id"
|
|
||||||
class="h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500"
|
|
||||||
@change="updateActivePublicState(($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<p class="text-sm text-slate-500">共 {{ items.length }} 张图片</p>
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearAll()" :disabled="uploading">清空</NButton>
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" @click="handleSubmit" :disabled="uploading">
|
|
||||||
{{ uploading ? '上传中...' : '提交上传' }}
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NAlert v-if="errorMsg" class="mt-3" type="error" :show-icon="false" :bordered="false" title="上传失败">
|
|
||||||
{{ errorMsg }}
|
|
||||||
</NAlert>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MapPickerModal
|
|
||||||
:open="mapPickerOpen"
|
|
||||||
:latitude="activeItem?.latitude ?? null"
|
|
||||||
:longitude="activeItem?.longitude ?? null"
|
|
||||||
description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
|
|
||||||
footer-text="建议点选大致拍摄位置,上传时会继续做模糊化处理。"
|
|
||||||
@close="closeMapPicker"
|
|
||||||
@confirm="confirmMapPicker"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
+13
-13
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
|
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user