重组前端功能目录并统一代码规范工具链 (#10)
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -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
|
||||
@@ -10,12 +10,16 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
.eslintcache
|
||||
.prettiercache
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
.idea
|
||||
.DS_Store
|
||||
.claude
|
||||
|
||||
@@ -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
|
||||
|
||||
- `npm run dev` — Vite dev server with HMR
|
||||
- `npm run lint` / `npm run lint:fix` — ESLint checks / automatic fixes for JS, TS, and Vue; warnings fail the check
|
||||
- `npm run format:check` / `npm run format` — Prettier check / formatting for source, config, and docs
|
||||
- `npm run check` — lint, formatting, style contracts, and typecheck
|
||||
- `npm run check:styles` — validates shared style contracts and blocks deprecated palettes/classes
|
||||
- `npm run build` — `vue-tsc -b && vite build` (typecheck must pass or build aborts)
|
||||
- `npx vue-tsc -b` — standalone typecheck (no script in package.json)
|
||||
- `npm run typecheck` — standalone Vue / TypeScript typecheck
|
||||
- `npm run preview` — previews the production build on strict port `5173`
|
||||
- No general linter, formatter, or test runner exists; `check:styles` is the project-specific visual-system check
|
||||
- ESLint uses `eslint.config.js`; Prettier uses `.prettierrc.json`. Keep formatting rules in Prettier. No general test runner exists; `check:styles` remains the project-specific visual-system check.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + FastAPI + AMap (高德地图) + Naive UI**
|
||||
- Path alias `@/` → `src/` (configured in both `vite.config.ts` and `tsconfig.app.json`)
|
||||
- FastAPI client at `src/lib/api.ts`; API base URL comes from `VITE_API_URL`
|
||||
- AMap loaded lazily via `src/lib/amap.ts` with type declarations in `src/types/amap.d.ts`
|
||||
- AMap loaded lazily via `src/shared/map/amap.ts` with type declarations in `src/shared/map/types/amap.d.ts`
|
||||
- UI language is Chinese (zh-CN)
|
||||
- All persistence, storage, authorization, and admin operations go through the FastAPI backend
|
||||
|
||||
## Directory Map
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `src/lib/` | Singletons and utilities: FastAPI client, amap, canvas patch, cloudTypes constants, cloudBadges canvas renderer, SEO meta builder |
|
||||
| `src/stores/` | Pinia stores: auth, clouds (cloud_types cache), encyclopedia (collection + unlock tracking), profile (user pages + cloud CRUD) |
|
||||
| `src/composables/` | Vue composables: `useUpload` (batch multipart upload, EXIF extraction, badge result handling) |
|
||||
| `src/components/cloud/` | Cloud-related modals and widgets: ImageDetailModal, CloudEditModal, MapPickerModal, QuickUploadModal, MiniLocationMap |
|
||||
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
|
||||
| `src/components/profile/` | ContributionHeatmap |
|
||||
| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components |
|
||||
| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts |
|
||||
| `src/lib/theme.ts` | Naive UI theme overrides aligned with the semantic tokens |
|
||||
| `src/views/` | Route-level page components (see Routes below) |
|
||||
| `src/types/` | TypeScript types: domain models (`database.ts`), API DTOs (`api.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
|
||||
| `docs/style-system.md` | Concise implementation and naming guide for the visual system |
|
||||
| `scripts/check-style-system.mjs` | Automated guard against deprecated palettes and malformed shared classes |
|
||||
| Path | Purpose |
|
||||
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `src/features/` | Business modules; each owns its route views, components, stores, and composables as needed |
|
||||
| `src/features/auth/` | Auth views, auth store, and Better Auth client |
|
||||
| `src/features/clouds/` | Shared cloud detail/edit/like components, cloud-type and likes stores, cloud-type constants |
|
||||
| `src/features/upload/` | UploadView, QuickUploadModal, and useUpload |
|
||||
| `src/features/profile/` | Profile views, profile store, and ContributionHeatmap |
|
||||
| `src/features/encyclopedia/` | Encyclopedia views and encyclopedia store |
|
||||
| `src/features/{map,gallery,community,admin,system}/views/` | Remaining route views grouped by feature |
|
||||
| `src/shared/map/` | Reusable location picker and mini map, AMap loader, and AMap declarations |
|
||||
| `src/lib/` | Shared infrastructure: API client and adapters, SEO meta builder, Naive UI theme |
|
||||
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
|
||||
| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components |
|
||||
| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts |
|
||||
| `src/lib/theme.ts` | Naive UI theme overrides aligned with the semantic tokens |
|
||||
| `src/router/` | Route registration, lazy view imports, guards, and per-route SEO |
|
||||
| `src/types/` | Shared domain models (`database.ts`), API DTOs (`api.ts`), and router meta (`router.d.ts`) |
|
||||
| `docs/project-structure.md` | File ownership, dependency conventions, and examples for placing new code |
|
||||
| `docs/style-system.md` | Concise implementation and naming guide for the visual system |
|
||||
| `scripts/check-style-system.mjs` | Automated guard against deprecated palettes and malformed shared classes |
|
||||
|
||||
- When adding or moving source files, follow `docs/project-structure.md`: colocate business code under its owning feature; keep reusable location primitives in `shared/map` and application layout in `components/layout`.
|
||||
- Import concrete files directly; route views remain lazy imports in `src/router/index.ts`.
|
||||
|
||||
## Routes
|
||||
|
||||
| Path | View | Auth Required |
|
||||
| --- | --- | :---: |
|
||||
| `/` | MapView | No |
|
||||
| `/login`, `/register`, `/forgot-password`, `/auth/confirm`, `/auth/reset-password` | Auth views | No |
|
||||
| `/upload` | UploadView | Yes |
|
||||
| `/encyclopedia` | EncyclopediaView | Yes |
|
||||
| `/encyclopedia/:id` | CloudTypeView | Yes |
|
||||
| `/gallery` | GalleryView | No |
|
||||
| `/community` | CommunityView | No |
|
||||
| `/profile` | ProfileView (own) | Yes |
|
||||
| `/profile/settings` | ProfileSettingsView | Yes |
|
||||
| `/profile/:id` | ProfileView (public) | No |
|
||||
| `/admin` | AdminView | Yes (admin) |
|
||||
| `/403` | ForbiddenView | No |
|
||||
| `/401` | AuthRequiredView | No |
|
||||
| `/:pathMatch(.*)*` | NotFoundView | No |
|
||||
| Path | View | Auth Required |
|
||||
| ---------------------------------------------------------------------------------- | -------------------- | :-----------: |
|
||||
| `/` | MapView | No |
|
||||
| `/login`, `/register`, `/forgot-password`, `/auth/confirm`, `/auth/reset-password` | Auth views | No |
|
||||
| `/upload` | UploadView | Yes |
|
||||
| `/encyclopedia` | EncyclopediaView | Yes |
|
||||
| `/encyclopedia/:id` | CloudTypeView | Yes |
|
||||
| `/gallery` | GalleryView | No |
|
||||
| `/community` | CommunityView | No |
|
||||
| `/profile` | ProfileView (own) | Yes |
|
||||
| `/profile/settings` | ProfileSettingsView | Yes |
|
||||
| `/profile/:id` | ProfileView (public) | No |
|
||||
| `/admin` | AdminView | Yes (admin) |
|
||||
| `/403` | ForbiddenView | No |
|
||||
| `/401` | AuthRequiredView | No |
|
||||
| `/:pathMatch(.*)*` | NotFoundView | No |
|
||||
|
||||
- Route guards in `router/index.ts`: `requiresAuth` redirects to `/401` with the intended URL in the `redirect` query; `requiresAdmin` redirects to `/403`
|
||||
- SEO meta tags applied per-route via `lib/seo.ts` in `router.afterEach`
|
||||
@@ -117,7 +128,6 @@
|
||||
- **Vercel** with `vercel.json`: SPA rewrites, security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). No CSP or HSTS configured.
|
||||
- Vite dev and preview servers both use strict port `5173`, matching the backend's default `CORS_ORIGINS` and `FRONTEND_URL`.
|
||||
- **SEO plugin** in `vite.config.ts`: generates `robots.txt` and `sitemap.xml` at build time. Auth-only routes, including `/encyclopedia`, are excluded from the sitemap and disallowed in `robots.txt`.
|
||||
- **`lib/canvas.ts`**: patches `HTMLCanvasElement.getContext('2d')` to always pass `willReadFrequently: true` — needed for the badge card renderer in `lib/cloudBadges.ts`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -137,11 +147,11 @@ Backend-only variables such as database credentials, JWT secrets, SMTP settings,
|
||||
- The design language is a clean sky atlas: airy sky-blue surfaces, fresh eucalyptus green, square editorial panels, pixel-cloud identity, and hard offset shadows.
|
||||
- Read `.agents/skills/opencloud-style/SKILL.md` before material visual changes. Treat `src/styles/tokens.css`, `src/styles/base.css`, `src/styles/components.css`, and `src/lib/theme.ts` as the implementation sources of truth.
|
||||
- Shared/stable decisions belong in layers:
|
||||
- tokens and palette remapping → `src/styles/tokens.css`
|
||||
- global baseline and square-corner policy → `src/styles/base.css`
|
||||
- cross-page semantic classes → `src/styles/components.css`
|
||||
- feature-specific map/slider/media details → the component's scoped style
|
||||
- one-off layout and responsive composition → Tailwind in the template
|
||||
- tokens and palette remapping → `src/styles/tokens.css`
|
||||
- global baseline and square-corner policy → `src/styles/base.css`
|
||||
- cross-page semantic classes → `src/styles/components.css`
|
||||
- feature-specific map/slider/media details → the component's scoped style
|
||||
- one-off layout and responsive composition → Tailwind in the template
|
||||
- The global interface is square by default. Ordinary cards, inputs, dropdowns, buttons, modals, and map controls use `0px` radius. Circular marker dots, media overlay controls, AMap-generated bubbles/cards, and the media-focused image-detail shell are explicit exceptions.
|
||||
- Use hard zero-blur offset shadows (`--oc-shadow-xs` through `--oc-shadow-xl`) for ordinary UI depth. Interactive controls may lift by `translate(-1px, -1px)` on hover.
|
||||
- Brand/interaction green is fresh eucalyptus: main `#34745d`, dark `#274c40`, light surface `#f2faf6`.
|
||||
@@ -160,18 +170,18 @@ Backend-only variables such as database credentials, JWT secrets, SMTP settings,
|
||||
- Global overrides are defined in `src/lib/theme.ts` and mounted by `App.vue`; keep brand/success colors aligned with `src/styles/tokens.css`.
|
||||
- **Do not rely on default Naive UI primary/secondary colors for page CTAs or panel actions.** Use the shared classes in `src/styles/components.css`.
|
||||
- Auth buttons use `oc-primary-button` with semantic variants:
|
||||
- `oc-primary-button--teal` — login / account-access actions
|
||||
- `oc-primary-button--sky` — register / create / forward actions
|
||||
- `oc-primary-button--teal` — login / account-access actions
|
||||
- `oc-primary-button--sky` — register / create / forward actions
|
||||
- Panel and utility buttons use `oc-panel-button` with semantic variants:
|
||||
- `oc-panel-button--neutral` — white card-style utility actions
|
||||
- `oc-panel-button--sky` — primary panel actions
|
||||
- `oc-panel-button--teal` — active toggles / confirm actions
|
||||
- `oc-panel-button--danger` — destructive actions
|
||||
- `oc-panel-button--amber` — admin/state-toggle actions where warning emphasis fits better than danger
|
||||
- `oc-panel-button--neutral` — white card-style utility actions
|
||||
- `oc-panel-button--sky` — primary panel actions
|
||||
- `oc-panel-button--teal` — active toggles / confirm actions
|
||||
- `oc-panel-button--danger` — destructive actions
|
||||
- `oc-panel-button--amber` — admin/state-toggle actions where warning emphasis fits better than danger
|
||||
- Card-like containers should prefer shared shadow classes instead of ad-hoc shadows:
|
||||
- `oc-panel-card`
|
||||
- `oc-panel-card-soft`
|
||||
- `oc-empty-card`
|
||||
- `oc-panel-card`
|
||||
- `oc-panel-card-soft`
|
||||
- `oc-empty-card`
|
||||
- When styling `NButton`, prefer `type="default"` plus the shared class when a custom panel button variant is intended. Otherwise Naive UI theme variables may override white backgrounds or semantic colors.
|
||||
- Primary and panel actions use dark text on light semantic surfaces; do not assume primary buttons should use white text.
|
||||
|
||||
|
||||
@@ -19,6 +19,29 @@ npm run dev
|
||||
## 检查与构建
|
||||
|
||||
```bash
|
||||
npx vue-tsc -b
|
||||
npm run check
|
||||
npm run build
|
||||
```
|
||||
|
||||
`check` 依次执行 ESLint、Prettier 格式检查、项目样式检查和 TypeScript 类型检查。也可以单独运行:
|
||||
|
||||
```bash
|
||||
npm run lint # 检查 JS、TS 和 Vue 代码,警告也会导致失败
|
||||
npm run lint:fix # 自动修复可修复的代码问题
|
||||
npm run format:check # 检查格式,不修改文件
|
||||
npm run format # 格式化源码、配置和文档
|
||||
npm run check:styles # 检查项目视觉规范
|
||||
npm run typecheck # Vue / TypeScript 类型检查
|
||||
```
|
||||
|
||||
ESLint 使用 flat config,启用 JavaScript、TypeScript 和 Vue 推荐规则;Prettier 负责格式,使用 4 个空格缩进、单引号和无分号风格。通过 `eslint-config-prettier` 关闭冲突的格式规则,构建产物、依赖、环境文件和工具管理的目录不参与格式化。
|
||||
|
||||
VS Code 用户可安装工作区推荐的 Vue、ESLint、Prettier 扩展,保存时自动格式化并执行 ESLint 修复。
|
||||
|
||||
配置参考:[Vue ESLint 官方指南](https://eslint.vuejs.org/user-guide/)、[typescript-eslint 入门](https://typescript-eslint.io/getting-started/)、[Prettier 安装指南](https://prettier.io/docs/install)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
业务代码按功能集中在 `src/features/`:页面、组件、store 和 composable 放入各自所属模块。跨业务的地图控件集中在 `src/shared/map/`,应用导航位于 `src/components/layout/`,API 客户端、全局样式和公共类型保留独立目录。
|
||||
|
||||
新增或移动文件时,参见[目录结构与依赖约定](docs/project-structure.md)。
|
||||
|
||||
@@ -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/base.css` | 页面基线、选中文本、全局圆角策略 |
|
||||
| 组件规则 | `src/styles/components.css` | 页面 Hero、容器、表单、面板、按钮等共享语义类 |
|
||||
| 第三方主题 | `src/lib/theme.ts` | Naive UI 的全局主题契约;值应与设计令牌保持一致 |
|
||||
| 单组件样式 | Vue 文件的 `<style scoped>` | 只属于一个功能的滑杆、动画、地图或媒体细节 |
|
||||
| 一次性布局 | Vue 模板中的 Tailwind 类 | flex/grid、间距、响应式排列及确实只出现一次的视觉调整 |
|
||||
| 层级 | 文件 | 职责 |
|
||||
| ---------- | --------------------------- | ----------------------------------------------------- |
|
||||
| 设计令牌 | `src/styles/tokens.css` | 颜色、字体、渐变、阴影、动效时长等稳定决策 |
|
||||
| 基础规则 | `src/styles/base.css` | 页面基线、选中文本、全局圆角策略 |
|
||||
| 组件规则 | `src/styles/components.css` | 页面 Hero、容器、表单、面板、按钮等共享语义类 |
|
||||
| 第三方主题 | `src/lib/theme.ts` | Naive UI 的全局主题契约;值应与设计令牌保持一致 |
|
||||
| 单组件样式 | Vue 文件的 `<style scoped>` | 只属于一个功能的滑杆、动画、地图或媒体细节 |
|
||||
| 一次性布局 | Vue 模板中的 Tailwind 类 | flex/grid、间距、响应式排列及确实只出现一次的视觉调整 |
|
||||
|
||||
`src/style.css` 只负责按以上顺序装配全局样式,不再直接堆放组件规则。
|
||||
|
||||
@@ -25,8 +25,8 @@ OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓
|
||||
- 不直接使用 Tailwind 默认 `green-*`、`lime-*`,也不要在组件里写亮绿色十六进制值或 RGB。
|
||||
- 新增绿色前先判断它属于品牌交互还是成功状态,并优先使用已有语义类或设计令牌。
|
||||
|
||||
| 语义 | 主色 | 深色 | 浅色表面 |
|
||||
| --- | --- | --- | --- |
|
||||
| 语义 | 主色 | 深色 | 浅色表面 |
|
||||
| --------- | --------- | --------- | --------- |
|
||||
| 品牌/交互 | `#34745d` | `#274c40` | `#f2faf6` |
|
||||
| 成功/通过 | `#4d7f3b` | `#35522c` | `#f4faef` |
|
||||
|
||||
@@ -44,15 +44,15 @@ OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓
|
||||
|
||||
```html
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<main class="oc-container oc-container--content oc-page-content">
|
||||
<!-- 页面内容 -->
|
||||
<!-- 页面内容 -->
|
||||
</main>
|
||||
```
|
||||
|
||||
@@ -63,9 +63,7 @@ Hero 同时包含左侧介绍和右侧状态卡时,使用 `oc-page-hero__split
|
||||
### 表单
|
||||
|
||||
```html
|
||||
<label class="oc-field-label">
|
||||
拍摄时间 <span class="oc-field-required">*</span>
|
||||
</label>
|
||||
<label class="oc-field-label"> 拍摄时间 <span class="oc-field-required">*</span> </label>
|
||||
<input class="oc-field-control" />
|
||||
<p class="oc-field-help">补充说明</p>
|
||||
<p class="oc-field-error">错误信息</p>
|
||||
@@ -88,12 +86,8 @@ Hero 同时包含左侧介绍和右侧状态卡时,使用 `oc-page-hero__split
|
||||
Naive UI 按钮继续使用 `type="default"`,视觉语义由共享类控制:
|
||||
|
||||
```html
|
||||
<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 class="oc-panel-button oc-panel-button--neutral" type="default"> 取消 </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` 是兼容命名,视觉上使用的是清新桉叶绿。
|
||||
|
||||
@@ -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>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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="description" content="OpenCloud - 活的天空地图,拍云、识别、收藏" />
|
||||
<title>OpenCloud</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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="description" content="OpenCloud - 活的天空地图,拍云、识别、收藏" />
|
||||
<title>OpenCloud</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+1789
File diff suppressed because it is too large
Load Diff
+46
-31
@@ -1,33 +1,48 @@
|
||||
{
|
||||
"name": "opencloud",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"check:styles": "node scripts/check-style-system.mjs",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.12.3",
|
||||
"@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",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.16",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
"name": "opencloud",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"typecheck": "vue-tsc -b",
|
||||
"check": "npm run lint && npm run format:check && npm run check:styles && npm run typecheck",
|
||||
"check:styles": "node scripts/check-style-system.mjs",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"better-auth": "^1.7.6",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.12.3",
|
||||
"@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 deprecatedPatterns = [
|
||||
{
|
||||
pattern: /\b(?:bg|border|ring|text)-(?:gray|indigo|purple|violet)-\d+\b/g,
|
||||
message: '使用项目约定的 slate/sky/自然绿色语义色,不要重新引入灰色或紫色系。',
|
||||
},
|
||||
{
|
||||
pattern: /\b(?:bg|border|ring|text)-(?:green|lime)-\d+\b/g,
|
||||
message: '绿色必须使用项目重映射后的 teal 品牌色或 emerald 成功色,不要引入默认 green/lime 荧光色。',
|
||||
},
|
||||
{
|
||||
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: /#(?:1f2937|9ca3af)\b/gi,
|
||||
message: '检测到默认 gray 调色板硬编码,请改用对应的 slate 色值或语义令牌。',
|
||||
},
|
||||
{
|
||||
pattern: /#60a5fa\b/gi,
|
||||
message: '检测到默认 blue-400 硬编码,请按用途改用 sky 或 amber 等项目语义色。',
|
||||
},
|
||||
{
|
||||
pattern: /text-xs uppercase tracking-\[0\.18em\] text-slate-500/g,
|
||||
message: '重复的小节标签应使用 oc-section-label。',
|
||||
vueOnly: true,
|
||||
},
|
||||
{
|
||||
pattern: /border border-slate-200 bg-slate-50 p-4/g,
|
||||
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。',
|
||||
vueOnly: true,
|
||||
},
|
||||
{
|
||||
pattern: /\b(?:bg|border|ring|text)-(?:gray|indigo|purple|violet)-\d+\b/g,
|
||||
message: '使用项目约定的 slate/sky/自然绿色语义色,不要重新引入灰色或紫色系。',
|
||||
},
|
||||
{
|
||||
pattern: /\b(?:bg|border|ring|text)-(?:green|lime)-\d+\b/g,
|
||||
message:
|
||||
'绿色必须使用项目重映射后的 teal 品牌色或 emerald 成功色,不要引入默认 green/lime 荧光色。',
|
||||
},
|
||||
{
|
||||
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: /#(?:1f2937|9ca3af)\b/gi,
|
||||
message: '检测到默认 gray 调色板硬编码,请改用对应的 slate 色值或语义令牌。',
|
||||
},
|
||||
{
|
||||
pattern: /#60a5fa\b/gi,
|
||||
message: '检测到默认 blue-400 硬编码,请按用途改用 sky 或 amber 等项目语义色。',
|
||||
},
|
||||
{
|
||||
pattern: /text-xs uppercase tracking-\[0\.18em\] text-slate-500/g,
|
||||
message: '重复的小节标签应使用 oc-section-label。',
|
||||
vueOnly: true,
|
||||
},
|
||||
{
|
||||
pattern: /border border-slate-200 bg-slate-50 p-4/g,
|
||||
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。',
|
||||
vueOnly: true,
|
||||
},
|
||||
]
|
||||
|
||||
const styleSourceExtensions = new Set(['.vue', '.css', '.ts'])
|
||||
|
||||
async function collectStyleSourceFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files = await Promise.all(entries.map(async (entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
return entry.isDirectory() ? collectStyleSourceFiles(path) : [path]
|
||||
}))
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
const files = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
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) {
|
||||
return source.slice(0, index).split('\n').length
|
||||
return source.slice(0, index).split('\n').length
|
||||
}
|
||||
|
||||
const violations = []
|
||||
|
||||
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) {
|
||||
if (rule.vueOnly && extname(file) !== '.vue') continue
|
||||
for (const rule of deprecatedPatterns) {
|
||||
if (rule.vueOnly && extname(file) !== '.vue') continue
|
||||
|
||||
for (const match of source.matchAll(rule.pattern)) {
|
||||
violations.push({
|
||||
file: relative(projectRoot.pathname, file),
|
||||
line: lineNumberAt(source, match.index),
|
||||
message: rule.message,
|
||||
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],
|
||||
})
|
||||
for (const match of source.matchAll(rule.pattern)) {
|
||||
violations.push({
|
||||
file: relative(projectRoot.pathname, file),
|
||||
line: lineNumberAt(source, match.index),
|
||||
message: rule.message,
|
||||
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],
|
||||
})
|
||||
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')) {
|
||||
violations.push({
|
||||
file: relative(projectRoot.pathname, file),
|
||||
line: lineNumberAt(source, match.index),
|
||||
message: 'oc-primary-button 修饰类必须与基础类一起使用。',
|
||||
value: match[0],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('样式体系检查失败:')
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation.file}:${violation.line} ${violation.message} (${violation.value})`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
console.error('样式体系检查失败:')
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
`- ${violation.file}:${violation.line} ${violation.message} (${violation.value})`,
|
||||
)
|
||||
}
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log('样式体系检查通过。')
|
||||
console.log('样式体系检查通过。')
|
||||
}
|
||||
|
||||
+29
-23
@@ -1,35 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
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 AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import { openCloudThemeOverrides } from '@/lib/theme'
|
||||
import { Analytics } from "@vercel/analytics/vue"
|
||||
import { SpeedInsights } from "@vercel/speed-insights/vue"
|
||||
import { Analytics } from '@vercel/analytics/vue'
|
||||
import { SpeedInsights } from '@vercel/speed-insights/vue'
|
||||
|
||||
const route = useRoute()
|
||||
const showAppHeader = computed(() => route.name !== 'admin')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NConfigProvider :theme-overrides="openCloudThemeOverrides">
|
||||
<NGlobalStyle />
|
||||
<NDialogProvider>
|
||||
<NNotificationProvider>
|
||||
<NMessageProvider>
|
||||
<div class="oc-app-shell">
|
||||
<AppHeader v-if="showAppHeader" />
|
||||
<main class="relative flex-1">
|
||||
<div class="oc-app-grid"></div>
|
||||
<div class="relative">
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</NMessageProvider>
|
||||
</NNotificationProvider>
|
||||
</NDialogProvider>
|
||||
</NConfigProvider>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
<NConfigProvider :theme-overrides="openCloudThemeOverrides">
|
||||
<NGlobalStyle />
|
||||
<NDialogProvider>
|
||||
<NNotificationProvider>
|
||||
<NMessageProvider>
|
||||
<div class="oc-app-shell">
|
||||
<AppHeader v-if="showAppHeader" />
|
||||
<main class="relative flex-1">
|
||||
<div class="oc-app-grid"></div>
|
||||
<div class="relative">
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</NMessageProvider>
|
||||
</NNotificationProvider>
|
||||
</NDialogProvider>
|
||||
</NConfigProvider>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</template>
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
<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 '@/components/cloud/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>
|
||||
@@ -1,307 +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.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"
|
||||
@click="close"
|
||||
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="关闭大图"
|
||||
>
|
||||
<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"
|
||||
@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-info-button.image-detail-info-button {
|
||||
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,384 +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 === '') {
|
||||
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>
|
||||
+398
-319
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
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 { useAuthStore } from '@/stores/auth'
|
||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const isMapRoute = computed(() => route.name === 'map')
|
||||
|
||||
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 inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800'
|
||||
|
||||
const displayUsername = computed(() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户')
|
||||
const displayUsername = computed(
|
||||
() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户',
|
||||
)
|
||||
const userEmail = computed(() => authStore.user?.email || '未绑定邮箱')
|
||||
const avatarFailed = ref(false)
|
||||
|
||||
watch(
|
||||
() => authStore.user?.image,
|
||||
() => {
|
||||
avatarFailed.value = false
|
||||
},
|
||||
)
|
||||
|
||||
let lastScrollY = 0
|
||||
let accountCloseTimer: number | null = null
|
||||
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
||||
|
||||
function showHeader(pin = false) {
|
||||
headerHidden.value = false
|
||||
headerPinnedOpen.value = pin
|
||||
headerHidden.value = false
|
||||
headerPinnedOpen.value = pin
|
||||
}
|
||||
|
||||
function hideHeader() {
|
||||
if (window.scrollY <= 96) return
|
||||
if (headerPinnedOpen.value) return
|
||||
headerHidden.value = true
|
||||
if (window.scrollY <= 96) return
|
||||
if (headerPinnedOpen.value) return
|
||||
headerHidden.value = true
|
||||
}
|
||||
|
||||
function releasePinnedHeader() {
|
||||
headerPinnedOpen.value = false
|
||||
headerPinnedOpen.value = false
|
||||
}
|
||||
|
||||
function forceHideHeader() {
|
||||
headerPinnedOpen.value = false
|
||||
headerHidden.value = true
|
||||
headerPinnedOpen.value = false
|
||||
headerHidden.value = true
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const currentScrollY = window.scrollY
|
||||
const delta = currentScrollY - lastScrollY
|
||||
const currentScrollY = window.scrollY
|
||||
const delta = currentScrollY - lastScrollY
|
||||
|
||||
if (isMapRoute.value) {
|
||||
lastScrollY = currentScrollY
|
||||
return
|
||||
}
|
||||
|
||||
if (currentScrollY <= 64) {
|
||||
showHeader(false)
|
||||
lastScrollY = currentScrollY
|
||||
return
|
||||
}
|
||||
|
||||
if (delta > 12) {
|
||||
hideHeader()
|
||||
} else if (delta < -8) {
|
||||
showHeader(false)
|
||||
}
|
||||
|
||||
if (isMapRoute.value) {
|
||||
lastScrollY = currentScrollY
|
||||
return
|
||||
}
|
||||
|
||||
if (currentScrollY <= 64) {
|
||||
showHeader(false)
|
||||
lastScrollY = currentScrollY
|
||||
return
|
||||
}
|
||||
|
||||
if (delta > 12) {
|
||||
hideHeader()
|
||||
} else if (delta < -8) {
|
||||
showHeader(false)
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
lastScrollY = window.scrollY
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
||||
lastScrollY = window.scrollY
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
||||
if (accountCloseTimer !== null) {
|
||||
window.clearTimeout(accountCloseTimer)
|
||||
accountCloseTimer = null
|
||||
}
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
||||
if (accountCloseTimer !== null) {
|
||||
window.clearTimeout(accountCloseTimer)
|
||||
accountCloseTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
function syncHeaderForRoute() {
|
||||
headerPinnedOpen.value = false
|
||||
headerHidden.value = false
|
||||
accountCardOpen.value = false
|
||||
headerPinnedOpen.value = false
|
||||
headerHidden.value = false
|
||||
accountCardOpen.value = false
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
syncHeaderForRoute()
|
||||
})
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
syncHeaderForRoute()
|
||||
},
|
||||
)
|
||||
|
||||
watch(isMapRoute, () => {
|
||||
syncHeaderForRoute()
|
||||
syncHeaderForRoute()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
syncHeaderForRoute()
|
||||
syncHeaderForRoute()
|
||||
})
|
||||
|
||||
function openAccountCard() {
|
||||
if (accountCloseTimer !== null) {
|
||||
window.clearTimeout(accountCloseTimer)
|
||||
accountCloseTimer = null
|
||||
}
|
||||
accountCardOpen.value = true
|
||||
if (accountCloseTimer !== null) {
|
||||
window.clearTimeout(accountCloseTimer)
|
||||
accountCloseTimer = null
|
||||
}
|
||||
accountCardOpen.value = true
|
||||
}
|
||||
|
||||
function closeAccountCard() {
|
||||
if (accountCloseTimer !== null) {
|
||||
window.clearTimeout(accountCloseTimer)
|
||||
}
|
||||
accountCloseTimer = window.setTimeout(() => {
|
||||
accountCardOpen.value = false
|
||||
accountCloseTimer = null
|
||||
}, 100)
|
||||
if (accountCloseTimer !== null) {
|
||||
window.clearTimeout(accountCloseTimer)
|
||||
}
|
||||
accountCloseTimer = window.setTimeout(() => {
|
||||
accountCardOpen.value = false
|
||||
accountCloseTimer = null
|
||||
}, 100)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
closeAccountCard()
|
||||
if (route.meta.requiresAuth) {
|
||||
router.push({ name: 'map' })
|
||||
}
|
||||
try {
|
||||
await authStore.logout()
|
||||
closeAccountCard()
|
||||
if (route.meta.requiresAuth) {
|
||||
void router.push({ name: 'map' })
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-28 md:h-16'">
|
||||
<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="headerHidden ? '-translate-y-full' : 'translate-y-0'"
|
||||
@mouseenter="showHeader(true)"
|
||||
@mouseleave="releasePinnedHeader"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<RouterLink to="/" class="flex items-center gap-3">
|
||||
<div class="oc-brand-mark">
|
||||
<svg
|
||||
class="h-8 w-8"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
shape-rendering="crispEdges"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="11" y="3" width="1" height="1" fill="#f59e0b" />
|
||||
<rect x="10" y="4" width="3" height="1" fill="#fbbf24" />
|
||||
<rect x="11" y="5" width="1" height="1" fill="#f59e0b" />
|
||||
<div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-28 md:h-16'">
|
||||
<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="headerHidden ? '-translate-y-full' : 'translate-y-0'"
|
||||
@mouseenter="showHeader(true)"
|
||||
@mouseleave="releasePinnedHeader"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<RouterLink to="/" class="flex items-center gap-3">
|
||||
<div class="oc-brand-mark">
|
||||
<svg
|
||||
class="h-8 w-8"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
shape-rendering="crispEdges"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="11" y="3" width="1" height="1" fill="#f59e0b" />
|
||||
<rect x="10" y="4" width="3" height="1" fill="#fbbf24" />
|
||||
<rect x="11" y="5" width="1" height="1" fill="#f59e0b" />
|
||||
|
||||
<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="9" width="7" height="1" fill="#38bdf8" />
|
||||
<rect x="5" y="10" width="4" 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="9" width="7" 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="4" y="6" width="7" 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="4" y="9" width="5" 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="3" y="7" width="8" 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="11" width="8" height="1" fill="#7dd3fc" opacity="0.55" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-500">Live Sky Atlas</div>
|
||||
<div class="text-lg font-bold text-slate-900">OpenCloud</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<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() }}
|
||||
<rect
|
||||
x="4"
|
||||
y="11"
|
||||
width="8"
|
||||
height="1"
|
||||
fill="#7dd3fc"
|
||||
opacity="0.55"
|
||||
/>
|
||||
</svg>
|
||||
</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 class="text-[11px] uppercase tracking-[0.22em] text-slate-500">
|
||||
Live Sky Atlas
|
||||
</div>
|
||||
<div class="text-lg font-bold text-slate-900">OpenCloud</div>
|
||||
</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">
|
||||
</RouterLink>
|
||||
|
||||
<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 ? '管理员' : '观测者' }}
|
||||
</span>
|
||||
<span>OpenCloud 账号</span> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<RouterLink
|
||||
to="/profile"
|
||||
class="oc-menu-item"
|
||||
role="menuitem"
|
||||
>
|
||||
<NIcon size="18"><User /></NIcon>
|
||||
<span>个人中心</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/profile/settings"
|
||||
class="oc-menu-item"
|
||||
role="menuitem"
|
||||
>
|
||||
<NIcon size="18"><Settings /></NIcon>
|
||||
<span>账号设置</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="authStore.isAdmin"
|
||||
to="/admin"
|
||||
class="oc-menu-item oc-menu-item--amber"
|
||||
role="menuitem"
|
||||
>
|
||||
<NIcon size="18"><Shield /></NIcon>
|
||||
<span>管理后台</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<RouterLink
|
||||
to="/profile"
|
||||
class="oc-menu-item"
|
||||
role="menuitem"
|
||||
>
|
||||
<NIcon size="18"><User /></NIcon>
|
||||
<span>个人中心</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/profile/settings"
|
||||
class="oc-menu-item"
|
||||
role="menuitem"
|
||||
>
|
||||
<NIcon size="18"><Settings /></NIcon>
|
||||
<span>账号设置</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="authStore.isAdmin"
|
||||
to="/admin"
|
||||
class="oc-menu-item oc-menu-item--amber"
|
||||
role="menuitem"
|
||||
>
|
||||
<NIcon size="18"><Shield /></NIcon>
|
||||
<span>管理后台</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 p-2">
|
||||
<button
|
||||
type="button"
|
||||
class="oc-menu-item oc-menu-item--danger px-2"
|
||||
role="menuitem"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<NIcon size="18"><Logout /></NIcon>
|
||||
<span>登出</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-slate-200 p-2">
|
||||
<button
|
||||
type="button"
|
||||
class="oc-menu-item oc-menu-item--danger px-2"
|
||||
role="menuitem"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<NIcon size="18"><Logout /></NIcon>
|
||||
<span>登出</span>
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="no-underline"
|
||||
<nav
|
||||
class="flex items-center gap-2 overflow-x-auto border-t border-slate-200/80 py-2 md:hidden"
|
||||
>
|
||||
<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"
|
||||
<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)"
|
||||
>
|
||||
<span
|
||||
class="oc-header-button oc-header-button--sky"
|
||||
:class="route.name === 'register' ? 'ring-0 ring-sky-300' : ''"
|
||||
>
|
||||
注册
|
||||
</span>
|
||||
</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>
|
||||
<NIcon size="15"><ChevronDown /></NIcon>
|
||||
<span>展开导航</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header-reveal-enter-active,
|
||||
.header-reveal-leave-active {
|
||||
transition:
|
||||
opacity 240ms ease,
|
||||
transform 240ms ease;
|
||||
transition:
|
||||
opacity 240ms ease,
|
||||
transform 240ms ease;
|
||||
}
|
||||
|
||||
.header-reveal-enter-from,
|
||||
.header-reveal-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.75rem);
|
||||
opacity: 0;
|
||||
transform: translateY(-0.75rem);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.header-reveal-enter-active,
|
||||
.header-reveal-leave-active {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
.header-reveal-enter-active,
|
||||
.header-reveal-leave-active {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
|
||||
.header-reveal-enter-from,
|
||||
.header-reveal-leave-to {
|
||||
transform: none;
|
||||
}
|
||||
.header-reveal-enter-from,
|
||||
.header-reveal-leave-to {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</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,216 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
import type { CloudCreateResponse } from '@/types/api'
|
||||
|
||||
export interface UploadItem {
|
||||
id: string
|
||||
file: File
|
||||
preview: string
|
||||
cloudCategoryId: number | null
|
||||
description: string
|
||||
isHidden: boolean
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
capturedAt: string
|
||||
errors: Record<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 = ''
|
||||
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,
|
||||
}
|
||||
}
|
||||
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',
|
||||
],
|
||||
})
|
||||
}
|
||||
+142
-136
@@ -1,9 +1,9 @@
|
||||
import type {
|
||||
ApiCloud,
|
||||
AuthUser,
|
||||
BackendCloudInfo,
|
||||
BackendCloudType,
|
||||
BackendUserProfile,
|
||||
ApiCloud,
|
||||
AuthUser,
|
||||
BackendCloudInfo,
|
||||
BackendCloudType,
|
||||
BackendUserProfile,
|
||||
} from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
@@ -13,185 +13,191 @@ export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace
|
||||
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
detail: unknown
|
||||
status: number
|
||||
detail: unknown
|
||||
|
||||
constructor(message: string, status: number, detail?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.detail = detail
|
||||
}
|
||||
constructor(message: string, status: number, detail?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: BodyInit | Record<string, unknown> | null
|
||||
auth?: boolean
|
||||
// Kept for callers that explicitly disable retries. Cookie sessions do not
|
||||
// use a refresh-and-retry flow, so this option is intentionally ignored.
|
||||
retry?: boolean
|
||||
body?: BodyInit | Record<string, unknown> | null
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
|
||||
return !!body
|
||||
&& typeof body === 'object'
|
||||
&& !(body instanceof FormData)
|
||||
&& !(body instanceof Blob)
|
||||
&& !(body instanceof URLSearchParams)
|
||||
&& !(body instanceof ArrayBuffer)
|
||||
return (
|
||||
!!body &&
|
||||
typeof body === 'object' &&
|
||||
!(body instanceof FormData) &&
|
||||
!(body instanceof Blob) &&
|
||||
!(body instanceof URLSearchParams) &&
|
||||
!(body instanceof ArrayBuffer)
|
||||
)
|
||||
}
|
||||
|
||||
function validationMessage(value: unknown) {
|
||||
if (typeof value !== 'string') return ''
|
||||
if (typeof value !== 'string') return ''
|
||||
|
||||
try {
|
||||
const issues = JSON.parse(value) as unknown
|
||||
if (!Array.isArray(issues)) return value
|
||||
const messages = issues
|
||||
.map(issue => issue && typeof issue === 'object' && 'message' in issue ? String(issue.message) : '')
|
||||
.filter(Boolean)
|
||||
return messages.join(';') || value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
try {
|
||||
const issues = JSON.parse(value) as unknown
|
||||
if (!Array.isArray(issues)) return value
|
||||
const messages = issues
|
||||
.map(issue =>
|
||||
issue && typeof issue === 'object' && 'message' in issue
|
||||
? String(issue.message)
|
||||
: '',
|
||||
)
|
||||
.filter(Boolean)
|
||||
return messages.join(';') || value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(payload: unknown, fallback: string) {
|
||||
if (!payload || typeof payload !== 'object') return fallback
|
||||
if (!payload || typeof payload !== 'object') return fallback
|
||||
|
||||
if ('detail' in payload) {
|
||||
const detail = payload.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
const messages = detail
|
||||
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
|
||||
.filter(Boolean)
|
||||
if (messages.length) return messages.join(';')
|
||||
if ('detail' in payload) {
|
||||
const detail = payload.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
const messages = detail
|
||||
.map(item =>
|
||||
item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '',
|
||||
)
|
||||
.filter(Boolean)
|
||||
if (messages.length) return messages.join(';')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ('error' in payload) {
|
||||
const error = payload.error
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
return validationMessage(error.message) || fallback
|
||||
if ('error' in payload) {
|
||||
const error = payload.error
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
return validationMessage(error.message) || fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function readPayload(response: Response) {
|
||||
const text = await response.text()
|
||||
if (!text) return undefined
|
||||
const text = await response.text()
|
||||
if (!text) return undefined
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (!contentType.includes('json')) return text
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (!contentType.includes('json')) return text
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as unknown
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as unknown
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
async function toApiError(response: Response) {
|
||||
const payload = await readPayload(response)
|
||||
const fallback = typeof payload === 'string' && payload.trim()
|
||||
? payload.trim()
|
||||
: `请求失败(${response.status})`
|
||||
const payload = await readPayload(response)
|
||||
const fallback =
|
||||
typeof payload === 'string' && payload.trim()
|
||||
? payload.trim()
|
||||
: `请求失败(${response.status})`
|
||||
|
||||
return new ApiError(errorMessage(payload, fallback), response.status, payload)
|
||||
return new ApiError(errorMessage(payload, fallback), response.status, payload)
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
const { body, auth = true, retry: _retry, ...requestOptions } = options
|
||||
const headers = new Headers(requestOptions.headers)
|
||||
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
||||
const { body, auth = true, ...requestOptions } = options
|
||||
const headers = new Headers(requestOptions.headers)
|
||||
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
||||
|
||||
if (isJsonBody(body)) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
requestBody = JSON.stringify(body)
|
||||
}
|
||||
if (isJsonBody(body)) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
requestBody = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
...requestOptions,
|
||||
body: requestBody,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
...requestOptions,
|
||||
body: requestBody,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (response.status === 401 && auth && typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
||||
}
|
||||
if (response.status === 401 && auth && typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
||||
}
|
||||
|
||||
if (!response.ok) throw await toApiError(response)
|
||||
return await readPayload(response) as T
|
||||
if (!response.ok) throw await toApiError(response)
|
||||
return (await readPayload(response)) as T
|
||||
}
|
||||
|
||||
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
return request<T>(path, options)
|
||||
return request<T>(path, options)
|
||||
}
|
||||
|
||||
export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') {
|
||||
return `${API_URL}/image/${encodeURIComponent(id)}/${variant}`
|
||||
return `${API_URL}/image/${encodeURIComponent(id)}/${variant}`
|
||||
}
|
||||
|
||||
export function toAuthUser(profile: BackendUserProfile): AuthUser {
|
||||
return {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
username: profile.name,
|
||||
avatar_id: profile.avatar_id,
|
||||
role: profile.role,
|
||||
is_disabled: profile.is_disabled,
|
||||
cloud_count: profile.cloud_count,
|
||||
received_like_count: profile.received_like_count,
|
||||
created_at: profile.created_at,
|
||||
}
|
||||
return {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
username: profile.name,
|
||||
image: profile.image,
|
||||
role: profile.role,
|
||||
is_disabled: profile.is_disabled,
|
||||
cloud_count: profile.cloud_count,
|
||||
received_like_count: profile.received_like_count,
|
||||
created_at: profile.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function toCloudType(row: BackendCloudType): CloudType {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
genus: row.genus || '',
|
||||
icon_id: row.icon_id,
|
||||
// Compatibility value for the excluded legacy admin view. Migrated pages
|
||||
// display rarity_value directly because API.md defines no semantic mapping.
|
||||
rarity: 'common',
|
||||
rarity_value: row.rarity,
|
||||
description: row.description,
|
||||
created_at: row.created_at,
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
genus: row.genus || '',
|
||||
icon_id: row.icon_id,
|
||||
// Compatibility value for the excluded legacy admin view. Migrated pages
|
||||
// display rarity_value directly because API.md defines no semantic mapping.
|
||||
rarity: 'common',
|
||||
rarity_value: row.rarity,
|
||||
description: row.description,
|
||||
created_at: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function toApiCloud(row: BackendCloudInfo): ApiCloud {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.owner.id,
|
||||
cloud_type_id: row.type.id,
|
||||
image_url: imageUrl(row.id, 'original'),
|
||||
thumbnail_url: imageUrl(row.id, 'preview'),
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
description: row.description,
|
||||
captured_at: row.captured_at,
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
created_at: row.uploaded_at,
|
||||
updated_at: row.updated_at,
|
||||
cloud_type: {
|
||||
id: row.type.id,
|
||||
name: row.type.name,
|
||||
rarity: 'common',
|
||||
rarity_value: row.type.rarity,
|
||||
},
|
||||
owner: { id: row.owner.id, username: row.owner.name },
|
||||
cloud_type_name: row.type.name,
|
||||
cloud_type_rarity: 'common',
|
||||
cloud_type_rarity_value: row.type.rarity,
|
||||
username: row.owner.name,
|
||||
received_like_count: row.received_like_count,
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.owner.id,
|
||||
cloud_type_id: row.type.id,
|
||||
image_url: imageUrl(row.id, 'original'),
|
||||
thumbnail_url: imageUrl(row.id, 'preview'),
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
description: row.description,
|
||||
captured_at: row.captured_at,
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
created_at: row.uploaded_at,
|
||||
updated_at: row.updated_at,
|
||||
cloud_type: {
|
||||
id: row.type.id,
|
||||
name: row.type.name,
|
||||
rarity: 'common',
|
||||
rarity_value: row.type.rarity,
|
||||
},
|
||||
owner: { id: row.owner.id, username: row.owner.name },
|
||||
cloud_type_name: row.type.name,
|
||||
cloud_type_rarity: 'common',
|
||||
cloud_type_rarity_value: row.type.rarity,
|
||||
username: row.owner.name,
|
||||
received_like_count: row.received_like_count,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
export const CLOUD_TYPES = [
|
||||
{ id: 1, name: '积云' },
|
||||
{ id: 2, name: '层云' },
|
||||
{ id: 3, name: '卷云' },
|
||||
{ id: 4, name: '积雨云' },
|
||||
{ id: 5, name: '层积云' },
|
||||
{ id: 6, name: '高积云' },
|
||||
{ id: 7, name: '高层云' },
|
||||
{ id: 8, name: '雨层云' },
|
||||
{ id: 9, name: '卷层云' },
|
||||
{ id: 10, name: '卷积云' },
|
||||
] as const
|
||||
|
||||
export function getCloudTypeName(id: string | string[]) {
|
||||
const cloudTypeId = Number(Array.isArray(id) ? id[0] : id)
|
||||
return CLOUD_TYPES.find(item => item.id === cloudTypeId)?.name ?? '云型详情'
|
||||
}
|
||||
+45
-41
@@ -4,60 +4,64 @@ const defaultTitle = 'OpenCloud'
|
||||
const defaultDescription = 'OpenCloud - 活的天空地图,拍云、识别、收藏'
|
||||
|
||||
function resolveSiteUrl() {
|
||||
return (import.meta.env.VITE_SITE_URL || window.location.origin).replace(/\/$/, '')
|
||||
return (import.meta.env.VITE_SITE_URL || window.location.origin).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function upsertMeta(name: string, content: string) {
|
||||
let element = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
|
||||
if (!element) {
|
||||
element = document.createElement('meta')
|
||||
element.name = name
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
element.content = content
|
||||
let element = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
|
||||
if (!element) {
|
||||
element = document.createElement('meta')
|
||||
element.name = name
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
element.content = content
|
||||
}
|
||||
|
||||
function upsertPropertyMeta(property: string, content: string) {
|
||||
let element = document.querySelector<HTMLMetaElement>(`meta[property="${property}"]`)
|
||||
if (!element) {
|
||||
element = document.createElement('meta')
|
||||
element.setAttribute('property', property)
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
element.content = content
|
||||
let element = document.querySelector<HTMLMetaElement>(`meta[property="${property}"]`)
|
||||
if (!element) {
|
||||
element = document.createElement('meta')
|
||||
element.setAttribute('property', property)
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
element.content = content
|
||||
}
|
||||
|
||||
function upsertCanonical(href: string) {
|
||||
let element = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
||||
if (!element) {
|
||||
element = document.createElement('link')
|
||||
element.rel = 'canonical'
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
element.href = href
|
||||
let element = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
||||
if (!element) {
|
||||
element = document.createElement('link')
|
||||
element.rel = 'canonical'
|
||||
document.head.appendChild(element)
|
||||
}
|
||||
element.href = href
|
||||
}
|
||||
|
||||
export function applyRouteSeo(route: RouteLocationNormalizedLoaded) {
|
||||
const title = typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title
|
||||
const description = typeof route.meta.description === 'function' ? route.meta.description(route) : route.meta.description
|
||||
const robots = route.meta.noindex ? 'noindex, nofollow' : 'index, follow'
|
||||
const canonicalPath = route.meta.canonicalPath || route.path
|
||||
const canonical = `${resolveSiteUrl()}${canonicalPath}`
|
||||
const pageTitle = title ? `${title} | OpenCloud` : defaultTitle
|
||||
const pageDescription = description || defaultDescription
|
||||
const title =
|
||||
typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title
|
||||
const description =
|
||||
typeof route.meta.description === 'function'
|
||||
? route.meta.description(route)
|
||||
: route.meta.description
|
||||
const robots = route.meta.noindex ? 'noindex, nofollow' : 'index, follow'
|
||||
const canonicalPath = route.meta.canonicalPath || route.path
|
||||
const canonical = `${resolveSiteUrl()}${canonicalPath}`
|
||||
const pageTitle = title ? `${title} | OpenCloud` : defaultTitle
|
||||
const pageDescription = description || defaultDescription
|
||||
|
||||
document.title = pageTitle
|
||||
upsertMeta('description', pageDescription)
|
||||
upsertMeta('robots', robots)
|
||||
upsertCanonical(canonical)
|
||||
document.title = pageTitle
|
||||
upsertMeta('description', pageDescription)
|
||||
upsertMeta('robots', robots)
|
||||
upsertCanonical(canonical)
|
||||
|
||||
upsertPropertyMeta('og:site_name', 'OpenCloud')
|
||||
upsertPropertyMeta('og:type', 'website')
|
||||
upsertPropertyMeta('og:title', pageTitle)
|
||||
upsertPropertyMeta('og:description', pageDescription)
|
||||
upsertPropertyMeta('og:url', canonical)
|
||||
upsertPropertyMeta('og:site_name', 'OpenCloud')
|
||||
upsertPropertyMeta('og:type', 'website')
|
||||
upsertPropertyMeta('og:title', pageTitle)
|
||||
upsertPropertyMeta('og:description', pageDescription)
|
||||
upsertPropertyMeta('og:url', canonical)
|
||||
|
||||
upsertMeta('twitter:card', 'summary')
|
||||
upsertMeta('twitter:title', pageTitle)
|
||||
upsertMeta('twitter:description', pageDescription)
|
||||
upsertMeta('twitter:card', 'summary')
|
||||
upsertMeta('twitter:title', pageTitle)
|
||||
upsertMeta('twitter:description', pageDescription)
|
||||
}
|
||||
|
||||
+52
-52
@@ -2,57 +2,57 @@ import type { GlobalThemeOverrides } from 'naive-ui'
|
||||
|
||||
/** Keep these values aligned with the semantic tokens in styles/tokens.css. */
|
||||
export const openCloudThemeOverrides: GlobalThemeOverrides = {
|
||||
common: {
|
||||
primaryColor: '#34745d',
|
||||
primaryColorHover: '#2d5f4e',
|
||||
primaryColorPressed: '#274c40',
|
||||
primaryColorSuppl: '#34745d',
|
||||
infoColor: '#0369a1',
|
||||
successColor: '#4d7f3b',
|
||||
successColorHover: '#3f6633',
|
||||
successColorPressed: '#35522c',
|
||||
successColorSuppl: '#4d7f3b',
|
||||
warningColor: '#d97706',
|
||||
errorColor: '#dc2626',
|
||||
borderRadius: '0px',
|
||||
borderRadiusSmall: '0px',
|
||||
fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif',
|
||||
fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace',
|
||||
bodyColor: '#eef4f8',
|
||||
cardColor: '#ffffff',
|
||||
modalColor: '#ffffff',
|
||||
popoverColor: '#ffffff',
|
||||
tableColor: '#ffffff',
|
||||
textColorBase: '#0f172a',
|
||||
textColor1: '#0f172a',
|
||||
textColor2: '#334155',
|
||||
textColor3: '#64748b',
|
||||
textColorDisabled: 'var(--oc-color-text-disabled)',
|
||||
dividerColor: '#d9e3ee',
|
||||
borderColor: '#d9e3ee',
|
||||
inputColor: '#ffffff',
|
||||
},
|
||||
Button: {
|
||||
borderRadiusTiny: '0px',
|
||||
borderRadiusSmall: '0px',
|
||||
borderRadiusMedium: '0px',
|
||||
borderRadiusLarge: '0px',
|
||||
textColorPrimary: '#0f172a',
|
||||
textColorHoverPrimary: '#0f172a',
|
||||
},
|
||||
Card: {
|
||||
borderRadius: '0px',
|
||||
borderRadiusEmbedded: '0px',
|
||||
paddingMedium: '24px',
|
||||
},
|
||||
Input: { borderRadius: '0px' },
|
||||
Select: {
|
||||
peers: {
|
||||
InternalSelection: { borderRadius: '0px' },
|
||||
common: {
|
||||
primaryColor: '#34745d',
|
||||
primaryColorHover: '#2d5f4e',
|
||||
primaryColorPressed: '#274c40',
|
||||
primaryColorSuppl: '#34745d',
|
||||
infoColor: '#0369a1',
|
||||
successColor: '#4d7f3b',
|
||||
successColorHover: '#3f6633',
|
||||
successColorPressed: '#35522c',
|
||||
successColorSuppl: '#4d7f3b',
|
||||
warningColor: '#d97706',
|
||||
errorColor: '#dc2626',
|
||||
borderRadius: '0px',
|
||||
borderRadiusSmall: '0px',
|
||||
fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif',
|
||||
fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace',
|
||||
bodyColor: '#eef4f8',
|
||||
cardColor: '#ffffff',
|
||||
modalColor: '#ffffff',
|
||||
popoverColor: '#ffffff',
|
||||
tableColor: '#ffffff',
|
||||
textColorBase: '#0f172a',
|
||||
textColor1: '#0f172a',
|
||||
textColor2: '#334155',
|
||||
textColor3: '#64748b',
|
||||
textColorDisabled: 'var(--oc-color-text-disabled)',
|
||||
dividerColor: '#d9e3ee',
|
||||
borderColor: '#d9e3ee',
|
||||
inputColor: '#ffffff',
|
||||
},
|
||||
},
|
||||
Tabs: { tabBorderRadius: '0px' },
|
||||
Tag: { borderRadius: '0px' },
|
||||
Alert: { borderRadius: '0px' },
|
||||
Modal: { borderRadius: '0px' },
|
||||
Button: {
|
||||
borderRadiusTiny: '0px',
|
||||
borderRadiusSmall: '0px',
|
||||
borderRadiusMedium: '0px',
|
||||
borderRadiusLarge: '0px',
|
||||
textColorPrimary: '#0f172a',
|
||||
textColorHoverPrimary: '#0f172a',
|
||||
},
|
||||
Card: {
|
||||
borderRadius: '0px',
|
||||
borderRadiusEmbedded: '0px',
|
||||
paddingMedium: '24px',
|
||||
},
|
||||
Input: { borderRadius: '0px' },
|
||||
Select: {
|
||||
peers: {
|
||||
InternalSelection: { borderRadius: '0px' },
|
||||
},
|
||||
},
|
||||
Tabs: { tabBorderRadius: '0px' },
|
||||
Tag: { borderRadius: '0px' },
|
||||
Alert: { borderRadius: '0px' },
|
||||
Modal: { borderRadius: '0px' },
|
||||
}
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -12,6 +12,6 @@ app.use(pinia)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
authStore.initialize().then(() => {
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
})
|
||||
|
||||
+162
-161
@@ -1,176 +1,177 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { getCloudTypeName } from '@/lib/cloudTypes'
|
||||
import { getCloudTypeName } from '@/features/clouds/cloudTypes'
|
||||
import { applyRouteSeo } from '@/lib/seo'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'map',
|
||||
component: () => import('@/views/map/MapView.vue'),
|
||||
meta: {
|
||||
title: '实时云图地图',
|
||||
description: '在 OpenCloud 地图上浏览社区拍摄的云图,按位置查看天空观测记录。',
|
||||
},
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'map',
|
||||
component: () => import('@/features/map/views/MapView.vue'),
|
||||
meta: {
|
||||
title: '实时云图地图',
|
||||
description: '在 OpenCloud 地图上浏览社区拍摄的云图,按位置查看天空观测记录。',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/features/auth/views/LoginView.vue'),
|
||||
meta: { title: '登录', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('@/features/auth/views/RegisterView.vue'),
|
||||
meta: { title: '注册', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/resend-confirmation',
|
||||
name: 'resend-confirmation',
|
||||
component: () => import('@/features/auth/views/ResendConfirmationView.vue'),
|
||||
meta: { title: '重发验证邮件', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('@/features/auth/views/ForgotPasswordView.vue'),
|
||||
meta: { title: '忘记密码', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/verify-email',
|
||||
alias: '/auth/confirm',
|
||||
name: 'auth-confirm',
|
||||
component: () => import('@/features/auth/views/AuthConfirmView.vue'),
|
||||
meta: { title: '确认邮箱', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
alias: '/auth/reset-password',
|
||||
name: 'auth-reset-password',
|
||||
component: () => import('@/features/auth/views/ResetPasswordView.vue'),
|
||||
meta: { title: '重置密码', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/upload',
|
||||
name: 'upload',
|
||||
component: () => import('@/features/upload/views/UploadView.vue'),
|
||||
meta: { requiresAuth: true, title: '上传云图', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/encyclopedia',
|
||||
name: 'encyclopedia',
|
||||
component: () => import('@/features/encyclopedia/views/EncyclopediaView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '云朵百科',
|
||||
description: '浏览基础云属,了解云型特征和社区公开观测记录。',
|
||||
noindex: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/encyclopedia/:id',
|
||||
name: 'cloud-type',
|
||||
component: () => import('@/features/encyclopedia/views/CloudTypeView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: route => getCloudTypeName(route.params.id),
|
||||
description: route =>
|
||||
`查看 ${getCloudTypeName(route.params.id)} 的识别要点、公开云图和社区观测记录。`,
|
||||
noindex: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/gallery',
|
||||
name: 'gallery',
|
||||
component: () => import('@/features/gallery/views/GalleryView.vue'),
|
||||
meta: {
|
||||
title: '云图画廊',
|
||||
description: '按时间浏览 OpenCloud 社区公开分享的云图照片和观测记录。',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/community',
|
||||
name: 'community',
|
||||
component: () => import('@/features/community/views/CommunityView.vue'),
|
||||
meta: {
|
||||
title: '社区',
|
||||
description: '查看 OpenCloud 社区动态,发现更多天空观测和云图收藏。',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('@/features/profile/views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true, title: '个人主页', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/profile/settings',
|
||||
name: 'profile-settings',
|
||||
component: () => import('@/features/profile/views/ProfileSettingsView.vue'),
|
||||
meta: { requiresAuth: true, title: '账号设置', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/profile/:id',
|
||||
name: 'user-profile',
|
||||
component: () => import('@/features/profile/views/ProfileView.vue'),
|
||||
meta: {
|
||||
title: '用户云图档案',
|
||||
description: '按用户名查看用户公开分享的云图记录和拍摄时间线。',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: () => import('@/features/admin/views/AdminView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: '管理后台', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: 'forbidden',
|
||||
component: () => import('@/features/system/views/ForbiddenView.vue'),
|
||||
meta: { title: '无权访问', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/401',
|
||||
name: 'auth-required',
|
||||
component: () => import('@/features/system/views/AuthRequiredView.vue'),
|
||||
meta: { title: '需要登录', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/features/system/views/NotFoundView.vue'),
|
||||
meta: { title: '页面不存在', noindex: true },
|
||||
},
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
meta: { title: '登录', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
meta: { title: '注册', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/resend-confirmation',
|
||||
name: 'resend-confirmation',
|
||||
component: () => import('@/views/auth/ResendConfirmationView.vue'),
|
||||
meta: { title: '重发验证邮件', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||
meta: { title: '忘记密码', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/verify-email',
|
||||
alias: '/auth/confirm',
|
||||
name: 'auth-confirm',
|
||||
component: () => import('@/views/auth/AuthConfirmView.vue'),
|
||||
meta: { title: '确认邮箱', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
alias: '/auth/reset-password',
|
||||
name: 'auth-reset-password',
|
||||
component: () => import('@/views/auth/ResetPasswordView.vue'),
|
||||
meta: { title: '重置密码', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/upload',
|
||||
name: 'upload',
|
||||
component: () => import('@/views/upload/UploadView.vue'),
|
||||
meta: { requiresAuth: true, title: '上传云图', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/encyclopedia',
|
||||
name: 'encyclopedia',
|
||||
component: () => import('@/views/encyclopedia/EncyclopediaView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '云朵百科',
|
||||
description: '浏览基础云属,了解云型特征和社区公开观测记录。',
|
||||
noindex: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/encyclopedia/:id',
|
||||
name: 'cloud-type',
|
||||
component: () => import('@/views/encyclopedia/CloudTypeView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: route => getCloudTypeName(route.params.id),
|
||||
description: route => `查看 ${getCloudTypeName(route.params.id)} 的识别要点、公开云图和社区观测记录。`,
|
||||
noindex: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/gallery',
|
||||
name: 'gallery',
|
||||
component: () => import('@/views/gallery/GalleryView.vue'),
|
||||
meta: {
|
||||
title: '云图画廊',
|
||||
description: '按时间浏览 OpenCloud 社区公开分享的云图照片和观测记录。',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/community',
|
||||
name: 'community',
|
||||
component: () => import('@/views/community/CommunityView.vue'),
|
||||
meta: {
|
||||
title: '社区',
|
||||
description: '查看 OpenCloud 社区动态,发现更多天空观测和云图收藏。',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('@/views/profile/ProfileView.vue'),
|
||||
meta: { requiresAuth: true, title: '个人主页', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/profile/settings',
|
||||
name: 'profile-settings',
|
||||
component: () => import('@/views/profile/ProfileSettingsView.vue'),
|
||||
meta: { requiresAuth: true, title: '账号设置', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/profile/:id',
|
||||
name: 'user-profile',
|
||||
component: () => import('@/views/profile/ProfileView.vue'),
|
||||
meta: {
|
||||
title: '用户云图档案',
|
||||
description: '按用户名查看用户公开分享的云图记录和拍摄时间线。',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: () => import('@/views/admin/AdminView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, title: '管理后台', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: 'forbidden',
|
||||
component: () => import('@/views/system/ForbiddenView.vue'),
|
||||
meta: { title: '无权访问', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/401',
|
||||
name: 'auth-required',
|
||||
component: () => import('@/views/system/AuthRequiredView.vue'),
|
||||
meta: { title: '需要登录', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/views/system/NotFoundView.vue'),
|
||||
meta: { title: '页面不存在', noindex: true },
|
||||
},
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||
next({
|
||||
name: 'auth-required',
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
})
|
||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
next({ name: 'forbidden' })
|
||||
} else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) {
|
||||
next({ name: 'map' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||
next({
|
||||
name: 'auth-required',
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
})
|
||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
next({ name: 'forbidden' })
|
||||
} else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) {
|
||||
next({ name: 'map' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach(to => {
|
||||
applyRouteSeo(to)
|
||||
applyRouteSeo(to)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -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,149 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api'
|
||||
import type { ActionResponse, BackendUserProfile, LoginResponse, RegisterResponse } from '@/types/api'
|
||||
import type { Profile } from '@/types/database'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<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,
|
||||
avatar_id: normalized.avatar_id,
|
||||
role: normalized.role,
|
||||
is_disabled: normalized.is_disabled,
|
||||
created_at: normalized.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
user.value = null
|
||||
profile.value = null
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me')
|
||||
applyProfile(backendProfile)
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
await apiRequest<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email, password },
|
||||
})
|
||||
await fetchMe()
|
||||
}
|
||||
|
||||
async function register(name: string, email: string, password: string) {
|
||||
return await apiRequest<RegisterResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { name, email, password },
|
||||
})
|
||||
}
|
||||
|
||||
async function resendConfirmation(email: string) {
|
||||
return await apiRequest<ActionResponse>('/auth/resend-confirmation', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email },
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmEmail(token: string) {
|
||||
return await apiRequest<ActionResponse>('/auth/confirm-email', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { token },
|
||||
})
|
||||
}
|
||||
|
||||
async function sendPasswordReset(email: string) {
|
||||
return await apiRequest<ActionResponse>('/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email },
|
||||
})
|
||||
}
|
||||
|
||||
async function resetPassword(email: string, token: string, newPassword: string) {
|
||||
const response = await apiRequest<ActionResponse>('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email, token, new_password: newPassword },
|
||||
})
|
||||
clearAuth()
|
||||
return response
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiRequest('/auth/logout', { method: 'POST', retry: false })
|
||||
} finally {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUsername(username: string) {
|
||||
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me', {
|
||||
method: 'PATCH',
|
||||
body: { user_name: username },
|
||||
})
|
||||
applyProfile(backendProfile)
|
||||
}
|
||||
|
||||
async function updatePassword(currentPassword: string, newPassword: string) {
|
||||
await apiRequest('/auth/password', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
},
|
||||
})
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (!listenerAttached) {
|
||||
window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
|
||||
listenerAttached = true
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchMe()
|
||||
} catch {
|
||||
clearAuth()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
profile,
|
||||
loading,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
login,
|
||||
register,
|
||||
resendConfirmation,
|
||||
confirmEmail,
|
||||
sendPasswordReset,
|
||||
resetPassword,
|
||||
logout,
|
||||
updateUsername,
|
||||
updatePassword,
|
||||
initialize,
|
||||
}
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiRequest, toCloudType } from '@/lib/api'
|
||||
import type { BackendCloudType } from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
export const useCloudsStore = defineStore('clouds', () => {
|
||||
const cloudTypes = ref<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
|
||||
}
|
||||
}
|
||||
|
||||
return { cloudTypes, loading, fetchCloudTypes }
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiRequest, toCloudType } from '@/lib/api'
|
||||
import type { BackendCloudType } from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||
const cloudTypes = ref<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
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cloudTypes,
|
||||
loadingCloudTypes,
|
||||
fetchCloudTypes,
|
||||
}
|
||||
})
|
||||
@@ -1,210 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ActionResponse, ApiCloud, BackendCloudInfo } from '@/types/api'
|
||||
import type { Profile } from '@/types/database'
|
||||
|
||||
export interface ProfileCloudItem {
|
||||
id: string
|
||||
cloud_type_id: number
|
||||
image_url: string
|
||||
thumbnail_url: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
created_at: string
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
cloudTypeName: string
|
||||
cloudTypeRarity: number
|
||||
receivedLikeCount: number
|
||||
}
|
||||
|
||||
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
||||
return {
|
||||
id: row.id,
|
||||
cloud_type_id: row.cloud_type_id as number,
|
||||
image_url: row.image_url,
|
||||
thumbnail_url: row.thumbnail_url as string,
|
||||
latitude: row.latitude as number,
|
||||
longitude: row.longitude as number,
|
||||
description: row.description,
|
||||
captured_at: row.captured_at,
|
||||
created_at: row.created_at,
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
cloudTypeName: row.cloud_type_name || '未知',
|
||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
||||
receivedLikeCount: row.received_like_count,
|
||||
}
|
||||
}
|
||||
|
||||
export const useProfileStore = defineStore('profile-page', () => {
|
||||
const authStore = useAuthStore()
|
||||
const profilesByKey = ref<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 {
|
||||
const owner = backendRows[0]?.owner
|
||||
profilesByKey.value[identifier] = {
|
||||
id: owner?.id || identifier,
|
||||
username: owner?.name || identifier,
|
||||
avatar_id: null,
|
||||
role: 'user',
|
||||
is_disabled: false,
|
||||
created_at: '',
|
||||
}
|
||||
}
|
||||
cloudsByKey.value[key] = cloudRows
|
||||
} catch (error) {
|
||||
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
|
||||
cloudsByKey.value[key] = []
|
||||
} finally {
|
||||
loadingKeys.value[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
function isLoading(identifier: string | null, isOwnProfile: boolean) {
|
||||
return !!identifier && !!loadingKeys.value[makeKey(identifier, isOwnProfile)]
|
||||
}
|
||||
|
||||
function patchCachedCloud(identifier: string, cloudId: string, patch: Partial<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,
|
||||
}
|
||||
})
|
||||
+19
-20
@@ -1,27 +1,26 @@
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--oc-color-text);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(15 23 42 / 2%), rgb(15 23 42 / 0%)),
|
||||
var(--oc-color-canvas);
|
||||
font-family: var(--oc-font-sans);
|
||||
}
|
||||
body {
|
||||
color: var(--oc-color-text);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(15 23 42 / 2%), rgb(15 23 42 / 0%)), var(--oc-color-canvas);
|
||||
font-family: var(--oc-font-sans);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--oc-color-selection);
|
||||
}
|
||||
::selection {
|
||||
background: var(--oc-color-selection);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* The atlas language is square by default. Documented exceptions may opt out. */
|
||||
[class~='rounded'],
|
||||
[class*='rounded-'] {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
/* The atlas language is square by default. Documented exceptions may opt out. */
|
||||
[class~='rounded'],
|
||||
[class*='rounded-'] {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
+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.
|
||||
*/
|
||||
:root {
|
||||
color-scheme: light;
|
||||
color-scheme: light;
|
||||
|
||||
/*
|
||||
/*
|
||||
* Tailwind compatibility palettes.
|
||||
* Existing teal/emerald utilities intentionally resolve to OpenCloud's
|
||||
* fresh eucalyptus and leaf colors instead of Tailwind's vivid defaults.
|
||||
*/
|
||||
--color-teal-50: #f2faf6;
|
||||
--color-teal-100: #ddf2e6;
|
||||
--color-teal-200: #bce4cf;
|
||||
--color-teal-300: #8fceb0;
|
||||
--color-teal-400: #60b18d;
|
||||
--color-teal-500: #438f71;
|
||||
--color-teal-600: #34745d;
|
||||
--color-teal-700: #2d5f4e;
|
||||
--color-teal-800: #274c40;
|
||||
--color-teal-900: #213e35;
|
||||
--color-teal-50: #f2faf6;
|
||||
--color-teal-100: #ddf2e6;
|
||||
--color-teal-200: #bce4cf;
|
||||
--color-teal-300: #8fceb0;
|
||||
--color-teal-400: #60b18d;
|
||||
--color-teal-500: #438f71;
|
||||
--color-teal-600: #34745d;
|
||||
--color-teal-700: #2d5f4e;
|
||||
--color-teal-800: #274c40;
|
||||
--color-teal-900: #213e35;
|
||||
|
||||
--color-emerald-50: #f4faef;
|
||||
--color-emerald-100: #e5f3d9;
|
||||
--color-emerald-200: #cce7b7;
|
||||
--color-emerald-300: #a8d58d;
|
||||
--color-emerald-400: #7fbc63;
|
||||
--color-emerald-500: #609d48;
|
||||
--color-emerald-600: #4d7f3b;
|
||||
--color-emerald-700: #3f6633;
|
||||
--color-emerald-800: #35522c;
|
||||
--color-emerald-900: #2d4427;
|
||||
--color-emerald-50: #f4faef;
|
||||
--color-emerald-100: #e5f3d9;
|
||||
--color-emerald-200: #cce7b7;
|
||||
--color-emerald-300: #a8d58d;
|
||||
--color-emerald-400: #7fbc63;
|
||||
--color-emerald-500: #609d48;
|
||||
--color-emerald-600: #4d7f3b;
|
||||
--color-emerald-700: #3f6633;
|
||||
--color-emerald-800: #35522c;
|
||||
--color-emerald-900: #2d4427;
|
||||
|
||||
--oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif;
|
||||
--oc-font-mono: 'IBM Plex Mono', 'SFMono-Regular', monospace;
|
||||
--oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif;
|
||||
--oc-font-mono: 'IBM Plex Mono', 'SFMono-Regular', monospace;
|
||||
|
||||
--oc-color-canvas: #eef4f8;
|
||||
--oc-color-surface: #ffffff;
|
||||
--oc-color-surface-muted: #f8fafc;
|
||||
--oc-color-text: #0f172a;
|
||||
--oc-color-text-secondary: #334155;
|
||||
--oc-color-text-muted: #64748b;
|
||||
--oc-color-text-disabled: #94a3b8;
|
||||
--oc-color-border: #d9e3ee;
|
||||
--oc-color-primary: #34745d;
|
||||
--oc-color-primary-hover: #2d5f4e;
|
||||
--oc-color-primary-pressed: #274c40;
|
||||
--oc-color-info: #0369a1;
|
||||
--oc-color-success: #4d7f3b;
|
||||
--oc-color-warning: #d97706;
|
||||
--oc-color-danger: #dc2626;
|
||||
--oc-color-selection: rgb(52 116 93 / 20%);
|
||||
--oc-color-canvas: #eef4f8;
|
||||
--oc-color-surface: #ffffff;
|
||||
--oc-color-surface-muted: #f8fafc;
|
||||
--oc-color-text: #0f172a;
|
||||
--oc-color-text-secondary: #334155;
|
||||
--oc-color-text-muted: #64748b;
|
||||
--oc-color-text-disabled: #94a3b8;
|
||||
--oc-color-border: #d9e3ee;
|
||||
--oc-color-primary: #34745d;
|
||||
--oc-color-primary-hover: #2d5f4e;
|
||||
--oc-color-primary-pressed: #274c40;
|
||||
--oc-color-info: #0369a1;
|
||||
--oc-color-success: #4d7f3b;
|
||||
--oc-color-warning: #d97706;
|
||||
--oc-color-danger: #dc2626;
|
||||
--oc-color-selection: rgb(52 116 93 / 20%);
|
||||
|
||||
/* Contribution heatmap: no activity + GitHub-style contribution quartiles. */
|
||||
--oc-heatmap-empty: #f1f5f9;
|
||||
--oc-heatmap-empty-border: #d9e3ee;
|
||||
--oc-heatmap-level-1: #ddf2e6;
|
||||
--oc-heatmap-level-1-border: #bce4cf;
|
||||
--oc-heatmap-level-2: #8fceb0;
|
||||
--oc-heatmap-level-2-border: #60b18d;
|
||||
--oc-heatmap-level-3: #438f71;
|
||||
--oc-heatmap-level-3-border: #34745d;
|
||||
--oc-heatmap-level-4: #274c40;
|
||||
--oc-heatmap-level-4-border: #213e35;
|
||||
/* Contribution heatmap: no activity + GitHub-style contribution quartiles. */
|
||||
--oc-heatmap-empty: #f1f5f9;
|
||||
--oc-heatmap-empty-border: #d9e3ee;
|
||||
--oc-heatmap-level-1: #ddf2e6;
|
||||
--oc-heatmap-level-1-border: #bce4cf;
|
||||
--oc-heatmap-level-2: #8fceb0;
|
||||
--oc-heatmap-level-2-border: #60b18d;
|
||||
--oc-heatmap-level-3: #438f71;
|
||||
--oc-heatmap-level-3-border: #34745d;
|
||||
--oc-heatmap-level-4: #274c40;
|
||||
--oc-heatmap-level-4-border: #213e35;
|
||||
|
||||
--oc-gradient-canvas:
|
||||
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
|
||||
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
|
||||
--oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%);
|
||||
--oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%);
|
||||
--oc-gradient-grid:
|
||||
linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(15 23 42 / 3%) 1px, transparent 1px);
|
||||
--oc-gradient-canvas:
|
||||
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
|
||||
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
|
||||
--oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%);
|
||||
--oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%);
|
||||
--oc-gradient-grid:
|
||||
linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(15 23 42 / 3%) 1px, transparent 1px);
|
||||
|
||||
--oc-shadow-xs: 3px 3px 0 0 rgb(15 23 42 / 6%);
|
||||
--oc-shadow-sm: 4px 4px 0 0 rgb(15 23 42 / 6%);
|
||||
--oc-shadow-md: 6px 6px 0 0 rgb(15 23 42 / 5%);
|
||||
--oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%);
|
||||
--oc-shadow-xl: 12px 12px 0 0 rgb(15 23 42 / 8%);
|
||||
--oc-shadow-sky: 4px 4px 0 0 rgb(14 165 233 / 14%);
|
||||
--oc-shadow-primary: 4px 4px 0 0 rgb(52 116 93 / 14%);
|
||||
--oc-shadow-primary-hover: 5px 5px 0 0 rgb(52 116 93 / 20%);
|
||||
--oc-shadow-amber: 5px 5px 0 0 rgb(245 158 11 / 12%);
|
||||
--oc-shadow-teal: var(--oc-shadow-primary);
|
||||
--oc-shadow-xs: 3px 3px 0 0 rgb(15 23 42 / 6%);
|
||||
--oc-shadow-sm: 4px 4px 0 0 rgb(15 23 42 / 6%);
|
||||
--oc-shadow-md: 6px 6px 0 0 rgb(15 23 42 / 5%);
|
||||
--oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%);
|
||||
--oc-shadow-xl: 12px 12px 0 0 rgb(15 23 42 / 8%);
|
||||
--oc-shadow-sky: 4px 4px 0 0 rgb(14 165 233 / 14%);
|
||||
--oc-shadow-primary: 4px 4px 0 0 rgb(52 116 93 / 14%);
|
||||
--oc-shadow-primary-hover: 5px 5px 0 0 rgb(52 116 93 / 20%);
|
||||
--oc-shadow-amber: 5px 5px 0 0 rgb(245 158 11 / 12%);
|
||||
--oc-shadow-teal: var(--oc-shadow-primary);
|
||||
|
||||
--oc-duration-fast: 160ms;
|
||||
--oc-ease-standard: ease;
|
||||
--oc-duration-fast: 160ms;
|
||||
--oc-ease-standard: ease;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+61
-65
@@ -1,98 +1,94 @@
|
||||
import type { Cloud, CloudType, Profile } from '@/types/database'
|
||||
|
||||
export interface BackendUserProfile {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
avatar_id: string | null
|
||||
cloud_count: number
|
||||
received_like_count: number
|
||||
last_online: string | null
|
||||
role: Profile['role']
|
||||
is_disabled: boolean
|
||||
created_at: string
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
image: string | null
|
||||
cloud_count: number
|
||||
received_like_count: number
|
||||
last_online: string | null
|
||||
role: Profile['role']
|
||||
is_disabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BackendCloudType {
|
||||
id: number
|
||||
name: string
|
||||
genus: string | null
|
||||
icon_id: string | null
|
||||
rarity: number
|
||||
description: string | null
|
||||
created_at: string
|
||||
id: number
|
||||
name: string
|
||||
genus: string | null
|
||||
icon_id: string | null
|
||||
rarity: number
|
||||
description: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BackendCloudOwner {
|
||||
id: string
|
||||
name: string
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface BackendCloudInfo {
|
||||
id: string
|
||||
owner: BackendCloudOwner
|
||||
type: BackendCloudType
|
||||
latitude: number
|
||||
longitude: number
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
uploaded_at: string
|
||||
updated_at: string
|
||||
received_like_count: number
|
||||
status: Cloud['status']
|
||||
is_hidden: boolean
|
||||
id: string
|
||||
owner: BackendCloudOwner
|
||||
type: BackendCloudType
|
||||
latitude: number
|
||||
longitude: number
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
uploaded_at: string
|
||||
updated_at: string
|
||||
received_like_count: number
|
||||
status: Cloud['status']
|
||||
is_hidden: boolean
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
status_code: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse extends ActionResponse {
|
||||
email_sent: boolean
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
status_code: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CloudCreateResponse extends ActionResponse {
|
||||
cloud_id: string
|
||||
cloud_id: string
|
||||
}
|
||||
|
||||
export interface CloudTypeCreateResponse extends ActionResponse {
|
||||
cloud_type_id: number
|
||||
}
|
||||
|
||||
// Internal view model. The backend profile is normalized here so existing
|
||||
// layout/admin consumers do not need to understand the wire representation.
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
avatar_id: string | null
|
||||
role: Profile['role']
|
||||
is_disabled: boolean
|
||||
cloud_count: number
|
||||
received_like_count: number
|
||||
created_at: string
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
image: string | null
|
||||
role: Profile['role']
|
||||
is_disabled: boolean
|
||||
cloud_count: number
|
||||
received_like_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ApiCloudTypeSummary {
|
||||
id: number
|
||||
name: string
|
||||
rarity: CloudType['rarity']
|
||||
rarity_value: number
|
||||
id: number
|
||||
name: string
|
||||
rarity: CloudType['rarity']
|
||||
rarity_value: number
|
||||
}
|
||||
|
||||
export interface ApiCloudOwner {
|
||||
id: string
|
||||
username: string
|
||||
id: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface ApiCloud extends Cloud {
|
||||
updated_at: string
|
||||
cloud_type: ApiCloudTypeSummary | null
|
||||
owner: ApiCloudOwner | null
|
||||
cloud_type_name: string | null
|
||||
cloud_type_rarity: CloudType['rarity'] | null
|
||||
cloud_type_rarity_value: number | null
|
||||
username: string | null
|
||||
received_like_count: number
|
||||
updated_at: string
|
||||
cloud_type: ApiCloudTypeSummary | null
|
||||
owner: ApiCloudOwner | null
|
||||
cloud_type_name: string | null
|
||||
cloud_type_rarity: CloudType['rarity'] | null
|
||||
cloud_type_rarity_value: number | null
|
||||
username: string | null
|
||||
received_like_count: number
|
||||
}
|
||||
|
||||
+26
-26
@@ -1,34 +1,34 @@
|
||||
export interface CloudType {
|
||||
id: number
|
||||
name: string
|
||||
genus: string
|
||||
icon_id: string | null
|
||||
rarity: 'common' | 'uncommon' | 'rare'
|
||||
rarity_value: number
|
||||
description: string | null
|
||||
created_at: string
|
||||
id: number
|
||||
name: string
|
||||
genus: string
|
||||
icon_id: string | null
|
||||
rarity: 'common' | 'uncommon' | 'rare'
|
||||
rarity_value: number
|
||||
description: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Cloud {
|
||||
id: string
|
||||
user_id: string
|
||||
cloud_type_id: number | null
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
created_at: string
|
||||
id: string
|
||||
user_id: string
|
||||
cloud_type_id: number | null
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
id: string
|
||||
username: string
|
||||
avatar_id: string | null
|
||||
role: 'user' | 'admin'
|
||||
is_disabled: boolean
|
||||
created_at: string
|
||||
id: string
|
||||
username: string
|
||||
image: string | null
|
||||
role: 'user' | 'admin'
|
||||
is_disabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
Vendored
+8
-8
@@ -1,12 +1,12 @@
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
description?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
canonicalPath?: string
|
||||
noindex?: boolean
|
||||
requiresAuth?: boolean
|
||||
requiresAdmin?: boolean
|
||||
}
|
||||
interface RouteMeta {
|
||||
title?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
description?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
canonicalPath?: string
|
||||
noindex?: boolean
|
||||
requiresAuth?: boolean
|
||||
requiresAdmin?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,74 +0,0 @@
|
||||
<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 '@/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>
|
||||
@@ -1,87 +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 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">输入注册邮箱,我们会发送有效期为 30 分钟的重置链接。</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>
|
||||
@@ -1,147 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, 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)
|
||||
const needsEmailConfirmation = computed(() => error.value.includes('邮箱尚未验证'))
|
||||
|
||||
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 }}
|
||||
<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>
|
||||
@@ -1,155 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { useAuthStore } from '@/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 emailDelivered = ref(true)
|
||||
const registerConflict = ref(false)
|
||||
const canResend = computed(() => registerConflict.value)
|
||||
|
||||
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 {
|
||||
const response = await authStore.register(trimmedName, trimmedEmail, password.value)
|
||||
email.value = trimmedEmail
|
||||
emailDelivered.value = response.email_sent
|
||||
registered.value = true
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
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="emailDelivered ? 'success' : 'warning'"
|
||||
title="确认你的邮箱"
|
||||
:description="emailDelivered
|
||||
? '确认邮件已经发送,请查收并点击链接完成注册。'
|
||||
: '账号已创建,但验证邮件暂未送达。请使用重发验证功能,不要重复注册。'"
|
||||
>
|
||||
<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="canResend" 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>
|
||||
@@ -1,83 +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 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>
|
||||
@@ -1,93 +0,0 @@
|
||||
<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 { useAuthStore } from '@/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(!email || !token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '')
|
||||
const invalid = ref(!email || !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(email, token, password.value)
|
||||
success.value = true
|
||||
startCountdown()
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试'
|
||||
if (error.value.includes('无效') || error.value.includes('过期')) 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>
|
||||
@@ -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,142 +0,0 @@
|
||||
<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 '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { apiRequest, imageUrl, toApiCloud } from '@/lib/api'
|
||||
import { useEncyclopediaStore } from '@/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" />
|
||||
</ImageDetailModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,92 +0,0 @@
|
||||
<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 '@/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>
|
||||
@@ -1,590 +0,0 @@
|
||||
<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 '@/components/cloud/CloudEditModal.vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
import { useProfileStore } from '@/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
|
||||
}
|
||||
|
||||
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 || '匿名',
|
||||
} 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
|
||||
@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">
|
||||
稀有度 {{ 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"
|
||||
/>
|
||||
|
||||
<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,961 +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, toApiCloud } from '@/lib/api'
|
||||
import { loadAMap } from '@/lib/amap'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ApiCloud, BackendCloudInfo } 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
|
||||
description: string | null
|
||||
cloudTypeName: string
|
||||
rarity: number
|
||||
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 markerBorder = '#64748b'
|
||||
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
|
||||
|
||||
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 = markerBorder
|
||||
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 = markerBorder
|
||||
const cardImage = cloud.thumbnailUrl || cloud.imageUrl
|
||||
const label = `稀有度 ${cloud.rarity}`
|
||||
|
||||
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,
|
||||
description: row.description,
|
||||
cloudTypeName: row.cloud_type_name || '未知',
|
||||
rarity: row.cloud_type_rarity_value ?? 0,
|
||||
username: row.username || '匿名',
|
||||
capturedAt: row.captured_at || row.created_at,
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCloudsByRange(field: 'captured_at' | 'uploaded_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<BackendCloudInfo[]>(`/cloud/map?${params}`, { auth: false })
|
||||
return rows.map(toApiCloud).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="`稀有度 ${previewCloud.rarity}`"
|
||||
:badge-class="rarityChip"
|
||||
@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">
|
||||
纬度 {{ formatCoordinate(previewCloud.latitude) }} / 经度 {{ formatCoordinate(previewCloud.longitude) }}
|
||||
</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"
|
||||
/>
|
||||
</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,220 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
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 = ''
|
||||
message.success('密码已更新,请重新登录')
|
||||
await router.push('/login')
|
||||
} 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>
|
||||
@@ -1,803 +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 { 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>(() => profileStore.getProfile(viewedIdentifier.value))
|
||||
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?.avatar_id"
|
||||
:src="profileData.avatar_id"
|
||||
: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 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"
|
||||
@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="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"
|
||||
/>
|
||||
|
||||
<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,403 +0,0 @@
|
||||
<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 '@/stores/clouds'
|
||||
import { useUpload } from '@/composables/useUpload'
|
||||
import MapPickerModal from '@/components/cloud/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"
|
||||
@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">支持 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"
|
||||
@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.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)"
|
||||
@change="onCapturedAtChange"
|
||||
class="oc-field-control"
|
||||
/>
|
||||
<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 : ''"
|
||||
@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>
|
||||
<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
|
||||
: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",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
|
||||
+2
-5
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
+20
-20
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
+32
-32
@@ -1,36 +1,36 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Frame-Options",
|
||||
"value": "DENY"
|
||||
},
|
||||
{
|
||||
"key": "X-Content-Type-Options",
|
||||
"value": "nosniff"
|
||||
},
|
||||
{
|
||||
"key": "Referrer-Policy",
|
||||
"value": "strict-origin"
|
||||
},
|
||||
{
|
||||
"key": "Strict-Transport-Security",
|
||||
"value": "max-age=31536000"
|
||||
},
|
||||
{
|
||||
"key": "Permissions-Policy",
|
||||
"value": "camera=(), microphone=(), payment=(), usb=(), geolocation=(self)"
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Frame-Options",
|
||||
"value": "DENY"
|
||||
},
|
||||
{
|
||||
"key": "X-Content-Type-Options",
|
||||
"value": "nosniff"
|
||||
},
|
||||
{
|
||||
"key": "Referrer-Policy",
|
||||
"value": "strict-origin"
|
||||
},
|
||||
{
|
||||
"key": "Strict-Transport-Security",
|
||||
"value": "max-age=31536000"
|
||||
},
|
||||
{
|
||||
"key": "Permissions-Policy",
|
||||
"value": "camera=(), microphone=(), payment=(), usb=(), geolocation=(self)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user