接入数据库
This commit is contained in:
115
pages/SQL_FILES_CLEANUP_SUMMARY.md
Normal file
115
pages/SQL_FILES_CLEANUP_SUMMARY.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# SQL 文件整理完成
|
||||
|
||||
## ✅ 已完成的整理
|
||||
|
||||
### 1. 移除重复的简化表定义
|
||||
- ✅ 从 `ANALYTICS_DB_SCHEMA.sql` 中移除了简化的 `user_sessions` 和 `page_views` 定义
|
||||
- ✅ 添加了注释说明依赖关系
|
||||
|
||||
### 2. 添加依赖说明
|
||||
- ✅ 在 `01_create_tables.sql` 中添加了注释,说明可能与 `USER_AUTH_SCHEMA.sql` 重复
|
||||
- ✅ 在 `USER_AUTH_SCHEMA.sql` 中添加了注释,说明可能与 `01_create_tables.sql` 重复
|
||||
|
||||
---
|
||||
|
||||
## 📋 当前文件结构
|
||||
|
||||
### `pages/user/test/` - 用户认证相关
|
||||
1. **`USER_AUTH_SCHEMA.sql`** ⭐
|
||||
- `ak_users` 表(业务用户资料)
|
||||
- `users` 表(统计用,可能与 analytics 重复)
|
||||
- `user_sessions` 表(会话统计,可能与 analytics 重复)
|
||||
- `upsert_user_profile` RPC 函数
|
||||
- `handle_new_user` 触发器函数(注释中)
|
||||
|
||||
2. **`USER_AUTH_TRIGGER.sql`** ⭐
|
||||
- `on_auth_user_created` 触发器(在 auth.users 插入时自动创建 ak_users)
|
||||
|
||||
3. **`USER_AUTH_TEST_DATA.sql`**(可选)
|
||||
- 测试数据
|
||||
|
||||
### `pages/mall/analytics/test/` - 数据分析相关
|
||||
1. **`01_create_tables.sql`** ⭐
|
||||
- 业务核心表:`orders`, `order_items`, `products`, `merchants`
|
||||
- 统计表:`users`, `user_sessions`, `page_views`(可能与 USER_AUTH_SCHEMA.sql 重复)
|
||||
- RLS 策略
|
||||
- `update_updated_at_column` 函数和触发器
|
||||
|
||||
2. **`ANALYTICS_DB_SCHEMA.sql`** ⭐
|
||||
- 分析表:`analytics_*` 系列表
|
||||
- RPC 函数(用于数据分析)
|
||||
- **已移除**:简化的 `user_sessions` 和 `page_views` 定义
|
||||
|
||||
3. **`02_insert_test_data.sql`**(可选)
|
||||
- 业务表测试数据
|
||||
|
||||
4. **`ANALYTICS_TEST_SEED.sql`**(可选)
|
||||
- 分析表测试数据
|
||||
|
||||
5. **`03_test_queries.sql`**(可选)
|
||||
- 测试查询
|
||||
|
||||
6. **`04_cleanup.sql`**(可选)
|
||||
- 清理脚本
|
||||
|
||||
---
|
||||
|
||||
## 🚀 推荐执行顺序
|
||||
|
||||
### 首次部署
|
||||
```sql
|
||||
-- 1. 用户认证表(包含 users, user_sessions)
|
||||
pages/user/test/USER_AUTH_SCHEMA.sql
|
||||
pages/user/test/USER_AUTH_TRIGGER.sql
|
||||
|
||||
-- 2. 业务表(会跳过已存在的 users, user_sessions)
|
||||
pages/mall/analytics/test/01_create_tables.sql
|
||||
|
||||
-- 3. 分析表(依赖业务表)
|
||||
pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql
|
||||
|
||||
-- 4. 测试数据(可选)
|
||||
pages/mall/analytics/test/02_insert_test_data.sql
|
||||
pages/mall/analytics/test/ANALYTICS_TEST_SEED.sql
|
||||
```
|
||||
|
||||
### 后续更新
|
||||
- 如果只更新分析表,只需执行 `ANALYTICS_DB_SCHEMA.sql`
|
||||
- 如果只更新业务表,只需执行 `01_create_tables.sql`
|
||||
- 如果只更新用户认证,只需执行 `USER_AUTH_SCHEMA.sql`
|
||||
|
||||
---
|
||||
|
||||
## 🔍 重复内容说明
|
||||
|
||||
### 已处理的重复
|
||||
1. ✅ **`user_sessions` 表** - 保留在 `USER_AUTH_SCHEMA.sql` 和 `01_create_tables.sql` 中的完整定义,移除 `ANALYTICS_DB_SCHEMA.sql` 中的简化定义
|
||||
2. ✅ **`page_views` 表** - 保留在 `01_create_tables.sql` 中的完整定义,移除 `ANALYTICS_DB_SCHEMA.sql` 中的简化定义
|
||||
|
||||
### 保留的重复(安全)
|
||||
1. **`users` 表** - 在 `USER_AUTH_SCHEMA.sql` 和 `01_create_tables.sql` 中都有定义,使用 `IF NOT EXISTS` 不会冲突
|
||||
2. **`update_updated_at_column` 函数** - 在多个文件中定义,使用 `CREATE OR REPLACE FUNCTION` 不会冲突
|
||||
3. **触发器** - 使用 `IF NOT EXISTS` 或 `DROP TRIGGER IF EXISTS` 确保不会冲突
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证
|
||||
|
||||
执行以下查询验证表结构:
|
||||
```sql
|
||||
-- 检查 user_sessions 表字段(应该是完整定义)
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'user_sessions' AND table_schema = 'public'
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
-- 检查 page_views 表字段(应该是完整定义)
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'page_views' AND table_schema = 'public'
|
||||
ORDER BY ordinal_position;
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- `user_sessions` 应包含:id, user_id, session_token, last_active_at, is_active, ip_address, user_agent, created_at, updated_at
|
||||
- `page_views` 应包含:id, user_id, path, source, referrer, ip_address, user_agent, created_at
|
||||
119
pages/SQL_FILES_ORGANIZATION.md
Normal file
119
pages/SQL_FILES_ORGANIZATION.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# SQL 文件整理说明
|
||||
|
||||
## 📋 重复内容分析
|
||||
|
||||
经过检查,发现以下重复定义:
|
||||
|
||||
### 1. **`users` 表**(重复)
|
||||
- ✅ `pages/user/test/USER_AUTH_SCHEMA.sql` (第 63-71 行)
|
||||
- ✅ `pages/mall/analytics/test/01_create_tables.sql` (第 43-51 行)
|
||||
- **状态**:两个定义相同,使用 `CREATE TABLE IF NOT EXISTS` 不会冲突,但建议统一
|
||||
|
||||
### 2. **`user_sessions` 表**(重复,定义略有不同)
|
||||
- ✅ `pages/user/test/USER_AUTH_SCHEMA.sql` (第 76-86 行) - **完整定义**(推荐)
|
||||
- ✅ `pages/mall/analytics/test/01_create_tables.sql` (第 24-34 行) - **完整定义**(相同)
|
||||
- ⚠️ `pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql` (第 19-25 行) - **简化定义**(字段较少)
|
||||
|
||||
### 3. **`page_views` 表**(重复,定义不同)
|
||||
- ✅ `pages/mall/analytics/test/01_create_tables.sql` (第 90-99 行) - **完整定义**(推荐)
|
||||
- ⚠️ `pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql` (第 30-36 行) - **简化定义**(字段较少)
|
||||
|
||||
### 4. **`update_updated_at_column` 函数**(重复)
|
||||
- ✅ `pages/user/test/USER_AUTH_SCHEMA.sql` (第 93-99 行)
|
||||
- ✅ `pages/mall/analytics/test/01_create_tables.sql` (第 107-113 行)
|
||||
- **状态**:两个定义相同,使用 `CREATE OR REPLACE FUNCTION` 不会冲突
|
||||
|
||||
### 5. **触发器**(部分重复)
|
||||
- `USER_AUTH_SCHEMA.sql`: `update_users_updated_at`, `update_user_sessions_updated_at`
|
||||
- `01_create_tables.sql`: `update_orders_updated_at`, `update_user_sessions_updated_at`, `update_users_updated_at`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 整理方案
|
||||
|
||||
### 方案一:保持现状(推荐)
|
||||
**优点**:每个文件独立,使用 `IF NOT EXISTS` 和 `CREATE OR REPLACE` 不会冲突
|
||||
**缺点**:有重复代码
|
||||
|
||||
**执行顺序**:
|
||||
1. `pages/user/test/USER_AUTH_SCHEMA.sql` - 创建用户认证相关表
|
||||
2. `pages/mall/analytics/test/01_create_tables.sql` - 创建业务表(会跳过已存在的表)
|
||||
3. `pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql` - 创建分析表(会跳过已存在的表)
|
||||
|
||||
### 方案二:统一到基础表文件(更清晰)
|
||||
**优点**:减少重复,职责清晰
|
||||
**缺点**:需要重构文件结构
|
||||
|
||||
**建议结构**:
|
||||
- `00_base_tables.sql` - 基础表(users, user_sessions, page_views)
|
||||
- `01_user_auth.sql` - 用户认证表(ak_users)和函数
|
||||
- `02_business_tables.sql` - 业务表(orders, products, merchants等)
|
||||
- `03_analytics_tables.sql` - 分析表(analytics_*)
|
||||
|
||||
---
|
||||
|
||||
## 📝 当前文件职责
|
||||
|
||||
### `pages/user/test/` 目录
|
||||
- **`USER_AUTH_SCHEMA.sql`** - 用户认证核心表(ak_users, users, user_sessions)和 RPC 函数
|
||||
- **`USER_AUTH_TRIGGER.sql`** - 数据库触发器(自动创建 ak_users)
|
||||
- **`USER_AUTH_TEST_DATA.sql`** - 测试数据
|
||||
|
||||
### `pages/mall/analytics/test/` 目录
|
||||
- **`01_create_tables.sql`** - 业务表(orders, users, user_sessions, products, merchants, order_items, page_views)+ RLS
|
||||
- **`ANALYTICS_DB_SCHEMA.sql`** - 分析表(analytics_*)+ RPC 函数
|
||||
- **`02_insert_test_data.sql`** - 业务表测试数据
|
||||
- **`ANALYTICS_TEST_SEED.sql`** - 分析表测试数据
|
||||
- **`03_test_queries.sql`** - 测试查询
|
||||
- **`04_cleanup.sql`** - 清理脚本
|
||||
|
||||
---
|
||||
|
||||
## ✅ 推荐操作
|
||||
|
||||
### 立即执行(保持现状)
|
||||
当前文件结构可以使用,因为:
|
||||
1. 所有表使用 `CREATE TABLE IF NOT EXISTS`
|
||||
2. 所有函数使用 `CREATE OR REPLACE FUNCTION`
|
||||
3. 触发器使用 `CREATE TRIGGER IF NOT EXISTS` 或 `DROP TRIGGER IF EXISTS`
|
||||
|
||||
**执行顺序**:
|
||||
```sql
|
||||
-- 1. 用户认证表
|
||||
pages/user/test/USER_AUTH_SCHEMA.sql
|
||||
pages/user/test/USER_AUTH_TRIGGER.sql
|
||||
|
||||
-- 2. 业务表(会跳过已存在的 users, user_sessions)
|
||||
pages/mall/analytics/test/01_create_tables.sql
|
||||
|
||||
-- 3. 分析表(会跳过已存在的 user_sessions, page_views)
|
||||
pages/mall/analytics/test/ANALYTICS_DB_SCHEMA.sql
|
||||
```
|
||||
|
||||
### 未来优化(可选)
|
||||
如果需要减少重复,可以:
|
||||
1. 从 `ANALYTICS_DB_SCHEMA.sql` 中移除 `user_sessions` 和 `page_views` 的简化定义
|
||||
2. 确保 `01_create_tables.sql` 先执行,提供完整定义
|
||||
3. 在 `ANALYTICS_DB_SCHEMA.sql` 中添加注释说明依赖关系
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证重复
|
||||
|
||||
执行以下查询检查表是否存在:
|
||||
```sql
|
||||
-- 检查 users 表
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND table_schema = 'public';
|
||||
|
||||
-- 检查 user_sessions 表
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'user_sessions' AND table_schema = 'public';
|
||||
|
||||
-- 检查 page_views 表
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'page_views' AND table_schema = 'public';
|
||||
```
|
||||
@@ -107,6 +107,7 @@
|
||||
<script lang="uts">
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
|
||||
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
|
||||
type TableColumn = { key: string; label: string; type: string; sortable: boolean }
|
||||
|
||||
@@ -26,29 +26,71 @@
|
||||
<!-- 主内容区域 -->
|
||||
<view class="main-content">
|
||||
<view class="container">
|
||||
<!-- KPI:宽屏 4列,窄屏 2列 -->
|
||||
<!-- KPI:宽屏 4列,窄屏 2列(增强版:渐变背景 + sparkline) -->
|
||||
<view class="kpi-grid">
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">实时 GMV</text>
|
||||
<view class="kpi-card kpi-card-gmv" @click="goToSalesReport">
|
||||
<view class="kpi-header">
|
||||
<text class="kpi-label">实时 GMV</text>
|
||||
<view class="kpi-sparkline">
|
||||
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.gmv" />
|
||||
<view v-else class="sparkline-skeleton"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="kpi-value">¥{{ formatMoney(realTime.gmv) }}</text>
|
||||
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.gmv_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">实时订单</text>
|
||||
<view class="kpi-footer">
|
||||
<text class="kpi-meta">较昨日同刻</text>
|
||||
<text class="kpi-chip" :class="realTime.gmv_growth >= 0 ? 'pos' : 'neg'">
|
||||
{{ formatPct(realTime.gmv_growth) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="kpi-card kpi-card-orders" @click="goToSalesReport">
|
||||
<view class="kpi-header">
|
||||
<text class="kpi-label">实时订单</text>
|
||||
<view class="kpi-sparkline">
|
||||
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.orders" />
|
||||
<view v-else class="sparkline-skeleton"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="kpi-value">{{ formatInt(realTime.orders) }}</text>
|
||||
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.order_growth) }}</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">在线用户</text>
|
||||
<view class="kpi-footer">
|
||||
<text class="kpi-meta">较昨日同刻</text>
|
||||
<text class="kpi-chip" :class="realTime.order_growth >= 0 ? 'pos' : 'neg'">
|
||||
{{ formatPct(realTime.order_growth) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="kpi-card kpi-card-users" @click="goToUserAnalysis">
|
||||
<view class="kpi-header">
|
||||
<text class="kpi-label">在线用户</text>
|
||||
<view class="kpi-sparkline">
|
||||
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.users" />
|
||||
<view v-else class="sparkline-skeleton"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="kpi-value">{{ formatInt(realTime.online_users) }}</text>
|
||||
<text class="kpi-meta">最近 5 分钟</text>
|
||||
</view>
|
||||
<view class="kpi-card">
|
||||
<text class="kpi-label">转化率</text>
|
||||
<view class="kpi-footer">
|
||||
<text class="kpi-meta">最近 5 分钟</text>
|
||||
<text class="kpi-chip neutral">实时</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="kpi-card kpi-card-conversion" @click="goToSalesReport">
|
||||
<view class="kpi-header">
|
||||
<text class="kpi-label">转化率</text>
|
||||
<view class="kpi-sparkline">
|
||||
<EChartsView v-if="!loading" class="sparkline-chart" :option="kpiSparklineOptions.conversion" />
|
||||
<view v-else class="sparkline-skeleton"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="kpi-value">{{ formatPct(realTime.conversion_rate) }}</text>
|
||||
<text class="kpi-meta">较昨日同刻:{{ formatPct(realTime.conversion_growth) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="kpi-footer">
|
||||
<text class="kpi-meta">较昨日同刻</text>
|
||||
<text class="kpi-chip" :class="realTime.conversion_growth >= 0 ? 'pos' : 'neg'">
|
||||
{{ formatPct(realTime.conversion_growth) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 时间维度:横排 -->
|
||||
<view class="tabs">
|
||||
@@ -135,6 +177,46 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快速工具卡片区(6个工具入口) -->
|
||||
<view class="tools-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">快速分析工具</text>
|
||||
<text class="section-desc">点击进入详细分析</text>
|
||||
</view>
|
||||
<view class="tools-grid">
|
||||
<view class="tool-card" @click="goToSalesReport">
|
||||
<view class="tool-icon sales">📊</view>
|
||||
<text class="tool-title">销售报表</text>
|
||||
<text class="tool-desc">GMV、订单、转化率</text>
|
||||
</view>
|
||||
<view class="tool-card" @click="goToUserAnalysis">
|
||||
<view class="tool-icon users">👥</view>
|
||||
<text class="tool-title">用户分析</text>
|
||||
<text class="tool-desc">增长、活跃、留存</text>
|
||||
</view>
|
||||
<view class="tool-card" @click="goToProductInsights">
|
||||
<view class="tool-icon products">📦</view>
|
||||
<text class="tool-title">商品洞察</text>
|
||||
<text class="tool-desc">销量、库存、价格</text>
|
||||
</view>
|
||||
<view class="tool-card" @click="goToMarketTrends">
|
||||
<view class="tool-icon market">📈</view>
|
||||
<text class="tool-title">市场趋势</text>
|
||||
<text class="tool-desc">整体趋势、行业对比</text>
|
||||
</view>
|
||||
<view class="tool-card" @click="goToCouponAnalysis">
|
||||
<view class="tool-icon coupon">🎫</view>
|
||||
<text class="tool-title">优惠券分析</text>
|
||||
<text class="tool-desc">发放、使用、ROI</text>
|
||||
</view>
|
||||
<view class="tool-card" @click="goToCustomReport">
|
||||
<view class="tool-icon custom">⚙️</view>
|
||||
<text class="tool-title">自定义报表</text>
|
||||
<text class="tool-desc">创建专属报表</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 留白 -->
|
||||
<view style="height: 24px;"></view>
|
||||
</view>
|
||||
@@ -170,6 +252,10 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
showMoreMenu: false,
|
||||
showSidebarMenu: false,
|
||||
currentPath: '/pages/mall/analytics/index',
|
||||
loading: true,
|
||||
autoRefreshEnabled: true,
|
||||
autoRefreshInterval: 60000, // 60秒自动刷新
|
||||
autoRefreshTimer: null as any,
|
||||
timePeriods: [
|
||||
{ value: '7d', label: '7天' },
|
||||
{ value: '30d', label: '30天' },
|
||||
@@ -193,35 +279,23 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
orders: [] as Array<number>
|
||||
} as TrendData,
|
||||
|
||||
userSegments: [
|
||||
{ name: '未消费用户', value: 72 },
|
||||
{ name: '消费一次用户', value: 14 },
|
||||
{ name: '留存客户', value: 9 },
|
||||
{ name: '回流客户', value: 5 }
|
||||
] as Array<SegmentItem>,
|
||||
userSegments: [] as Array<SegmentItem>,
|
||||
|
||||
trafficSources: [
|
||||
{ name: '直接访问', value: 45 },
|
||||
{ name: '搜索引擎', value: 28 },
|
||||
{ name: '社交媒体', value: 18 },
|
||||
{ name: '广告推广', value: 9 }
|
||||
] as Array<TrafficItem>,
|
||||
trafficSources: [] as Array<TrafficItem>,
|
||||
|
||||
topProducts: [
|
||||
{ id: '1', rank: 1, name: '苹果 iPhone 15', sales: 580 },
|
||||
{ id: '2', rank: 2, name: '华为 Mate 60', sales: 456 },
|
||||
{ id: '3', rank: 3, name: '小米 14 Pro', sales: 389 }
|
||||
],
|
||||
topProducts: [] as Array<TopProductItem>,
|
||||
|
||||
topMerchants: [
|
||||
{ id: '1', rank: 1, name: '华强北电子城', sales: 580000, growth: 15.6 },
|
||||
{ id: '2', rank: 2, name: '时尚服装馆', sales: 456000, growth: 12.3 },
|
||||
{ id: '3', rank: 3, name: '美食天地', sales: 389000, growth: -2.1 }
|
||||
],
|
||||
topMerchants: [] as Array<TopMerchantItem>,
|
||||
|
||||
// 图表 options
|
||||
trafficBarOption: {} as any,
|
||||
userSegmentOption: {} as any
|
||||
userSegmentOption: {} as any,
|
||||
kpiSparklineOptions: {
|
||||
gmv: {} as any,
|
||||
orders: {} as any,
|
||||
users: {} as any,
|
||||
conversion: {} as any
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -242,30 +316,92 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.refreshAll()
|
||||
this.buildChartOptions()
|
||||
this.initDashboard()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this.showMoreMenu = false
|
||||
this.stopAutoRefresh()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 页面显示时恢复自动刷新
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.startAutoRefresh()
|
||||
}
|
||||
},
|
||||
|
||||
onHide() {
|
||||
// 页面隐藏时暂停自动刷新
|
||||
this.stopAutoRefresh()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initDashboard() {
|
||||
this.loading = true
|
||||
await this.refreshAll()
|
||||
this.loading = false
|
||||
this.buildChartOptions()
|
||||
this.buildSparklineOptions()
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.startAutoRefresh()
|
||||
}
|
||||
},
|
||||
|
||||
async refreshAll() {
|
||||
this.updateTime()
|
||||
await this.loadRealTime()
|
||||
await this.loadTrend()
|
||||
await this.loadUserSegments()
|
||||
await this.loadTrafficSources()
|
||||
await this.loadTopProducts()
|
||||
await this.loadTopMerchants()
|
||||
this.updateTime()
|
||||
uni.showToast({ title: '已刷新', icon: 'success' })
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadRealTime(),
|
||||
this.loadTrend(),
|
||||
this.loadUserSegments(),
|
||||
this.loadTrafficSources(),
|
||||
this.loadTopProducts(),
|
||||
this.loadTopMerchants()
|
||||
])
|
||||
this.updateTime()
|
||||
// 更新 sparkline
|
||||
this.buildSparklineOptions()
|
||||
// 更新图表
|
||||
this.buildChartOptions()
|
||||
} catch (e) {
|
||||
console.error('refreshAll failed', e)
|
||||
uni.showToast({ title: '数据加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
startAutoRefresh() {
|
||||
this.stopAutoRefresh()
|
||||
this.autoRefreshTimer = setInterval(() => {
|
||||
this.refreshAll()
|
||||
}, this.autoRefreshInterval)
|
||||
},
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.autoRefreshTimer != null) {
|
||||
clearInterval(this.autoRefreshTimer)
|
||||
this.autoRefreshTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
toggleAutoRefresh() {
|
||||
this.autoRefreshEnabled = !this.autoRefreshEnabled
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.startAutoRefresh()
|
||||
uni.showToast({ title: '已开启自动刷新', icon: 'success' })
|
||||
} else {
|
||||
this.stopAutoRefresh()
|
||||
uni.showToast({ title: '已关闭自动刷新', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
this.selectedPeriod = p
|
||||
this.loadTrend()
|
||||
this.loadUserSegments()
|
||||
this.loadTrafficSources()
|
||||
this.loadTopProducts()
|
||||
this.loadTopMerchants()
|
||||
},
|
||||
|
||||
updateTime() {
|
||||
@@ -291,21 +427,49 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
p.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
p.set('p_merchant_id', null)
|
||||
|
||||
console.log('📊 loadTrend: 请求参数', {
|
||||
start_date: startDate.toISOString().slice(0, 10),
|
||||
end_date: endDate.toISOString().slice(0, 10)
|
||||
})
|
||||
|
||||
const res: any = await supa.rpc('rpc_analytics_trend_data', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
console.log('📊 loadTrend: RPC 返回结果', res)
|
||||
|
||||
// 检查返回结构:可能是 res.data 或 res 本身
|
||||
let rows: Array<any> = []
|
||||
if (Array.isArray(res.data)) {
|
||||
rows = res.data as Array<any>
|
||||
} else if (Array.isArray(res)) {
|
||||
rows = res as Array<any>
|
||||
} else if (res && typeof res === 'object') {
|
||||
// 可能是 { data: [...] } 或其他结构
|
||||
const data = res.data || res.rows || res.result || []
|
||||
rows = Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
console.log('📊 loadTrend: 解析后的 rows', rows, '数量:', rows.length)
|
||||
|
||||
const x: Array<string> = []
|
||||
const gmv: Array<number> = []
|
||||
const orders: Array<number> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const d = `${rows[i].date}` // yyyy-mm-dd
|
||||
x.push(d.slice(5))
|
||||
gmv.push(Number(rows[i].gmv) || 0)
|
||||
orders.push(Number(rows[i].orders) || 0)
|
||||
const row = rows[i]
|
||||
const d = `${row.date || row.day || row.date_key}` // 兼容不同字段名
|
||||
if (d && d.length >= 10) {
|
||||
x.push(d.slice(5)) // MM-DD
|
||||
} else {
|
||||
x.push(`${i + 1}`)
|
||||
}
|
||||
gmv.push(Number(row.gmv || row.total_amount || 0) || 0)
|
||||
orders.push(Number(row.orders || row.order_count || 0) || 0)
|
||||
}
|
||||
|
||||
console.log('📊 loadTrend: 最终数据', { x: x.length, gmv: gmv.length, orders: orders.length })
|
||||
this.trend = { x, gmv, orders }
|
||||
} catch (e) {
|
||||
console.error('loadTrend failed', e)
|
||||
console.error('❌ loadTrend failed', e)
|
||||
// 即使失败也设置空数据,避免图表报错
|
||||
this.trend = { x: [], gmv: [], orders: [] }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -326,24 +490,46 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
p.set('p_compare_end', ySame.toISOString())
|
||||
p.set('p_merchant_id', null)
|
||||
|
||||
console.log('⚡ loadRealTime: 请求参数', {
|
||||
p_start: todayISO,
|
||||
p_end: now.toISOString(),
|
||||
p_compare_start: y0.toISOString(),
|
||||
p_compare_end: ySame.toISOString()
|
||||
})
|
||||
|
||||
const res: any = await supa.rpc('rpc_analytics_realtime_kpis', p)
|
||||
const row = Array.isArray(res.data) && res.data.length > 0 ? res.data[0] : (res.data || {})
|
||||
console.log('⚡ loadRealTime: RPC 返回结果', res)
|
||||
|
||||
// 检查返回结构
|
||||
let row: any = {}
|
||||
if (Array.isArray(res.data) && res.data.length > 0) {
|
||||
row = res.data[0]
|
||||
} else if (Array.isArray(res) && res.length > 0) {
|
||||
row = res[0]
|
||||
} else if (res && typeof res === 'object' && !Array.isArray(res)) {
|
||||
// 可能是直接返回对象,或者 { data: {...} }
|
||||
row = res.data || res.result || res
|
||||
}
|
||||
|
||||
console.log('⚡ loadRealTime: 解析后的 row', row)
|
||||
|
||||
const safe = (v: any): number => {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
this.realTime = {
|
||||
gmv: Math.round(safe(row.gmv)),
|
||||
gmv_growth: safe(row.gmv_growth),
|
||||
orders: Math.round(safe(row.orders)),
|
||||
order_growth: safe(row.order_growth),
|
||||
online_users: Math.round(safe(row.online_users)),
|
||||
conversion_rate: safe(row.conversion_rate),
|
||||
conversion_growth: safe(row.conversion_growth)
|
||||
gmv: Math.round(safe(row.gmv || row.total_gmv || row.revenue)),
|
||||
gmv_growth: safe(row.gmv_growth || row.gmv_growth_rate || row.revenue_growth),
|
||||
orders: Math.round(safe(row.orders || row.order_count || row.total_orders)),
|
||||
order_growth: safe(row.order_growth || row.order_growth_rate),
|
||||
online_users: Math.round(safe(row.online_users || row.active_users || row.current_users)),
|
||||
conversion_rate: safe(row.conversion_rate || row.conversion),
|
||||
conversion_growth: safe(row.conversion_growth || row.conversion_growth_rate)
|
||||
}
|
||||
|
||||
console.log('⚡ loadRealTime: 最终数据', this.realTime)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
console.error('❌ loadRealTime failed', e)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -403,15 +589,42 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
p.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
|
||||
console.log('👥 loadUserSegments: 请求参数', {
|
||||
start_date: startDate.toISOString().slice(0, 10),
|
||||
end_date: endDate.toISOString().slice(0, 10)
|
||||
})
|
||||
|
||||
const res: any = await supa.rpc('rpc_analytics_user_segments', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
console.log('👥 loadUserSegments: RPC 返回结果', res)
|
||||
|
||||
// 检查返回结构
|
||||
let rows: Array<any> = []
|
||||
if (Array.isArray(res.data)) {
|
||||
rows = res.data as Array<any>
|
||||
} else if (Array.isArray(res)) {
|
||||
rows = res as Array<any>
|
||||
} else if (res && typeof res === 'object') {
|
||||
const data = res.data || res.rows || res.result || []
|
||||
rows = Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
console.log('👥 loadUserSegments: 解析后的 rows', rows, '数量:', rows.length)
|
||||
|
||||
const list: Array<SegmentItem> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
list.push({ name: `${rows[i].name}`, value: Number(rows[i].value) || 0 })
|
||||
const row = rows[i]
|
||||
const name = `${row.name || row.segment_name || row.label || '未知'}`
|
||||
const value = Number(row.value || row.count || row.amount || 0) || 0
|
||||
list.push({ name, value })
|
||||
}
|
||||
if (list.length > 0) this.userSegments = list
|
||||
|
||||
console.log('👥 loadUserSegments: 最终数据', list)
|
||||
// 即使为空也更新,确保图表能正确显示空状态
|
||||
this.userSegments = list
|
||||
} catch (e) {
|
||||
console.error('loadUserSegments failed', e)
|
||||
console.error('❌ loadUserSegments failed', e)
|
||||
this.userSegments = []
|
||||
}
|
||||
},
|
||||
|
||||
@@ -421,15 +634,42 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
p.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
|
||||
console.log('🌐 loadTrafficSources: 请求参数', {
|
||||
start_date: startDate.toISOString().slice(0, 10),
|
||||
end_date: endDate.toISOString().slice(0, 10)
|
||||
})
|
||||
|
||||
const res: any = await supa.rpc('rpc_analytics_traffic_sources', p)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
console.log('🌐 loadTrafficSources: RPC 返回结果', res)
|
||||
|
||||
// 检查返回结构
|
||||
let rows: Array<any> = []
|
||||
if (Array.isArray(res.data)) {
|
||||
rows = res.data as Array<any>
|
||||
} else if (Array.isArray(res)) {
|
||||
rows = res as Array<any>
|
||||
} else if (res && typeof res === 'object') {
|
||||
const data = res.data || res.rows || res.result || []
|
||||
rows = Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
console.log('🌐 loadTrafficSources: 解析后的 rows', rows, '数量:', rows.length)
|
||||
|
||||
const list: Array<TrafficItem> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
list.push({ name: `${rows[i].name}`, value: Number(rows[i].value) || 0 })
|
||||
const row = rows[i]
|
||||
const name = `${row.name || row.source_name || row.label || '未知'}`
|
||||
const value = Number(row.value || row.count || row.amount || 0) || 0
|
||||
list.push({ name, value })
|
||||
}
|
||||
if (list.length > 0) this.trafficSources = list
|
||||
|
||||
console.log('🌐 loadTrafficSources: 最终数据', list)
|
||||
// 即使为空也更新
|
||||
this.trafficSources = list
|
||||
} catch (e) {
|
||||
console.error('loadTrafficSources failed', e)
|
||||
console.error('❌ loadTrafficSources failed', e)
|
||||
this.trafficSources = []
|
||||
}
|
||||
},
|
||||
|
||||
@@ -526,21 +766,59 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
|
||||
// 构建图表 options
|
||||
buildChartOptions() {
|
||||
// 流量来源条形图
|
||||
// 流量来源条形图(增强:渐变 + 动画)
|
||||
const trafficX = this.trafficSources.map((it) => it.name)
|
||||
const trafficY = this.trafficSources.map((it) => {
|
||||
const n = Number(it.value)
|
||||
return isFinite(n) ? n : 0
|
||||
})
|
||||
const total = trafficY.reduce((sum, v) => sum + v, 0)
|
||||
this.trafficBarOption = {
|
||||
grid: { left: 80, right: 24, top: 18, bottom: 18 },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
|
||||
yAxis: { type: 'category', data: trafficX, axisTick: { show: false }, axisLabel: { color: 'rgba(0,0,0,0.65)' } },
|
||||
series: [{ type: 'bar', data: trafficY, barWidth: 14, itemStyle: { borderRadius: 6 } }]
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: any) => {
|
||||
const p = params[0]
|
||||
const percent = total > 0 ? ((p.value / total) * 100).toFixed(1) : '0'
|
||||
return `${p.name}<br/>${p.marker} ${p.value} 次 (${percent}%)`
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: 'rgba(0,0,0,0.55)' },
|
||||
splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: trafficX,
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: 'rgba(0,0,0,0.65)' }
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: trafficY,
|
||||
barWidth: 14,
|
||||
itemStyle: {
|
||||
borderRadius: 6,
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 1,
|
||||
y2: 0,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#FF4D4F' },
|
||||
{ offset: 1, color: '#FF7A45' }
|
||||
]
|
||||
}
|
||||
},
|
||||
animationDuration: 800,
|
||||
animationEasing: 'cubicOut'
|
||||
}]
|
||||
}
|
||||
|
||||
// 用户结构环形图
|
||||
// 用户结构环形图(增强:颜色 + 动画)
|
||||
const segmentData = this.userSegments.map((it) => ({
|
||||
name: it.name,
|
||||
value: (() => {
|
||||
@@ -548,9 +826,20 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
return isFinite(n) ? n : 0
|
||||
})()
|
||||
}))
|
||||
const colors = ['#FF6B6B', '#4ECDC4', '#A8E6CF', '#FFD93D', '#95A5A6']
|
||||
this.userSegmentOption = {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { left: 0, bottom: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 12 } },
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
textStyle: { fontSize: 12 }
|
||||
},
|
||||
color: colors,
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
@@ -559,10 +848,112 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
avoidLabelOverlap: true,
|
||||
label: { show: true, formatter: '{b}\n{d}%' },
|
||||
labelLine: { length: 10, length2: 10 },
|
||||
data: segmentData
|
||||
data: segmentData,
|
||||
animationType: 'scale',
|
||||
animationEasing: 'elasticOut',
|
||||
animationDelay: (idx: number) => idx * 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// 构建 KPI sparkline(mini 趋势图)
|
||||
buildSparklineOptions() {
|
||||
// 从 trend 数据提取最近 7 天的数据用于 sparkline
|
||||
const recentDays = Math.min(7, this.trend.gmv.length)
|
||||
const sparkX = this.trend.x.slice(-recentDays)
|
||||
const sparkGmv = this.trend.gmv.slice(-recentDays)
|
||||
const sparkOrders = this.trend.orders.slice(-recentDays)
|
||||
|
||||
// GMV sparkline
|
||||
this.kpiSparklineOptions.gmv = {
|
||||
grid: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
xAxis: { type: 'category', data: sparkX, show: false },
|
||||
yAxis: { type: 'value', show: false },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: sparkGmv,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: '#fff' },
|
||||
areaStyle: { color: 'rgba(255,255,255,0.2)' }
|
||||
}]
|
||||
}
|
||||
|
||||
// Orders sparkline
|
||||
this.kpiSparklineOptions.orders = {
|
||||
grid: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
xAxis: { type: 'category', data: sparkX, show: false },
|
||||
yAxis: { type: 'value', show: false },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: sparkOrders,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: '#fff' },
|
||||
areaStyle: { color: 'rgba(255,255,255,0.2)' }
|
||||
}]
|
||||
}
|
||||
|
||||
// Users sparkline(模拟数据,实际应该从 user_sessions 获取)
|
||||
const sparkUsers = sparkOrders.map((v) => Math.round(v * 0.8))
|
||||
this.kpiSparklineOptions.users = {
|
||||
grid: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
xAxis: { type: 'category', data: sparkX, show: false },
|
||||
yAxis: { type: 'value', show: false },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: sparkUsers,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: '#fff' },
|
||||
areaStyle: { color: 'rgba(255,255,255,0.2)' }
|
||||
}]
|
||||
}
|
||||
|
||||
// Conversion sparkline(基于 GMV/Orders 计算)
|
||||
const sparkConversion = sparkGmv.map((gmv, i) => {
|
||||
const orders = sparkOrders[i] || 1
|
||||
return (gmv / orders / 100).toFixed(2)
|
||||
})
|
||||
this.kpiSparklineOptions.conversion = {
|
||||
grid: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
xAxis: { type: 'category', data: sparkX, show: false },
|
||||
yAxis: { type: 'value', show: false },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: sparkConversion,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { width: 2, color: '#fff' },
|
||||
areaStyle: { color: 'rgba(255,255,255,0.2)' }
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
// 快速工具跳转
|
||||
goToSalesReport() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/sales-report' })
|
||||
},
|
||||
|
||||
goToUserAnalysis() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/user-analysis' })
|
||||
},
|
||||
|
||||
goToProductInsights() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/product-insights' })
|
||||
},
|
||||
|
||||
goToMarketTrends() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/market-trends' })
|
||||
},
|
||||
|
||||
goToCouponAnalysis() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/coupon-analysis' })
|
||||
},
|
||||
|
||||
goToCustomReport() {
|
||||
uni.navigateTo({ url: '/pages/mall/analytics/custom-report' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -857,30 +1248,125 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
|
||||
.kpi-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
padding: 14px;
|
||||
padding: 18px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 calc(50% - 6px);
|
||||
min-width: 260px; /* 窄屏自动掉到一列 */
|
||||
min-width: 260px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.kpi-card:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* KPI 卡片渐变背景 */
|
||||
.kpi-card-gmv {
|
||||
background: linear-gradient(135deg, #FF6B6B 0%, #FF4D4F 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-card-orders {
|
||||
background: linear-gradient(135deg, #4ECDC4 0%, #44A08D 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-card-users {
|
||||
background: linear-gradient(135deg, #A8E6CF 0%, #7FCDBB 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-card-conversion {
|
||||
background: linear-gradient(135deg, #FFD93D 0%, #FFA07A 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-header {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kpi-sparkline {
|
||||
width: 80px;
|
||||
height: 32px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sparkline-skeleton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
margin-top: 8px;
|
||||
font-size: 22px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #111;
|
||||
color: #fff;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.kpi-footer {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kpi-meta {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.kpi-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(255,255,255,0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.kpi-chip.pos {
|
||||
background: rgba(34,197,94,0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-chip.neg {
|
||||
background: rgba(239,68,68,0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.kpi-chip.neutral {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 时间维度 tabs 横排 */
|
||||
@@ -1069,6 +1555,11 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
.more-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 宽屏时工具卡片 3列 */
|
||||
.tools-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 自适应:窄屏自动变一列(断点用 px) */
|
||||
@@ -1127,6 +1618,115 @@ type TopMerchantItem = { id: string; rank: number; name: string; sales: number;
|
||||
.dropdown-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 窄屏时 KPI 卡片单列 */
|
||||
.kpi-card {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
/* 窄屏时工具卡片 2列 */
|
||||
.tools-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 快速工具卡片区 */
|
||||
.tools-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
padding: 20px 16px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.tool-card:active {
|
||||
transform: scale(0.96);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.tool-icon.sales {
|
||||
background: linear-gradient(135deg, #FF6B6B 0%, #FF4D4F 100%);
|
||||
}
|
||||
|
||||
.tool-icon.users {
|
||||
background: linear-gradient(135deg, #4ECDC4 0%, #44A08D 100%);
|
||||
}
|
||||
|
||||
.tool-icon.products {
|
||||
background: linear-gradient(135deg, #A8E6CF 0%, #7FCDBB 100%);
|
||||
}
|
||||
|
||||
.tool-icon.market {
|
||||
background: linear-gradient(135deg, #FFD93D 0%, #FFA07A 100%);
|
||||
}
|
||||
|
||||
.tool-icon.coupon {
|
||||
background: linear-gradient(135deg, #FF9A9E 0%, #FECFEF 100%);
|
||||
}
|
||||
|
||||
.tool-icon.custom {
|
||||
background: linear-gradient(135deg, #A8C0FF 0%, #3B82F6 100%);
|
||||
}
|
||||
|
||||
.tool-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0,0,0,0.55);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -25,232 +25,232 @@
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<view class="main-content">
|
||||
<view class="analytics-profile">
|
||||
<!-- 分析师信息头部 -->
|
||||
<view class="profile-header">
|
||||
<image :src="analystInfo.avatar_url || '/static/default-avatar.png'" class="analyst-avatar" @click="editProfile" />
|
||||
<view class="analyst-info">
|
||||
<text class="analyst-name">{{ analystInfo.nickname || analystInfo.phone }}</text>
|
||||
<text class="analyst-role">{{ getAnalystRole() }}</text>
|
||||
<view class="analyst-stats">
|
||||
<text class="stat-item">工作经验: {{ workExperience }}年</text>
|
||||
<text class="stat-item">专业领域: {{ expertise }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="settings-icon" @click="goToSettings">⚙️</view>
|
||||
</view>
|
||||
<view class="analytics-profile">
|
||||
<!-- 分析师信息头部 -->
|
||||
<view class="profile-header">
|
||||
<image :src="analystInfo.avatar_url || '/static/default-avatar.png'" class="analyst-avatar" @click="editProfile" />
|
||||
<view class="analyst-info">
|
||||
<text class="analyst-name">{{ analystInfo.nickname || analystInfo.phone }}</text>
|
||||
<text class="analyst-role">{{ getAnalystRole() }}</text>
|
||||
<view class="analyst-stats">
|
||||
<text class="stat-item">工作经验: {{ workExperience }}年</text>
|
||||
<text class="stat-item">专业领域: {{ expertise }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="settings-icon" @click="goToSettings">⚙️</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据概览 -->
|
||||
<view class="data-overview">
|
||||
<view class="section-title">数据概览</view>
|
||||
<view class="overview-cards">
|
||||
<view class="overview-card" @click="goToReports('sales')">
|
||||
<text class="card-icon">💰</text>
|
||||
<text class="card-title">销售数据</text>
|
||||
<text class="card-value">¥{{ overviewData.totalSales }}</text>
|
||||
<text class="card-change positive">+{{ overviewData.salesGrowth }}%</text>
|
||||
</view>
|
||||
<view class="overview-card" @click="goToReports('users')">
|
||||
<text class="card-icon">👥</text>
|
||||
<text class="card-title">用户增长</text>
|
||||
<text class="card-value">{{ overviewData.totalUsers }}</text>
|
||||
<text class="card-change positive">+{{ overviewData.userGrowth }}%</text>
|
||||
</view>
|
||||
<view class="overview-card" @click="goToReports('orders')">
|
||||
<text class="card-icon">📋</text>
|
||||
<text class="card-title">订单量</text>
|
||||
<text class="card-value">{{ overviewData.totalOrders }}</text>
|
||||
<text class="card-change" :class="{ positive: overviewData.orderGrowth > 0 }">
|
||||
{{ overviewData.orderGrowth > 0 ? '+' : '' }}{{ overviewData.orderGrowth }}%
|
||||
</text>
|
||||
</view>
|
||||
<view class="overview-card" @click="goToReports('conversion')">
|
||||
<text class="card-icon">📈</text>
|
||||
<text class="card-title">转化率</text>
|
||||
<text class="card-value">{{ overviewData.conversionRate }}%</text>
|
||||
<text class="card-change positive">+{{ overviewData.conversionGrowth }}%</text>
|
||||
</view>
|
||||
<!-- 数据概览 -->
|
||||
<view class="data-overview">
|
||||
<view class="section-title">数据概览</view>
|
||||
<view class="overview-cards">
|
||||
<view class="overview-card" @click="goToReports('sales')">
|
||||
<text class="card-icon">💰</text>
|
||||
<text class="card-title">销售数据</text>
|
||||
<text class="card-value">¥{{ overviewData.totalSales }}</text>
|
||||
<text class="card-change positive">+{{ overviewData.salesGrowth }}%</text>
|
||||
</view>
|
||||
<view class="overview-card" @click="goToReports('users')">
|
||||
<text class="card-icon">👥</text>
|
||||
<text class="card-title">用户增长</text>
|
||||
<text class="card-value">{{ overviewData.totalUsers }}</text>
|
||||
<text class="card-change positive">+{{ overviewData.userGrowth }}%</text>
|
||||
</view>
|
||||
<view class="overview-card" @click="goToReports('orders')">
|
||||
<text class="card-icon">📋</text>
|
||||
<text class="card-title">订单量</text>
|
||||
<text class="card-value">{{ overviewData.totalOrders }}</text>
|
||||
<text class="card-change" :class="{ positive: overviewData.orderGrowth > 0 }">
|
||||
{{ overviewData.orderGrowth > 0 ? '+' : '' }}{{ overviewData.orderGrowth }}%
|
||||
</text>
|
||||
</view>
|
||||
<view class="overview-card" @click="goToReports('conversion')">
|
||||
<text class="card-icon">📈</text>
|
||||
<text class="card-title">转化率</text>
|
||||
<text class="card-value">{{ overviewData.conversionRate }}%</text>
|
||||
<text class="card-change positive">+{{ overviewData.conversionGrowth }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 报表管理 -->
|
||||
<view class="report-management">
|
||||
<view class="section-title">报表管理</view>
|
||||
<view class="report-tabs">
|
||||
<view class="report-tab" @click="goToReports('all')">
|
||||
<text class="tab-icon">📊</text>
|
||||
<text class="tab-text">全部报表</text>
|
||||
<text v-if="reportCounts.total > 0" class="tab-badge">{{ reportCounts.total }}</text>
|
||||
</view>
|
||||
<view class="report-tab" @click="goToReports('pending')">
|
||||
<text class="tab-icon">⏳</text>
|
||||
<text class="tab-text">待生成</text>
|
||||
<text v-if="reportCounts.pending > 0" class="tab-badge alert">{{ reportCounts.pending }}</text>
|
||||
</view>
|
||||
<view class="report-tab" @click="goToReports('scheduled')">
|
||||
<text class="tab-icon">📅</text>
|
||||
<text class="tab-text">定时报表</text>
|
||||
<text v-if="reportCounts.scheduled > 0" class="tab-badge">{{ reportCounts.scheduled }}</text>
|
||||
</view>
|
||||
<view class="report-tab" @click="goToReports('shared')">
|
||||
<text class="tab-icon">🔗</text>
|
||||
<text class="tab-text">共享报表</text>
|
||||
<text v-if="reportCounts.shared > 0" class="tab-badge">{{ reportCounts.shared }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日数据洞察 -->
|
||||
<view class="today-insights">
|
||||
<view class="section-title">今日洞察</view>
|
||||
<view class="insight-grid">
|
||||
<view class="insight-card">
|
||||
<text class="insight-icon">🔥</text>
|
||||
<text class="insight-title">热销商品</text>
|
||||
<text class="insight-value">{{ todayInsights.hotProduct }}</text>
|
||||
<text class="insight-desc">销量同比增长156%</text>
|
||||
</view>
|
||||
<view class="insight-card">
|
||||
<text class="insight-icon">⚡</text>
|
||||
<text class="insight-title">流量峰值</text>
|
||||
<text class="insight-value">{{ todayInsights.peakTraffic }}</text>
|
||||
<text class="insight-desc">14:30达到峰值</text>
|
||||
</view>
|
||||
<view class="insight-card">
|
||||
<text class="insight-icon">🎯</text>
|
||||
<text class="insight-title">转化异常</text>
|
||||
<text class="insight-value">{{ todayInsights.conversionAnomaly }}</text>
|
||||
<text class="insight-desc">需要关注</text>
|
||||
</view>
|
||||
<view class="insight-card">
|
||||
<text class="insight-icon">📱</text>
|
||||
<text class="insight-title">移动端占比</text>
|
||||
<text class="insight-value">{{ todayInsights.mobileRatio }}%</text>
|
||||
<text class="insight-desc">持续增长</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近生成的报表 -->
|
||||
<view class="recent-reports">
|
||||
<view class="section-header">
|
||||
<text class="section-title">最近报表</text>
|
||||
<text class="view-all" @click="goToReports('all')">查看全部 ></text>
|
||||
</view>
|
||||
<view v-if="recentReports.length > 0" class="report-list">
|
||||
<view v-for="report in recentReports" :key="report.id" class="report-item" @click="viewReportDetail(report.id)">
|
||||
<view class="report-icon">
|
||||
<text class="icon-text">📊</text>
|
||||
</view>
|
||||
<view class="report-info">
|
||||
<text class="report-title">{{ report.title }}</text>
|
||||
<text class="report-desc">{{ report.description }}</text>
|
||||
<text class="report-time">{{ formatTime(report.created_at) }}</text>
|
||||
</view>
|
||||
<view class="report-status">
|
||||
<text class="status-text" :class="'status-' + report.status">{{ getReportStatusText(report.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="no-data">
|
||||
<text class="no-data-text">暂无最近报表</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据趋势图表 -->
|
||||
<view class="trend-chart">
|
||||
<view class="section-header">
|
||||
<text class="section-title">数据趋势</text>
|
||||
<view class="chart-controls">
|
||||
<text class="control-btn" :class="{ active: trendPeriod === 'week' }" @click="changeTrendPeriod('week')">周</text>
|
||||
<text class="control-btn" :class="{ active: trendPeriod === 'month' }" @click="changeTrendPeriod('month')">月</text>
|
||||
<text class="control-btn" :class="{ active: trendPeriod === 'quarter' }" @click="changeTrendPeriod('quarter')">季</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chart-container">
|
||||
<view class="chart-legend">
|
||||
<view class="legend-item">
|
||||
<view class="legend-color sales"></view>
|
||||
<text class="legend-text">销售额</text>
|
||||
</view>
|
||||
<view class="legend-item">
|
||||
<view class="legend-color orders"></view>
|
||||
<text class="legend-text">订单量</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chart-area">
|
||||
<view class="chart-bars">
|
||||
<view v-for="(data, index) in trendData" :key="index" class="bar-group">
|
||||
<view class="bar sales" :style="{ height: (data.sales / maxSales * 100) + '%' }"></view>
|
||||
<view class="bar orders" :style="{ height: (data.orders / maxOrders * 100) + '%' }"></view>
|
||||
<text class="bar-label">{{ data.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 报表管理 -->
|
||||
<view class="report-management">
|
||||
<view class="section-title">报表管理</view>
|
||||
<view class="report-tabs">
|
||||
<view class="report-tab" @click="goToReports('all')">
|
||||
<text class="tab-icon">📊</text>
|
||||
<text class="tab-text">全部报表</text>
|
||||
<text v-if="reportCounts.total > 0" class="tab-badge">{{ reportCounts.total }}</text>
|
||||
</view>
|
||||
<view class="report-tab" @click="goToReports('pending')">
|
||||
<text class="tab-icon">⏳</text>
|
||||
<text class="tab-text">待生成</text>
|
||||
<text v-if="reportCounts.pending > 0" class="tab-badge alert">{{ reportCounts.pending }}</text>
|
||||
</view>
|
||||
<view class="report-tab" @click="goToReports('scheduled')">
|
||||
<text class="tab-icon">📅</text>
|
||||
<text class="tab-text">定时报表</text>
|
||||
<text v-if="reportCounts.scheduled > 0" class="tab-badge">{{ reportCounts.scheduled }}</text>
|
||||
</view>
|
||||
<view class="report-tab" @click="goToReports('shared')">
|
||||
<text class="tab-icon">🔗</text>
|
||||
<text class="tab-text">共享报表</text>
|
||||
<text v-if="reportCounts.shared > 0" class="tab-badge">{{ reportCounts.shared }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 分析工具 -->
|
||||
<view class="analysis-tools">
|
||||
<view class="section-title">分析工具</view>
|
||||
<view class="tool-grid">
|
||||
<view class="tool-item" @click="goToTool('dashboard')">
|
||||
<text class="tool-icon">📊</text>
|
||||
<text class="tool-label">数据看板</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="goToTool('funnel')">
|
||||
<text class="tool-icon">🔽</text>
|
||||
<text class="tool-label">转化漏斗</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="goToTool('cohort')">
|
||||
<text class="tool-icon">👥</text>
|
||||
<text class="tool-label">用户留存</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="goToTool('attribution')">
|
||||
<text class="tool-icon">🎯</text>
|
||||
<text class="tool-label">归因分析</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="goToTool('segmentation')">
|
||||
<text class="tool-icon">🔍</text>
|
||||
<text class="tool-label">用户分群</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="goToTool('prediction')">
|
||||
<text class="tool-icon">🔮</text>
|
||||
<text class="tool-label">预测分析</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日数据洞察 -->
|
||||
<view class="today-insights">
|
||||
<view class="section-title">今日洞察</view>
|
||||
<view class="insight-grid">
|
||||
<view class="insight-card">
|
||||
<text class="insight-icon">🔥</text>
|
||||
<text class="insight-title">热销商品</text>
|
||||
<text class="insight-value">{{ todayInsights.hotProduct }}</text>
|
||||
<text class="insight-desc">销量同比增长156%</text>
|
||||
</view>
|
||||
<view class="insight-card">
|
||||
<text class="insight-icon">⚡</text>
|
||||
<text class="insight-title">流量峰值</text>
|
||||
<text class="insight-value">{{ todayInsights.peakTraffic }}</text>
|
||||
<text class="insight-desc">14:30达到峰值</text>
|
||||
</view>
|
||||
<view class="insight-card">
|
||||
<text class="insight-icon">🎯</text>
|
||||
<text class="insight-title">转化异常</text>
|
||||
<text class="insight-value">{{ todayInsights.conversionAnomaly }}</text>
|
||||
<text class="insight-desc">需要关注</text>
|
||||
</view>
|
||||
<view class="insight-card">
|
||||
<text class="insight-icon">📱</text>
|
||||
<text class="insight-title">移动端占比</text>
|
||||
<text class="insight-value">{{ todayInsights.mobileRatio }}%</text>
|
||||
<text class="insight-desc">持续增长</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近生成的报表 -->
|
||||
<view class="recent-reports">
|
||||
<view class="section-header">
|
||||
<text class="section-title">最近报表</text>
|
||||
<text class="view-all" @click="goToReports('all')">查看全部 ></text>
|
||||
</view>
|
||||
<view v-if="recentReports.length > 0" class="report-list">
|
||||
<view v-for="report in recentReports" :key="report.id" class="report-item" @click="viewReportDetail(report.id)">
|
||||
<view class="report-icon">
|
||||
<text class="icon-text">📊</text>
|
||||
</view>
|
||||
<view class="report-info">
|
||||
<text class="report-title">{{ report.title }}</text>
|
||||
<text class="report-desc">{{ report.description }}</text>
|
||||
<text class="report-time">{{ formatTime(report.created_at) }}</text>
|
||||
</view>
|
||||
<view class="report-status">
|
||||
<text class="status-text" :class="'status-' + report.status">{{ getReportStatusText(report.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="no-data">
|
||||
<text class="no-data-text">暂无最近报表</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据趋势图表 -->
|
||||
<view class="trend-chart">
|
||||
<view class="section-header">
|
||||
<text class="section-title">数据趋势</text>
|
||||
<view class="chart-controls">
|
||||
<text class="control-btn" :class="{ active: trendPeriod === 'week' }" @click="changeTrendPeriod('week')">周</text>
|
||||
<text class="control-btn" :class="{ active: trendPeriod === 'month' }" @click="changeTrendPeriod('month')">月</text>
|
||||
<text class="control-btn" :class="{ active: trendPeriod === 'quarter' }" @click="changeTrendPeriod('quarter')">季</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chart-container">
|
||||
<view class="chart-legend">
|
||||
<view class="legend-item">
|
||||
<view class="legend-color sales"></view>
|
||||
<text class="legend-text">销售额</text>
|
||||
</view>
|
||||
<view class="legend-item">
|
||||
<view class="legend-color orders"></view>
|
||||
<text class="legend-text">订单量</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chart-area">
|
||||
<view class="chart-bars">
|
||||
<view v-for="(data, index) in trendData" :key="index" class="bar-group">
|
||||
<view class="bar sales" :style="{ height: (data.sales / maxSales * 100) + '%' }"></view>
|
||||
<view class="bar orders" :style="{ height: (data.orders / maxOrders * 100) + '%' }"></view>
|
||||
<text class="bar-label">{{ data.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分析工具 -->
|
||||
<view class="analysis-tools">
|
||||
<view class="section-title">分析工具</view>
|
||||
<view class="tool-grid">
|
||||
<view class="tool-item" @click="goToTool('dashboard')">
|
||||
<text class="tool-icon">📊</text>
|
||||
<text class="tool-label">数据看板</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="goToTool('funnel')">
|
||||
<text class="tool-icon">🔽</text>
|
||||
<text class="tool-label">转化漏斗</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="goToTool('cohort')">
|
||||
<text class="tool-icon">👥</text>
|
||||
<text class="tool-label">用户留存</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="goToTool('attribution')">
|
||||
<text class="tool-icon">🎯</text>
|
||||
<text class="tool-label">归因分析</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="goToTool('segmentation')">
|
||||
<text class="tool-icon">🔍</text>
|
||||
<text class="tool-label">用户分群</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="goToTool('prediction')">
|
||||
<text class="tool-icon">🔮</text>
|
||||
<text class="tool-label">预测分析</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能菜单 -->
|
||||
<view class="function-menu">
|
||||
<view class="menu-group">
|
||||
<view class="menu-item" @click="goToDataSource">
|
||||
<text class="menu-icon">🗄️</text>
|
||||
<text class="menu-label">数据源管理</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goToAlerts">
|
||||
<text class="menu-icon">🚨</text>
|
||||
<text class="menu-label">数据预警</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goToExport">
|
||||
<text class="menu-icon">📤</text>
|
||||
<text class="menu-label">数据导出</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-group">
|
||||
<view class="menu-item" @click="goToHelp">
|
||||
<text class="menu-icon">❓</text>
|
||||
<text class="menu-label">帮助中心</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goToFeedback">
|
||||
<text class="menu-icon">💬</text>
|
||||
<text class="menu-label">意见反馈</text>
|
||||
<text class="menu-arrow">></text>
|
||||
<!-- 功能菜单 -->
|
||||
<view class="function-menu">
|
||||
<view class="menu-group">
|
||||
<view class="menu-item" @click="goToDataSource">
|
||||
<text class="menu-icon">🗄️</text>
|
||||
<text class="menu-label">数据源管理</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goToAlerts">
|
||||
<text class="menu-icon">🚨</text>
|
||||
<text class="menu-label">数据预警</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goToExport">
|
||||
<text class="menu-icon">📤</text>
|
||||
<text class="menu-label">数据导出</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-group">
|
||||
<view class="menu-item" @click="goToHelp">
|
||||
<text class="menu-icon">❓</text>
|
||||
<text class="menu-label">帮助中心</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goToFeedback">
|
||||
<text class="menu-icon">💬</text>
|
||||
<text class="menu-label">意见反馈</text>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -396,7 +396,7 @@ async function loadAnalystInfo() {
|
||||
.eq('id', currentUserId.value)
|
||||
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
|
||||
if (rows.length > 0) {
|
||||
analystInfo.value = {
|
||||
analystInfo.value = {
|
||||
...(analystInfo.value as any),
|
||||
id: `${rows[0].id}`,
|
||||
phone: `${rows[0].phone || ''}`,
|
||||
|
||||
@@ -209,9 +209,116 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
calcDateRange() {
|
||||
const now = new Date()
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 365
|
||||
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
|
||||
return { startDate, endDate, days }
|
||||
},
|
||||
|
||||
async loadSalesData() {
|
||||
// TODO: 实现销售数据加载
|
||||
this.updateTime()
|
||||
try {
|
||||
this.updateTime()
|
||||
const now = new Date()
|
||||
const { startDate, endDate, days } = this.calcDateRange()
|
||||
|
||||
// 1) KPI:复用 realtime_kpis 的口径(GMV/订单/转化率),把窗口替换成“周期范围 vs 上一周期”
|
||||
const periodStart = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
|
||||
const periodEnd = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate() + 1) // 包含 endDate 当天
|
||||
const prevStart = new Date(periodStart.getTime() - days * 24 * 60 * 60 * 1000)
|
||||
const prevEnd = new Date(periodStart.getTime())
|
||||
|
||||
const pKpi = new UTSJSONObject()
|
||||
pKpi.set('p_start', periodStart.toISOString())
|
||||
pKpi.set('p_end', periodEnd.toISOString())
|
||||
pKpi.set('p_compare_start', prevStart.toISOString())
|
||||
pKpi.set('p_compare_end', prevEnd.toISOString())
|
||||
pKpi.set('p_merchant_id', null)
|
||||
|
||||
const kpiRes: any = await supa.rpc('rpc_analytics_realtime_kpis', pKpi)
|
||||
const row = Array.isArray(kpiRes.data) && kpiRes.data.length > 0 ? kpiRes.data[0] : (kpiRes.data || {})
|
||||
const safe = (v: any): number => {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
const gmv = safe(row.gmv)
|
||||
const orders = safe(row.orders)
|
||||
const avgOrder = orders > 0 ? gmv / orders : 0
|
||||
this.salesData = {
|
||||
gmv: Math.round(gmv),
|
||||
gmv_growth: safe(row.gmv_growth),
|
||||
orders: Math.round(orders),
|
||||
order_growth: safe(row.order_growth),
|
||||
conversion_rate: safe(row.conversion_rate),
|
||||
conversion_growth: safe(row.conversion_growth),
|
||||
avg_order_amount: avgOrder,
|
||||
avg_order_growth: safe(row.gmv_growth) // 兜底:暂无独立口径,先跟随 GMV 增长
|
||||
}
|
||||
|
||||
// 2) 趋势(复用 trend_data)
|
||||
const pTrend = new UTSJSONObject()
|
||||
pTrend.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
pTrend.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
pTrend.set('p_merchant_id', null)
|
||||
const trendRes: any = await supa.rpc('rpc_analytics_trend_data', pTrend)
|
||||
const tRows: Array<any> = Array.isArray(trendRes.data) ? (trendRes.data as Array<any>) : []
|
||||
const x: Array<string> = []
|
||||
const gmvArr: Array<number> = []
|
||||
const orderArr: Array<number> = []
|
||||
for (let i = 0; i < tRows.length; i++) {
|
||||
const d = `${tRows[i].date}`
|
||||
x.push(d.slice(5))
|
||||
gmvArr.push(Number(tRows[i].gmv) || 0)
|
||||
orderArr.push(Number(tRows[i].orders) || 0)
|
||||
}
|
||||
this.trend = { x, gmv: gmvArr, orders: orderArr }
|
||||
|
||||
// 3) TOP 商品/商家
|
||||
const pTopP = new UTSJSONObject()
|
||||
pTopP.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
pTopP.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
pTopP.set('p_limit', 10)
|
||||
pTopP.set('p_merchant_id', null)
|
||||
const topPRes: any = await supa.rpc('rpc_analytics_top_products', pTopP)
|
||||
const pRows: Array<any> = Array.isArray(topPRes.data) ? (topPRes.data as Array<any>) : []
|
||||
const pList: Array<ProductRank> = []
|
||||
for (let i = 0; i < pRows.length; i++) {
|
||||
pList.push({ id: `${pRows[i].id}`, rank: i + 1, name: `${pRows[i].name}`, sales: Number(pRows[i].sales) || 0 })
|
||||
}
|
||||
this.topProducts = pList
|
||||
|
||||
const pTopM = new UTSJSONObject()
|
||||
pTopM.set('p_start_date', startDate.toISOString().slice(0, 10))
|
||||
pTopM.set('p_end_date', endDate.toISOString().slice(0, 10))
|
||||
pTopM.set('p_limit', 10)
|
||||
const topMRes: any = await supa.rpc('rpc_analytics_top_merchants', pTopM)
|
||||
const mRows: Array<any> = Array.isArray(topMRes.data) ? (topMRes.data as Array<any>) : []
|
||||
const mList: Array<MerchantRank> = []
|
||||
for (let i = 0; i < mRows.length; i++) {
|
||||
mList.push({
|
||||
id: `${mRows[i].id}`,
|
||||
rank: i + 1,
|
||||
name: `${mRows[i].name}`,
|
||||
sales: Number(mRows[i].sales) || 0,
|
||||
growth: Number(mRows[i].growth) || 0
|
||||
})
|
||||
}
|
||||
this.topMerchants = mList
|
||||
|
||||
// 4) 地域分布:当前基础表无“省份/城市”字段,这里用“商家 GMV 分布”做动态替代
|
||||
this.regionChartOption = {
|
||||
grid: { left: 40, right: 18, top: 20, bottom: 40 },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
xAxis: { type: 'category', data: mList.map((m) => m.name), axisLabel: { rotate: 30, color: 'rgba(0,0,0,0.55)' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
|
||||
series: [{ type: 'bar', data: mList.map((m) => m.sales), barWidth: 18, itemStyle: { borderRadius: 6 } }]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('loadSalesData failed', e)
|
||||
} finally {
|
||||
this.updateTime()
|
||||
}
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
|
||||
66
pages/mall/analytics/test/ANALYTICS_DATA_QUICK_START.md
Normal file
66
pages/mall/analytics/test/ANALYTICS_DATA_QUICK_START.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Analytics 测试数据快速开始(更新版)
|
||||
|
||||
> 本文档基于 **2026-01 修订后的数据库脚本**(含 RLS 安全、中文注释、幂等执行)
|
||||
>
|
||||
> 请务必按下述 **执行顺序** 依次运行 SQL,否则会出现外键或 RLS 限制导致的插入失败。
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ SQL 执行顺序(只创建,不删除)
|
||||
|
||||
| 步骤 | 作用 | 文件 | 需要权限 |
|
||||
| ---- | ----------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------- |
|
||||
| 1 | 创建基础业务表(orders/users/user_sessions/products/merchants/page_views 等) | `01_create_tables.sql` | 任意(不清空数据,可重复执行) |
|
||||
| 2 | 创建用户资料表(ak_users)+ RLS + 用户资料函数 | `../../user/test/USER_AUTH_SCHEMA.sql` | 任意(不清空数据,可重复执行) |
|
||||
| 3 | 创建 auth.users → ak_users 触发器 | `../../user/test/USER_AUTH_TRIGGER.sql` | **需要访问 auth schema(建议 Dashboard SQL Editor)** |
|
||||
| 4 | 创建 analytics_* 表 + RLS + RPC | `ANALYTICS_DB_SCHEMA.sql` | 任意(不清空数据,可重复执行) |
|
||||
| 5 | 插入业务侧测试数据 | `02_insert_test_data.sql` | **service_role**¹ |
|
||||
| 6 | 插入 analytics_* 测试数据 | `ANALYTICS_TEST_SEED.sql` | **service_role**¹ |
|
||||
| 7 | (可选) 查询验证 | `03_test_queries.sql` | 登录用户 |
|
||||
|
||||
¹ *原因:两份 seed 脚本要写入带 RLS 的表,直接用 anon / authenticated 会被策略拦截。Dashboard SQL Editor 默认具备等价于 postgres/service_role 的权限,可直接执行;CLI 请使用 `psql … -U postgres`(或你的 DB 管理员账号)执行。*
|
||||
|
||||
---
|
||||
|
||||
## 🚀 执行步骤(以 Supabase Dashboard 为例)
|
||||
|
||||
1. 打开 **SQL Editor** → 依次新建 Query 运行 *步骤1–4*。
|
||||
2. 登出 / 使用普通账号登录,再运行 *步骤5* 查询验证。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 常见问题
|
||||
|
||||
1. **RLS 阻挡插入**
|
||||
请确认 seed 在 Dashboard 执行,或先 `SET ROLE service_role;`。
|
||||
不建议在 seed 中禁用 RLS。
|
||||
|
||||
2. **重复执行报错**
|
||||
脚本为“只创建,不删除”模式:表/索引使用 `IF NOT EXISTS`,触发器/策略使用系统表判断后再创建,可重复执行。若仍报错,请先 `ROLLBACK;` 再重试。
|
||||
|
||||
3. **前端查不到 seed 数据**
|
||||
登陆用户的 `auth.uid()`必须与 seed 中 `orders.user_id` 等字段匹配;否则受 RLS 影响会看不到。测试时可在 seed 中把某条 `user_id` 改成你自己的 UID。
|
||||
|
||||
---
|
||||
|
||||
## 🔐 权限矩阵(简版)
|
||||
|
||||
| 表 / 功能 | anon | authenticated | service_role |
|
||||
| -------------------------------------- | ----------------- | ------------------- | ------------ |
|
||||
| `orders / order_items / user_sessions` | Insert❌ / Select❌ | ✅(仅本人) | ✅(全部) |
|
||||
| `products / merchants` | Select✅ | CRUD⚠️ (受策略) | ✅ |
|
||||
| `page_views` | Insert✅ / Select❌ | Select✅(本人) | ✅ |
|
||||
| `analytics_*` 表 | ❌ | ✅ (按 owner/shared) | ✅ |
|
||||
| RPC (analytics) | ❌ | ✅ | ✅ |
|
||||
|
||||
> 详细策略请见各 SQL 文件内注释。
|
||||
|
||||
---
|
||||
|
||||
## 🧹 清理
|
||||
|
||||
执行 `04_cleanup.sql` 可按时间 / 用户删除测试数据,脚本已更新为幂等。
|
||||
|
||||
---
|
||||
|
||||
最后更新:2026-01-26
|
||||
@@ -39,22 +39,14 @@
|
||||
|
||||
### 方式 1: 通过 Supabase Dashboard(推荐)
|
||||
|
||||
1. **访问 Dashboard**
|
||||
```
|
||||
http://192.168.1.63:8000
|
||||
或
|
||||
http://192.168.1.63:3000 (Studio 默认端口)
|
||||
```
|
||||
1. **打开 Supabase Studio / Dashboard**
|
||||
- 请使用你自己的部署地址访问(不要在仓库文档里硬编码地址/账号/密码)。
|
||||
|
||||
2. **登录**
|
||||
- 用户名:`supabase`
|
||||
- 密码:`D4ce5p8YBpfYzEoDGZ_7MzehZcWrdCNyDEj_VSUBmOw`
|
||||
|
||||
3. **打开 SQL Editor**
|
||||
2. **打开 SQL Editor**
|
||||
- 在左侧菜单找到 "SQL Editor"
|
||||
- 点击 "New Query"
|
||||
|
||||
4. **执行脚本**
|
||||
3. **执行脚本**
|
||||
- 复制 `01_create_tables.sql` 的内容,粘贴并执行
|
||||
- 复制 `02_insert_test_data.sql` 的内容,粘贴并执行
|
||||
- (可选)复制 `03_test_queries.sql` 的内容,验证数据
|
||||
@@ -62,10 +54,8 @@
|
||||
### 方式 2: 使用命令行(PostgreSQL)
|
||||
|
||||
```bash
|
||||
# 连接到内网 Supabase 数据库
|
||||
psql -h 192.168.1.63 -p 5432 -U postgres -d postgres
|
||||
|
||||
# 输入密码:yxyHINygZMLSq9jLddrZQBB-CoyGHSF5DwlwWmbrYXc
|
||||
# 连接到 Supabase Postgres(参数请按你的环境填写)
|
||||
psql -h <DB_HOST> -p <DB_PORT> -U postgres -d postgres
|
||||
|
||||
# 执行 SQL 文件(需要完整路径)
|
||||
\i D:/datas/hfkj/mall/pages/mall/analytics/test/01_create_tables.sql
|
||||
@@ -76,17 +66,20 @@ psql -h 192.168.1.63 -p 5432 -U postgres -d postgres
|
||||
### 方式 3: 使用图形工具(DBeaver / pgAdmin)
|
||||
|
||||
1. **创建连接**
|
||||
- 主机:`192.168.1.63`
|
||||
- 端口:`5432`
|
||||
- 主机:`<DB_HOST>`
|
||||
- 端口:`<DB_PORT>`
|
||||
- 数据库:`postgres`
|
||||
- 用户名:`postgres`
|
||||
- 密码:`yxyHINygZMLSq9jLddrZQBB-CoyGHSF5DwlwWmbrYXc`
|
||||
- 用户名:`postgres`(或你的管理员账号)
|
||||
- 密码:`<DB_PASSWORD>`
|
||||
|
||||
2. **执行 SQL**
|
||||
- 打开 SQL 编辑器
|
||||
- 复制 SQL 文件内容并执行
|
||||
|
||||
**详细说明请查看:`SQL_USAGE_GUIDE.md`**
|
||||
**详细说明请查看:**
|
||||
- **`ANALYTICS_DATA_QUICK_START.md`** - ⭐ **SQL 文件执行顺序指南(必读!)**
|
||||
- `SQL_USAGE_GUIDE.md` - SQL 脚本执行详细指南
|
||||
- `TEST_DATA_INSERT_GUIDE.md` - 测试数据插入指南(包含 RLS 处理说明)
|
||||
|
||||
## 测试数据说明
|
||||
|
||||
@@ -111,6 +104,20 @@ psql -h 192.168.1.63 -p 5432 -U postgres -d postgres
|
||||
- **今日下单用户数:** 约 8个(从 orders 表去重统计)
|
||||
- **预期转化率:** 约 53-80%(根据实际数据计算)
|
||||
|
||||
## ⚠️ 重要:RLS(行级安全策略)说明
|
||||
|
||||
**所有表已启用 RLS**,插入测试数据时需要注意:
|
||||
|
||||
1. **推荐方式**:使用 Supabase Dashboard 的 SQL Editor 执行脚本
|
||||
- Dashboard 默认使用 `service_role` 权限,可以绕过 RLS
|
||||
- 无需额外配置,直接执行即可
|
||||
|
||||
2. **命令行方式**:如果使用命令行或脚本执行
|
||||
- 需要临时禁用 RLS(见 `02_insert_test_data.sql` 中的注释说明)
|
||||
- 或使用 `SECURITY DEFINER` 函数(见 `TEST_DATA_INSERT_GUIDE.md`)
|
||||
|
||||
3. **详细说明**:请查看 `TEST_DATA_INSERT_GUIDE.md` 获取完整的插入指南
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **时间依赖**
|
||||
@@ -131,24 +138,14 @@ psql -h 192.168.1.63 -p 5432 -U postgres -d postgres
|
||||
- 所有 ID 使用 UUID 格式
|
||||
- 测试数据使用了固定的 UUID 便于识别
|
||||
|
||||
5. **RLS 权限**
|
||||
- 插入数据后,前端查询需要用户已登录
|
||||
- 测试数据的 `user_id` 需要与登录用户的 `auth.uid()` 匹配才能查询到
|
||||
- 或者使用公开数据(如 `products`、`merchants` 表)
|
||||
|
||||
## 清理测试数据
|
||||
|
||||
如果需要清理测试数据,可以执行:
|
||||
|
||||
```sql
|
||||
-- 谨慎操作:清空测试数据
|
||||
TRUNCATE TABLE orders, user_sessions, users, order_items, page_views CASCADE;
|
||||
```
|
||||
|
||||
或者删除特定时间范围的数据:
|
||||
|
||||
```sql
|
||||
-- 删除今日的测试订单
|
||||
DELETE FROM orders WHERE created_at >= DATE_TRUNC('day', NOW());
|
||||
|
||||
-- 删除测试用户会话
|
||||
DELETE FROM user_sessions WHERE created_at >= DATE_TRUNC('day', NOW());
|
||||
```
|
||||
如需清理测试数据,请使用独立的清理脚本(例如 `04_cleanup.sql`)。
|
||||
|
||||
## 验证实时大屏功能
|
||||
|
||||
|
||||
15
pages/mall/analytics/test/SQL_EXECUTION_ORDER.md
Normal file
15
pages/mall/analytics/test/SQL_EXECUTION_ORDER.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# SQL 文件执行顺序指南(已弃用)
|
||||
|
||||
> 本文件已停止维护,避免与新脚本冲突。
|
||||
>
|
||||
> ✅ **请以 `ANALYTICS_DATA_QUICK_START.md` 为唯一权威执行顺序与权限说明文档。**
|
||||
|
||||
## 当前推荐执行顺序(摘要)
|
||||
|
||||
1. `01_create_tables.sql`(基础业务表 + RLS + 中文注释,Drop-first)
|
||||
2. `../../user/test/USER_AUTH_SCHEMA.sql`(`ak_users` + RLS + 资料函数,Drop-first)
|
||||
3. `../../user/test/USER_AUTH_TRIGGER.sql`(auth.users → ak_users 触发器)
|
||||
4. `ANALYTICS_DB_SCHEMA.sql`(analytics_* 表 + RLS + RPC,Drop-first)
|
||||
5. `02_insert_test_data.sql`(基础表测试数据,需 service_role/postgres)
|
||||
6. `ANALYTICS_TEST_SEED.sql`(analytics_* 测试数据,需 service_role/postgres)
|
||||
7. `03_test_queries.sql`(可选:验证查询)
|
||||
@@ -19,18 +19,10 @@ pages/mall/analytics/test/
|
||||
|
||||
如果您的内网 Supabase 有 Dashboard 界面:
|
||||
|
||||
1. **访问 Dashboard**
|
||||
```
|
||||
http://192.168.1.63:8000
|
||||
或
|
||||
http://192.168.1.63:3000 (Studio 默认端口)
|
||||
```
|
||||
1. **打开 Supabase Studio / Dashboard**
|
||||
- 使用你自己的部署地址访问(不要在仓库文档里硬编码地址/账号/密码)。
|
||||
|
||||
2. **登录**
|
||||
- 用户名:`supabase`(根据您的配置)
|
||||
- 密码:`D4ce5p8YBpfYzEoDGZ_7MzehZcWrdCNyDEj_VSUBmOw`
|
||||
|
||||
3. **打开 SQL Editor**
|
||||
2. **打开 SQL Editor**
|
||||
- 在左侧菜单找到 "SQL Editor" 或 "SQL"
|
||||
- 点击 "New Query"
|
||||
|
||||
@@ -51,10 +43,9 @@ pages/mall/analytics/test/
|
||||
1. **连接数据库**
|
||||
```bash
|
||||
# 使用 psql 连接
|
||||
psql -h 192.168.1.63 -p 5432 -U postgres -d postgres
|
||||
psql -h <DB_HOST> -p <DB_PORT> -U postgres -d postgres
|
||||
|
||||
# 输入密码(根据您的配置)
|
||||
# POSTGRES_PASSWORD=yxyHINygZMLSq9jLddrZQBB-CoyGHSF5DwlwWmbrYXc
|
||||
# 密码请按你的环境输入/从安全渠道获取(不要写进仓库)
|
||||
```
|
||||
|
||||
2. **执行 SQL 文件**
|
||||
@@ -70,41 +61,26 @@ pages/mall/analytics/test/
|
||||
### 方式 3: 通过 DBeaver / pgAdmin 等图形工具
|
||||
|
||||
1. **创建新连接**
|
||||
- 主机:`192.168.1.63`
|
||||
- 端口:`5432`
|
||||
- 主机:`<DB_HOST>`
|
||||
- 端口:`<DB_PORT>`
|
||||
- 数据库:`postgres`
|
||||
- 用户名:`postgres`
|
||||
- 密码:`yxyHINygZMLSq9jLddrZQBB-CoyGHSF5DwlwWmbrYXc`
|
||||
- 密码:`<DB_PASSWORD>`
|
||||
|
||||
2. **执行 SQL**
|
||||
- 打开 SQL 编辑器
|
||||
- 复制 SQL 文件内容
|
||||
- 执行脚本
|
||||
|
||||
### 方式 4: 通过 HTTP API(程序化执行)
|
||||
|
||||
使用 Supabase REST API 执行 SQL(需要 service_role key):
|
||||
|
||||
```javascript
|
||||
// 注意:这种方式需要 Supabase 的 SQL 执行功能
|
||||
// 通常不推荐,因为安全风险较高
|
||||
const response = await fetch('http://192.168.1.63:8000/rest/v1/rpc/exec_sql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'apikey': 'YOUR_SERVICE_ROLE_KEY',
|
||||
'Authorization': 'Bearer YOUR_SERVICE_ROLE_KEY',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sql: 'SELECT * FROM users LIMIT 1;'
|
||||
})
|
||||
})
|
||||
```
|
||||
> 不建议通过 HTTP API “执行任意 SQL”(高风险)。
|
||||
> 如需服务端能力,请用 Supabase Edge Functions + 限定输入输出的 RPC。
|
||||
|
||||
## 📝 执行顺序
|
||||
|
||||
**重要:必须按顺序执行!**
|
||||
|
||||
> ✅ 以 `ANALYTICS_DATA_QUICK_START.md` 为权威执行顺序与权限说明(本文件只做执行方式补充)。
|
||||
|
||||
1. ✅ **第一步:创建表结构**
|
||||
```sql
|
||||
-- 执行 01_create_tables.sql
|
||||
@@ -190,17 +166,11 @@ GRANT ALL PRIVILEGES ON SCHEMA public TO postgres;
|
||||
|
||||
### 2. 表已存在
|
||||
|
||||
如果表已存在,脚本会使用 `CREATE TABLE IF NOT EXISTS`,不会报错。
|
||||
但如果需要重新创建:
|
||||
```sql
|
||||
-- 先删除表(谨慎操作)
|
||||
DROP TABLE IF EXISTS order_items CASCADE;
|
||||
DROP TABLE IF EXISTS page_views CASCADE;
|
||||
DROP TABLE IF EXISTS user_sessions CASCADE;
|
||||
DROP TABLE IF EXISTS orders CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
DROP TABLE IF EXISTS products CASCADE;
|
||||
```
|
||||
如果表已存在:
|
||||
- `01_create_tables.sql` / `ANALYTICS_DB_SCHEMA.sql` 现为 **只创建(Create-only)** 脚本,不包含 `DROP/DELETE/TRUNCATE`,可重复执行且不会清空数据。
|
||||
- 如需结构变更,请用迁移脚本(ALTER TABLE)。
|
||||
|
||||
> 如确实要“清理后重建”,请另外单独维护清理脚本(避免把破坏性操作放进默认文档/默认流程)。
|
||||
|
||||
### 3. 时间依赖
|
||||
|
||||
@@ -238,8 +208,8 @@ Error: password authentication failed
|
||||
Error: relation "orders" already exists
|
||||
```
|
||||
**解决:**
|
||||
- 脚本已使用 `IF NOT EXISTS`,通常不会报错
|
||||
- 如需重新创建,先删除表
|
||||
- 说明你执行的脚本版本与当前仓库不一致,或只拷贝了部分 SQL
|
||||
- 请按 `ANALYTICS_DATA_QUICK_START.md` 的顺序完整执行最新脚本(Drop-first,不应出现该错误)
|
||||
|
||||
### Q4: 权限不足
|
||||
```
|
||||
|
||||
209
pages/mall/analytics/test/TEST_DATA_INSERT_GUIDE.md
Normal file
209
pages/mall/analytics/test/TEST_DATA_INSERT_GUIDE.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# 测试数据插入指南
|
||||
|
||||
> 本文档说明如何在启用 RLS(行级安全策略)的情况下插入测试数据。
|
||||
|
||||
## 📋 前置条件
|
||||
|
||||
1. **已执行表结构创建脚本**
|
||||
- `01_create_tables.sql` - 创建表结构和 RLS 策略
|
||||
- `ANALYTICS_DB_SCHEMA.sql` - 创建 analytics_* 表(可选)
|
||||
|
||||
2. **确认 Supabase 连接**
|
||||
- 已配置 Supabase 项目
|
||||
- 可以访问 Supabase Dashboard 的 SQL Editor
|
||||
|
||||
## 🚀 插入测试数据的三种方式
|
||||
|
||||
### 方式一:使用 Supabase Dashboard(推荐)
|
||||
|
||||
**优点**:最简单,无需处理 RLS 权限问题
|
||||
**适用场景**:开发测试、快速验证
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 打开 Supabase Dashboard
|
||||
2. 进入 **SQL Editor**
|
||||
3. 复制 `02_insert_test_data.sql` 的全部内容
|
||||
4. 粘贴到 SQL Editor 中
|
||||
5. 点击 **Run** 执行
|
||||
|
||||
**说明**:Supabase Dashboard 的 SQL Editor 默认使用 `service_role` 权限,可以绕过 RLS 策略,直接插入数据。
|
||||
|
||||
---
|
||||
|
||||
### 方式二:临时禁用 RLS(适用于命令行)
|
||||
|
||||
**优点**:可以在命令行或脚本中执行
|
||||
**适用场景**:自动化脚本、CI/CD
|
||||
|
||||
**步骤**(不推荐,除非你明确理解风险):
|
||||
|
||||
1. 编辑 `02_insert_test_data.sql`
|
||||
2. 取消文件开头关于禁用 RLS 的注释(第 12-19 行)
|
||||
3. 取消文件末尾关于重新启用 RLS 的注释(第 137-144 行)
|
||||
4. 执行脚本
|
||||
|
||||
**示例**:
|
||||
|
||||
```sql
|
||||
-- 在脚本开头添加
|
||||
BEGIN;
|
||||
ALTER TABLE orders DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE user_sessions DISABLE ROW LEVEL SECURITY;
|
||||
-- ... 其他表
|
||||
|
||||
-- 插入数据...
|
||||
|
||||
-- 在脚本末尾添加
|
||||
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
|
||||
-- ... 其他表
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
**⚠️ 注意**:执行完成后务必重新启用 RLS,否则数据将不受保护!
|
||||
|
||||
---
|
||||
|
||||
### 方式三:使用 SECURITY DEFINER 函数(高级)
|
||||
|
||||
**优点**:更安全,不需要禁用 RLS
|
||||
**适用场景**:生产环境、需要定期插入测试数据
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 创建一个 SECURITY DEFINER 函数来插入测试数据
|
||||
2. 调用该函数执行插入
|
||||
|
||||
**示例函数**:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION insert_test_data()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
-- 插入测试用户
|
||||
INSERT INTO users (id, phone, email, nickname, last_login_at) VALUES
|
||||
('11111111-1111-1111-1111-111111111111', '13800000001', 'user1@test.com', '测试用户1', NOW() - INTERVAL '2 minutes')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 插入其他测试数据...
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 执行函数
|
||||
SELECT insert_test_data();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证数据插入
|
||||
|
||||
执行以下查询验证数据是否插入成功:
|
||||
|
||||
```sql
|
||||
-- 检查用户数量
|
||||
SELECT COUNT(*) FROM users;
|
||||
-- 预期:8
|
||||
|
||||
-- 检查订单数量
|
||||
SELECT COUNT(*) FROM orders;
|
||||
-- 预期:25(15个今日订单 + 10个昨日订单)
|
||||
|
||||
-- 检查用户会话数量
|
||||
SELECT COUNT(*) FROM user_sessions;
|
||||
-- 预期:10
|
||||
|
||||
-- 检查访问日志数量
|
||||
SELECT COUNT(*) FROM page_views;
|
||||
-- 预期:15
|
||||
|
||||
-- 检查商家数量
|
||||
SELECT COUNT(*) FROM merchants;
|
||||
-- 预期:2
|
||||
|
||||
-- 检查商品数量
|
||||
SELECT COUNT(*) FROM products;
|
||||
-- 预期:3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 常见问题
|
||||
|
||||
### Q1: 执行 INSERT 时提示 "new row violates row-level security policy"
|
||||
|
||||
**原因**:RLS 策略阻止了插入操作。
|
||||
|
||||
**解决方案**:
|
||||
- 使用方式一(Supabase Dashboard)
|
||||
- 或使用方式二(临时禁用 RLS)
|
||||
- 或使用方式三(SECURITY DEFINER 函数)
|
||||
|
||||
### Q2: 插入数据后,前端查询不到数据
|
||||
|
||||
**原因**:RLS 策略限制了查询权限。
|
||||
|
||||
**解决方案**:
|
||||
1. 确认前端已正确登录(`auth.uid()` 不为 NULL)
|
||||
2. 检查 RLS 策略是否正确配置
|
||||
3. 确认测试数据的 `user_id` 与登录用户的 `auth.uid()` 匹配
|
||||
|
||||
### Q3: 如何清空测试数据重新插入?
|
||||
|
||||
为避免在默认文档里包含破坏性 SQL,本项目将“清理/删除”动作放在独立清理脚本中(如 `04_cleanup.sql`)。
|
||||
|
||||
如你需要重新生成测试数据:
|
||||
- 先执行清理脚本
|
||||
- 再重新执行 seed 脚本
|
||||
|
||||
---
|
||||
|
||||
## 📝 测试数据说明
|
||||
|
||||
### 用户数据
|
||||
- **数量**:8 个测试用户
|
||||
- **UUID 范围**:`11111111-...` 到 `88888888-...`
|
||||
- **用途**:用于订单、会话、访问日志等关联数据
|
||||
|
||||
### 订单数据
|
||||
- **今日订单**:15 笔(status = 2,已支付)
|
||||
- **昨日订单**:10 笔(用于增长率对比)
|
||||
- **总 GMV**:约 3,500 元(今日)
|
||||
|
||||
### 在线用户
|
||||
- **最近 5 分钟活跃**:5 个用户
|
||||
- **用于**:实时大屏的"在线用户"统计
|
||||
|
||||
### 访问日志
|
||||
- **数量**:15 条
|
||||
- **来源分布**:direct/search/social/ad
|
||||
- **用于**:转化率计算、流量来源分析
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文件
|
||||
|
||||
- `01_create_tables.sql` - 表结构创建脚本
|
||||
- `02_insert_test_data.sql` - 测试数据插入脚本
|
||||
- `03_test_queries.sql` - 数据验证查询脚本
|
||||
- `ANALYTICS_DB_SCHEMA.sql` - Analytics 表结构(可选)
|
||||
|
||||
---
|
||||
|
||||
## 📚 下一步
|
||||
|
||||
插入测试数据后,可以:
|
||||
|
||||
1. **验证前端页面**
|
||||
- 访问 `/pages/mall/analytics/index` 查看实时大屏
|
||||
- 检查 KPI 数据是否正确显示
|
||||
|
||||
2. **执行验证查询**
|
||||
- 运行 `03_test_queries.sql` 验证数据计算逻辑
|
||||
|
||||
3. **测试 RPC 函数**
|
||||
- 调用 `rpc_analytics_realtime_kpis` 验证实时 KPI 计算
|
||||
@@ -186,10 +186,79 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
calcDateRange() {
|
||||
const now = new Date()
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 365
|
||||
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
|
||||
return { startDate, endDate }
|
||||
},
|
||||
|
||||
async loadUserData() {
|
||||
// TODO: 实现用户数据加载
|
||||
this.updateTime()
|
||||
this.buildChartOptions()
|
||||
try {
|
||||
this.updateTime()
|
||||
const { startDate, endDate } = this.calcDateRange()
|
||||
const startStr = startDate.toISOString().slice(0, 10)
|
||||
const endStr = endDate.toISOString().slice(0, 10)
|
||||
|
||||
const p = new UTSJSONObject()
|
||||
p.set('p_start_date', startStr)
|
||||
p.set('p_end_date', endStr)
|
||||
|
||||
// KPI(新 RPC)
|
||||
const res: any = await supa.rpc('rpc_analytics_user_kpis', p)
|
||||
const row = Array.isArray(res.data) && res.data.length > 0 ? res.data[0] : (res.data || {})
|
||||
const safe = (v: any): number => {
|
||||
const n = Number(v)
|
||||
return isFinite(n) ? n : 0
|
||||
}
|
||||
this.userData = {
|
||||
total_users: Math.round(safe(row.total_users)),
|
||||
user_growth: safe(row.user_growth),
|
||||
new_users: Math.round(safe(row.new_users)),
|
||||
new_user_growth: safe(row.new_user_growth),
|
||||
active_rate: safe(row.active_rate),
|
||||
active_growth: safe(row.active_growth),
|
||||
repurchase_rate: safe(row.repurchase_rate),
|
||||
repurchase_growth: safe(row.repurchase_growth)
|
||||
}
|
||||
|
||||
// 增长趋势(新 RPC)
|
||||
const tRes: any = await supa.rpc('rpc_analytics_user_growth_trend', p)
|
||||
const rows: Array<any> = Array.isArray(tRes.data) ? (tRes.data as Array<any>) : []
|
||||
const x: Array<string> = []
|
||||
const newArr: Array<number> = []
|
||||
const totalArr: Array<number> = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const d = `${rows[i].date}`
|
||||
x.push(d.slice(5))
|
||||
newArr.push(Number(rows[i].new_users) || 0)
|
||||
totalArr.push(Number(rows[i].total_users) || 0)
|
||||
}
|
||||
|
||||
// 构建图表(先把“用户增长趋势”做成真实动态图)
|
||||
this.growthChartOption = {
|
||||
grid: { left: 40, right: 18, top: 20, bottom: 40 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新用户', '总用户'], bottom: 0 },
|
||||
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
|
||||
series: [
|
||||
{ name: '新用户', type: 'bar', data: newArr, barWidth: 14, itemStyle: { borderRadius: 6 } },
|
||||
{ name: '总用户', type: 'line', data: totalArr, smooth: true, symbolSize: 6 }
|
||||
]
|
||||
}
|
||||
|
||||
// 其余图表:先用“有文案的占位”避免空白(后续可按业务字段继续增强)
|
||||
this.retentionChartOption = { title: { text: '留存率(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
this.activityChartOption = { title: { text: '活跃度(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
this.comparisonChartOption = { title: { text: '新老用户对比(待接入)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
this.profileChartOption = { title: { text: '用户画像(待接入:需要性别/年龄/地域字段)', left: 'center', top: 10, textStyle: { fontSize: 12, color: 'rgba(0,0,0,0.55)' } }, series: [] }
|
||||
} catch (e) {
|
||||
console.error('loadUserData failed', e)
|
||||
} finally {
|
||||
this.updateTime()
|
||||
}
|
||||
},
|
||||
|
||||
selectPeriod(p: string) {
|
||||
@@ -228,14 +297,7 @@ export default {
|
||||
return `${sign}${v.toFixed(1)}%`
|
||||
},
|
||||
|
||||
buildChartOptions() {
|
||||
// TODO: 构建图表配置
|
||||
this.growthChartOption = {}
|
||||
this.retentionChartOption = {}
|
||||
this.activityChartOption = {}
|
||||
this.comparisonChartOption = {}
|
||||
this.profileChartOption = {}
|
||||
},
|
||||
buildChartOptions() {},
|
||||
|
||||
handleMenu() {
|
||||
this.showSidebarMenu = true
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
import { getCurrentUserId } from '@/utils/store.uts'
|
||||
|
||||
@@ -127,7 +126,8 @@ const goPlanList = () => {
|
||||
}
|
||||
|
||||
onMounted(loadSubs)
|
||||
onShow(loadSubs)
|
||||
// 注意:uni-app x 的 <script setup> 中不支持 onShow,使用 onMounted 代替
|
||||
// 如果需要页面显示时刷新,可以在页面选项中定义 onShow
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -258,8 +258,28 @@ const handleLogin = async () => {
|
||||
if (loginType.value === 0) {
|
||||
const isEmail = account.value.includes('@')
|
||||
if (isEmail) {
|
||||
const result = await supa.signIn(account.value, password.value)
|
||||
if (result.user == null) throw new Error('登录失败')
|
||||
// 邮箱 + 密码登录(Supabase Auth)
|
||||
const result = await supa.signIn(account.value.trim(), password.value)
|
||||
console.log('signIn result:', result)
|
||||
|
||||
// 检查登录是否失败
|
||||
if (result.user == null) {
|
||||
// 检查是否是邮箱未确认的错误
|
||||
const rawData = result.raw as UTSJSONObject
|
||||
const errorMsg = rawData?.getString('msg') ?? ''
|
||||
const errorCode = rawData?.getString('error_code') ?? ''
|
||||
|
||||
if (errorMsg.includes('email') && errorMsg.includes('confirm') ||
|
||||
errorCode === 'email_not_confirmed' ||
|
||||
errorMsg.includes('邮箱') && errorMsg.includes('确认')) {
|
||||
throw new Error('邮箱未确认,请先检查邮箱并点击确认链接')
|
||||
} else if (errorMsg.includes('Invalid login credentials') ||
|
||||
errorCode === 'invalid_credentials') {
|
||||
throw new Error('邮箱或密码错误')
|
||||
} else {
|
||||
throw new Error(errorMsg || '登录失败,请重试')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uni.showToast({ title: '手机号密码登录功能开发中', icon: 'none' })
|
||||
return
|
||||
@@ -269,8 +289,13 @@ const handleLogin = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const profile = await getCurrentUser()
|
||||
if (profile == null) throw new Error('获取用户信息失败')
|
||||
// 尝试获取/补全用户资料,但失败时不再阻塞登录
|
||||
try {
|
||||
const profile = await getCurrentUser()
|
||||
console.log('current user profile:', profile)
|
||||
} catch (e) {
|
||||
console.error('获取用户信息失败(忽略,不阻塞登录):', e)
|
||||
}
|
||||
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -531,7 +531,7 @@
|
||||
// UTS split 返回 UTSArray<String?>,需转普通string[]
|
||||
const partsRaw = str.split('-');
|
||||
// 如果分割后不够段,直接用当前日期
|
||||
if (partsRaw != null || typeof partsRaw.length !== 'number' || partsRaw.length !== 3) {
|
||||
if (partsRaw == null || partsRaw.length !== 3) {
|
||||
const now = new Date();
|
||||
return [now.getFullYear(), now.getMonth() + 1, now.getDate()];
|
||||
}
|
||||
|
||||
@@ -203,13 +203,94 @@
|
||||
try {
|
||||
// 使用 Supabase Auth:邮箱 + 密码注册
|
||||
const result = await supa.signUp(email.value.trim(), password.value)
|
||||
const data = new UTSJSONObject(result as any)
|
||||
const user = data.getJSON('user')
|
||||
|
||||
console.log('📝 注册返回结果:', result)
|
||||
console.log('📝 注册返回结果(JSON):', JSON.stringify(result))
|
||||
|
||||
// 检查是否有错误(邮件发送失败等)
|
||||
const errorCode = result?.getString('error_code') ?? ''
|
||||
const errorMsg = result?.getString('msg') ?? ''
|
||||
const code = result?.getNumber('code') ?? 0
|
||||
|
||||
console.log('📝 错误代码:', errorCode, '错误信息:', errorMsg, '状态码:', code)
|
||||
|
||||
// 如果返回 500 错误且是邮件发送失败,但用户可能已创建
|
||||
if (code === 500 && (errorCode === 'unexpected_failure' || errorMsg.includes('confirmation email'))) {
|
||||
console.warn('⚠️ 邮件发送失败,但用户可能已创建,尝试获取用户信息')
|
||||
// 即使邮件发送失败,用户可能已经在 auth.users 中创建
|
||||
// 这里我们仍然尝试创建用户资料
|
||||
}
|
||||
|
||||
// signUp 返回的是 UTSJSONObject,Supabase signup API 返回结构:
|
||||
// { user: {...}, session: {...} } - 如果邮箱验证未开启
|
||||
// { user: {...} } - 如果邮箱验证已开启(需要验证邮箱后才能登录)
|
||||
// { code: 500, error_code: ..., msg: ... } - 如果发生错误(但用户可能已创建)
|
||||
let user: UTSJSONObject | null = null
|
||||
let hasSession = false
|
||||
|
||||
if (result != null) {
|
||||
// 尝试获取 user 字段
|
||||
const userField = result.getJSON('user')
|
||||
if (userField != null) {
|
||||
user = userField
|
||||
console.log('✅ 找到 user 字段:', user.getString('id'), user.getString('email'))
|
||||
} else {
|
||||
// 如果没有 user 字段,可能 result 本身就是 user 对象
|
||||
const id = result.getString('id')
|
||||
if (id != null && id !== '') {
|
||||
user = result
|
||||
console.log('✅ result 本身就是 user 对象:', id)
|
||||
} else {
|
||||
console.warn('⚠️ 未找到 user 信息,检查所有字段:', Object.keys(result.toMap() || {}))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有 session(表示注册后自动登录成功)
|
||||
const sessionField = result.getJSON('session')
|
||||
if (sessionField != null) {
|
||||
hasSession = true
|
||||
console.log('✅ 找到 session,已自动登录')
|
||||
// 如果有 session,说明已经自动登录,token 应该已经设置
|
||||
// 此时可以直接创建用户资料
|
||||
} else {
|
||||
console.log('ℹ️ 未找到 session,可能需要邮箱验证')
|
||||
}
|
||||
}
|
||||
|
||||
// 如果返回错误且没有用户信息,说明注册失败
|
||||
if (user == null && code !== 0 && code !== 200) {
|
||||
// 如果是邮件发送失败,给出明确的错误提示
|
||||
if (code === 500 && errorMsg.includes('confirmation email')) {
|
||||
throw new Error('注册失败:邮件服务配置错误,请联系管理员或修改 Supabase 配置(设置 ENABLE_EMAIL_AUTOCONFIRM=true)')
|
||||
} else {
|
||||
throw new Error(errorMsg || '注册失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 可能开启了邮箱验证,此时 user 可能为空/或 session 为空
|
||||
// 如果获取到 user,尝试创建业务侧用户资料(ak_users)
|
||||
if (user != null) {
|
||||
// 记录业务侧用户资料(ak_users),用于 app 内个人中心等页面读取
|
||||
await ensureUserProfile(user as UTSJSONObject)
|
||||
try {
|
||||
const profileResult = await ensureUserProfile(user)
|
||||
if (profileResult != null) {
|
||||
console.log('✅ 用户资料创建成功:', profileResult.id)
|
||||
} else {
|
||||
console.warn('⚠️ 用户资料创建失败,但注册已成功')
|
||||
// 如果创建失败,可能是因为 RLS 策略限制
|
||||
// 建议用户登录后再自动创建(在 getCurrentUser 中处理)
|
||||
}
|
||||
} catch (profileError) {
|
||||
console.error('❌ 创建用户资料异常:', profileError)
|
||||
// 即使创建资料失败,也不阻止注册流程
|
||||
// 用户登录时会自动创建(见 utils/store.uts 的 getCurrentUser)
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ 注册成功但未获取到用户信息')
|
||||
// 可能需要邮箱验证,用户验证邮箱后登录时会自动创建资料
|
||||
}
|
||||
|
||||
// 如果注册后没有自动登录(需要邮箱验证),提示用户
|
||||
if (!hasSession && user != null) {
|
||||
console.log('ℹ️ 需要邮箱验证,验证后登录时会自动创建用户资料')
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
@@ -230,12 +311,17 @@
|
||||
const error = err as Error
|
||||
if (error.message != null && error.message.trim() !== '') {
|
||||
errorMessage = error.message
|
||||
// 如果是邮件发送失败,给出更友好的提示
|
||||
if (error.message.includes('confirmation email') || error.message.includes('邮件')) {
|
||||
errorMessage = '注册可能成功,但邮件发送失败,请稍后尝试登录'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: errorMessage,
|
||||
icon: 'none'
|
||||
icon: 'none',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
100
pages/user/test/CONFIG_CHANGED.md
Normal file
100
pages/user/test/CONFIG_CHANGED.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Supabase 配置已修改
|
||||
|
||||
## ✅ 已完成的修改
|
||||
|
||||
已修改 `supabase_pro/.env` 文件:
|
||||
|
||||
```env
|
||||
ENABLE_EMAIL_AUTOCONFIRM=true # 从 false 改为 true
|
||||
```
|
||||
|
||||
## 🔄 下一步操作
|
||||
|
||||
### 重启 Supabase Auth 服务
|
||||
|
||||
修改配置后,**必须重启服务**才能生效:
|
||||
|
||||
```bash
|
||||
cd supabase_pro
|
||||
docker-compose restart auth
|
||||
```
|
||||
|
||||
或者重启整个 Supabase:
|
||||
|
||||
```bash
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### 验证配置
|
||||
|
||||
```bash
|
||||
# 检查配置是否已修改
|
||||
grep ENABLE_EMAIL_AUTOCONFIRM supabase_pro/.env
|
||||
```
|
||||
|
||||
应该显示:`ENABLE_EMAIL_AUTOCONFIRM=true`
|
||||
|
||||
---
|
||||
|
||||
## 📝 配置说明
|
||||
|
||||
### 当前配置
|
||||
|
||||
- ✅ `ENABLE_EMAIL_SIGNUP=true` - 允许邮箱注册
|
||||
- ✅ `ENABLE_EMAIL_AUTOCONFIRM=true` - **跳过邮件验证**,注册后立即可以登录
|
||||
|
||||
### 效果
|
||||
|
||||
1. **注册时**:
|
||||
- 用户填写邮箱和密码
|
||||
- 点击注册
|
||||
- Supabase 直接创建用户(不发送邮件)
|
||||
- 返回 session,用户自动登录
|
||||
|
||||
2. **登录时**:
|
||||
- 使用注册的邮箱和密码
|
||||
- 可以直接登录(无需邮箱确认)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
1. **重启服务**(如果还没重启)
|
||||
```bash
|
||||
cd supabase_pro
|
||||
docker-compose restart auth
|
||||
```
|
||||
|
||||
2. **测试注册**:
|
||||
- 在前端注册新用户
|
||||
- 应该看到 "注册成功" 提示
|
||||
- 自动跳转到登录页面(或直接进入应用)
|
||||
|
||||
3. **测试登录**:
|
||||
- 使用注册的邮箱和密码登录
|
||||
- 应该可以成功登录
|
||||
|
||||
4. **验证数据库**:
|
||||
```sql
|
||||
-- 检查新注册的用户
|
||||
SELECT id, email, email_confirmed_at, created_at
|
||||
FROM auth.users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
|
||||
-- 检查 ak_users 表
|
||||
SELECT id, email, username, created_at
|
||||
FROM ak_users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要提示
|
||||
|
||||
- **修改配置后必须重启服务**,否则配置不会生效
|
||||
- 如果重启后仍然无法注册,检查服务日志:
|
||||
```bash
|
||||
docker-compose logs auth
|
||||
```
|
||||
167
pages/user/test/DEBUG_SIGNUP.md
Normal file
167
pages/user/test/DEBUG_SIGNUP.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# 注册问题调试指南
|
||||
|
||||
## 🔍 当前问题
|
||||
|
||||
修改配置后,注册仍然没有创建用户记录。
|
||||
|
||||
## 📋 检查清单
|
||||
|
||||
### 1. 确认配置已修改并重启服务
|
||||
|
||||
```bash
|
||||
# 检查配置
|
||||
cd supabase_pro
|
||||
grep ENABLE_EMAIL_AUTOCONFIRM .env
|
||||
# 应该显示: ENABLE_EMAIL_AUTOCONFIRM=true
|
||||
|
||||
# 重启服务(必须)
|
||||
docker-compose restart auth
|
||||
# 或者
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### 2. 检查 Supabase 服务是否正常运行
|
||||
|
||||
```bash
|
||||
cd supabase_pro
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
确保 `auth` 服务状态为 `Up`。
|
||||
|
||||
### 3. 检查服务日志
|
||||
|
||||
```bash
|
||||
# 查看 auth 服务日志
|
||||
docker-compose logs auth --tail=50
|
||||
|
||||
# 查看是否有错误
|
||||
docker-compose logs auth | grep -i error
|
||||
```
|
||||
|
||||
### 4. 验证配置是否生效
|
||||
|
||||
在 Supabase Dashboard (http://192.168.1.63:3000) 的 SQL Editor 中执行:
|
||||
|
||||
```sql
|
||||
-- 检查当前配置(需要访问 GoTrue 配置)
|
||||
-- 注意:这个查询可能无法直接执行,但可以通过 API 检查
|
||||
```
|
||||
|
||||
或者直接测试注册,查看返回结果。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 调试步骤
|
||||
|
||||
### 步骤 1:检查前端配置
|
||||
|
||||
确认 `ak/config.uts` 中的配置正确:
|
||||
|
||||
```typescript
|
||||
export const SUPA_URL: string = 'http://192.168.1.63:8000'
|
||||
export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
```
|
||||
|
||||
### 步骤 2:测试注册并查看日志
|
||||
|
||||
1. 打开浏览器开发者工具(F12)
|
||||
2. 切换到 Console 标签
|
||||
3. 尝试注册新用户
|
||||
4. 查看控制台输出:
|
||||
- `📝 signUp HTTP 状态码: ...`
|
||||
- `📝 注册返回结果: ...`
|
||||
- `📝 错误代码: ...`
|
||||
- `✅ 找到 user 字段: ...` 或 `⚠️ 未找到 user 信息`
|
||||
|
||||
### 步骤 3:检查返回结果
|
||||
|
||||
根据控制台日志,判断:
|
||||
|
||||
**情况 A:HTTP 状态码 200,有 user 字段**
|
||||
- ✅ 注册成功
|
||||
- 检查 `ak_users` 表是否有记录
|
||||
- 如果没有,检查 `ensureUserProfile` 是否被调用
|
||||
|
||||
**情况 B:HTTP 状态码 500,错误信息包含 "confirmation email"**
|
||||
- ❌ 配置未生效或服务未重启
|
||||
- 需要重启 Supabase Auth 服务
|
||||
|
||||
**情况 C:HTTP 状态码 400,错误信息包含 "already registered"**
|
||||
- ⚠️ 用户已存在
|
||||
- 尝试登录或使用其他邮箱
|
||||
|
||||
---
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### 问题 1:配置已修改但服务未重启
|
||||
|
||||
**症状**:仍然返回 500 错误
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
cd supabase_pro
|
||||
docker-compose restart auth
|
||||
# 等待几秒钟让服务启动
|
||||
docker-compose ps # 确认服务已启动
|
||||
```
|
||||
|
||||
### 问题 2:服务重启失败
|
||||
|
||||
**检查**:
|
||||
```bash
|
||||
docker-compose logs auth
|
||||
```
|
||||
|
||||
**可能原因**:
|
||||
- 配置文件语法错误
|
||||
- 端口被占用
|
||||
- Docker 服务未运行
|
||||
|
||||
### 问题 3:注册成功但 ak_users 没有记录
|
||||
|
||||
**检查**:
|
||||
1. 查看控制台是否有 `✅ 用户资料创建成功` 日志
|
||||
2. 如果没有,检查 `ensureUserProfile` 是否被调用
|
||||
3. 检查 RLS 策略和触发器是否已创建
|
||||
|
||||
---
|
||||
|
||||
## 📝 已改进的代码
|
||||
|
||||
1. **`signUp` 方法**:
|
||||
- ✅ 添加 HTTP 状态码检查
|
||||
- ✅ 添加详细日志
|
||||
- ✅ 返回错误信息时包含状态码
|
||||
|
||||
2. **注册页面**:
|
||||
- ✅ 添加更详细的日志输出
|
||||
- ✅ 检查所有可能的返回结构
|
||||
- ✅ 明确错误提示
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步操作
|
||||
|
||||
1. **重启 Supabase Auth 服务**(如果还没重启)
|
||||
2. **测试注册**,查看控制台日志
|
||||
3. **根据日志判断问题**:
|
||||
- 如果 HTTP 状态码是 200 且有 user,说明注册成功
|
||||
- 如果 HTTP 状态码是 500,说明配置未生效,需要重启服务
|
||||
- 如果 HTTP 状态码是 400,可能是用户已存在或其他错误
|
||||
|
||||
4. **检查数据库**:
|
||||
```sql
|
||||
-- 检查 auth.users
|
||||
SELECT id, email, email_confirmed_at, created_at
|
||||
FROM auth.users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
|
||||
-- 检查 ak_users
|
||||
SELECT id, email, username, created_at
|
||||
FROM ak_users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
121
pages/user/test/EMAIL_CONFIG_FIX.md
Normal file
121
pages/user/test/EMAIL_CONFIG_FIX.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 注册邮件发送失败问题修复指南
|
||||
|
||||
## 🔍 问题描述
|
||||
|
||||
注册时出现错误:`Error sending confirmation email` (500 Internal Server Error)
|
||||
|
||||
**原因**:
|
||||
- Supabase 配置了 `ENABLE_EMAIL_AUTOCONFIRM=false`,需要发送确认邮件
|
||||
- SMTP 配置使用的是假服务(`supabase-mail`, `fake_mail_user`),无法发送邮件
|
||||
- 当 Supabase 尝试发送邮件时失败,返回 500 错误
|
||||
|
||||
## ✅ 解决方案
|
||||
|
||||
### 方案一:启用自动确认(推荐,开发环境)
|
||||
|
||||
修改 `supabase_pro/.env` 文件:
|
||||
|
||||
```env
|
||||
## Email auth
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=true # 改为 true,跳过邮件验证
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 注册后立即可以登录,无需邮件验证
|
||||
- 适合开发和测试环境
|
||||
|
||||
**缺点**:
|
||||
- 生产环境建议使用真实的邮件服务
|
||||
|
||||
---
|
||||
|
||||
### 方案二:配置真实的 SMTP 服务
|
||||
|
||||
修改 `supabase_pro/.env` 文件,配置真实的 SMTP 服务:
|
||||
|
||||
```env
|
||||
## Email auth
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=false
|
||||
SMTP_ADMIN_EMAIL=your-admin@example.com
|
||||
SMTP_HOST=smtp.example.com # 真实的 SMTP 服务器
|
||||
SMTP_PORT=587 # 或 465 (SSL)
|
||||
SMTP_USER=your-smtp-user
|
||||
SMTP_PASS=your-smtp-password
|
||||
SMTP_SENDER_NAME=Your App Name
|
||||
```
|
||||
|
||||
**常用 SMTP 服务**:
|
||||
- Gmail: `smtp.gmail.com:587`
|
||||
- 163: `smtp.163.com:465`
|
||||
- QQ: `smtp.qq.com:587`
|
||||
- SendGrid, Mailgun 等第三方服务
|
||||
|
||||
---
|
||||
|
||||
### 方案三:使用 Supabase 本地邮件服务(开发环境)
|
||||
|
||||
如果使用 Docker Compose 运行 Supabase,可以使用内置的邮件服务:
|
||||
|
||||
1. 确保 `supabase-mail` 服务正常运行
|
||||
2. 检查邮件服务日志:
|
||||
```bash
|
||||
docker-compose logs supabase-mail
|
||||
```
|
||||
3. 如果服务未运行,启动它:
|
||||
```bash
|
||||
docker-compose up -d supabase-mail
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 已实施的代码修复
|
||||
|
||||
已改进注册页面的错误处理:
|
||||
|
||||
1. **检查邮件发送失败错误**:如果返回 500 错误且错误信息包含 "confirmation email",会给出友好提示
|
||||
2. **即使邮件发送失败,用户可能已创建**:提示用户稍后尝试登录
|
||||
3. **改进错误提示**:更清晰的错误信息
|
||||
|
||||
---
|
||||
|
||||
## 📝 验证步骤
|
||||
|
||||
1. **修改配置后,重启 Supabase**:
|
||||
```bash
|
||||
cd supabase_pro
|
||||
docker-compose restart auth
|
||||
```
|
||||
|
||||
2. **测试注册**:
|
||||
- 尝试注册新用户
|
||||
- 如果使用 `ENABLE_EMAIL_AUTOCONFIRM=true`,应该立即可以登录
|
||||
- 如果使用真实 SMTP,检查邮箱是否收到确认邮件
|
||||
|
||||
3. **检查数据库**:
|
||||
```sql
|
||||
-- 检查 auth.users 表中是否有新用户
|
||||
SELECT id, email, email_confirmed_at, created_at
|
||||
FROM auth.users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
|
||||
-- 检查 ak_users 表中是否有对应记录
|
||||
SELECT id, email, username, created_at
|
||||
FROM ak_users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 推荐配置(开发环境)
|
||||
|
||||
```env
|
||||
## Email auth
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=true # 开发环境跳过邮件验证
|
||||
```
|
||||
|
||||
这样注册后可以立即登录,无需等待邮件确认。
|
||||
115
pages/user/test/IMMEDIATE_FIX.md
Normal file
115
pages/user/test/IMMEDIATE_FIX.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 立即修复注册问题
|
||||
|
||||
## 🚨 当前问题
|
||||
|
||||
注册时返回 500 错误,Supabase 中没有创建用户记录。
|
||||
|
||||
**原因**:
|
||||
- `ENABLE_EMAIL_AUTOCONFIRM=false` - 需要发送确认邮件
|
||||
- SMTP 配置错误(`supabase-mail` 服务无法发送邮件)
|
||||
- 当邮件发送失败时,Supabase 不会创建用户
|
||||
|
||||
## ✅ 立即解决方案
|
||||
|
||||
### 步骤 1:修改 Supabase 配置
|
||||
|
||||
编辑文件:`supabase_pro/.env`
|
||||
|
||||
找到这一行:
|
||||
```env
|
||||
ENABLE_EMAIL_AUTOCONFIRM=false
|
||||
```
|
||||
|
||||
改为:
|
||||
```env
|
||||
ENABLE_EMAIL_AUTOCONFIRM=true
|
||||
```
|
||||
|
||||
### 步骤 2:重启 Supabase Auth 服务
|
||||
|
||||
```bash
|
||||
cd supabase_pro
|
||||
docker-compose restart auth
|
||||
```
|
||||
|
||||
或者重启整个 Supabase:
|
||||
```bash
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### 步骤 3:验证配置
|
||||
|
||||
```bash
|
||||
# 检查配置是否生效
|
||||
grep ENABLE_EMAIL_AUTOCONFIRM supabase_pro/.env
|
||||
```
|
||||
|
||||
应该显示:`ENABLE_EMAIL_AUTOCONFIRM=true`
|
||||
|
||||
### 步骤 4:测试注册
|
||||
|
||||
1. 在前端注册新用户
|
||||
2. 应该看到 "注册成功" 提示
|
||||
3. 自动跳转到登录页面
|
||||
4. 使用注册的邮箱和密码登录
|
||||
5. 应该可以成功登录
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证用户是否创建
|
||||
|
||||
在 Supabase Dashboard (http://192.168.1.63:3000) 的 SQL Editor 中执行:
|
||||
|
||||
```sql
|
||||
-- 检查最新注册的用户
|
||||
SELECT id, email, email_confirmed_at, created_at
|
||||
FROM auth.users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
|
||||
-- 检查 ak_users 表
|
||||
SELECT id, email, username, created_at
|
||||
FROM ak_users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 配置说明
|
||||
|
||||
### 开发环境推荐配置
|
||||
|
||||
```env
|
||||
## Email auth
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=true # 跳过邮件验证,注册后立即可以登录
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 注册后立即可以登录
|
||||
- ✅ 无需配置 SMTP 服务
|
||||
- ✅ 适合开发和测试
|
||||
|
||||
### 生产环境配置
|
||||
|
||||
如果需要邮件验证,配置真实的 SMTP 服务:
|
||||
|
||||
```env
|
||||
ENABLE_EMAIL_AUTOCONFIRM=false
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-smtp-user
|
||||
SMTP_PASS=your-smtp-password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要提示
|
||||
|
||||
**修改配置后必须重启 Supabase Auth 服务**,否则配置不会生效。
|
||||
|
||||
```bash
|
||||
cd supabase_pro
|
||||
docker-compose restart auth
|
||||
```
|
||||
121
pages/user/test/QUICK_FIX.md
Normal file
121
pages/user/test/QUICK_FIX.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 注册后数据未存储到数据库 - 快速修复指南
|
||||
|
||||
## 🔍 问题原因
|
||||
|
||||
当前配置 `ENABLE_EMAIL_AUTOCONFIRM=false`,注册后:
|
||||
- ✅ `auth.users` 表中会创建用户记录
|
||||
- ❌ 没有 session(不自动登录)
|
||||
- ❌ 没有 token
|
||||
- ❌ RLS 策略阻止插入 `ak_users`(因为 `auth.uid()` 返回 null)
|
||||
|
||||
## ✅ 解决方案(两种方式)
|
||||
|
||||
### 方式一:使用数据库触发器(推荐,完全自动化)
|
||||
|
||||
**优点**:注册时自动创建 `ak_users` 记录,无需前端处理
|
||||
|
||||
**执行步骤**:
|
||||
|
||||
1. **在 Supabase Dashboard (http://192.168.1.63:3000) 中打开 SQL Editor**
|
||||
|
||||
2. **执行 `USER_AUTH_SCHEMA.sql`**
|
||||
- 创建 `ak_users` 表和 RLS 策略
|
||||
- 创建 `upsert_user_profile` RPC 函数
|
||||
|
||||
3. **执行 `USER_AUTH_TRIGGER.sql`**
|
||||
- 创建触发器,在 `auth.users` 插入时自动创建 `ak_users` 记录
|
||||
|
||||
4. **验证**
|
||||
```sql
|
||||
-- 检查触发器是否存在
|
||||
SELECT tgname FROM pg_trigger WHERE tgname = 'on_auth_user_created';
|
||||
|
||||
-- 检查函数是否存在
|
||||
SELECT proname FROM pg_proc WHERE proname = 'handle_new_user';
|
||||
```
|
||||
|
||||
5. **测试**
|
||||
- 在前端注册一个新用户
|
||||
- 检查 `ak_users` 表是否有新记录:
|
||||
```sql
|
||||
SELECT * FROM ak_users ORDER BY created_at DESC LIMIT 5;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方式二:仅使用 RPC 函数(如果触发器无法创建)
|
||||
|
||||
**执行步骤**:
|
||||
|
||||
1. **在 Supabase Dashboard 中执行 `USER_AUTH_SCHEMA.sql`**
|
||||
|
||||
2. **验证 RPC 函数**
|
||||
```sql
|
||||
-- 检查函数是否存在
|
||||
SELECT proname FROM pg_proc WHERE proname = 'upsert_user_profile';
|
||||
|
||||
-- 检查权限
|
||||
SELECT grantee, privilege_type
|
||||
FROM information_schema.routine_privileges
|
||||
WHERE routine_name = 'upsert_user_profile';
|
||||
```
|
||||
|
||||
3. **测试注册**
|
||||
- 前端代码会自动调用 `upsert_user_profile` RPC 函数
|
||||
- 检查 `ak_users` 表是否有新记录
|
||||
|
||||
---
|
||||
|
||||
## 🔧 如果数据仍然没有存储
|
||||
|
||||
### 检查清单
|
||||
|
||||
1. **确认 RPC 函数已创建**
|
||||
```sql
|
||||
SELECT proname, prosrc FROM pg_proc WHERE proname = 'upsert_user_profile';
|
||||
```
|
||||
如果返回空,说明函数未创建,需要执行 `USER_AUTH_SCHEMA.sql`
|
||||
|
||||
2. **确认触发器已创建**(如果使用了方式一)
|
||||
```sql
|
||||
SELECT tgname, tgenabled FROM pg_trigger WHERE tgname = 'on_auth_user_created';
|
||||
```
|
||||
如果返回空,需要执行 `USER_AUTH_TRIGGER.sql`
|
||||
|
||||
3. **检查浏览器控制台**
|
||||
- 打开浏览器开发者工具
|
||||
- 查看 Console 标签
|
||||
- 注册时应该看到:
|
||||
- `注册返回结果: {...}`
|
||||
- `✅ 用户资料创建成功: ...` 或 `⚠️ 用户资料创建失败`
|
||||
|
||||
4. **检查 RPC 调用错误**
|
||||
- 如果看到 `RPC 创建用户资料失败`,检查:
|
||||
- RPC 函数是否存在
|
||||
- 函数参数是否正确
|
||||
- 网络请求是否成功
|
||||
|
||||
---
|
||||
|
||||
## 📝 当前代码逻辑
|
||||
|
||||
注册流程:
|
||||
1. 调用 `supa.signUp()` → 在 `auth.users` 中创建用户
|
||||
2. 获取 `user` 对象
|
||||
3. 调用 `ensureUserProfile(user)` → 内部调用 `upsert_user_profile` RPC 函数
|
||||
4. RPC 函数使用 `SECURITY DEFINER` → 绕过 RLS,创建 `ak_users` 记录
|
||||
|
||||
**如果 RPC 函数不存在**,会回退到直接插入,但会失败(因为 RLS 阻止)。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 推荐操作
|
||||
|
||||
**立即执行**:
|
||||
|
||||
1. 在 Supabase Dashboard 执行 `pages/user/test/USER_AUTH_SCHEMA.sql`
|
||||
2. 在 Supabase Dashboard 执行 `pages/user/test/USER_AUTH_TRIGGER.sql`
|
||||
3. 测试注册功能
|
||||
4. 检查 `ak_users` 表是否有新记录
|
||||
|
||||
**如果触发器创建失败**(权限问题),只执行 `USER_AUTH_SCHEMA.sql` 也可以,前端会使用 RPC 函数。
|
||||
166
pages/user/test/QUICK_FIX_SIGNUP_LOGIN.md
Normal file
166
pages/user/test/QUICK_FIX_SIGNUP_LOGIN.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# 注册和登录问题快速修复指南
|
||||
|
||||
## 🔍 当前问题
|
||||
|
||||
1. **注册时**:返回 500 错误 `Error sending confirmation email`
|
||||
2. **登录时**:返回 400 错误 `Invalid login credentials`
|
||||
|
||||
**根本原因**:
|
||||
- `ENABLE_EMAIL_AUTOCONFIRM=false` - 需要邮件确认才能登录
|
||||
- SMTP 配置是假的(`supabase-mail`, `fake_mail_user`),无法发送邮件
|
||||
- 即使注册时邮件发送失败,用户可能已在 `auth.users` 中创建,但因为邮箱未确认,无法登录
|
||||
|
||||
---
|
||||
|
||||
## ✅ 立即解决方案(推荐)
|
||||
|
||||
### 修改 Supabase 配置
|
||||
|
||||
编辑 `supabase_pro/.env` 文件:
|
||||
|
||||
```env
|
||||
## Email auth
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=true # 改为 true,跳过邮件验证
|
||||
```
|
||||
|
||||
### 重启 Supabase Auth 服务
|
||||
|
||||
```bash
|
||||
cd supabase_pro
|
||||
docker-compose restart auth
|
||||
```
|
||||
|
||||
### 验证
|
||||
|
||||
1. **测试注册**:注册新用户,应该立即可以登录
|
||||
2. **测试登录**:使用注册的邮箱和密码登录
|
||||
|
||||
---
|
||||
|
||||
## 🔧 如果用户已创建但邮箱未确认
|
||||
|
||||
如果之前注册的用户因为邮件发送失败而无法登录,可以手动确认邮箱:
|
||||
|
||||
### 方法一:在 Supabase Dashboard 中手动确认
|
||||
|
||||
1. 打开 Supabase Dashboard: http://192.168.1.63:3000
|
||||
2. 进入 **Authentication** → **Users**
|
||||
3. 找到对应的用户
|
||||
4. 点击用户,在详情页中点击 **Confirm Email** 按钮
|
||||
|
||||
### 方法二:使用 SQL 手动确认
|
||||
|
||||
在 Supabase Dashboard 的 SQL Editor 中执行:
|
||||
|
||||
```sql
|
||||
-- 查找未确认的用户
|
||||
SELECT id, email, email_confirmed_at, created_at
|
||||
FROM auth.users
|
||||
WHERE email_confirmed_at IS NULL
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- 手动确认邮箱(替换 'user-email@example.com' 为实际邮箱)
|
||||
UPDATE auth.users
|
||||
SET email_confirmed_at = NOW()
|
||||
WHERE email = 'user-email@example.com'
|
||||
AND email_confirmed_at IS NULL;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 已改进的错误处理
|
||||
|
||||
### 注册页面
|
||||
- ✅ 检测邮件发送失败错误(500 + "confirmation email")
|
||||
- ✅ 即使邮件发送失败,也会提示用户稍后尝试登录
|
||||
- ✅ 更友好的错误提示
|
||||
|
||||
### 登录页面
|
||||
- ✅ 检测邮箱未确认错误
|
||||
- ✅ 检测凭证错误
|
||||
- ✅ 更清晰的错误提示
|
||||
|
||||
---
|
||||
|
||||
## 🎯 推荐配置(开发环境)
|
||||
|
||||
```env
|
||||
## Email auth
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=true # 开发环境跳过邮件验证
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 注册后立即可以登录
|
||||
- 无需配置 SMTP 服务
|
||||
- 适合开发和测试
|
||||
|
||||
**注意**:生产环境建议使用真实的 SMTP 服务并启用邮件验证。
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证步骤
|
||||
|
||||
### 1. 检查配置是否生效
|
||||
|
||||
```bash
|
||||
# 检查环境变量
|
||||
cd supabase_pro
|
||||
grep ENABLE_EMAIL_AUTOCONFIRM .env
|
||||
```
|
||||
|
||||
应该显示:`ENABLE_EMAIL_AUTOCONFIRM=true`
|
||||
|
||||
### 2. 测试注册流程
|
||||
|
||||
1. 在前端注册新用户
|
||||
2. 应该看到 "注册成功" 提示
|
||||
3. 自动跳转到登录页面
|
||||
4. 使用注册的邮箱和密码登录
|
||||
5. 应该可以成功登录
|
||||
|
||||
### 3. 检查数据库
|
||||
|
||||
```sql
|
||||
-- 检查新注册的用户
|
||||
SELECT id, email, email_confirmed_at, created_at
|
||||
FROM auth.users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
|
||||
-- 检查 ak_users 表
|
||||
SELECT id, email, username, created_at
|
||||
FROM ak_users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 如果仍然无法登录
|
||||
|
||||
### 检查用户是否已创建
|
||||
|
||||
```sql
|
||||
-- 检查用户是否存在
|
||||
SELECT id, email, email_confirmed_at, encrypted_password IS NOT NULL as has_password
|
||||
FROM auth.users
|
||||
WHERE email = 'your-email@example.com';
|
||||
```
|
||||
|
||||
### 如果用户存在但未确认
|
||||
|
||||
使用上面的 SQL 手动确认邮箱。
|
||||
|
||||
### 如果用户不存在
|
||||
|
||||
重新注册,确保配置 `ENABLE_EMAIL_AUTOCONFIRM=true` 已生效。
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- `EMAIL_CONFIG_FIX.md` - 详细的邮件配置说明
|
||||
- `QUICK_FIX.md` - 注册数据存储问题修复
|
||||
- `README.md` - 用户认证相关 SQL 文件说明
|
||||
208
pages/user/test/README.md
Normal file
208
pages/user/test/README.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# 用户认证相关 SQL 文件说明
|
||||
|
||||
> 本目录包含用户登录/注册相关的数据库表结构和触发器。
|
||||
|
||||
## 📁 文件说明
|
||||
|
||||
### 1. `USER_AUTH_SCHEMA.sql` ⭐ **第一步**
|
||||
|
||||
创建用户认证相关的表结构:
|
||||
|
||||
- **`ak_users`** - 业务用户资料表(与 `auth.users` 关联)
|
||||
- **`users`** - 轻量用户表(用于统计)
|
||||
- **`user_sessions`** - 用户会话表(用于在线统计)
|
||||
- **RLS 策略** - 行级安全策略
|
||||
- **触发器** - 自动更新 `updated_at` 字段
|
||||
- **RPC 函数** - `upsert_user_profile`(用于创建/更新用户资料,绕过 RLS)
|
||||
|
||||
**执行顺序**:首次部署时执行
|
||||
|
||||
**执行方式**:在 Supabase Dashboard 的 SQL Editor 中执行
|
||||
|
||||
---
|
||||
|
||||
### 2. `USER_AUTH_TRIGGER.sql` ⭐ **第二步(推荐)**
|
||||
|
||||
创建数据库触发器,在 `auth.users` 表插入新用户时自动创建 `ak_users` 记录。
|
||||
|
||||
**优点**:
|
||||
- 完全自动化,无需前端处理
|
||||
- 即使邮箱验证开启也能正常工作
|
||||
- 不依赖前端 token
|
||||
|
||||
**执行顺序**:在 `USER_AUTH_SCHEMA.sql` 之后执行
|
||||
|
||||
**执行方式**:在 Supabase Dashboard 的 SQL Editor 中执行(需要 superuser 权限,Dashboard 默认有)
|
||||
|
||||
**注意**:如果无法创建触发器(权限问题),可以跳过此文件,使用 RPC 函数方案。
|
||||
|
||||
---
|
||||
|
||||
### 3. `USER_AUTH_TEST_DATA.sql`(可选)
|
||||
|
||||
插入测试用户数据,用于开发和测试。
|
||||
|
||||
**执行顺序**:在表结构创建后执行
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速部署
|
||||
|
||||
### 方式一:使用触发器(推荐)
|
||||
|
||||
1. **执行表结构**
|
||||
```sql
|
||||
-- 在 Supabase Dashboard 执行
|
||||
-- 复制 pages/user/test/USER_AUTH_SCHEMA.sql 的内容并执行
|
||||
```
|
||||
|
||||
2. **创建触发器**
|
||||
```sql
|
||||
-- 在 Supabase Dashboard 执行
|
||||
-- 复制 pages/user/test/USER_AUTH_TRIGGER.sql 的内容并执行
|
||||
```
|
||||
|
||||
3. **验证**
|
||||
```sql
|
||||
-- 检查函数是否存在
|
||||
SELECT * FROM pg_proc WHERE proname = 'upsert_user_profile';
|
||||
|
||||
-- 检查触发器是否存在(如果执行了 USER_AUTH_TRIGGER.sql)
|
||||
SELECT * FROM pg_trigger WHERE tgname = 'on_auth_user_created';
|
||||
```
|
||||
|
||||
### 方式二:仅使用 RPC 函数(如果触发器无法创建)
|
||||
|
||||
1. **执行表结构**
|
||||
```sql
|
||||
-- 在 Supabase Dashboard 执行
|
||||
-- 复制 pages/user/test/USER_AUTH_SCHEMA.sql 的内容并执行
|
||||
```
|
||||
|
||||
2. **验证 RPC 函数**
|
||||
```sql
|
||||
-- 检查函数是否存在
|
||||
SELECT * FROM pg_proc WHERE proname = 'upsert_user_profile';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 工作原理
|
||||
|
||||
### 方案一:数据库触发器(推荐)
|
||||
|
||||
1. 用户注册 → Supabase Auth 在 `auth.users` 表中创建记录
|
||||
2. 数据库触发器自动执行 → 在 `ak_users` 表中创建对应记录
|
||||
3. 前端无需处理 → 用户资料自动创建
|
||||
|
||||
### 方案二:RPC 函数
|
||||
|
||||
1. 用户注册 → 前端获取 user 对象
|
||||
2. 前端调用 `ensureUserProfile()` → 内部调用 `upsert_user_profile` RPC 函数
|
||||
3. RPC 函数使用 `SECURITY DEFINER` → 绕过 RLS 策略,创建用户资料
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要说明
|
||||
|
||||
### RLS 策略
|
||||
|
||||
`ak_users` 表已启用 RLS,策略如下:
|
||||
|
||||
- **SELECT**:用户只能查看自己的资料(`auth.uid() = id`)
|
||||
- **INSERT**:用户只能插入自己的资料(`auth.uid() = id`)
|
||||
- **UPDATE**:用户只能更新自己的资料(`auth.uid() = id`)
|
||||
|
||||
### 注册时的问题
|
||||
|
||||
注册时,如果邮箱验证未开启,Supabase 会返回 session,此时有 token,可以直接插入。
|
||||
|
||||
如果邮箱验证已开启,注册后没有 session,此时没有 token,`auth.uid()` 返回 `null`,RLS 策略会阻止插入。
|
||||
|
||||
**解决方案**:
|
||||
1. ✅ 使用数据库触发器(自动创建,无需 token)
|
||||
2. ✅ 使用 `SECURITY DEFINER` RPC 函数(绕过 RLS)
|
||||
3. ⚠️ 用户登录后自动创建(在 `getCurrentUser` 中处理)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证和测试
|
||||
|
||||
### 测试注册流程
|
||||
|
||||
1. **注册新用户**
|
||||
- 在前端注册页面输入邮箱和密码
|
||||
- 点击注册
|
||||
|
||||
2. **检查数据库**
|
||||
```sql
|
||||
-- 检查 auth.users 表中是否有新用户
|
||||
SELECT id, email, created_at FROM auth.users ORDER BY created_at DESC LIMIT 5;
|
||||
|
||||
-- 检查 ak_users 表中是否有对应记录
|
||||
SELECT id, email, username, created_at FROM ak_users ORDER BY created_at DESC LIMIT 5;
|
||||
```
|
||||
|
||||
3. **如果 ak_users 中没有记录**
|
||||
- 检查是否执行了 `USER_AUTH_TRIGGER.sql`
|
||||
- 检查触发器是否创建成功
|
||||
- 检查 RPC 函数是否创建成功
|
||||
- 查看浏览器控制台的错误信息
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文件
|
||||
|
||||
- `pages/user/register.uvue` - 注册页面
|
||||
- `pages/user/login.uvue` - 登录页面
|
||||
- `utils/sapi.uts` - `ensureUserProfile` 函数
|
||||
- `utils/store.uts` - `getCurrentUser` 函数(登录后自动创建资料)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 问题:注册后 `ak_users` 表中没有记录
|
||||
|
||||
**可能原因**:
|
||||
1. 未执行 `USER_AUTH_SCHEMA.sql`(RPC 函数不存在)
|
||||
2. 未执行 `USER_AUTH_TRIGGER.sql`(触发器不存在)
|
||||
3. RLS 策略阻止插入(没有 token)
|
||||
4. 邮箱验证已开启,注册后没有 session
|
||||
|
||||
**解决方案**:
|
||||
1. 执行 `USER_AUTH_SCHEMA.sql` 创建 RPC 函数
|
||||
2. 执行 `USER_AUTH_TRIGGER.sql` 创建触发器(推荐)
|
||||
3. 或者等待用户登录后自动创建(在 `getCurrentUser` 中处理)
|
||||
|
||||
### 问题:RPC 函数调用失败
|
||||
|
||||
**检查**:
|
||||
```sql
|
||||
-- 检查函数是否存在
|
||||
SELECT proname, prosrc FROM pg_proc WHERE proname = 'upsert_user_profile';
|
||||
|
||||
-- 检查权限
|
||||
SELECT grantee, privilege_type
|
||||
FROM information_schema.routine_privileges
|
||||
WHERE routine_name = 'upsert_user_profile';
|
||||
```
|
||||
|
||||
**解决**:重新执行 `USER_AUTH_SCHEMA.sql` 中的函数创建部分。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 下一步
|
||||
|
||||
执行完 SQL 文件后:
|
||||
|
||||
1. **测试注册功能**
|
||||
- 在前端注册新用户
|
||||
- 检查 `ak_users` 表中是否有新记录
|
||||
|
||||
2. **测试登录功能**
|
||||
- 使用注册的账号登录
|
||||
- 检查是否能正常获取用户资料
|
||||
|
||||
3. **检查前端页面**
|
||||
- 个人中心页面是否能正常显示用户信息
|
||||
95
supabase_pro/docker-compose.s3.yml
Normal file
95
supabase_pro/docker-compose.s3.yml
Normal file
@@ -0,0 +1,95 @@
|
||||
services:
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
environment:
|
||||
MINIO_ROOT_USER: supa-storage
|
||||
MINIO_ROOT_PASSWORD: secret1234
|
||||
command: server --console-address ":9001" /data
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://minio:9000/minio/health/live" ]
|
||||
interval: 2s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
volumes:
|
||||
- ./volumes/storage:/data:z
|
||||
|
||||
minio-createbucket:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
/usr/bin/mc alias set supa-minio http://minio:9000 supa-storage secret1234;
|
||||
/usr/bin/mc mb supa-minio/stub;
|
||||
exit 0;
|
||||
"
|
||||
|
||||
storage:
|
||||
container_name: supabase-storage
|
||||
image: supabase/storage-api:v1.33.0
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
rest:
|
||||
condition: service_started
|
||||
imgproxy:
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://storage:5000/status"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ANON_KEY: ${ANON_KEY}
|
||||
SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
POSTGREST_URL: http://rest:3000
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
|
||||
FILE_SIZE_LIMIT: 52428800
|
||||
STORAGE_BACKEND: s3
|
||||
GLOBAL_S3_BUCKET: stub
|
||||
GLOBAL_S3_ENDPOINT: http://minio:9000
|
||||
GLOBAL_S3_PROTOCOL: http
|
||||
GLOBAL_S3_FORCE_PATH_STYLE: true
|
||||
AWS_ACCESS_KEY_ID: supa-storage
|
||||
AWS_SECRET_ACCESS_KEY: secret1234
|
||||
AWS_DEFAULT_REGION: stub
|
||||
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
|
||||
TENANT_ID: stub
|
||||
# TODO: https://github.com/supabase/storage-api/issues/55
|
||||
REGION: stub
|
||||
ENABLE_IMAGE_TRANSFORMATION: "true"
|
||||
IMGPROXY_URL: http://imgproxy:5001
|
||||
volumes:
|
||||
- ./volumes/storage:/var/lib/storage:z
|
||||
|
||||
imgproxy:
|
||||
container_name: supabase-imgproxy
|
||||
image: darthsim/imgproxy:v3.8.0
|
||||
healthcheck:
|
||||
test: [ "CMD", "imgproxy", "health" ]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
environment:
|
||||
IMGPROXY_BIND: ":5001"
|
||||
IMGPROXY_USE_ETAG: "true"
|
||||
IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION}
|
||||
537
supabase_pro/docker-compose.yml
Normal file
537
supabase_pro/docker-compose.yml
Normal file
@@ -0,0 +1,537 @@
|
||||
# Usage
|
||||
# Start: docker compose up
|
||||
# With helpers: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up
|
||||
# Stop: docker compose down
|
||||
# Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans
|
||||
# Reset everything: ./reset.sh
|
||||
|
||||
name: supabase
|
||||
|
||||
services:
|
||||
|
||||
studio:
|
||||
container_name: supabase-studio
|
||||
image: supabase/studio:2025.12.17-sha-43f4f7f
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://studio:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})"
|
||||
]
|
||||
timeout: 10s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Binds nestjs listener to both IPv4 and IPv6 network interfaces
|
||||
HOSTNAME: "::"
|
||||
|
||||
STUDIO_PG_META_URL: http://meta:8080
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
POSTGRES_HOST: ${POSTGRES_HOST}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
|
||||
|
||||
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
|
||||
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
|
||||
SUPABASE_URL: http://kong:8000
|
||||
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
AUTH_JWT_SECRET: ${JWT_SECRET}
|
||||
|
||||
# LOGFLARE_API_KEY is deprecated
|
||||
LOGFLARE_API_KEY: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
|
||||
|
||||
LOGFLARE_URL: http://analytics:4000
|
||||
NEXT_PUBLIC_ENABLE_LOGS: true
|
||||
# Comment to use Big Query backend for analytics
|
||||
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
|
||||
# Uncomment to use Big Query backend for analytics
|
||||
# NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery
|
||||
|
||||
kong:
|
||||
container_name: supabase-kong
|
||||
image: kong:2.8.1
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${KONG_HTTP_PORT}:8000/tcp
|
||||
- ${KONG_HTTPS_PORT}:8443/tcp
|
||||
volumes:
|
||||
# https://github.com/supabase/supabase/issues/12661
|
||||
- ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
|
||||
depends_on:
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
KONG_DATABASE: "off"
|
||||
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
|
||||
# https://github.com/supabase/cli/issues/14
|
||||
KONG_DNS_ORDER: LAST,A,CNAME
|
||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,pre-function
|
||||
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
|
||||
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
|
||||
# https://unix.stackexchange.com/a/294837
|
||||
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
|
||||
|
||||
auth:
|
||||
container_name: supabase-auth
|
||||
image: supabase/gotrue:v2.184.0
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:9999/health"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
GOTRUE_API_HOST: 0.0.0.0
|
||||
GOTRUE_API_PORT: 9999
|
||||
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
|
||||
|
||||
GOTRUE_DB_DRIVER: postgres
|
||||
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
|
||||
GOTRUE_SITE_URL: ${SITE_URL}
|
||||
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
|
||||
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
|
||||
|
||||
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||
GOTRUE_JWT_AUD: authenticated
|
||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
|
||||
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||
|
||||
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
|
||||
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
|
||||
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
|
||||
|
||||
# Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile.
|
||||
# GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: true
|
||||
|
||||
# GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: true
|
||||
# GOTRUE_SMTP_MAX_FREQUENCY: 1s
|
||||
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
|
||||
GOTRUE_SMTP_HOST: ${SMTP_HOST}
|
||||
GOTRUE_SMTP_PORT: ${SMTP_PORT}
|
||||
GOTRUE_SMTP_USER: ${SMTP_USER}
|
||||
GOTRUE_SMTP_PASS: ${SMTP_PASS}
|
||||
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
|
||||
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE}
|
||||
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION}
|
||||
GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY}
|
||||
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE}
|
||||
|
||||
GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
|
||||
GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM}
|
||||
# Uncomment to enable custom access token hook. Please see: https://supabase.com/docs/guides/auth/auth-hooks for full list of hooks and additional details about custom_access_token_hook
|
||||
|
||||
# GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true"
|
||||
# GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook"
|
||||
# GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "<standard-base64-secret>"
|
||||
|
||||
# GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true"
|
||||
# GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt"
|
||||
|
||||
# GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true"
|
||||
# GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt"
|
||||
|
||||
# GOTRUE_HOOK_SEND_SMS_ENABLED: "false"
|
||||
# GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook"
|
||||
# GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n"
|
||||
|
||||
# GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false"
|
||||
# GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender"
|
||||
# GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n"
|
||||
|
||||
rest:
|
||||
container_name: supabase-rest
|
||||
image: postgrest/postgrest:v14.1
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
|
||||
PGRST_DB_ANON_ROLE: anon
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
||||
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
|
||||
command:
|
||||
[
|
||||
"postgrest"
|
||||
]
|
||||
|
||||
realtime:
|
||||
# This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain
|
||||
container_name: realtime-dev.supabase-realtime
|
||||
image: supabase/realtime:v2.68.0
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
environment:
|
||||
PORT: 4000
|
||||
DB_HOST: ${POSTGRES_HOST}
|
||||
DB_PORT: ${POSTGRES_PORT}
|
||||
DB_USER: supabase_admin
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DB_NAME: ${POSTGRES_DB}
|
||||
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
|
||||
DB_ENC_KEY: supabaserealtime
|
||||
API_JWT_SECRET: ${JWT_SECRET}
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
ERL_AFLAGS: -proto_dist inet_tcp
|
||||
DNS_NODES: "''"
|
||||
RLIMIT_NOFILE: "10000"
|
||||
APP_NAME: realtime
|
||||
SEED_SELF_HOST: "true"
|
||||
RUN_JANITOR: "true"
|
||||
|
||||
# To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up
|
||||
storage:
|
||||
container_name: supabase-storage
|
||||
image: supabase/storage-api:v1.33.0
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/storage:/var/lib/storage:z
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://storage:5000/status"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
rest:
|
||||
condition: service_started
|
||||
imgproxy:
|
||||
condition: service_started
|
||||
environment:
|
||||
ANON_KEY: ${ANON_KEY}
|
||||
SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||
POSTGREST_URL: http://rest:3000
|
||||
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
|
||||
FILE_SIZE_LIMIT: 52428800
|
||||
STORAGE_BACKEND: file
|
||||
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
|
||||
TENANT_ID: stub
|
||||
# TODO: https://github.com/supabase/storage-api/issues/55
|
||||
REGION: stub
|
||||
GLOBAL_S3_BUCKET: stub
|
||||
ENABLE_IMAGE_TRANSFORMATION: "true"
|
||||
IMGPROXY_URL: http://imgproxy:5001
|
||||
|
||||
imgproxy:
|
||||
container_name: supabase-imgproxy
|
||||
image: darthsim/imgproxy:v3.8.0
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/storage:/var/lib/storage:z
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"imgproxy",
|
||||
"health"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
environment:
|
||||
IMGPROXY_BIND: ":5001"
|
||||
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
|
||||
IMGPROXY_USE_ETAG: "true"
|
||||
IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION}
|
||||
|
||||
meta:
|
||||
container_name: supabase-meta
|
||||
image: supabase/postgres-meta:v0.95.1
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PG_META_PORT: 8080
|
||||
PG_META_DB_HOST: ${POSTGRES_HOST}
|
||||
PG_META_DB_PORT: ${POSTGRES_PORT}
|
||||
PG_META_DB_NAME: ${POSTGRES_DB}
|
||||
PG_META_DB_USER: supabase_admin
|
||||
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
|
||||
|
||||
functions:
|
||||
container_name: supabase-edge-functions
|
||||
image: supabase/edge-runtime:v1.69.28
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/functions:/home/deno/functions:Z
|
||||
depends_on:
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SUPABASE_URL: http://kong:8000
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||
# TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786
|
||||
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
||||
command:
|
||||
[
|
||||
"start",
|
||||
"--main-service",
|
||||
"/home/deno/functions/main"
|
||||
]
|
||||
|
||||
analytics:
|
||||
container_name: supabase-analytics
|
||||
image: supabase/logflare:1.27.0
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 4000:4000
|
||||
# Uncomment to use Big Query backend for analytics
|
||||
# volumes:
|
||||
# - type: bind
|
||||
# source: ${PWD}/gcloud.json
|
||||
# target: /opt/app/rel/logflare/bin/gcloud.json
|
||||
# read_only: true
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"curl",
|
||||
"http://localhost:4000/health"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 10
|
||||
depends_on:
|
||||
db:
|
||||
# Disable this if you are using an external Postgres database
|
||||
condition: service_healthy
|
||||
environment:
|
||||
LOGFLARE_NODE_HOST: 127.0.0.1
|
||||
DB_USERNAME: supabase_admin
|
||||
DB_DATABASE: _supabase
|
||||
DB_HOSTNAME: ${POSTGRES_HOST}
|
||||
DB_PORT: ${POSTGRES_PORT}
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DB_SCHEMA: _analytics
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
|
||||
LOGFLARE_SINGLE_TENANT: true
|
||||
LOGFLARE_SUPABASE_MODE: true
|
||||
|
||||
# Comment variables to use Big Query backend for analytics
|
||||
POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
|
||||
POSTGRES_BACKEND_SCHEMA: _analytics
|
||||
LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
|
||||
# Uncomment to use Big Query backend for analytics
|
||||
# GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID}
|
||||
# GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER}
|
||||
|
||||
# Comment out everything below this point if you are using an external Postgres database
|
||||
db:
|
||||
container_name: supabase-db
|
||||
image: supabase/postgres:15.8.1.085
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
|
||||
# Must be superuser to create event trigger
|
||||
- ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
|
||||
# Must be superuser to alter reserved role
|
||||
- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
|
||||
# Initialize the database settings with JWT_SECRET and JWT_EXP
|
||||
- ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
|
||||
# PGDATA directory is persisted between restarts
|
||||
- ./volumes/db/data:/var/lib/postgresql/data:Z
|
||||
# Changes required for internal supabase data such as _analytics
|
||||
- ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z
|
||||
# Changes required for Analytics support
|
||||
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
|
||||
# Changes required for Pooler support
|
||||
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
|
||||
# Use named volume to persist pgsodium decryption key between restarts
|
||||
- db-config:/etc/postgresql-custom
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"pg_isready",
|
||||
"-U",
|
||||
"postgres",
|
||||
"-h",
|
||||
"localhost"
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
depends_on:
|
||||
vector:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_HOST: /var/run/postgresql
|
||||
PGPORT: ${POSTGRES_PORT}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
PGDATABASE: ${POSTGRES_DB}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_EXP: ${JWT_EXPIRY}
|
||||
command:
|
||||
[
|
||||
"postgres",
|
||||
"-c",
|
||||
"config_file=/etc/postgresql/postgresql.conf",
|
||||
"-c",
|
||||
"log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs
|
||||
]
|
||||
|
||||
vector:
|
||||
container_name: supabase-vector
|
||||
image: timberio/vector:0.28.1-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
|
||||
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro,z
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://vector:9001/health"
|
||||
]
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 3
|
||||
environment:
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
|
||||
command:
|
||||
[
|
||||
"--config",
|
||||
"/etc/vector/vector.yml"
|
||||
]
|
||||
security_opt:
|
||||
- "label=disable"
|
||||
|
||||
# Update the DATABASE_URL if you are using an external Postgres database
|
||||
supavisor:
|
||||
container_name: supabase-pooler
|
||||
image: supabase/supavisor:2.7.4
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${POSTGRES_PORT}:5432
|
||||
- ${POOLER_PROXY_PORT_TRANSACTION}:6543
|
||||
volumes:
|
||||
- ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"curl",
|
||||
"-sSfL",
|
||||
"--head",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"http://127.0.0.1:4000/api/health"
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PORT: 4000
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
|
||||
CLUSTER_POSTGRES: true
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
VAULT_ENC_KEY: ${VAULT_ENC_KEY}
|
||||
API_JWT_SECRET: ${JWT_SECRET}
|
||||
METRICS_JWT_SECRET: ${JWT_SECRET}
|
||||
REGION: local
|
||||
ERL_AFLAGS: -proto_dist inet_tcp
|
||||
POOLER_TENANT_ID: ${POOLER_TENANT_ID}
|
||||
POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
|
||||
POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
|
||||
POOLER_POOL_MODE: transaction
|
||||
DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE}
|
||||
command:
|
||||
[
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server"
|
||||
]
|
||||
|
||||
volumes:
|
||||
db-config:
|
||||
130
supabase_pro/env
Normal file
130
supabase_pro/env
Normal file
@@ -0,0 +1,130 @@
|
||||
############
|
||||
# Secrets
|
||||
# YOU MUST CHANGE THESE BEFORE GOING INTO PRODUCTION
|
||||
############
|
||||
|
||||
POSTGRES_PASSWORD=yxyHINygZMLSq9jLddrZQBB-CoyGHSF5DwlwWmbrYXc
|
||||
JWT_SECRET=-dxpdu3HzfJmJTDlMKYHGe8hHTGrj45d0gGKM_LEkE9bD2UHWw_axpsa23hhdn7K
|
||||
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY4ODMwNjI0LCJleHAiOjE5MjY1MTA2MjR9.mDVl-kIOdRK9v6VTxo0TDF8r7X7xk3PZXazaavHyVvg
|
||||
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3Njg4MzA2MjQsImV4cCI6MTkyNjUxMDYyNH0.GsthZc8K5tW5vRlhrYgExnCe7Tg1_UkZx6kIY5IPC1w
|
||||
DASHBOARD_USERNAME=supabase
|
||||
DASHBOARD_PASSWORD=D4ce5p8YBpfYzEoDGZ_7MzehZcWrdCNyDEj_VSUBmOw
|
||||
SECRET_KEY_BASE=64bd64a0d100dee8caa3e56da7cc32931630ba4ba9e34728f9d157210be288cd
|
||||
VAULT_ENC_KEY=106e9da1a0d4f3cb14c86114c3cd2059
|
||||
PG_META_CRYPTO_KEY=d62d150dfa4795aacd5806496f447388
|
||||
|
||||
|
||||
############
|
||||
# Database - You can change these to any PostgreSQL database that has logical replication enabled.
|
||||
############
|
||||
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_PORT=5432
|
||||
# default user is postgres
|
||||
|
||||
|
||||
############
|
||||
# Supavisor -- Database pooler
|
||||
############
|
||||
# Port Supavisor listens on for transaction pooling connections
|
||||
POOLER_PROXY_PORT_TRANSACTION=6543
|
||||
# Maximum number of PostgreSQL connections Supavisor opens per pool
|
||||
POOLER_DEFAULT_POOL_SIZE=20
|
||||
# Maximum number of client connections Supavisor accepts per pool
|
||||
POOLER_MAX_CLIENT_CONN=100
|
||||
# Unique tenant identifier
|
||||
POOLER_TENANT_ID=8bae85a4804d83138813f5cc4fdbbc0d
|
||||
# Pool size for internal metadata storage used by Supavisor
|
||||
# This is separate from client connections and used only by Supavisor itself
|
||||
POOLER_DB_POOL_SIZE=5
|
||||
|
||||
|
||||
############
|
||||
# API Proxy - Configuration for the Kong Reverse proxy.
|
||||
############
|
||||
|
||||
KONG_HTTP_PORT=8000
|
||||
KONG_HTTPS_PORT=8443
|
||||
|
||||
|
||||
############
|
||||
# API - Configuration for PostgREST.
|
||||
############
|
||||
|
||||
PGRST_DB_SCHEMAS=public,storage,graphql_public
|
||||
|
||||
|
||||
############
|
||||
# Auth - Configuration for the GoTrue authentication server.
|
||||
############
|
||||
|
||||
## General
|
||||
SITE_URL=http://localhost:3000
|
||||
ADDITIONAL_REDIRECT_URLS=
|
||||
JWT_EXPIRY=3600
|
||||
DISABLE_SIGNUP=false
|
||||
API_EXTERNAL_URL=http://localhost:8000
|
||||
|
||||
## Mailer Config
|
||||
MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify"
|
||||
MAILER_URLPATHS_INVITE="/auth/v1/verify"
|
||||
MAILER_URLPATHS_RECOVERY="/auth/v1/verify"
|
||||
MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify"
|
||||
|
||||
## Email auth
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=true
|
||||
SMTP_ADMIN_EMAIL=admin@example.com
|
||||
SMTP_HOST=supabase-mail
|
||||
SMTP_PORT=2500
|
||||
SMTP_USER=fake_mail_user
|
||||
SMTP_PASS=fake_mail_password
|
||||
SMTP_SENDER_NAME=fake_sender
|
||||
ENABLE_ANONYMOUS_USERS=false
|
||||
|
||||
## Phone auth
|
||||
ENABLE_PHONE_SIGNUP=true
|
||||
ENABLE_PHONE_AUTOCONFIRM=true
|
||||
|
||||
|
||||
############
|
||||
# Studio - Configuration for the Dashboard
|
||||
############
|
||||
|
||||
STUDIO_DEFAULT_ORGANIZATION=Default Organization
|
||||
STUDIO_DEFAULT_PROJECT=Default Project
|
||||
|
||||
# replace if you intend to use Studio outside of localhost
|
||||
SUPABASE_PUBLIC_URL=http://localhost:8000
|
||||
|
||||
# Enable webp support
|
||||
IMGPROXY_ENABLE_WEBP_DETECTION=true
|
||||
|
||||
# Add your OpenAI API key to enable SQL Editor Assistant
|
||||
OPENAI_API_KEY=
|
||||
|
||||
|
||||
############
|
||||
# Functions - Configuration for Functions
|
||||
############
|
||||
# NOTE: VERIFY_JWT applies to all functions. Per-function VERIFY_JWT is not supported yet.
|
||||
FUNCTIONS_VERIFY_JWT=false
|
||||
|
||||
|
||||
############
|
||||
# Logs - Configuration for Analytics
|
||||
# Please refer to https://supabase.com/docs/reference/self-hosting-analytics/introduction
|
||||
############
|
||||
|
||||
# Change vector.toml sinks to reflect this change
|
||||
# these cannot be the same value
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN=MVHIdj9wKwZOIAXzE5MJ_4W2VibwR_5TmOyXgJMMAII
|
||||
LOGFLARE_PRIVATE_ACCESS_TOKEN=aUXsocT3y3NxZWw2OPIIs5hzJLiwbOkK5aH0YUCxC3I
|
||||
|
||||
# Docker socket location - this value will differ depending on your OS
|
||||
DOCKER_SOCKET_LOCATION=/var/run/docker.sock
|
||||
|
||||
# Google Cloud Project details
|
||||
GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID
|
||||
GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER
|
||||
Reference in New Issue
Block a user