#1 大幅改进页面显示效果 #1

Merged
Mplan merged 8 commits from test into main 2026-05-30 00:39:20 +08:00
28 changed files with 400 additions and 1366 deletions
+22
View File
@@ -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)
-319
View File
@@ -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
View File
@@ -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>
-959
View File
@@ -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
View File
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

+2 -2
View File
@@ -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>
+3 -2
View File
@@ -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"
> >
+2 -2
View File
@@ -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>
+30 -7
View File
@@ -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' : ''"
> >
注册 注册
+18 -6
View File
@@ -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
View File
@@ -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-'] {
+1
View File
@@ -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[]
+18 -13
View File
@@ -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>
+2 -2
View File
@@ -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>
+1
View File
@@ -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">
+2 -1
View File
@@ -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 ? '注册中...' : '注册' }}
+2 -1
View File
@@ -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"
> >
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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>
+16 -12
View File
@@ -483,15 +483,16 @@ onUnmounted(() => {
<div class="flex gap-3 overflow-x-auto pb-2"> <div class="flex gap-3 overflow-x-auto pb-2">
<NButton <NButton
v-for="tab in filterTabs" v-for="tab in filterTabs"
:key="tab.id" :key="tab.id"
secondary secondary
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>
</div> </div>
</section> </section>
@@ -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)"
> >
下一页 下一页
+8 -2
View File
@@ -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;
} }
+6 -4
View File
@@ -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"
> >
+17 -14
View File
@@ -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>
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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>
+7 -7
View File
@@ -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
View File
@@ -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() {