#1 大幅改进页面显示效果 #1
@@ -26,6 +26,7 @@
|
|||||||
| `src/components/cloud/` | Cloud-related modals and widgets: ImageDetailModal, CloudEditModal, MapPickerModal, QuickUploadModal, MiniLocationMap |
|
| `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/layout/` | AppHeader (top nav bar with auth state) |
|
||||||
| `src/components/profile/` | ContributionHeatmap |
|
| `src/components/profile/` | ContributionHeatmap |
|
||||||
|
| `src/style.css` | Global visual system hooks: shared button/card utility classes, base typography, body background |
|
||||||
| `src/views/` | Route-level page components (see Routes below) |
|
| `src/views/` | Route-level page components (see Routes below) |
|
||||||
| `src/types/` | TypeScript types: database models (`database.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
|
| `src/types/` | TypeScript types: database models (`database.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
|
||||||
|
|
||||||
@@ -121,6 +122,27 @@ Future (not in `.env`):
|
|||||||
- Used for modals, buttons, inputs, tags, progress bars, alerts, dropdowns, empty states, skeletons, and message toasts.
|
- Used for modals, buttons, inputs, tags, progress bars, alerts, dropdowns, empty states, skeletons, and message toasts.
|
||||||
- No SSR — pure client-side rendering.
|
- No SSR — pure client-side rendering.
|
||||||
- Custom CSS overrides via scoped `<style>` blocks for slider styling, transitions, and component tweaks.
|
- Custom CSS overrides via scoped `<style>` blocks for slider styling, transitions, and component tweaks.
|
||||||
|
- **Do not rely on default Naive UI primary/secondary colors for page CTAs or panel actions.** The project now uses shared global classes in `src/style.css` to keep buttons visually consistent with the sky-atlas theme.
|
||||||
|
- 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
|
||||||
|
- 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
|
||||||
|
- Card-like containers should prefer shared shadow classes instead of ad-hoc shadows:
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## Visual Notes
|
||||||
|
|
||||||
|
- The site icon and header brand mark are a matching pixel-style cloud motif; avoid reintroducing emoji or unrelated icon styles for primary brand surfaces.
|
||||||
|
- Header action buttons use slight hover lift (`translate(-1px, -1px)`) with hard-offset shadow growth; new header-like actions should follow that interaction pattern.
|
||||||
|
- Heatmap view toggles in `ContributionHeatmap` intentionally use separate buttons with gap spacing instead of `NButtonGroup`, because grouped buttons visually collide once hard shadows and hover transforms are applied.
|
||||||
|
|
||||||
## MVP Constraints (from plan.md)
|
## MVP Constraints (from plan.md)
|
||||||
|
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
已根据你的要求,将 Web 端技术栈从 Next.js 调整为 Vue。以下是更新后的完整规划文档,修改部分已用 **【修订标记】** 标注。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# OpenCloud 产品规划文档
|
|
||||||
|
|
||||||
**版本**:1.1
|
|
||||||
**日期**:2026年5月20日
|
|
||||||
**作者**:OpenCloud 产品团队
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
1. [项目概述](#1-项目概述)
|
|
||||||
2. [市场与用户分析](#2-市场与用户分析)
|
|
||||||
3. [竞品分析](#3-竞品分析)
|
|
||||||
4. [产品定位与差异化](#4-产品定位与差异化)
|
|
||||||
5. [核心功能模块设计](#5-核心功能模块设计)
|
|
||||||
6. [商业模式](#6-商业模式)
|
|
||||||
7. [技术架构与选型](#7-技术架构与选型)
|
|
||||||
8. [实施路线图](#8-实施路线图)
|
|
||||||
9. [风险与应对策略](#9-风险与应对策略)
|
|
||||||
10. [结语](#10-结语)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 项目概述
|
|
||||||
|
|
||||||
### 1.1 愿景
|
|
||||||
**成为全球天空爱好者共同绘制、探索与收藏的“活的天空地图”。**
|
|
||||||
|
|
||||||
### 1.2 使命
|
|
||||||
通过AI识别、实时地理映射与轻量社交,让每一次仰望天空都变成一次有意义的发现与连接。
|
|
||||||
|
|
||||||
### 1.3 产品简介
|
|
||||||
OpenCloud 是一款面向云朵爱好者、气象爱好者及摄影爱好者的跨平台应用(Web + 移动端)。它整合了**实时云图地图**、**游戏化云朵图鉴**与**轻量社区**三大核心模块,让用户可以随时拍摄天空、自动识别云的类型,将作品实时呈现在世界地图上,同时像收集精灵一样解锁各类云朵成就,并与全球同好温和互动。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 市场与用户分析
|
|
||||||
|
|
||||||
### 2.1 市场背景
|
|
||||||
- **兴趣社群成熟**:国际赏云协会(Cloud Appreciation Society)拥有近 6 万付费会员,证明云朵主题具有激发付费意愿的情感基础。
|
|
||||||
- **视觉社交需求**:Instagram 上 #clouds 标签有上亿帖子,小红书、抖音上云朵内容持续火爆,但缺乏垂直整合拍摄、识别、社区的专业工具。
|
|
||||||
- **AI 视觉技术成熟**:以 GPT-4o 为代表的视觉模型使低成本的实时云朵分类成为可能,用户可以零学习成本获得专业级气象信息。
|
|
||||||
|
|
||||||
### 2.2 目标用户画像
|
|
||||||
- **核心用户**:云朵摄影爱好者、赏云协会成员、气象爱好者。
|
|
||||||
- **潜力用户**:喜欢记录日常的普通人、追求新奇 App 的年轻群体、教育工作者(用于自然教学)。
|
|
||||||
- **需求特征**:渴望被识别、收藏、展示自己的天空作品;喜欢地理发现和轻量收集游戏;对深度社交压力敏感。
|
|
||||||
|
|
||||||
### 2.3 用户痛点与需求
|
|
||||||
| 痛点 | OpenCloud 解决方案 |
|
|
||||||
|------|---------------------|
|
|
||||||
| 拍了云不知道它叫什么 | AI 一键识别,附带气象知识 |
|
|
||||||
| 云图分享缺乏专属平台 | 主题化社区,无算法噪音 |
|
|
||||||
| 记录零散,缺乏成就感 | 游戏化图鉴,解锁稀有成就 |
|
|
||||||
| 看不到全球此刻的天空 | 实时云图地图,感受世界脉动 |
|
|
||||||
| 害怕隐私泄露 | 多层模糊化处理,隐身模式 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 竞品分析
|
|
||||||
|
|
||||||
### 3.1 核心竞品
|
|
||||||
| 产品 | 特点 | 差距与机会 |
|
|
||||||
|------|------|-------------|
|
|
||||||
| **CloudSpotter** | 赏云协会官方 App,图鉴+社区 | 无实时地图,AI 识别为后期添加,互动偏学术 |
|
|
||||||
| **See My Clouds** | 云朵版 Instagram,个人画廊 | 无 AI 识别,无地图,纯粹社交 |
|
|
||||||
| **Cloud Point** | AI 识别 + 学习 + 社区 | 功能较全,但创新深度不足,无地图实时性 |
|
|
||||||
| **每日一云小程序** | 国内头部云主题小程序 | 依托微信,功能受限,无独立 App 体验 |
|
|
||||||
|
|
||||||
### 3.2 间接与替代品
|
|
||||||
- **AI 云识别工具**:Atmosphere、云识别扫描器 —— 单点功能,无社区。
|
|
||||||
- **天气应用**:Your Weather、Poweather —— 天气数据为主,缺少用户生成内容。
|
|
||||||
- **通用社交平台**:Instagram、小红书 —— 内容海量但无结构化识别与收集。
|
|
||||||
|
|
||||||
### 3.3 竞争优势总结
|
|
||||||
OpenCloud 是首个将 **实时地图、游戏化图鉴、轻社交** 深度融合的产品,形成“拍摄→识别→地图浮现→收藏→社区分享→再次出发”的闭环体验。竞品多在某一模块有所建树,但缺乏完整闭环。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 产品定位与差异化
|
|
||||||
|
|
||||||
### 4.1 核心价值主张
|
|
||||||
**“你拍的每一朵云,都将在这张世界地图上活起来。”**
|
|
||||||
- **实时性**:云不再是一张静态照片,而是此刻天空的脉动。
|
|
||||||
- **专业性**:AI 精准识别 + 气象数据加持,让普通用户触及科学之美。
|
|
||||||
- **游戏性**:图鉴系统提供长期激励,消除低频兴趣的留存难题。
|
|
||||||
- **温暖感**:轻社交设计,远离评论压力,仅保留对天空的共同赞叹。
|
|
||||||
|
|
||||||
### 4.2 差异化特征
|
|
||||||
- **天空实时仪表盘**:结合天气图层与用户上传的实时云标,形成预测-拍摄-验证的主动探索体验。
|
|
||||||
- **分层云朵图鉴**:基础属、变种、稀有度、环境成就四维收集体系,深度远超简单的类型列表。
|
|
||||||
- **隐私优先的地图设计**:默认模糊定位+隐身模式,在酷炫与安全间取得平衡。
|
|
||||||
- **跨平台一致体验**:Web 端浏览与传播,移动端拍摄与即时互动,后台统一管理。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 核心功能模块设计
|
|
||||||
|
|
||||||
### 5.1 总体架构
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ OpenCloud 平台 │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
│ │ │
|
|
||||||
┌─────────┼──────────┼──────────┼─────────┐
|
|
||||||
▼ ▼ ▼ ▼ ▼
|
|
||||||
实时地图 云朵图鉴 轻量社区 用户系统 AI引擎
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 模块一:实时云图地图
|
|
||||||
**核心目标**:让用户每次打开都能感受到“此刻的天空在发生什么”。
|
|
||||||
|
|
||||||
#### 功能点:
|
|
||||||
- **实时浮现**:最近30分钟内上传的云以动画淡入,2小时后半透明,24小时后默认移出主视图(可手动开启历史层)。
|
|
||||||
- **天气图层叠加**:显示云层覆盖、降水区域,结合用户上传形成“追云”预期。
|
|
||||||
- **定位模糊与隐身**:
|
|
||||||
- 默认坐标精度约1km(小数点后2位)
|
|
||||||
- 用户可选择精确(100m)或仅城市级别
|
|
||||||
- 隐身模式:上传但地图不显示位置,仅存在于个人图鉴
|
|
||||||
- **卡片交互**:点击云图标弹出精致卡片(缩略图、云类型、拍摄者、时间、天气简述),并提供「看同类云」「看这里其他云」快捷操作。
|
|
||||||
- **愿望清单联动**:在地图上看到稀有云,可一键加入“我想拍这个”清单,附近出现时推送通知。
|
|
||||||
|
|
||||||
#### 隐私策略:
|
|
||||||
- 绝对不显示可识别个人住址的标记。
|
|
||||||
- 多张照片的位置元数据不会组合推断轨迹(服务器端即时模糊后丢弃原始精确坐标)。
|
|
||||||
- 符合 GDPR 及《个人信息保护法》要求。
|
|
||||||
|
|
||||||
### 5.3 模块二:游戏化云朵图鉴
|
|
||||||
**核心目标**:将一次性新奇转化为有深度、有进度的收集游戏,提升长期留存。
|
|
||||||
|
|
||||||
#### 收集体系:
|
|
||||||
- **基础云属**(10 种):积云、层云、卷云、积雨云等。
|
|
||||||
- **变种与特征**:乳状云、波状云、雨幡洞、晕、虹彩云等。
|
|
||||||
- **稀有度分级**:
|
|
||||||
- 常见(积云、层云)
|
|
||||||
- 少见(荚状云)
|
|
||||||
- 罕见(贝母云、夜光云)
|
|
||||||
- **环境成就**:黄金时刻云、暴风雨前云、高海拔云(>3000m)、跨赤道云等。
|
|
||||||
|
|
||||||
#### AI 识别与验证:
|
|
||||||
- 拍摄后自动调用 OpenAI Vision(或后续自研模型)识别,返回云型与置信度。
|
|
||||||
- 用户可修正类型;稀有云型提交后进入“待验证”队列,由其他资深用户或管理员投票验证,通过后获得金框认证。
|
|
||||||
|
|
||||||
#### 激励与视觉呈现:
|
|
||||||
- 每解锁一项获得一枚精致云形徽章,可生成卡片分享至社交媒体。
|
|
||||||
- 图鉴内附带手绘风格气象小知识,成为一本可阅读的“云朵百科全书”。
|
|
||||||
- 愿望清单功能:用户标记未解锁云型,地图附近出现或天气条件有利时推送通知。
|
|
||||||
|
|
||||||
### 5.4 模块三:轻量社交社区
|
|
||||||
**核心目标**:提供温暖、低负担的交流空间,不追求高互动频率,但求每次浏览都有美感体验。
|
|
||||||
|
|
||||||
#### 每日天空精选(The Daily Sky)
|
|
||||||
- 每日由编辑/算法从全球选出 9-12 张云图拼成“今日天空拼图”,作为社区首页。
|
|
||||||
- 浏览体验类似翻日历,拒绝无限信息流带来的疲劳感。
|
|
||||||
|
|
||||||
#### 微频道(基于云型)
|
|
||||||
- 每个云型自动生成频道:积云频道、卷云频道、乳状云频道……内容自动归入。
|
|
||||||
- 避免用户自建话题冷场,保证每个频道总有内容。
|
|
||||||
|
|
||||||
#### 情绪反应代替文字评论
|
|
||||||
- 提供6种与天空共情的情绪:✨震撼、🌿宁静、🌧忧伤、🔥热烈、💭梦幻、🫂温暖。
|
|
||||||
- 文字评论保留但非主要引导方式,降低互动门槛。
|
|
||||||
|
|
||||||
#### 个人天空日志
|
|
||||||
- 每位用户拥有个人主页,按时间线展示其云图,形成天空日记。
|
|
||||||
- 允许“订阅”其他拍摄者,获得新上传推送,但不公开关注数,避免社交压力。
|
|
||||||
|
|
||||||
#### 共同观察事件
|
|
||||||
- 定期发起主题挑战(如“本周最奇特的云”)。
|
|
||||||
- 特殊天象发生时推送区域用户,集体拍摄,形成同一时刻的天空共鸣。
|
|
||||||
|
|
||||||
### 5.5 模块四:用户系统与内容审核
|
|
||||||
|
|
||||||
#### 用户注册登录
|
|
||||||
- 支持邮箱注册、Google/Apple 第三方登录。
|
|
||||||
- 使用 Supabase Auth 实现,与数据库 RLS 深度集成。
|
|
||||||
|
|
||||||
#### 审核流程
|
|
||||||
1. **自动审核**:上传后由 OpenAI Vision 同时完成云型识别与违规内容检测(色情、暴力、人脸、车牌等)。
|
|
||||||
2. **人工兜底**:标记低置信度或疑似违规内容进入管理后台待审队列。
|
|
||||||
3. **社区举报**:允许用户举报不当内容,补充审核闭环。
|
|
||||||
4. **审核结果**:
|
|
||||||
- 通过:标记为 `approved`,公开可见。
|
|
||||||
- 拒绝:标记为 `rejected`,仅自己可见或自动删除原图。
|
|
||||||
|
|
||||||
#### 后台管理(Web 端)
|
|
||||||
- 管理员通过 Web 端专用路由(如 `/admin`)访问,基于用户角色鉴权。
|
|
||||||
**【修订】**:Web 端使用 **Vue 3 + Vue Router** 构建,管理后台作为受路由守卫保护的一组页面。
|
|
||||||
- 功能包括:待审核队列操作、用户管理(禁用/恢复)、数据统计仪表盘、云型标准库维护。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 商业模式
|
|
||||||
|
|
||||||
**基础免费 + 增值订阅 + 周边衍生**
|
|
||||||
|
|
||||||
### 收入来源:
|
|
||||||
1. **会员订阅(核心)**
|
|
||||||
- 免费版:基础识别、普通图鉴、带水印地图、广告支持。
|
|
||||||
- Pro 版(月/年费):高精度识别、去水印高清原图、稀有云型验证优先权、天气预测提醒、不限容量的云朵存储空间。
|
|
||||||
2. **虚拟商品**
|
|
||||||
- 特殊徽章外框、主题皮肤、图鉴装饰。
|
|
||||||
3. **联名与周边**
|
|
||||||
- 与赏云协会、气象机构合作推出实体云图日历、图册。
|
|
||||||
- 定制天气周边(如“今日云朵”明信片)电商导流。
|
|
||||||
4. **企业级 API**
|
|
||||||
- 向教育、科研、天气 App 提供云朵识别 API(后期)。
|
|
||||||
|
|
||||||
### 定价参考
|
|
||||||
- 参考赏云协会年费约 $5,提供更丰富数字体验,Pro 订阅可定价 $2.99/月 或 $19.99/年。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 技术架构与选型
|
|
||||||
|
|
||||||
### 7.1 整体架构 【修订】
|
|
||||||
```
|
|
||||||
客户端:React Native (移动端) + Vue 3 (Web/管理后台)
|
|
||||||
│
|
|
||||||
├── 实时通信:Supabase Realtime (WebSocket)
|
|
||||||
├── 数据库查询:Supabase JS SDK
|
|
||||||
└── 文件上传:Supabase Storage (S3)
|
|
||||||
│
|
|
||||||
后端服务:Supabase (托管 PostgreSQL, Auth, Storage, Edge Functions)
|
|
||||||
│
|
|
||||||
├── AI 审核与识别:OpenAI GPT-4o Vision (初期)
|
|
||||||
└── 天气数据:OpenWeatherMap API
|
|
||||||
│
|
|
||||||
部署:Vercel / Netlify (Web) + Expo EAS (App) + Supabase Cloud
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 核心选型理由 【修订】
|
|
||||||
| 组件 | 技术 | 理由 |
|
|
||||||
|------|------|------|
|
|
||||||
| **移动端** | React Native + Expo | 成熟的跨平台移动框架,与 Vue 共享 TypeScript 业务逻辑与 Supabase 客户端 |
|
|
||||||
| **Web 前端** | **Vue 3** + **Vite** + **Tailwind CSS** | 按团队技术偏好选择,轻量且高效。如后期需要 SEO,可平滑迁移至 **Nuxt 3**(基于 Vue 的服务端渲染框架)。 |
|
|
||||||
| **Web 路由与状态管理** | Vue Router + Pinia | 官方配套,管理页面路由与全局状态 |
|
|
||||||
| **管理后台** | 同 Web 端 Vue 项目,使用路由守卫区分权限 | 无额外学习成本,与用户端共享组件和 API 层 |
|
|
||||||
| **后端服务** | Supabase | 提供即时认证、REST API、实时订阅、存储,省去大量后端开发 |
|
|
||||||
| **数据库** | PostgreSQL (Supabase内置) | 成熟的空间查询支持 (PostGIS),RLS 精细权限控制 |
|
|
||||||
| **AI 识别** | OpenAI GPT-4o-mini (Vision) | 按量付费,每图约$0.002,可同时完成识别和合规检查 |
|
|
||||||
| **地图** | react-native-maps (App) / Leaflet 或 Mapbox (Web) | 移动端原生性能,Web端灵活 |
|
|
||||||
| **天气数据** | OpenWeatherMap | 免费套餐可满足初期需求 |
|
|
||||||
| **代码共享** | 共享 Supabase 客户端配置、TypeScript 类型定义、业务工具函数 | 通过 monorepo 或 npm 私有包在不同平台间复用逻辑层 |
|
|
||||||
|
|
||||||
### 7.3 数据隐私与合规
|
|
||||||
- 所有用户上传图片默认通过 Supabase Storage 存储,开启自动模糊化处理(需自行实现或调用第三方)。
|
|
||||||
- 坐标在服务器端舍入后丢弃原始精确定位。
|
|
||||||
- 使用 Row Level Security 确保用户只能访问自己被允许的数据。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 实施路线图
|
|
||||||
|
|
||||||
### 阶段 1:MVP(核心闭环,预计 6-8 周)
|
|
||||||
**目标**:验证核心价值——“拍云→识别→地图浮现→图鉴收集”。
|
|
||||||
|
|
||||||
- **Web 端(Vue 3)**:
|
|
||||||
- 用户注册登录(Supabase Auth)
|
|
||||||
- 云图上传(手动选类型+拍摄时间),调用 OpenAI 识别
|
|
||||||
- 基础画廊:按类型筛选浏览
|
|
||||||
- 极简管理后台:查看/审核图片
|
|
||||||
- **移动端(App)**:
|
|
||||||
- 拍照/相册选取,自动获取位置
|
|
||||||
- 展示识别结果,支持手动修正
|
|
||||||
- 上传后在基础地图上显示(刷新可见,非实时)
|
|
||||||
- **后端**:
|
|
||||||
- Supabase 表结构搭建(users, clouds, cloud_types)
|
|
||||||
- 审核 Edge Function 实现
|
|
||||||
|
|
||||||
### 阶段 2:社交与地图增强(预计 8-12 周)
|
|
||||||
**目标**:打造“实时地图”与“轻社区”,强化留存。
|
|
||||||
|
|
||||||
- 地图实时浮现(Supabase Realtime 订阅)
|
|
||||||
- 天气图层叠加
|
|
||||||
- 游戏化图鉴系统(基础属+变种,稀有度,徽章分享)
|
|
||||||
- 每日天空精选与云型微频道
|
|
||||||
- 情绪反应互动系统
|
|
||||||
- 个人主页与订阅
|
|
||||||
- 推送通知(附近稀有云、天气提醒)
|
|
||||||
|
|
||||||
### 阶段 3:AI 优化与商业化(长期迭代)
|
|
||||||
**目标**:建立壁垒,实现正向营收。
|
|
||||||
|
|
||||||
- 收集用户反馈数据,训练自有云朵分类模型(基于 CloudSEN12+ 等数据集微调)
|
|
||||||
- 上线 Pro 会员订阅
|
|
||||||
- 多语言支持,全球化运营
|
|
||||||
- 对外提供识别 API
|
|
||||||
- 与赏云协会等建立品牌合作
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 风险与应对策略
|
|
||||||
|
|
||||||
| 风险 | 影响 | 应对策略 |
|
|
||||||
|------|------|----------|
|
|
||||||
| **冷启动内容匮乏** | 新用户流失 | 种子内容计划:引入 Unsplash 等免版权云图并标注;邀请早期核心用户内测生产内容 |
|
|
||||||
| **用户使用频次低** | DAU 低迷 | 图鉴愿望清单+天气预测推送,变被动等待为主动引导 |
|
|
||||||
| **AI 识别准确率不足** | 用户信任崩塌 | 允许用户修正并作为训练数据;稀有云型增加人工验证机制,维护专业形象 |
|
|
||||||
| **隐私与合规问题** | 法律风险、口碑危机 | 地图默认模糊定位,提供隐身模式;严格数据丢弃策略;尽早获取法律意见 |
|
|
||||||
| **审核遗漏违规内容** | 应用商店下架、法律纠纷 | 多层审核:AI 初筛→人工复核→社区举报;敏感内容立即隐藏 |
|
|
||||||
| **大平台竞争** | 用户流失 | 坚持垂直深度体验,不与通用平台比拼内容量;以“专属图鉴”和“天空日记”沉淀用户数字资产,提高迁移成本 |
|
|
||||||
| **商业变现困难** | 无法持续运营 | 初期靠付费订阅覆盖边际成本;后期拓展教育与天气衍生价值,控制团队规模,保持精益 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 结语
|
|
||||||
|
|
||||||
OpenCloud 不仅仅是一个云朵识别工具,它是一次将自然观察、科技美学与人文连接相结合的尝试。在注意力被无限撕扯的时代,我们希望为人们保留一小块仰望天空的空间——在那里,每一朵云都值得被看见、被理解、被世界记住。
|
|
||||||
|
|
||||||
本规划文档定义了产品的方向、骨架与演进路径。接下来,我们将基于此进行最小可行性版本的敏捷开发,用真实用户反馈打磨每一个呼吸的角落。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*天空无界,探索不止。*
|
|
||||||
**OpenCloud 团队**
|
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" sizes="any" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="OpenCloud - 活的天空地图,拍云、识别、收藏" />
|
<meta name="description" content="OpenCloud - 活的天空地图,拍云、识别、收藏" />
|
||||||
<title>OpenCloud</title>
|
<title>OpenCloud</title>
|
||||||
|
|||||||
@@ -1,959 +0,0 @@
|
|||||||
# OpenCloud Web MVP — 详细实施方案
|
|
||||||
|
|
||||||
**版本**:1.0
|
|
||||||
**日期**:2026年5月20日
|
|
||||||
**技术栈**:Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + Supabase + 高德地图 JS API 2.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
1. [关键决策](#1-关键决策)
|
|
||||||
2. [技术栈与依赖清单](#2-技术栈与依赖清单)
|
|
||||||
3. [目录结构](#3-目录结构)
|
|
||||||
4. [阶段 0:基础设施搭建](#4-阶段-0基础设施搭建)
|
|
||||||
5. [阶段 1:用户认证](#5-阶段-1用户认证)
|
|
||||||
6. [阶段 2:云图上传](#6-阶段-2云图上传)
|
|
||||||
7. [阶段 3:高德 3D 云图地图](#7-阶段-3高德-3d-云图地图)
|
|
||||||
8. [阶段 4:图鉴系统](#8-阶段-4图鉴系统)
|
|
||||||
9. [阶段 5:社区 + 管理后台 + 收尾](#9-阶段-5社区--管理后台--收尾)
|
|
||||||
10. [数据库设计总览](#10-数据库设计总览)
|
|
||||||
11. [环境变量清单](#11-环境变量清单)
|
|
||||||
12. [风险与注意事项](#12-风险与注意事项)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 关键决策
|
|
||||||
|
|
||||||
| 决策项 | 选择 | 理由 |
|
|
||||||
|--------|------|------|
|
|
||||||
| 地图库 | **高德地图 JS API 2.0** | 国内数据准确,3D/卫星/视角切换,免费额度充足 |
|
|
||||||
| 实时性 | MVP **跳过 Supabase Realtime** | 刷新加载代替,降低复杂度,快速跑通闭环 |
|
|
||||||
| 认证方式 | MVP **仅邮箱密码** | OAuth 配置复杂,留到后续阶段 |
|
|
||||||
| AI 识别 | MVP **暂不接入** | 先完成手动选型上传闭环,降低开发复杂度;后续接入 OpenAI Vision |
|
|
||||||
| 目标用户 | 国内用户为主 | 高德地图优先;若后续全球化需接入 Mapbox 双引擎 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 技术栈与依赖清单
|
|
||||||
|
|
||||||
### 生产依赖
|
|
||||||
|
|
||||||
| 包名 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `vue` | 核心框架(已安装 ^3.5.34) |
|
|
||||||
| `vue-router` | SPA 路由管理 |
|
|
||||||
| `pinia` | 全局状态管理 |
|
|
||||||
| `@supabase/supabase-js` | Supabase 客户端(已安装 ^2.106.1) |
|
|
||||||
| `@amap/amap-jsapi-loader` | 高德地图 JS API 官方加载器 |
|
|
||||||
|
|
||||||
### 开发依赖
|
|
||||||
|
|
||||||
| 包名 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `tailwindcss` | 原子化 CSS 框架 |
|
|
||||||
| `@tailwindcss/vite` | Tailwind Vite 插件 |
|
|
||||||
| `vite` | 构建工具(已安装 ^8.0.12) |
|
|
||||||
| `@vitejs/plugin-vue` | Vue Vite 插件(已安装 ^6.0.6) |
|
|
||||||
| `typescript` | 类型系统(已安装 ~6.0.2) |
|
|
||||||
| `vue-tsc` | Vue TypeScript 类型检查(已安装 ^3.2.8) |
|
|
||||||
| `@types/node` | Node.js 类型定义(已安装 ^24.12.3) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── assets/ # 静态资源(图标、图片)
|
|
||||||
├── components/ # 通用组件
|
|
||||||
│ ├── layout/ # 布局组件 (AppHeader, AppFooter, AppSidebar)
|
|
||||||
│ ├── cloud/ # 云朵相关组件 (CloudCard, CloudMarker, CloudBadge)
|
|
||||||
│ ├── map/ # 地图相关组件 (MapContainer, MapPopup)
|
|
||||||
│ └── ui/ # 基础 UI 组件 (Button, Input, Modal, Badge)
|
|
||||||
├── composables/ # 组合式函数
|
|
||||||
│ ├── useAuth.ts # 认证逻辑
|
|
||||||
│ ├── useClouds.ts # 云图数据操作
|
|
||||||
│ └── useMap.ts # 地图逻辑封装
|
|
||||||
├── lib/ # 第三方库初始化
|
|
||||||
│ ├── supabase.ts # Supabase 客户端
|
|
||||||
│ └── amap.ts # 高德地图加载器封装
|
|
||||||
├── router/ # 路由配置
|
|
||||||
│ └── index.ts # 路由表 + 导航守卫
|
|
||||||
├── stores/ # Pinia stores
|
|
||||||
│ ├── auth.ts # 用户认证状态
|
|
||||||
│ ├── clouds.ts # 云图数据状态
|
|
||||||
│ └── encyclopedia.ts # 图鉴数据状态
|
|
||||||
├── types/ # TypeScript 类型定义
|
|
||||||
│ ├── database.ts # Supabase 数据库类型
|
|
||||||
│ ├── cloud.ts # 云朵相关类型
|
|
||||||
│ └── user.ts # 用户相关类型
|
|
||||||
├── views/ # 页面组件
|
|
||||||
│ ├── auth/
|
|
||||||
│ │ ├── LoginView.vue
|
|
||||||
│ │ └── RegisterView.vue
|
|
||||||
│ ├── map/
|
|
||||||
│ │ └── MapView.vue
|
|
||||||
│ ├── upload/
|
|
||||||
│ │ └── UploadView.vue
|
|
||||||
│ ├── encyclopedia/
|
|
||||||
│ │ ├── EncyclopediaView.vue
|
|
||||||
│ │ └── CloudTypeView.vue
|
|
||||||
│ ├── gallery/
|
|
||||||
│ │ └── GalleryView.vue
|
|
||||||
│ ├── profile/
|
|
||||||
│ │ └── ProfileView.vue
|
|
||||||
│ └── admin/
|
|
||||||
│ ├── AdminView.vue
|
|
||||||
│ └── ReviewQueue.vue
|
|
||||||
├── App.vue
|
|
||||||
├── main.ts
|
|
||||||
└── style.css
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 阶段 0:基础设施搭建
|
|
||||||
|
|
||||||
**预计工期**:1-2 天
|
|
||||||
|
|
||||||
### 4.1 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install vue-router pinia @amap/amap-jsapi-loader
|
|
||||||
npm install -D tailwindcss @tailwindcss/vite
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 清理脚手架
|
|
||||||
|
|
||||||
| 操作 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| 删除 `src/components/HelloWorld.vue` | 默认模板组件 |
|
|
||||||
| 清空并重写 `src/style.css` | 替换为 Tailwind 指令 |
|
|
||||||
| 删除 `src/assets/hero.png` | 模板资源 |
|
|
||||||
| 删除 `src/assets/vite.svg` | 模板资源 |
|
|
||||||
| 删除 `src/assets/vue.svg` | 模板资源 |
|
|
||||||
| 删除 `public/icons.svg` | 模板资源 |
|
|
||||||
| 替换 `public/favicon.svg` | 使用 OpenCloud 图标(临时可用占位图标) |
|
|
||||||
|
|
||||||
### 4.3 Vite 配置
|
|
||||||
|
|
||||||
`vite.config.ts` 更新内容:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue(), tailwindcss()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 TypeScript 配置
|
|
||||||
|
|
||||||
`tsconfig.app.json` 新增 `paths`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
新增 `src/types/amap.d.ts`:高德地图类型声明,避免 TS 报错。
|
|
||||||
|
|
||||||
### 4.5 Tailwind CSS 配置
|
|
||||||
|
|
||||||
`src/style.css`:
|
|
||||||
|
|
||||||
```css
|
|
||||||
@import 'tailwindcss';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6 Supabase 客户端
|
|
||||||
|
|
||||||
`src/lib/supabase.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createClient } from '@supabase/supabase-js'
|
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
|
||||||
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
|
||||||
|
|
||||||
export const supabase = createClient(supabaseUrl, supabaseKey)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.7 高德地图加载器
|
|
||||||
|
|
||||||
`src/lib/amap.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.8 Vue Router 基础配置
|
|
||||||
|
|
||||||
`src/router/index.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
name: 'map',
|
|
||||||
component: () => import('@/views/map/MapView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
name: 'login',
|
|
||||||
component: () => import('@/views/auth/LoginView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/register',
|
|
||||||
name: 'register',
|
|
||||||
component: () => import('@/views/auth/RegisterView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/upload',
|
|
||||||
name: 'upload',
|
|
||||||
component: () => import('@/views/upload/UploadView.vue'),
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/encyclopedia',
|
|
||||||
name: 'encyclopedia',
|
|
||||||
component: () => import('@/views/encyclopedia/EncyclopediaView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/encyclopedia/:id',
|
|
||||||
name: 'cloud-type',
|
|
||||||
component: () => import('@/views/encyclopedia/CloudTypeView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/gallery',
|
|
||||||
name: 'gallery',
|
|
||||||
component: () => import('@/views/gallery/GalleryView.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/profile',
|
|
||||||
name: 'profile',
|
|
||||||
component: () => import('@/views/profile/ProfileView.vue'),
|
|
||||||
meta: { requiresAuth: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin',
|
|
||||||
name: 'admin',
|
|
||||||
component: () => import('@/views/admin/AdminView.vue'),
|
|
||||||
meta: { requiresAuth: true, requiresAdmin: true },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
router.beforeEach((to, _from, next) => {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
|
||||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
|
||||||
} else {
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.9 Pinia 注册
|
|
||||||
|
|
||||||
`src/main.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createApp } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import App from './App.vue'
|
|
||||||
import router from './router'
|
|
||||||
import './style.css'
|
|
||||||
|
|
||||||
const app = createApp(App)
|
|
||||||
app.use(createPinia())
|
|
||||||
app.use(router)
|
|
||||||
app.mount('#app')
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.10 应用外壳
|
|
||||||
|
|
||||||
`src/App.vue`:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
|
||||||
<AppHeader />
|
|
||||||
<main>
|
|
||||||
<RouterView />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.11 环境变量更新
|
|
||||||
|
|
||||||
`.env` 新增:
|
|
||||||
|
|
||||||
```
|
|
||||||
VITE_AMAP_KEY=你的高德Key
|
|
||||||
VITE_AMAP_SECRET=你的高德安全密钥
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.12 阶段 0 验收标准
|
|
||||||
|
|
||||||
- [ ] `npm run dev` 启动无报错
|
|
||||||
- [ ] Tailwind 类名生效(可测试 `bg-blue-500 text-white`)
|
|
||||||
- [ ] `@/` 路径别名正常工作
|
|
||||||
- [ ] Vue Router 路由切换正常
|
|
||||||
- [ ] 浏览器控制台无 TS/amap 类型报错
|
|
||||||
- [ ] 脚手架模板内容已全部清除
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 阶段 1:用户认证
|
|
||||||
|
|
||||||
**预计工期**:2-3 天
|
|
||||||
|
|
||||||
### 5.1 数据库表设计
|
|
||||||
|
|
||||||
#### `profiles` 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE profiles (
|
|
||||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
username TEXT UNIQUE NOT NULL,
|
|
||||||
avatar_url TEXT,
|
|
||||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- RLS
|
|
||||||
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY "Public profiles are viewable by everyone"
|
|
||||||
ON profiles FOR SELECT USING (true);
|
|
||||||
|
|
||||||
CREATE POLICY "Users can update own profile"
|
|
||||||
ON profiles FOR UPDATE USING (auth.uid() = id);
|
|
||||||
|
|
||||||
-- 注册时自动创建 profile
|
|
||||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO public.profiles (id, username, avatar_url)
|
|
||||||
VALUES (
|
|
||||||
NEW.id,
|
|
||||||
COALESCE(NEW.raw_user_meta_data->>'username', 'user_' || LEFT(NEW.id::text, 6)),
|
|
||||||
NEW.raw_user_meta_data->>'avatar_url'
|
|
||||||
);
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
||||||
|
|
||||||
CREATE TRIGGER on_auth_user_created
|
|
||||||
AFTER INSERT ON auth.users
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 Auth Store
|
|
||||||
|
|
||||||
`src/stores/auth.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { supabase } from '@/lib/supabase'
|
|
||||||
import type { User } from '@supabase/supabase-js'
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
|
||||||
const user = ref<User | null>(null)
|
|
||||||
const profile = ref<{ username: string; avatar_url: string | null; role: string } | null>(null)
|
|
||||||
const loading = ref(true)
|
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!user.value)
|
|
||||||
const isAdmin = computed(() => profile.value?.role === 'admin')
|
|
||||||
|
|
||||||
async function fetchProfile() { /* ... */ }
|
|
||||||
async function login(email: string, password: string) { /* ... */ }
|
|
||||||
async function register(email: string, password: string, username: string) { /* ... */ }
|
|
||||||
async function logout() { /* ... */ }
|
|
||||||
|
|
||||||
// 监听认证状态变化
|
|
||||||
supabase.auth.onAuthStateChange((_event, session) => {
|
|
||||||
user.value = session?.user ?? null
|
|
||||||
if (user.value) fetchProfile()
|
|
||||||
else profile.value = null
|
|
||||||
})
|
|
||||||
|
|
||||||
return { user, profile, loading, isLoggedIn, isAdmin, login, register, logout }
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 Auth Composable
|
|
||||||
|
|
||||||
`src/composables/useAuth.ts`:封装表单逻辑、验证、错误处理。
|
|
||||||
|
|
||||||
### 5.4 页面实现
|
|
||||||
|
|
||||||
#### `/login` — LoginView.vue
|
|
||||||
|
|
||||||
- 邮箱 + 密码输入表单
|
|
||||||
- "没有账号?去注册" 链接
|
|
||||||
- 登录成功后跳转首页或 redirect 参数指定页面
|
|
||||||
- 错误提示(邮箱未验证、密码错误等)
|
|
||||||
|
|
||||||
#### `/register` — RegisterView.vue
|
|
||||||
|
|
||||||
- 用户名 + 邮箱 + 密码 + 确认密码
|
|
||||||
- 基础前端验证(邮箱格式、密码长度≥8、两次密码一致、用户名唯一性检查)
|
|
||||||
- 注册成功后自动登录并跳转首页
|
|
||||||
|
|
||||||
### 5.5 路由守卫完善
|
|
||||||
|
|
||||||
- `requiresAuth` 路由:未登录跳转 `/login?redirect=xxx`
|
|
||||||
- `requiresAdmin` 路由:非管理员跳转首页
|
|
||||||
- 已登录用户访问 `/login` `/register` 自动跳转首页
|
|
||||||
|
|
||||||
### 5.6 阶段 1 验收标准
|
|
||||||
|
|
||||||
- [ ] 邮箱注册成功,profiles 表自动生成记录
|
|
||||||
- [ ] 登录后获取到 profile 数据
|
|
||||||
- [ ] 登出后清除状态,跳转首页
|
|
||||||
- [ ] 未登录访问 `/upload` 跳转登录页,登录后回到 /upload
|
|
||||||
- [ ] 非管理员访问 `/admin` 被拦截
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 阶段 2:云图上传
|
|
||||||
|
|
||||||
**预计工期**:2-3 天
|
|
||||||
|
|
||||||
### 6.1 数据库表设计
|
|
||||||
|
|
||||||
#### `cloud_types` 表(预置数据)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE cloud_types (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name TEXT UNIQUE NOT NULL, -- 积云、层云、卷云...
|
|
||||||
name_en TEXT NOT NULL, -- Cumulus, Stratus, Cirrus...
|
|
||||||
genus TEXT NOT NULL, -- 基础属名
|
|
||||||
rarity TEXT NOT NULL DEFAULT 'common' CHECK (rarity IN ('common', 'uncommon', 'rare')),
|
|
||||||
description TEXT, -- 气象小知识
|
|
||||||
icon_url TEXT, -- 图鉴图标
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 预置 10 种基础云属
|
|
||||||
INSERT INTO cloud_types (name, name_en, genus, rarity, description) VALUES
|
|
||||||
('积云', 'Cumulus', 'Cumulus', 'common', '最常见的基础云型,晴天常客,像棉花糖一样蓬松。'),
|
|
||||||
('层云', 'Stratus', 'Stratus', 'common', '灰蒙蒙的低云,常覆盖整个天空,像一层灰色的毯子。'),
|
|
||||||
('卷云', 'Cirrus', 'Cirrus', 'common', '高空的丝缕状云,由冰晶组成,天气变化的先行者。'),
|
|
||||||
('积雨云', 'Cumulonimbus', 'Cumulonimbus', 'uncommon', '雷暴的制造者, towering 巨人,顶部常呈铁砧状。'),
|
|
||||||
('层积云', 'Stratocumulus', 'Stratocumulus', 'common', '成片的低云群,常有缝隙露出蓝天。'),
|
|
||||||
('高积云', 'Altocumulus', 'Altocumulus', 'common', '中层的云群,常呈鱼鳞状排列,谚语"鱼鳞天"说的就是它。'),
|
|
||||||
('高层云', 'Altostratus', 'Altostratus', 'common', '中层的灰白色云层,透过它太阳像隔了层磨砂玻璃。'),
|
|
||||||
('雨层云', 'Nimbostratus', 'Nimbostratus', 'common', '灰暗厚重的低云,持续性降水的标志。'),
|
|
||||||
('卷层云', 'Cirrostratus', 'Cirrostratus', 'uncommon', '薄纱般的高云,常产生日晕和月晕现象。'),
|
|
||||||
('卷积云', 'Cirrocumulus', 'Cirrocumulus', 'uncommon', '高空的小云朵群,排列如涟漪,是天气变化的信号。');
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `clouds` 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE clouds (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
cloud_type_id INTEGER NOT NULL REFERENCES cloud_types(id),
|
|
||||||
image_url TEXT NOT NULL,
|
|
||||||
thumbnail_url TEXT,
|
|
||||||
latitude DOUBLE PRECISION, -- 模糊化后的纬度(~1km精度)
|
|
||||||
longitude DOUBLE PRECISION, -- 模糊化后的经度(~1km精度)
|
|
||||||
location_name TEXT, -- 城市/区域名
|
|
||||||
weather TEXT, -- 拍摄时天气简述(用户手动输入)
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
|
|
||||||
is_hidden BOOLEAN NOT NULL DEFAULT false, -- 隐身模式
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- RLS
|
|
||||||
ALTER TABLE clouds ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY "Approved clouds are viewable by everyone"
|
|
||||||
ON clouds FOR SELECT USING (status = 'approved' AND NOT is_hidden);
|
|
||||||
|
|
||||||
CREATE POLICY "Users can view own clouds"
|
|
||||||
ON clouds FOR SELECT USING (auth.uid() = user_id);
|
|
||||||
|
|
||||||
CREATE POLICY "Authenticated users can insert clouds"
|
|
||||||
ON clouds FOR INSERT WITH CHECK (auth.uid() = user_id);
|
|
||||||
|
|
||||||
CREATE POLICY "Users can update own clouds"
|
|
||||||
ON clouds FOR UPDATE USING (auth.uid() = user_id);
|
|
||||||
|
|
||||||
-- 索引
|
|
||||||
CREATE INDEX idx_clouds_status ON clouds(status);
|
|
||||||
CREATE INDEX idx_clouds_location ON clouds(latitude, longitude);
|
|
||||||
CREATE INDEX idx_clouds_user_id ON clouds(user_id);
|
|
||||||
CREATE INDEX idx_clouds_created_at ON clouds(created_at DESC);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Supabase Storage 配置
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 创建 clouds bucket
|
|
||||||
INSERT INTO storage.buckets (id, name, public) VALUES ('clouds', 'clouds', true);
|
|
||||||
|
|
||||||
-- 上传策略:已登录用户可上传
|
|
||||||
CREATE POLICY "Authenticated users can upload"
|
|
||||||
ON storage.objects FOR INSERT
|
|
||||||
WITH CHECK (bucket_id = 'clouds' AND auth.role() = 'authenticated');
|
|
||||||
|
|
||||||
-- 读取策略:所有人可读
|
|
||||||
CREATE POLICY "Clouds images are publicly accessible"
|
|
||||||
ON storage.objects FOR SELECT
|
|
||||||
USING (bucket_id = 'clouds');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 上传页面实现
|
|
||||||
|
|
||||||
#### `/upload` — UploadView.vue
|
|
||||||
|
|
||||||
流程:
|
|
||||||
|
|
||||||
1. **选择图片** — 文件选择器或拖拽上传,前端预览
|
|
||||||
2. **选择云型** — 从 10 种基础云属下拉列表中手动选择(MVP 无 AI 识别,后续接入)
|
|
||||||
3. **位置信息** — 调用高德 Geolocation 获取当前位置,或手动输入城市名
|
|
||||||
4. **天气描述** — 用户手动输入(如"晴天"、"多云")
|
|
||||||
5. **隐私选项** — 是否隐身模式(地图不显示)
|
|
||||||
6. **提交上传** — 图片 → Supabase Storage,元数据 → clouds 表
|
|
||||||
|
|
||||||
#### 关键实现细节
|
|
||||||
|
|
||||||
- 图片上传前压缩至合理大小(maxWidth: 1920, quality: 0.8)
|
|
||||||
- 坐标模糊化:前端将经纬度保留小数点后 2 位(~1km 精度),不上传精确坐标原始值
|
|
||||||
- 生成缩略图:前端用 Canvas 生成 300x300 缩略图,同时上传至 Storage
|
|
||||||
- 云型选择提供图文辅助:每种云型附带简短描述和示意图,帮助用户正确选择
|
|
||||||
|
|
||||||
### 6.4 审核流程
|
|
||||||
|
|
||||||
MVP 阶段采用管理员手动审核:
|
|
||||||
|
|
||||||
- 用户上传后状态为 `pending`
|
|
||||||
- 管理员在后台审核通过后状态变为 `approved`,地图和画廊可见
|
|
||||||
- 审核不通过状态变为 `rejected`,仅用户本人可见
|
|
||||||
|
|
||||||
### 6.5 阶段 2 验收标准
|
|
||||||
|
|
||||||
- [ ] 选择图片后可手动选择云型并提交
|
|
||||||
- [ ] 图片成功上传至 Supabase Storage
|
|
||||||
- [ ] clouds 表记录正确写入(含模糊化坐标)
|
|
||||||
- [ ] 上传后状态为 pending,等待管理员审核
|
|
||||||
- [ ] 不登录无法访问上传页面
|
|
||||||
|
|
||||||
> **后续迭代**:接入 OpenAI Vision Edge Function 实现自动识别 + 违规检测,用户可在 AI 结果基础上修正。clouds 表届时可新增 `confidence` 和 `user_corrected_type_id` 字段。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 阶段 3:高德 3D 云图地图
|
|
||||||
|
|
||||||
**预计工期**:3-4 天
|
|
||||||
|
|
||||||
### 7.1 地图初始化
|
|
||||||
|
|
||||||
`src/views/map/MapView.vue`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { loadAMap } from '@/lib/amap'
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const AMap = await loadAMap()
|
|
||||||
const map = new AMap.Map('map-container', {
|
|
||||||
viewMode: '3D',
|
|
||||||
pitch: 45,
|
|
||||||
rotation: -15,
|
|
||||||
zoom: 5,
|
|
||||||
center: [104.07, 30.67], // 成都
|
|
||||||
mapStyle: 'amap://styles/fresh',
|
|
||||||
features: ['bg', 'road', 'building', 'point'],
|
|
||||||
})
|
|
||||||
|
|
||||||
map.addControl(new AMap.Scale())
|
|
||||||
map.addControl(new AMap.ToolBar({ position: 'RT' }))
|
|
||||||
map.addControl(new AMap.ControlBar({ position: { right: '10px', top: '80px' } }))
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 图层切换
|
|
||||||
|
|
||||||
实现卫星/路网/3D 地形视图切换:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const satellite = new AMap.TileLayer.Satellite()
|
|
||||||
const roadNet = new AMap.TileLayer.RoadNet()
|
|
||||||
|
|
||||||
function switchToSatellite() {
|
|
||||||
map.add(satellite)
|
|
||||||
map.add(roadNet)
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchToNormal() {
|
|
||||||
map.remove(satellite)
|
|
||||||
map.remove(roadNet)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 云标记渲染
|
|
||||||
|
|
||||||
从 Supabase 获取已审核云图数据,在地图上渲染标记:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
async function loadCloudMarkers() {
|
|
||||||
const { data } = await supabase
|
|
||||||
.from('clouds')
|
|
||||||
.select('id, latitude, longitude, cloud_type_id, image_url, thumbnail_url, created_at, profiles(username)')
|
|
||||||
.eq('status', 'approved')
|
|
||||||
.eq('is_hidden', false)
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.limit(500)
|
|
||||||
|
|
||||||
data?.forEach(cloud => {
|
|
||||||
const marker = new AMap.Marker({
|
|
||||||
position: [cloud.longitude, cloud.latitude],
|
|
||||||
content: getMarkerContent(cloud.cloud_type_id), // 自定义标记样式
|
|
||||||
offset: new AMap.Pixel(-15, -15),
|
|
||||||
})
|
|
||||||
|
|
||||||
marker.on('click', () => showCloudCard(cloud))
|
|
||||||
map.add(marker)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 云卡片弹窗
|
|
||||||
|
|
||||||
点击标记弹出 InfoWindow,展示:
|
|
||||||
|
|
||||||
- 云图片缩略图
|
|
||||||
- 云类型名称
|
|
||||||
- 拍摄者用户名
|
|
||||||
- 拍摄时间
|
|
||||||
- 天气简述
|
|
||||||
- 快捷操作:看同类云、看这里其他云
|
|
||||||
|
|
||||||
### 7.5 标记样式
|
|
||||||
|
|
||||||
按云型稀有度区分标记样式:
|
|
||||||
|
|
||||||
| 稀有度 | 标记样式 |
|
|
||||||
|--------|----------|
|
|
||||||
| common | 白色圆形标记 |
|
|
||||||
| uncommon | 蓝色圆形标记 + 闪光效果 |
|
|
||||||
| rare | 紫色圆形标记 + 呼吸动画 |
|
|
||||||
|
|
||||||
### 7.6 时间衰减显示
|
|
||||||
|
|
||||||
```ts
|
|
||||||
function getMarkerOpacity(createdAt: string): number {
|
|
||||||
const hoursDiff = (Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60)
|
|
||||||
if (hoursDiff <= 0.5) return 1 // 30min 内全透明度
|
|
||||||
if (hoursDiff <= 2) return 0.6 // 2h 内半透明
|
|
||||||
return 0.3 // 24h 内低透明度
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
24h 以上的云标记默认不加载,用户可通过开关显示历史层。
|
|
||||||
|
|
||||||
### 7.7 阶段 3 验收标准
|
|
||||||
|
|
||||||
- [ ] 地图正常加载,3D 模式可旋转/俯仰
|
|
||||||
- [ ] 卫星/路网图层切换正常
|
|
||||||
- [ ] 云标记正确显示在对应位置
|
|
||||||
- [ ] 点击标记弹出云卡片
|
|
||||||
- [ ] 标记透明度随时间衰减
|
|
||||||
- [ ] 地图性能流畅(500 标记无卡顿)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 阶段 4:图鉴系统
|
|
||||||
|
|
||||||
**预计工期**:2-3 天
|
|
||||||
|
|
||||||
### 8.1 数据库补充
|
|
||||||
|
|
||||||
#### `user_collections` 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE user_collections (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
cloud_type_id INTEGER NOT NULL REFERENCES cloud_types(id),
|
|
||||||
first_cloud_id UUID REFERENCES clouds(id), -- 解锁该类型的第一张云图
|
|
||||||
unlocked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE(user_id, cloud_type_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE user_collections ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY "Users can view own collections"
|
|
||||||
ON user_collections FOR SELECT USING (auth.uid() = user_id);
|
|
||||||
|
|
||||||
CREATE POLICY "Users can insert own collections"
|
|
||||||
ON user_collections FOR INSERT WITH CHECK (auth.uid() = user_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 解锁触发器
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 云图审核通过时,自动解锁对应用户的图鉴
|
|
||||||
CREATE OR REPLACE FUNCTION unlock_cloud_type()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NEW.status = 'approved' AND (OLD.status IS NULL OR OLD.status != 'approved') THEN
|
|
||||||
INSERT INTO user_collections (user_id, cloud_type_id, first_cloud_id)
|
|
||||||
VALUES (NEW.user_id, NEW.cloud_type_id, NEW.id)
|
|
||||||
ON CONFLICT (user_id, cloud_type_id) DO NOTHING;
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
||||||
|
|
||||||
CREATE TRIGGER on_cloud_approved
|
|
||||||
AFTER UPDATE ON clouds
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION unlock_cloud_type();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 图鉴页面
|
|
||||||
|
|
||||||
#### `/encyclopedia` — EncyclopediaView.vue
|
|
||||||
|
|
||||||
- 网格布局展示 10 种基础云属
|
|
||||||
- 每张卡片包含:云型图标、名称、稀有度标签
|
|
||||||
- 已解锁:正常显示 + 金色边框 + 首次拍摄缩略图
|
|
||||||
- 未解锁:灰色剪影 + 锁图标
|
|
||||||
- 点击已解锁卡片进入详情页
|
|
||||||
- 点击未解锁卡片提示"拍摄该类型云朵即可解锁"
|
|
||||||
|
|
||||||
#### `/encyclopedia/:id` — CloudTypeView.vue
|
|
||||||
|
|
||||||
- 顶部大图/手绘风格插图区域
|
|
||||||
- 云型名称 + 英文名 + 稀有度标签
|
|
||||||
- 气象小知识(来自 cloud_types.description)
|
|
||||||
- 该类型云图画廊(来自 clouds 表 + Storage)
|
|
||||||
- 收集统计:已拍 X 次,首次解锁时间
|
|
||||||
|
|
||||||
### 8.3 图鉴 Store
|
|
||||||
|
|
||||||
`src/stores/encyclopedia.ts`:
|
|
||||||
|
|
||||||
- `cloudTypes`:所有云型列表(来自 cloud_types 表)
|
|
||||||
- `myCollection`:当前用户已解锁列表(来自 user_collections 表)
|
|
||||||
- `isUnlocked(cloudTypeId)`:判断某类型是否已解锁
|
|
||||||
- `unlockProgress`:解锁进度(X/10)
|
|
||||||
|
|
||||||
### 8.4 徽章分享
|
|
||||||
|
|
||||||
每解锁一枚云型徽章:
|
|
||||||
- 弹出祝贺弹窗,展示徽章动画
|
|
||||||
- 生成分享卡片(Canvas 绘制),包含:徽章图标、云型名称、解锁日期、用户名
|
|
||||||
- 提供"保存到相册"和"分享到社交平台"按钮(MVP 阶段可简化为仅保存图片)
|
|
||||||
|
|
||||||
### 8.5 阶段 4 验收标准
|
|
||||||
|
|
||||||
- [ ] 图鉴页面正确展示 10 种云型(已解锁/未解锁状态)
|
|
||||||
- [ ] 云图审核通过后自动解锁对应图鉴
|
|
||||||
- [ ] 详情页展示气象知识和该类型云图画廊
|
|
||||||
- [ ] 个人图鉴进度正确显示
|
|
||||||
- [ ] 徽章解锁时有祝贺提示
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 阶段 5:社区 + 管理后台 + 收尾
|
|
||||||
|
|
||||||
**预计工期**:2-3 天
|
|
||||||
|
|
||||||
### 9.1 画廊浏览
|
|
||||||
|
|
||||||
#### `/gallery` — GalleryView.vue
|
|
||||||
|
|
||||||
- 顶部云型筛选栏(全部 + 10 种云型 tab)
|
|
||||||
- 瀑布流或网格展示云图
|
|
||||||
- 每张卡片:缩略图、云型标签、拍摄者、时间
|
|
||||||
- 点击进入大图查看模式
|
|
||||||
- 分页加载(每页 20 张,滚动加载更多)
|
|
||||||
|
|
||||||
### 9.2 情绪反应
|
|
||||||
|
|
||||||
#### `reactions` 表
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE reactions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
cloud_id UUID NOT NULL REFERENCES clouds(id) ON DELETE CASCADE,
|
|
||||||
emoji TEXT NOT NULL CHECK (emoji IN ('✨', '🌿', '🌧', '🔥', '💭', '🫂')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
UNIQUE(user_id, cloud_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE reactions ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY "Reactions are viewable by everyone"
|
|
||||||
ON reactions FOR SELECT USING (true);
|
|
||||||
|
|
||||||
CREATE POLICY "Authenticated users can add reactions"
|
|
||||||
ON reactions FOR INSERT WITH CHECK (auth.uid() = user_id);
|
|
||||||
|
|
||||||
CREATE POLICY "Users can remove own reactions"
|
|
||||||
ON reactions FOR DELETE USING (auth.uid() = user_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
实现方式:每张大图下方展示 6 个情绪按钮,已点击的高亮显示,旁边显示计数。
|
|
||||||
|
|
||||||
### 9.3 管理后台
|
|
||||||
|
|
||||||
#### `/admin` — AdminView.vue
|
|
||||||
|
|
||||||
- 侧边栏导航:待审核队列、用户管理、数据统计
|
|
||||||
- **待审核队列**:展示 status=pending 的云图列表,支持批量通过/拒绝
|
|
||||||
- **用户管理**:展示用户列表,支持禁用/恢复(profiles 表加 is_disabled 字段)
|
|
||||||
- **数据统计**:总用户数、今日上传数、待审核数、各云型分布
|
|
||||||
- 路由守卫:仅 `role = 'admin'` 可访问
|
|
||||||
|
|
||||||
### 9.4 个人主页
|
|
||||||
|
|
||||||
#### `/profile` — ProfileView.vue
|
|
||||||
|
|
||||||
- 用户头像、用户名、注册日期
|
|
||||||
- 收集进度条(X/10 云型已解锁)
|
|
||||||
- 个人云图时间线(按时间倒序展示)
|
|
||||||
- 天空日志概念:按月分组展示
|
|
||||||
- 基础统计:总拍摄数、最常拍云型、拍摄天数
|
|
||||||
|
|
||||||
### 9.5 响应式适配
|
|
||||||
|
|
||||||
- 移动端:地图全屏、底部导航栏(地图/图鉴/上传/我的)
|
|
||||||
- 桌面端:顶部导航栏、侧边面板
|
|
||||||
- 上传页面:移动端简化流程(更少的手动输入)
|
|
||||||
|
|
||||||
### 9.6 阶段 5 验收标准
|
|
||||||
|
|
||||||
- [ ] 画廊筛选和分页加载正常
|
|
||||||
- [ ] 情绪反应可添加/取消,计数正确
|
|
||||||
- [ ] 管理后台可审核云图、查看统计
|
|
||||||
- [ ] 个人主页展示图鉴进度和云图时间线
|
|
||||||
- [ ] 移动端布局适配正常
|
|
||||||
- [ ] 核心流程端到端跑通
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 数据库设计总览
|
|
||||||
|
|
||||||
### ER 关系
|
|
||||||
|
|
||||||
```
|
|
||||||
auth.users (Supabase 内置)
|
|
||||||
│ 1:1
|
|
||||||
▼
|
|
||||||
profiles
|
|
||||||
│ 1:N
|
|
||||||
├──────────► clouds ──► cloud_types
|
|
||||||
│ │
|
|
||||||
│ ├── 1:N
|
|
||||||
│ ▼
|
|
||||||
│ reactions
|
|
||||||
│
|
|
||||||
└──────────► user_collections ──► cloud_types
|
|
||||||
```
|
|
||||||
|
|
||||||
### 完整表清单
|
|
||||||
|
|
||||||
| 表名 | 用途 | 主要字段 |
|
|
||||||
|------|------|----------|
|
|
||||||
| `profiles` | 用户资料 | id, username, avatar_url, role, is_disabled |
|
|
||||||
| `cloud_types` | 云型标准库 | id, name, name_en, genus, rarity, description, icon_url |
|
|
||||||
| `clouds` | 云图记录 | id, user_id, cloud_type_id, image_url, latitude, longitude, status, is_hidden |
|
|
||||||
| `user_collections` | 图鉴收集 | id, user_id, cloud_type_id, first_cloud_id, unlocked_at |
|
|
||||||
| `reactions` | 情绪反应 | id, user_id, cloud_id, emoji |
|
|
||||||
| `storage.objects` | 图片文件 | bucket_id: 'clouds' |
|
|
||||||
|
|
||||||
### RLS 策略总览
|
|
||||||
|
|
||||||
| 表 | SELECT | INSERT | UPDATE | DELETE |
|
|
||||||
|----|--------|--------|--------|--------|
|
|
||||||
| `profiles` | 所有人 | 触发器创建 | 仅本人 | - |
|
|
||||||
| `clouds` | approved 或本人 | 仅本人 | 仅本人 | - |
|
|
||||||
| `user_collections` | 仅本人 | 仅本人 | - | - |
|
|
||||||
| `reactions` | 所有人 | 已登录用户 | - | 仅本人 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 环境变量清单
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Supabase
|
|
||||||
VITE_SUPABASE_URL=https://xxx.supabase.co
|
|
||||||
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxx
|
|
||||||
|
|
||||||
# 高德地图
|
|
||||||
VITE_AMAP_KEY=你的高德Key
|
|
||||||
VITE_AMAP_SECRET=你的高德安全密钥
|
|
||||||
```
|
|
||||||
|
|
||||||
> **后续迭代环境变量**(AI 识别接入时在 Supabase Dashboard 中配置):
|
|
||||||
> - `OPENAI_API_KEY` — OpenAI API 密钥
|
|
||||||
> - `OPENWEATHERMAP_API_KEY` — 天气数据 API 密钥
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 风险与注意事项
|
|
||||||
|
|
||||||
| 风险 | 应对 |
|
|
||||||
|------|------|
|
|
||||||
| 高德地图 Key 申请被拒 | 提前申请,准备企业资质;备选方案 Mapbox |
|
|
||||||
| Supabase Storage 流量超限 | 图片压缩 + 缩略图策略,使用 CDN |
|
|
||||||
| 高德地图国内定位不准 | 提供手动输入位置备选方案 |
|
|
||||||
| 500+ 地图标记卡顿 | 聚合标记(AMap.MarkerCluster);按视口区域加载 |
|
|
||||||
| 图片审核遗漏违规内容 | 管理员人工审核 + 社区举报;后续接入 AI 自动检测 |
|
|
||||||
| MVP 上线后冷启动 | 预置 Unsplash 免版权云图作为种子内容 |
|
|
||||||
| 用户选错云型 | 云型选择提供图文辅助;后续接入 AI 识别自动建议 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*天空无界,探索不止。*
|
|
||||||
**OpenCloud 团队**
|
|
||||||
+31
-1
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -251,8 +251,8 @@ watch(() => props.initialValue, value => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4">
|
<div class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4">
|
||||||
<NButton secondary strong @click="close">取消</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="close">取消</NButton>
|
||||||
<NButton type="primary" secondary strong :loading="saving" @click="save">
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" :loading="saving" @click="save">
|
||||||
保存修改
|
保存修改
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const currentColor = computed(() => progressColor(props.isLoggedIn ? props.perce
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NCard class="border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
<NCard class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-slate-500">当前进度</p>
|
<p class="text-sm text-slate-500">当前进度</p>
|
||||||
|
|||||||
@@ -194,11 +194,12 @@ onBeforeUnmount(() => {
|
|||||||
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4">
|
<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>
|
<p class="text-sm text-slate-500">{{ footerText }}</p>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<NButton secondary strong @click="close">取消</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="close">取消</NButton>
|
||||||
<NButton
|
<NButton
|
||||||
type="primary"
|
type="default"
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
:disabled="pickedLat === null || pickedLng === null"
|
:disabled="pickedLat === null || pickedLng === null"
|
||||||
@click="confirm"
|
@click="confirm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -375,8 +375,8 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 border-t border-slate-200 bg-slate-50 px-5 py-4">
|
<div class="flex items-center justify-end gap-3 border-t border-slate-200 bg-slate-50 px-5 py-4">
|
||||||
<NButton secondary strong :disabled="uploading" @click="close">关闭</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" :disabled="uploading" @click="close">关闭</NButton>
|
||||||
<NButton type="primary" secondary strong :disabled="uploading || items.length === 0" @click="handleSubmit">
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" :disabled="uploading || items.length === 0" @click="handleSubmit">
|
||||||
{{ uploading ? '上传中...' : '提交审核' }}
|
{{ uploading ? '上传中...' : '提交审核' }}
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,8 +138,31 @@ async function handleLogout() {
|
|||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-16 items-center justify-between">
|
<div class="flex h-16 items-center justify-between">
|
||||||
<RouterLink to="/" class="flex items-center gap-3">
|
<RouterLink to="/" class="flex items-center gap-3">
|
||||||
<div class="flex h-10 w-10 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-xl shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]">
|
<div class="flex h-10 w-10 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]">
|
||||||
☁️
|
<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="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>
|
<div>
|
||||||
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-500">Live Sky Atlas</div>
|
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-500">Live Sky Atlas</div>
|
||||||
@@ -186,7 +209,7 @@ async function handleLogout() {
|
|||||||
class="no-underline"
|
class="no-underline"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium uppercase tracking-[0.12em] text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-colors hover:bg-sky-50 hover:text-sky-900 md:h-10 md:px-4"
|
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium uppercase tracking-[0.12em] text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-[background-color,color,transform,box-shadow] hover:-translate-x-px hover:-translate-y-px hover:bg-sky-50 hover:text-sky-900 hover:shadow-[5px_5px_0_0_rgba(14,165,233,0.18)] md:h-10 md:px-4"
|
||||||
:class="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
|
:class="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
|
||||||
>
|
>
|
||||||
上传
|
上传
|
||||||
@@ -203,8 +226,8 @@ async function handleLogout() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-8 max-w-[8.5rem] items-center gap-2 border border-teal-100 bg-white/80 px-3 text-sm font-medium text-teal-800 shadow-[3px_3px_0_0_rgba(20,184,166,0.08)] transition-colors hover:border-teal-200 hover:bg-teal-50 md:h-10 md:max-w-none"
|
class="inline-flex h-8 max-w-[8.5rem] items-center gap-2 border border-teal-100 bg-white/80 px-3 text-sm font-medium text-teal-700 shadow-[3px_3px_0_0_rgba(20,184,166,0.08)] transition-[background-color,color,transform,box-shadow,border-color] hover:-translate-x-px hover:-translate-y-px hover:border-teal-200 hover:bg-teal-50/70 hover:text-teal-800 hover:shadow-[4px_4px_0_0_rgba(20,184,166,0.12)] md:h-10 md:max-w-none"
|
||||||
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'bg-teal-50 ring-1 ring-teal-200' : ''"
|
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200 shadow-[4px_4px_0_0_rgba(20,184,166,0.12)]' : ''"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
:aria-expanded="accountCardOpen"
|
:aria-expanded="accountCardOpen"
|
||||||
>
|
>
|
||||||
@@ -288,7 +311,7 @@ async function handleLogout() {
|
|||||||
class="no-underline"
|
class="no-underline"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="inline-flex h-8 items-center border border-slate-200 bg-white/80 px-3 text-sm font-medium text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800 md:h-10 md:px-4"
|
class="inline-flex h-8 items-center border border-slate-200 bg-white/80 px-3 text-sm font-medium text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-[background-color,color,transform,box-shadow,border-color] hover:-translate-x-px hover:-translate-y-px hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800 hover:shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] md:h-10 md:px-4"
|
||||||
:class="route.name === 'login' ? 'bg-teal-50 ring-1 ring-teal-200 text-teal-800' : ''"
|
:class="route.name === 'login' ? 'bg-teal-50 ring-1 ring-teal-200 text-teal-800' : ''"
|
||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
@@ -299,7 +322,7 @@ async function handleLogout() {
|
|||||||
class="no-underline"
|
class="no-underline"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-colors hover:bg-sky-50 hover:text-sky-900 md:h-10 md:px-4"
|
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-[background-color,color,transform,box-shadow] hover:-translate-x-px hover:-translate-y-px hover:bg-sky-50 hover:text-sky-900 hover:shadow-[5px_5px_0_0_rgba(14,165,233,0.18)] md:h-10 md:px-4"
|
||||||
:class="route.name === 'register' ? 'ring-1 ring-sky-300' : ''"
|
:class="route.name === 'register' ? 'ring-1 ring-sky-300' : ''"
|
||||||
>
|
>
|
||||||
注册
|
注册
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { NButton, NButtonGroup, NSelect } from 'naive-ui'
|
import { NButton, NSelect } from 'naive-ui'
|
||||||
|
|
||||||
export interface ContributionHeatmapCell {
|
export interface ContributionHeatmapCell {
|
||||||
date: string
|
date: string
|
||||||
@@ -284,7 +284,7 @@ function formatMonthTooltip(monthKey: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="border border-slate-200 bg-white p-6 shadow-sm">
|
<div class="oc-panel-card border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-6">
|
<div class="flex flex-wrap items-start justify-between gap-6">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
|
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
|
||||||
@@ -295,14 +295,26 @@ function formatMonthTooltip(monthKey: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-end gap-3">
|
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||||
<NButtonGroup>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<NButton :type="viewMode === 'year' ? 'primary' : 'default'" secondary strong @click="viewMode = 'year'">
|
<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>
|
||||||
<NButton :type="viewMode === 'month' ? 'primary' : 'default'" secondary strong @click="viewMode = 'month'">
|
<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>
|
</NButton>
|
||||||
</NButtonGroup>
|
</div>
|
||||||
<NSelect
|
<NSelect
|
||||||
v-model:value="selectedYear"
|
v-model:value="selectedYear"
|
||||||
:options="yearOptions"
|
:options="yearOptions"
|
||||||
|
|||||||
+201
@@ -18,6 +18,207 @@ body {
|
|||||||
background: rgba(15, 118, 110, 0.18);
|
background: rgba(15, 118, 110, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.oc-primary-button.n-button {
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-primary-button.n-button .n-button__content {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-primary-button.n-button:not(.n-button--disabled):hover {
|
||||||
|
transform: translate(-1px, -1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-primary-button--teal.n-button {
|
||||||
|
border-color: rgb(153 246 228) !important;
|
||||||
|
background: rgb(242 250 247) !important;
|
||||||
|
color: rgb(17 94 89) !important;
|
||||||
|
box-shadow: 4px 4px 0 0 rgba(20, 184, 166, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-primary-button--teal.n-button:hover,
|
||||||
|
.oc-primary-button--teal.n-button:focus-visible {
|
||||||
|
border-color: rgb(94 234 212) !important;
|
||||||
|
background: rgb(234 250 245) !important;
|
||||||
|
color: rgb(19 78 74) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-primary-button--teal.n-button:not(.n-button--disabled):hover {
|
||||||
|
box-shadow: 5px 5px 0 0 rgba(20, 184, 166, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-primary-button--sky.n-button {
|
||||||
|
border-color: rgb(186 230 253) !important;
|
||||||
|
background: rgb(224 242 254) !important;
|
||||||
|
color: rgb(7 89 133) !important;
|
||||||
|
box-shadow: 4px 4px 0 0 rgba(14, 165, 233, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-primary-button--sky.n-button:hover,
|
||||||
|
.oc-primary-button--sky.n-button:focus-visible {
|
||||||
|
border-color: rgb(125 211 252) !important;
|
||||||
|
background: rgb(240 249 255) !important;
|
||||||
|
color: rgb(12 74 110) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-primary-button--sky.n-button:not(.n-button--disabled):hover {
|
||||||
|
box-shadow: 5px 5px 0 0 rgba(14, 165, 233, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-card {
|
||||||
|
box-shadow: 6px 6px 0 0 rgba(15, 23, 42, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-card-soft {
|
||||||
|
box-shadow: 6px 6px 0 0 rgba(15, 23, 42, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-empty-card {
|
||||||
|
box-shadow: 6px 6px 0 0 rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button.n-button {
|
||||||
|
--n-color: rgb(255 255 255) !important;
|
||||||
|
--n-color-hover: rgb(240 253 250) !important;
|
||||||
|
--n-color-pressed: rgb(236 253 245) !important;
|
||||||
|
--n-border: 1px solid rgb(226 232 240) !important;
|
||||||
|
--n-border-hover: 1px solid rgb(153 246 228) !important;
|
||||||
|
--n-border-pressed: 1px solid rgb(94 234 212) !important;
|
||||||
|
--n-text-color: rgb(71 85 105) !important;
|
||||||
|
--n-text-color-hover: rgb(19 78 74) !important;
|
||||||
|
--n-text-color-pressed: rgb(17 94 89) !important;
|
||||||
|
border-color: rgb(226 232 240) !important;
|
||||||
|
background: rgb(255 255 255) !important;
|
||||||
|
color: rgb(71 85 105) !important;
|
||||||
|
box-shadow: 3px 3px 0 0 rgba(15, 23, 42, 0.06) !important;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button.n-button .n-button__content {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button.n-button:not(.n-button--disabled):hover,
|
||||||
|
.oc-panel-button.n-button:focus-visible {
|
||||||
|
transform: translate(-1px, -1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button--neutral.n-button:hover,
|
||||||
|
.oc-panel-button--neutral.n-button:focus-visible {
|
||||||
|
border-color: rgb(153 246 228) !important;
|
||||||
|
background: rgb(240 253 250) !important;
|
||||||
|
color: rgb(19 78 74) !important;
|
||||||
|
box-shadow: 4px 4px 0 0 rgba(20, 184, 166, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button--sky.n-button {
|
||||||
|
--n-color: rgb(224 242 254) !important;
|
||||||
|
--n-color-hover: rgb(240 249 255) !important;
|
||||||
|
--n-color-pressed: rgb(224 242 254) !important;
|
||||||
|
--n-border: 1px solid rgb(186 230 253) !important;
|
||||||
|
--n-border-hover: 1px solid rgb(125 211 252) !important;
|
||||||
|
--n-border-pressed: 1px solid rgb(56 189 248) !important;
|
||||||
|
--n-text-color: rgb(7 89 133) !important;
|
||||||
|
--n-text-color-hover: rgb(12 74 110) !important;
|
||||||
|
--n-text-color-pressed: rgb(12 74 110) !important;
|
||||||
|
border-color: rgb(186 230 253) !important;
|
||||||
|
background: rgb(224 242 254) !important;
|
||||||
|
color: rgb(7 89 133) !important;
|
||||||
|
box-shadow: 4px 4px 0 0 rgba(14, 165, 233, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button--sky.n-button:hover,
|
||||||
|
.oc-panel-button--sky.n-button:focus-visible {
|
||||||
|
border-color: rgb(125 211 252) !important;
|
||||||
|
background: rgb(240 249 255) !important;
|
||||||
|
color: rgb(12 74 110) !important;
|
||||||
|
box-shadow: 5px 5px 0 0 rgba(14, 165, 233, 0.18) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button--teal.n-button {
|
||||||
|
--n-color: rgb(242 250 247) !important;
|
||||||
|
--n-color-hover: rgb(234 250 245) !important;
|
||||||
|
--n-color-pressed: rgb(220 252 231) !important;
|
||||||
|
--n-border: 1px solid rgb(153 246 228) !important;
|
||||||
|
--n-border-hover: 1px solid rgb(94 234 212) !important;
|
||||||
|
--n-border-pressed: 1px solid rgb(45 212 191) !important;
|
||||||
|
--n-text-color: rgb(17 94 89) !important;
|
||||||
|
--n-text-color-hover: rgb(19 78 74) !important;
|
||||||
|
--n-text-color-pressed: rgb(19 78 74) !important;
|
||||||
|
border-color: rgb(153 246 228) !important;
|
||||||
|
background: rgb(242 250 247) !important;
|
||||||
|
color: rgb(17 94 89) !important;
|
||||||
|
box-shadow: 4px 4px 0 0 rgba(20, 184, 166, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button--teal.n-button:hover,
|
||||||
|
.oc-panel-button--teal.n-button:focus-visible {
|
||||||
|
border-color: rgb(94 234 212) !important;
|
||||||
|
background: rgb(234 250 245) !important;
|
||||||
|
color: rgb(19 78 74) !important;
|
||||||
|
box-shadow: 5px 5px 0 0 rgba(20, 184, 166, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button--danger.n-button {
|
||||||
|
--n-color: rgb(255 241 242) !important;
|
||||||
|
--n-color-hover: rgb(255 228 230) !important;
|
||||||
|
--n-color-pressed: rgb(255 228 230) !important;
|
||||||
|
--n-border: 1px solid rgb(254 205 211) !important;
|
||||||
|
--n-border-hover: 1px solid rgb(253 164 175) !important;
|
||||||
|
--n-border-pressed: 1px solid rgb(251 113 133) !important;
|
||||||
|
--n-text-color: rgb(190 24 93) !important;
|
||||||
|
--n-text-color-hover: rgb(159 18 57) !important;
|
||||||
|
--n-text-color-pressed: rgb(159 18 57) !important;
|
||||||
|
border-color: rgb(254 205 211) !important;
|
||||||
|
background: rgb(255 241 242) !important;
|
||||||
|
color: rgb(190 24 93) !important;
|
||||||
|
box-shadow: 4px 4px 0 0 rgba(244, 63, 94, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button--danger.n-button:hover,
|
||||||
|
.oc-panel-button--danger.n-button:focus-visible {
|
||||||
|
border-color: rgb(253 164 175) !important;
|
||||||
|
background: rgb(255 228 230) !important;
|
||||||
|
color: rgb(159 18 57) !important;
|
||||||
|
box-shadow: 5px 5px 0 0 rgba(244, 63, 94, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button--amber.n-button {
|
||||||
|
--n-color: rgb(255 251 235) !important;
|
||||||
|
--n-color-hover: rgb(254 243 199) !important;
|
||||||
|
--n-color-pressed: rgb(253 230 138) !important;
|
||||||
|
--n-border: 1px solid rgb(253 230 138) !important;
|
||||||
|
--n-border-hover: 1px solid rgb(252 211 77) !important;
|
||||||
|
--n-border-pressed: 1px solid rgb(245 158 11) !important;
|
||||||
|
--n-text-color: rgb(146 64 14) !important;
|
||||||
|
--n-text-color-hover: rgb(120 53 15) !important;
|
||||||
|
--n-text-color-pressed: rgb(120 53 15) !important;
|
||||||
|
border-color: rgb(253 230 138) !important;
|
||||||
|
background: rgb(255 251 235) !important;
|
||||||
|
color: rgb(146 64 14) !important;
|
||||||
|
box-shadow: 4px 4px 0 0 rgba(245, 158, 11, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oc-panel-button--amber.n-button:hover,
|
||||||
|
.oc-panel-button--amber.n-button:focus-visible {
|
||||||
|
border-color: rgb(252 211 77) !important;
|
||||||
|
background: rgb(254 243 199) !important;
|
||||||
|
color: rgb(120 53 15) !important;
|
||||||
|
box-shadow: 5px 5px 0 0 rgba(245, 158, 11, 0.14) !important;
|
||||||
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
[class~='rounded'],
|
[class~='rounded'],
|
||||||
[class*='rounded-'] {
|
[class*='rounded-'] {
|
||||||
|
|||||||
Vendored
+1
@@ -26,6 +26,7 @@ declare namespace AMap {
|
|||||||
pitch?: number
|
pitch?: number
|
||||||
rotation?: number
|
rotation?: number
|
||||||
zoom?: number
|
zoom?: number
|
||||||
|
zooms?: [number, number]
|
||||||
center?: [number, number]
|
center?: [number, number]
|
||||||
mapStyle?: string
|
mapStyle?: string
|
||||||
features?: string[]
|
features?: string[]
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ onMounted(loadAdminData)
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NButton secondary strong :loading="loading" @click="loadAdminData">
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" :loading="loading" @click="loadAdminData">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<NIcon><Refresh /></NIcon>
|
<NIcon><Refresh /></NIcon>
|
||||||
</template>
|
</template>
|
||||||
@@ -577,13 +577,14 @@ onMounted(loadAdminData)
|
|||||||
<p class="mt-1 text-sm text-slate-500">共 {{ pendingImages.length }} 张待处理图片。</p>
|
<p class="mt-1 text-sm text-slate-500">共 {{ pendingImages.length }} 张待处理图片。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<NButton secondary strong @click="toggleAllPendingSelection">
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="toggleAllPendingSelection">
|
||||||
{{ selectedReviewCount === pendingImages.length && pendingImages.length ? '取消全选' : '全选待审核' }}
|
{{ selectedReviewCount === pendingImages.length && pendingImages.length ? '取消全选' : '全选待审核' }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton
|
<NButton
|
||||||
type="success"
|
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
:disabled="!selectedReviewCount"
|
:disabled="!selectedReviewCount"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
@click="updateCloudStatus(Array.from(selectedReviewIds), 'approved')"
|
@click="updateCloudStatus(Array.from(selectedReviewIds), 'approved')"
|
||||||
@@ -591,9 +592,10 @@ onMounted(loadAdminData)
|
|||||||
批量通过 {{ selectedReviewCount || '' }}
|
批量通过 {{ selectedReviewCount || '' }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton
|
<NButton
|
||||||
type="error"
|
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--danger"
|
||||||
:disabled="!selectedReviewCount"
|
:disabled="!selectedReviewCount"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
@click="updateCloudStatus(Array.from(selectedReviewIds), 'rejected')"
|
@click="updateCloudStatus(Array.from(selectedReviewIds), 'rejected')"
|
||||||
@@ -624,15 +626,15 @@ onMounted(loadAdminData)
|
|||||||
<p class="mt-3 text-sm text-slate-600">{{ item.location_name || '未填写位置' }} · {{ formatDateTime(item.created_at) }}</p>
|
<p class="mt-3 text-sm text-slate-600">{{ item.location_name || '未填写位置' }} · {{ formatDateTime(item.created_at) }}</p>
|
||||||
<p class="mt-2 line-clamp-2 text-sm leading-6 text-slate-500">{{ item.description || '没有图片说明。' }}</p>
|
<p class="mt-2 line-clamp-2 text-sm leading-6 text-slate-500">{{ item.description || '没有图片说明。' }}</p>
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
<NButton size="small" type="success" secondary strong :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--teal" :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">
|
||||||
<template #icon><NIcon><Check /></NIcon></template>
|
<template #icon><NIcon><Check /></NIcon></template>
|
||||||
通过
|
通过
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton size="small" type="error" secondary strong :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">
|
||||||
<template #icon><NIcon><X /></NIcon></template>
|
<template #icon><NIcon><X /></NIcon></template>
|
||||||
拒绝
|
拒绝
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton size="small" secondary strong @click="selectedImage = item">查看大图</NButton>
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看大图</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -669,6 +671,8 @@ onMounted(loadAdminData)
|
|||||||
size="small"
|
size="small"
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--amber"
|
||||||
:disabled="user.id === authStore.user?.id && user.role === 'admin'"
|
:disabled="user.id === authStore.user?.id && user.role === 'admin'"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
@click="updateUserRole(user, user.role === 'admin' ? 'user' : 'admin')"
|
@click="updateUserRole(user, user.role === 'admin' ? 'user' : 'admin')"
|
||||||
@@ -677,9 +681,10 @@ onMounted(loadAdminData)
|
|||||||
</NButton>
|
</NButton>
|
||||||
<NButton
|
<NButton
|
||||||
size="small"
|
size="small"
|
||||||
:type="user.is_disabled ? 'success' : 'error'"
|
type="default"
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
|
:class="user.is_disabled ? 'oc-panel-button oc-panel-button--teal' : 'oc-panel-button oc-panel-button--danger'"
|
||||||
:disabled="user.id === authStore.user?.id"
|
:disabled="user.id === authStore.user?.id"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
@click="toggleUserDisabled(user)"
|
@click="toggleUserDisabled(user)"
|
||||||
@@ -724,10 +729,10 @@ onMounted(loadAdminData)
|
|||||||
</NTag>
|
</NTag>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
<NButton size="small" secondary strong @click="selectedImage = item">查看</NButton>
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看</NButton>
|
||||||
<NButton size="small" type="success" secondary strong :disabled="item.status === 'approved'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">通过</NButton>
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--teal" :disabled="item.status === 'approved'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">通过</NButton>
|
||||||
<NButton size="small" type="error" secondary strong :disabled="item.status === 'rejected'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">拒绝</NButton>
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :disabled="item.status === 'rejected'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">拒绝</NButton>
|
||||||
<NButton size="small" secondary strong :loading="actionLoading" @click="toggleImageVisibility(item)">
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--amber" :loading="actionLoading" @click="toggleImageVisibility(item)">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<NIcon>
|
<NIcon>
|
||||||
<Eye v-if="item.is_hidden" />
|
<Eye v-if="item.is_hidden" />
|
||||||
@@ -736,7 +741,7 @@ onMounted(loadAdminData)
|
|||||||
</template>
|
</template>
|
||||||
{{ item.is_hidden ? '公开' : '隐藏' }}
|
{{ item.is_hidden ? '公开' : '隐藏' }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton size="small" type="error" secondary strong :loading="actionLoading" @click="deleteImage(item)">
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :loading="actionLoading" @click="deleteImage(item)">
|
||||||
<template #icon><NIcon><Trash /></NIcon></template>
|
<template #icon><NIcon><Trash /></NIcon></template>
|
||||||
删除
|
删除
|
||||||
</NButton>
|
</NButton>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ onUnmounted(() => {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
|
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
|
||||||
<RouterLink to="/login" class="no-underline">
|
<RouterLink to="/login" class="no-underline">
|
||||||
<NButton type="primary">立即登录</NButton>
|
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">立即登录</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -90,7 +90,7 @@ onUnmounted(() => {
|
|||||||
<NResult status="error" title="认证失败" description="邮箱确认链接无效或已过期,请重新注册。">
|
<NResult status="error" title="认证失败" description="邮箱确认链接无效或已过期,请重新注册。">
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<RouterLink to="/register" class="no-underline">
|
<RouterLink to="/register" class="no-underline">
|
||||||
<NButton type="primary">重新注册</NButton>
|
<NButton type="primary" class="oc-primary-button oc-primary-button--sky">重新注册</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</NResult>
|
</NResult>
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ async function handleSendResetEmail() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
size="large"
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--teal"
|
||||||
:loading="resetMode ? resetLoading : loading"
|
:loading="resetMode ? resetLoading : loading"
|
||||||
>
|
>
|
||||||
<template v-if="resetMode">
|
<template v-if="resetMode">
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ async function handleRegister() {
|
|||||||
<span class="font-semibold text-slate-700">{{ email }}</span>
|
<span class="font-semibold text-slate-700">{{ email }}</span>
|
||||||
</p>
|
</p>
|
||||||
<RouterLink to="/login" class="no-underline">
|
<RouterLink to="/login" class="no-underline">
|
||||||
<NButton type="primary">去登录</NButton>
|
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">去登录</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -152,6 +152,7 @@ async function handleRegister() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
size="large"
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--sky"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
>
|
>
|
||||||
{{ loading ? '注册中...' : '注册' }}
|
{{ loading ? '注册中...' : '注册' }}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ async function handleResetPassword() {
|
|||||||
description="现在可以使用新密码登录。"
|
description="现在可以使用新密码登录。"
|
||||||
>
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<NButton type="primary" @click="router.push('/login')">返回登录</NButton>
|
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">返回登录</NButton>
|
||||||
</template>
|
</template>
|
||||||
</NResult>
|
</NResult>
|
||||||
|
|
||||||
@@ -123,6 +123,7 @@ async function handleResetPassword() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
size="large"
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--teal"
|
||||||
:disabled="!canSubmit"
|
:disabled="!canSubmit"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ watch(() => route.params.id, () => {
|
|||||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<RouterLink to="/encyclopedia">
|
<RouterLink to="/encyclopedia">
|
||||||
<NButton text type="primary">← 返回图鉴总览</NButton>
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回图鉴总览</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ onMounted(async () => {
|
|||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<NCard v-for="n in 6" :key="n">
|
<NCard v-for="n in 6" :key="n" class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
<NSkeleton class="h-44 w-full" />
|
<NSkeleton class="h-44 w-full" />
|
||||||
<NSkeleton class="mt-5 h-5 w-2/5" />
|
<NSkeleton class="mt-5 h-5 w-2/5" />
|
||||||
<NSkeleton class="mt-3 h-4 w-1/3" />
|
<NSkeleton class="mt-3 h-4 w-1/3" />
|
||||||
@@ -176,7 +176,7 @@ onMounted(async () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="border border-dashed border-slate-300 bg-white px-6 py-12">
|
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white px-6 py-12">
|
||||||
<NEmpty description="暂时还没有图鉴数据" />
|
<NEmpty description="暂时还没有图鉴数据" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -489,7 +489,8 @@ onUnmounted(() => {
|
|||||||
strong
|
strong
|
||||||
@click="selectedTypeId = tab.id"
|
@click="selectedTypeId = tab.id"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
:type="selectedTypeId === tab.id ? 'primary' : 'default'"
|
:class="selectedTypeId === tab.id ? 'oc-panel-button oc-panel-button--sky' : 'oc-panel-button oc-panel-button--neutral'"
|
||||||
|
type="default"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</NButton>
|
</NButton>
|
||||||
@@ -508,7 +509,7 @@ onUnmounted(() => {
|
|||||||
<div
|
<div
|
||||||
v-for="n in 8"
|
v-for="n in 8"
|
||||||
:key="n"
|
:key="n"
|
||||||
class="mb-3 break-inside-avoid border border-slate-200 bg-white p-3"
|
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="h-44 w-full" />
|
||||||
<NSkeleton class="mt-4 h-4 w-3/5" />
|
<NSkeleton class="mt-4 h-4 w-3/5" />
|
||||||
@@ -563,7 +564,7 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else class="mt-6 border border-dashed border-slate-300 bg-white px-6 py-12">
|
<section v-else class="oc-empty-card mt-6 border border-dashed border-slate-300 bg-white px-6 py-12">
|
||||||
<NEmpty description="还没有符合条件的云图">
|
<NEmpty description="还没有符合条件的云图">
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<p class="text-sm text-slate-500">换个云型筛选试试,或者等社区上传更多作品。</p>
|
<p class="text-sm text-slate-500">换个云型筛选试试,或者等社区上传更多作品。</p>
|
||||||
@@ -576,6 +577,7 @@ onUnmounted(() => {
|
|||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
:disabled="currentPage <= 1"
|
:disabled="currentPage <= 1"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
@click="goToPage(currentPage - 1)"
|
@click="goToPage(currentPage - 1)"
|
||||||
>
|
>
|
||||||
上一页
|
上一页
|
||||||
@@ -586,7 +588,8 @@ onUnmounted(() => {
|
|||||||
v-if="page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2"
|
v-if="page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2"
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
:type="page === currentPage ? 'primary' : 'default'"
|
:class="page === currentPage ? 'oc-panel-button oc-panel-button--sky' : 'oc-panel-button oc-panel-button--neutral'"
|
||||||
|
type="default"
|
||||||
@click="goToPage(page)"
|
@click="goToPage(page)"
|
||||||
>
|
>
|
||||||
{{ page }}
|
{{ page }}
|
||||||
@@ -601,6 +604,7 @@ onUnmounted(() => {
|
|||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
:disabled="currentPage >= totalPages"
|
:disabled="currentPage >= totalPages"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
@click="goToPage(currentPage + 1)"
|
@click="goToPage(currentPage + 1)"
|
||||||
>
|
>
|
||||||
下一页
|
下一页
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ onMounted(async () => {
|
|||||||
pitch: 0,
|
pitch: 0,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
zoom: 5,
|
zoom: 5,
|
||||||
|
zooms: [4, 18],
|
||||||
center: [104.07, 30.67],
|
center: [104.07, 30.67],
|
||||||
mapStyle: 'amap://styles/normal',
|
mapStyle: 'amap://styles/normal',
|
||||||
features: ['bg', 'road', 'building', 'point'],
|
features: ['bg', 'road', 'building', 'point'],
|
||||||
@@ -415,7 +416,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
mapInst.addControl(new AMapLib.Scale())
|
mapInst.addControl(new AMapLib.Scale())
|
||||||
mapInst.addControl(new AMapLib.ToolBar({ position: 'LT' } as Record<string, unknown>))
|
mapInst.addControl(new AMapLib.ToolBar({ position: 'LT' } as Record<string, unknown>))
|
||||||
mapInst.addControl(new AMapLib.ControlBar({ position: { right: '10px', top: '80px' } } as Record<string, unknown>))
|
mapInst.addControl(new AMapLib.ControlBar({ position: { right: '50px', bottom: '224px' } } as Record<string, unknown>))
|
||||||
mapInst.on('click', () => { previewCloud.value = null; hideHoverCard() })
|
mapInst.on('click', () => { previewCloud.value = null; hideHoverCard() })
|
||||||
mapInst.on('zoomstart', hideHeader)
|
mapInst.on('zoomstart', hideHeader)
|
||||||
mapInst.on('movestart', hideHeader)
|
mapInst.on('movestart', hideHeader)
|
||||||
@@ -442,7 +443,7 @@ onUnmounted(() => {
|
|||||||
<div class="relative h-[100dvh] min-h-screen">
|
<div class="relative h-[100dvh] min-h-screen">
|
||||||
<div ref="mapEl" class="w-full h-full"></div>
|
<div ref="mapEl" class="w-full h-full"></div>
|
||||||
|
|
||||||
<div class="absolute bottom-6 right-4 flex flex-col items-end gap-2 z-20">
|
<div class="absolute bottom-6 right-7 z-20 flex w-10 flex-col items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50"
|
class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50"
|
||||||
@@ -642,6 +643,11 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
:deep(.amap-controlbar) {
|
||||||
|
transform: translateX(50%) scale(1);
|
||||||
|
transform-origin: bottom right;
|
||||||
|
}
|
||||||
|
|
||||||
.map-archive-button.map-archive-button {
|
.map-archive-button.map-archive-button {
|
||||||
border-radius: 9999px !important;
|
border-radius: 9999px !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ async function savePassword() {
|
|||||||
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] px-4 py-10">
|
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] px-4 py-10">
|
||||||
<div class="mx-auto max-w-4xl">
|
<div class="mx-auto max-w-4xl">
|
||||||
<RouterLink to="/profile">
|
<RouterLink to="/profile">
|
||||||
<NButton text type="primary">← 返回个人主页</NButton>
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回个人主页</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
@@ -111,7 +111,7 @@ async function savePassword() {
|
|||||||
{{ success }}
|
{{ success }}
|
||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
<NCard class="border border-slate-200 bg-white shadow-sm" :bordered="false">
|
<NCard class="oc-panel-card border border-slate-200 bg-white shadow-sm" :bordered="false">
|
||||||
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-slate-900">公开昵称</h2>
|
<h2 class="text-2xl font-bold text-slate-900">公开昵称</h2>
|
||||||
@@ -127,9 +127,10 @@ async function savePassword() {
|
|||||||
show-count
|
show-count
|
||||||
/>
|
/>
|
||||||
<NButton
|
<NButton
|
||||||
type="primary"
|
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
:loading="usernameSaving"
|
:loading="usernameSaving"
|
||||||
@click="saveUsername"
|
@click="saveUsername"
|
||||||
>
|
>
|
||||||
@@ -139,7 +140,7 @@ async function savePassword() {
|
|||||||
</div>
|
</div>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
<NCard class="border border-slate-200 bg-white shadow-sm" :bordered="false">
|
<NCard class="oc-panel-card border border-slate-200 bg-white shadow-sm" :bordered="false">
|
||||||
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
|
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
|
||||||
@@ -166,6 +167,7 @@ async function savePassword() {
|
|||||||
<NButton
|
<NButton
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
:loading="passwordSaving"
|
:loading="passwordSaving"
|
||||||
@click="savePassword"
|
@click="savePassword"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -476,12 +476,12 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
<div class="border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]">
|
<div class="border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]">
|
||||||
<div class="max-w-6xl mx-auto px-4 py-10">
|
<div class="max-w-6xl mx-auto px-4 py-10">
|
||||||
<div v-if="loading" class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr]">
|
<div v-if="loading" class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr]">
|
||||||
<NCard>
|
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
<NSkeleton class="h-6 w-1/3" />
|
<NSkeleton class="h-6 w-1/3" />
|
||||||
<NSkeleton class="mt-4 h-10 w-2/3" />
|
<NSkeleton class="mt-4 h-10 w-2/3" />
|
||||||
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
|
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
|
||||||
</NCard>
|
</NCard>
|
||||||
<NCard>
|
<NCard class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||||||
<NSkeleton class="h-5 w-1/3" />
|
<NSkeleton class="h-5 w-1/3" />
|
||||||
<NSkeleton class="mt-4 h-8 w-1/2" />
|
<NSkeleton class="mt-4 h-8 w-1/2" />
|
||||||
<NSkeleton class="mt-5 h-3 w-full" />
|
<NSkeleton class="mt-5 h-3 w-full" />
|
||||||
@@ -545,7 +545,7 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
:is-logged-in="true"
|
:is-logged-in="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NCard v-else class="border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
<NCard v-else class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||||||
<p class="text-sm text-slate-500">公开贡献</p>
|
<p class="text-sm text-slate-500">公开贡献</p>
|
||||||
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
|
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
|
||||||
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
|
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
|
||||||
@@ -585,12 +585,12 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<NCard class="border border-slate-200 shadow-sm">
|
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
<p class="text-sm text-slate-500">总拍摄数</p>
|
<p class="text-sm text-slate-500">总拍摄数</p>
|
||||||
<p class="mt-2 text-3xl font-bold text-slate-900">{{ totalShots }}</p>
|
<p class="mt-2 text-3xl font-bold text-slate-900">{{ totalShots }}</p>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
<NCard class="border border-slate-200 shadow-sm">
|
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
<p class="text-sm text-slate-500">最常拍云型</p>
|
<p class="text-sm text-slate-500">最常拍云型</p>
|
||||||
<p class="mt-2 text-2xl font-bold text-slate-900">{{ mostCommonCloudType.name }}</p>
|
<p class="mt-2 text-2xl font-bold text-slate-900">{{ mostCommonCloudType.name }}</p>
|
||||||
<p class="mt-2 text-sm text-slate-500" v-if="mostCommonCloudType.count">
|
<p class="mt-2 text-sm text-slate-500" v-if="mostCommonCloudType.count">
|
||||||
@@ -598,12 +598,12 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
</p>
|
</p>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
<NCard class="border border-slate-200 shadow-sm">
|
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
<p class="text-sm text-slate-500">拍摄天数</p>
|
<p class="text-sm text-slate-500">拍摄天数</p>
|
||||||
<p class="mt-2 text-3xl font-bold text-slate-900">{{ shootingDays }}</p>
|
<p class="mt-2 text-3xl font-bold text-slate-900">{{ shootingDays }}</p>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
<NCard class="border border-slate-200 shadow-sm">
|
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
<p class="text-sm text-slate-500">{{ isOwnProfile ? '已通过 / 待审核' : '公开通过数' }}</p>
|
<p class="text-sm text-slate-500">{{ isOwnProfile ? '已通过 / 待审核' : '公开通过数' }}</p>
|
||||||
<p class="mt-2 text-2xl font-bold text-slate-900">
|
<p class="mt-2 text-2xl font-bold text-slate-900">
|
||||||
<template v-if="isOwnProfile">{{ approvedShots }} / {{ pendingShots }}</template>
|
<template v-if="isOwnProfile">{{ approvedShots }} / {{ pendingShots }}</template>
|
||||||
@@ -639,7 +639,8 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
v-if="isOwnProfile && selectedCloudCount"
|
v-if="isOwnProfile && selectedCloudCount"
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
type="error"
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--danger"
|
||||||
@click="deleteCloudByIds(Array.from(selectedCloudIds))"
|
@click="deleteCloudByIds(Array.from(selectedCloudIds))"
|
||||||
>
|
>
|
||||||
删除已选 {{ selectedCloudCount }}
|
删除已选 {{ selectedCloudCount }}
|
||||||
@@ -648,23 +649,25 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
v-if="isOwnProfile"
|
v-if="isOwnProfile"
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
@click="toggleSelectionMode"
|
@click="toggleSelectionMode"
|
||||||
>
|
>
|
||||||
{{ selectionMode ? '取消选择' : '选择图片' }}
|
{{ selectionMode ? '取消选择' : '选择图片' }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton secondary strong @click="loadProfilePage(true)">
|
<NButton secondary strong class="oc-panel-button oc-panel-button--neutral" @click="loadProfilePage(true)">
|
||||||
刷新数据
|
刷新数据
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton
|
<NButton
|
||||||
v-if="selectedUploadDate"
|
v-if="selectedUploadDate"
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
@click="clearSelectedUploadDate"
|
@click="clearSelectedUploadDate"
|
||||||
>
|
>
|
||||||
清除筛选
|
清除筛选
|
||||||
</NButton>
|
</NButton>
|
||||||
<RouterLink v-if="isOwnProfile" to="/upload">
|
<RouterLink v-if="isOwnProfile" to="/upload">
|
||||||
<NButton secondary strong type="primary">继续上传</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky">继续上传</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -681,7 +684,7 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
<div v-for="n in 2" :key="n">
|
<div v-for="n in 2" :key="n">
|
||||||
<NSkeleton class="h-6 w-40" />
|
<NSkeleton class="h-6 w-40" />
|
||||||
<div class="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
<div class="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<NCard v-for="m in 3" :key="m">
|
<NCard v-for="m in 3" :key="m" class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
<NSkeleton class="h-52 w-full" />
|
<NSkeleton class="h-52 w-full" />
|
||||||
<NSkeleton class="mt-4 h-5 w-2/3" />
|
<NSkeleton class="mt-4 h-5 w-2/3" />
|
||||||
<NSkeleton class="mt-3 h-4 w-1/2" />
|
<NSkeleton class="mt-3 h-4 w-1/2" />
|
||||||
@@ -764,13 +767,13 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
|
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10">
|
||||||
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '这位用户还没有公开云图')">
|
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '这位用户还没有公开云图')">
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<RouterLink v-if="isOwnProfile && !selectedUploadDate" to="/upload">
|
<RouterLink v-if="isOwnProfile && !selectedUploadDate" to="/upload">
|
||||||
<NButton secondary strong type="primary">去上传第一张</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky">去上传第一张</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<NButton v-else-if="selectedUploadDate" secondary strong @click="clearSelectedUploadDate">返回全部记录</NButton>
|
<NButton v-else-if="selectedUploadDate" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="clearSelectedUploadDate">返回全部记录</NButton>
|
||||||
</template>
|
</template>
|
||||||
</NEmpty>
|
</NEmpty>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import { RouterLink } from 'vue-router'
|
|||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
<RouterLink to="/">
|
<RouterLink to="/">
|
||||||
<NButton type="primary" secondary strong>返回地图</NButton>
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky">返回地图</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/profile">
|
<RouterLink to="/profile">
|
||||||
<NButton secondary strong>前往个人主页</NButton>
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">前往个人主页</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import { RouterLink } from 'vue-router'
|
|||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
<RouterLink to="/">
|
<RouterLink to="/">
|
||||||
<NButton type="primary" secondary strong>返回地图</NButton>
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky">返回地图</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/gallery">
|
<RouterLink to="/gallery">
|
||||||
<NButton secondary strong>去画廊看看</NButton>
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">去画廊看看</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -252,16 +252,16 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex gap-3">
|
<div class="mt-6 flex gap-3">
|
||||||
<NButton secondary strong type="primary" class="flex-1" @click="saveBadge(badge)">保存分享卡片</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky flex-1" @click="saveBadge(badge)">保存分享卡片</NButton>
|
||||||
<NButton secondary strong class="flex-1" @click="router.push(`/encyclopedia/${badge.cloudTypeId}`)">查看详情</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral flex-1" @click="router.push(`/encyclopedia/${badge.cloudTypeId}`)">查看详情</NButton>
|
||||||
</div>
|
</div>
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
<NButton secondary strong @click="resetAfterSuccess">继续上传</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="resetAfterSuccess">继续上传</NButton>
|
||||||
<NButton secondary strong type="primary" @click="router.push('/encyclopedia')">前往图鉴</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky" @click="router.push('/encyclopedia')">前往图鉴</NButton>
|
||||||
<NButton secondary strong @click="router.push('/profile')">返回个人主页</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="router.push('/profile')">返回个人主页</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,8 +474,8 @@ onUnmounted(() => {
|
|||||||
<div class="mt-6 flex items-center justify-between">
|
<div class="mt-6 flex items-center justify-between">
|
||||||
<p class="text-sm text-gray-500">共 {{ items.length }} 张图片</p>
|
<p class="text-sm text-gray-500">共 {{ items.length }} 张图片</p>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<NButton secondary strong @click="clearAll()" :disabled="uploading">清空</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearAll()" :disabled="uploading">清空</NButton>
|
||||||
<NButton type="primary" secondary strong @click="handleSubmit" :disabled="uploading">
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" @click="handleSubmit" :disabled="uploading">
|
||||||
{{ uploading ? '上传中...' : '提交上传' }}
|
{{ uploading ? '上传中...' : '提交上传' }}
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-3
@@ -13,9 +13,8 @@ const publicRoutes = [
|
|||||||
|
|
||||||
function getSiteUrl() {
|
function getSiteUrl() {
|
||||||
const explicitUrl = process.env.VITE_SITE_URL
|
const explicitUrl = process.env.VITE_SITE_URL
|
||||||
const vercelUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL
|
const siteUrl = explicitUrl
|
||||||
const siteUrl = explicitUrl || (vercelUrl ? `https://${vercelUrl}` : 'https://cloud.catpl.dev')
|
return siteUrl
|
||||||
return siteUrl.replace(/\/$/, '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function seoFilesPlugin() {
|
function seoFilesPlugin() {
|
||||||
|
|||||||
Reference in New Issue
Block a user