接入数据库

This commit is contained in:
comlibmb
2026-01-26 21:34:17 +08:00
parent c14f67cfc8
commit 3fbd9a2b3d
26 changed files with 3559 additions and 427 deletions

View 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

View 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';
```

View File

@@ -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 }

View File

@@ -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 sparklinemini 趋势图)
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>

View File

@@ -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 || ''}`,

View File

@@ -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) {

View 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 运行 *步骤14*
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

View File

@@ -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`)。
## 验证实时大屏功能

View 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 + RPCDrop-first
5. `02_insert_test_data.sql`(基础表测试数据,需 service_role/postgres
6. `ANALYTICS_TEST_SEED.sql`analytics_* 测试数据,需 service_role/postgres
7. `03_test_queries.sql`(可选:验证查询)

View File

@@ -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: 权限不足
```

View 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;
-- 预期2515个今日订单 + 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 计算

View File

@@ -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

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -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()];
}

View File

@@ -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 返回的是 UTSJSONObjectSupabase 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

View 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
```

View 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检查返回结果
根据控制台日志,判断:
**情况 AHTTP 状态码 200有 user 字段**
- ✅ 注册成功
- 检查 `ak_users` 表是否有记录
- 如果没有,检查 `ensureUserProfile` 是否被调用
**情况 BHTTP 状态码 500错误信息包含 "confirmation email"**
- ❌ 配置未生效或服务未重启
- 需要重启 Supabase Auth 服务
**情况 CHTTP 状态码 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;
```

View 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 # 开发环境跳过邮件验证
```
这样注册后可以立即登录,无需等待邮件确认。

View 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
```

View 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 函数。

View 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
View 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. **检查前端页面**
- 个人中心页面是否能正常显示用户信息

View 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}

View 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
View 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