docs: add product guide and MVP implementation plan
This commit is contained in:
@@ -0,0 +1,959 @@
|
||||
# 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 团队**
|
||||
Reference in New Issue
Block a user