Files
opencloud/plan.md
T

28 KiB
Raw Blame History

OpenCloud Web MVP — 详细实施方案

版本1.0 日期2026年5月20日 技术栈Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + Supabase + 高德地图 JS API 2.0


目录

  1. 关键决策
  2. 技术栈与依赖清单
  3. 目录结构
  4. 阶段 0:基础设施搭建
  5. 阶段 1:用户认证
  6. 阶段 2:云图上传
  7. 阶段 3:高德 3D 云图地图
  8. 阶段 4:图鉴系统
  9. 阶段 5:社区 + 管理后台 + 收尾
  10. 数据库设计总览
  11. 环境变量清单
  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 安装依赖

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 更新内容:

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

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

新增 src/types/amap.d.ts:高德地图类型声明,避免 TS 报错。

4.5 Tailwind CSS 配置

src/style.css

@import 'tailwindcss';

4.6 Supabase 客户端

src/lib/supabase.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

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

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

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

<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

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

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 表(预置数据)

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

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 配置

-- 创建 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 表届时可新增 confidenceuser_corrected_type_id 字段。


7. 阶段 3:高德 3D 云图地图

预计工期3-4 天

7.1 地图初始化

src/views/map/MapView.vue

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 地形视图切换:

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 获取已审核云图数据,在地图上渲染标记:

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 时间衰减显示

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

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);

解锁触发器

-- 云图审核通过时,自动解锁对应用户的图鉴
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 画廊浏览

  • 顶部云型筛选栏(全部 + 10 种云型 tab)
  • 瀑布流或网格展示云图
  • 每张卡片:缩略图、云型标签、拍摄者、时间
  • 点击进入大图查看模式
  • 分页加载(每页 20 张,滚动加载更多)

9.2 情绪反应

reactions

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. 环境变量清单

# 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 团队