2 Commits
Author SHA1 Message Date
Mplan 0af0256c97 重组前端功能目录并统一代码规范工具链 (#10)
Reviewed-on: #10
2026-09-29 18:09:38 +08:00
Mplan 720ad29f06 feat: 适配新版后端 API 并接入邮箱验证 (#9)
## 概述
本分支对前端进行了大规模适配与升级,核心是对齐新版后端 API 接口,并接入邮箱验证与密码找回流程。

## 主要变更
- **API 适配**:对齐新版后端接口契约,重构 api 客户端与类型定义,移除前端臆造字段
- **认证流程**:接入邮箱验证(确认 / 重发确认)、密码找回(忘记密码 / 重置密码)全流程
- **管理后台**:前端对齐更新后的 API 契约
- **云图鉴**:展示云类型图标图片
- **UI 优化**:图片预览改为直角样式并调整关闭按钮

## 影响范围
- API 层:src/lib/api.ts、src/types/api.ts、src/types/database.ts
- 认证视图:src/views/auth/*
- 管理后台:src/views/admin/AdminView.vue
- 图鉴视图:src/views/encyclopedia/*
- 状态管理:src/stores/*

Reviewed-on: #9
2026-08-22 21:39:42 +08:00
101 changed files with 14644 additions and 10309 deletions
+12
View File
@@ -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
+4
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
dist-ssr/
coverage/
.agents/
.codex/
.claude/
.env*
package-lock.json
skills-lock.json
+10
View File
@@ -0,0 +1,10 @@
{
"tabWidth": 4,
"useTabs": false,
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all",
"arrowParens": "avoid",
"endOfLine": "lf"
}
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
+11
View File
@@ -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"]
}
+60 -50
View File
@@ -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.
+440 -591
View File
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -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)。
+80
View File
@@ -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
View File
@@ -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` 是兼容命名,视觉上使用的是清新桉叶绿。
+53
View File
@@ -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
View File
@@ -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>
+1789
View File
File diff suppressed because it is too large Load Diff
+46 -31
View File
@@ -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"
}
}
+91 -85
View File
@@ -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
View File
@@ -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>
-232
View File
@@ -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>
-307
View File
@@ -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>
-219
View File
@@ -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>
-123
View File
@@ -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>
-384
View File
@@ -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
View File
@@ -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>
-216
View File
@@ -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
+35
View File
@@ -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)
}
+155
View File
@@ -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>
+165
View File
@@ -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>
+240
View File
@@ -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>
+17
View File
@@ -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>
+27
View File
@@ -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 }
})
+116
View File
@@ -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>
+658
View File
@@ -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>
+233
View File
@@ -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>
+937
View File
@@ -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,
}
}
+560
View File
@@ -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>
-16
View File
@@ -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
View File
@@ -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,
}
}
-17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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>
+126
View File
@@ -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
}
-149
View File
@@ -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,
}
})
-23
View File
@@ -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 }
})
-29
View File
@@ -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,
}
})
-210
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+71 -71
View File
@@ -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;
}
-126
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+8 -8
View File
@@ -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
-74
View File
@@ -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>
-87
View File
@@ -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>
-147
View File
@@ -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>
-155
View File
@@ -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>
-83
View File
@@ -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>
-93
View File
@@ -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>
-14
View File
@@ -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>
-142
View File
@@ -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>
-590
View File
@@ -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>
-961
View File
@@ -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>
-220
View File
@@ -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>
-803
View File
@@ -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>
-51
View File
@@ -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>
-27
View File
@@ -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>
-27
View File
@@ -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>
-403
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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