数据分析ui补充完善,接入数据库

This commit is contained in:
comlibmb
2026-01-29 17:30:39 +08:00
parent 3e89513e8b
commit b53d2376ff
13 changed files with 1161 additions and 889 deletions

View File

@@ -27,3 +27,6 @@ export const WS_URL: string = 'ws://192.168.1.63:8000/realtime/v1/websocket'
// 路由配置
export const HOME_REDIRECT: string = '/pages/mall/consumer/index'
export const TABORPAGE: string = '/pages/mall/consumer/index'
// 测试模式:放开任意跳转(禁用启动页/登录/401 的强制重定向)
export const IS_TEST_MODE: boolean = true

View File

@@ -1,34 +1,4 @@
// /components/supadb/aksupainstance.uts
import { createClient } from './aksupa.uts'
// 创建并导出 Supabase 客户端实例
const supabaseUrl = 'https://your-project.supabase.co' // 替换为你的 Supabase URL
const supabaseAnonKey = 'your-anon-key' // 替换为你的匿名密钥
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
// 导出 Supabase 实例就绪状态
export const isSupabaseReady = true
// 如果有其他需要导出的函数,可以这样导出:
export function initializeSupabase(url: string, key: string) {
return createClient(url, key)
}
// 检查连接状态的函数
export function checkConnection() {
return new Promise((resolve) => {
// 模拟连接检查
setTimeout(() => {
resolve(true)
}, 500)
})
}
// 不再使用 supaready 变量,而是提供函数
export async function ensureSupabaseReady() {
return await checkConnection()
}
import AkSupa from './aksupa.uts'
import { SUPA_URL, SUPA_KEY } from '@/ak/config.uts'
@@ -38,17 +8,23 @@ const supa = new AkSupa(SUPA_URL, SUPA_KEY)
// Instead, mark supa as ready if an existing session is present; otherwise defer sign-in to explicit user action.
const supaReady: Promise<boolean> = (async () => {
try {
const sess = supa.getSession();
const sess = supa.getSession()
if (sess != null && sess.session != null) {
return true;
return true
}
// No session found — do not auto sign-in with hard-coded credentials.
return true;
return true
} catch (err) {
console.error('Supabase instance init failed', err)
return false;
return false
}
})()
// 兼容具名导出(部分代码使用 import { supabase, ensureSupabaseReady }
export const supabase = supa
export async function ensureSupabaseReady(): Promise<boolean> {
return await supaReady
}
export { supaReady }
export default supa
export default supa

View File

@@ -109,7 +109,7 @@
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import supa, { ensureSupabaseReady } 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'
@@ -176,9 +176,160 @@ export default {
methods: {
async loadCouponData() {
// TODO: 实现优惠券数据加载
this.updateTime()
this.buildChartOptions()
try {
// 1) 计算时间范围
const now = new Date()
const start = new Date(now.getTime())
if (this.selectedPeriod === '7d') start.setDate(start.getDate() - 7)
else if (this.selectedPeriod === '30d') start.setDate(start.getDate() - 30)
else if (this.selectedPeriod === '90d') start.setDate(start.getDate() - 90)
else if (this.selectedPeriod === '1y') start.setFullYear(start.getFullYear() - 1)
const startIso = start.toISOString()
const endIso = now.toISOString()
// 2) 确保 Supabase 会话就绪
await ensureSupabaseReady()
// 3) 优先调用后端 RPC推荐在 Supabase 中创建对应函数)
// - rpc_coupon_effectiveness_overview 概览 KPI
// - rpc_coupon_type_stats 按券类型统计
// - rpc_coupon_channel_stats 发放渠道统计
// - rpc_coupon_trend_daily 每日发放/使用趋势
// - rpc_coupon_conversion_effect GMV/订单转化效果
let overviewRow: UTSJSONObject | null = null
let typeList: Array<UTSJSONObject> = []
let channelList: Array<UTSJSONObject> = []
let trendList: Array<UTSJSONObject> = []
let conversionList: Array<UTSJSONObject> = []
// 3.1 概览 KPI
const overviewRes = await supa.rpc('rpc_coupon_effectiveness_overview', {
p_start: startIso,
p_end: endIso
})
if (overviewRes.status === 404) {
// RPC 未创建:保留默认 0 值,由后续 SQL 实现补上真实逻辑
console.warn('rpc_coupon_effectiveness_overview not found, using default zeros')
} else if (overviewRes.error != null) {
throw overviewRes.error
} else {
const anyData = overviewRes.data as any
if (Array.isArray(anyData) && anyData.length > 0) {
overviewRow = anyData[0] as UTSJSONObject
}
}
// 3.2 券类型统计
const typeRes = await supa.rpc('rpc_coupon_type_stats', {
p_start: startIso,
p_end: endIso
})
if (typeRes.status === 404) {
console.warn('rpc_coupon_type_stats not found, type analysis will be empty')
} else if (typeRes.error != null) {
throw typeRes.error
} else {
const typeAny = typeRes.data as any
typeList = Array.isArray(typeAny) ? typeAny as Array<UTSJSONObject> : []
}
// 3.3 渠道统计
const channelRes = await supa.rpc('rpc_coupon_channel_stats', {
p_start: startIso,
p_end: endIso
})
if (channelRes.status === 404) {
console.warn('rpc_coupon_channel_stats not found, channel analysis will be empty')
} else if (channelRes.error != null) {
throw channelRes.error
} else {
const chAny = channelRes.data as any
channelList = Array.isArray(chAny) ? chAny as Array<UTSJSONObject> : []
}
// 3.4 使用趋势
const trendRes = await supa.rpc('rpc_coupon_trend_daily', {
p_start: startIso,
p_end: endIso
})
if (trendRes.status === 404) {
console.warn('rpc_coupon_trend_daily not found, trend analysis will be empty')
} else if (trendRes.error != null) {
throw trendRes.error
} else {
const trAny = trendRes.data as any
trendList = Array.isArray(trAny) ? trAny as Array<UTSJSONObject> : []
}
// 3.5 转化效果GMV/订单)
const convRes = await supa.rpc('rpc_coupon_conversion_effect', {
p_start: startIso,
p_end: endIso
})
if (convRes.status === 404) {
console.warn('rpc_coupon_conversion_effect not found, conversion chart will be empty')
} else if (convRes.error != null) {
throw convRes.error
} else {
const cvAny = convRes.data as any
conversionList = Array.isArray(cvAny) ? cvAny as Array<UTSJSONObject> : []
}
// 4) 计算 KPI 概览
let totalIssued = 0
let totalUsed = 0
let gmvIncrease = 0.0
let issuedGrowth = 0.0
let usageRate = 0.0
let gmvGrowth = 0.0
let roi = 0.0
if (overviewRow != null) {
totalIssued = overviewRow.getNumber('total_issued') ?? 0
totalUsed = overviewRow.getNumber('total_used') ?? 0
gmvIncrease = overviewRow.getNumber('gmv_increase') ?? 0
issuedGrowth = overviewRow.getNumber('issued_growth') ?? 0
usageRate = overviewRow.getNumber('usage_rate') ?? 0
gmvGrowth = overviewRow.getNumber('gmv_growth') ?? 0
roi = overviewRow.getNumber('roi') ?? 0
} else {
// 概览 RPC 不存在时,使用类型统计简单近似(只保证页面可用)
for (let i = 0; i < typeList.length; i++) {
const r = typeList[i]
totalIssued += r.getNumber('total_issued') ?? 0
totalUsed += r.getNumber('total_used') ?? 0
}
if (totalIssued > 0) {
usageRate = (totalUsed / totalIssued) * 100
}
}
this.couponData = {
total_issued: totalIssued,
issued_growth: issuedGrowth,
total_used: totalUsed,
usage_rate: usageRate,
gmv_increase: gmvIncrease,
gmv_growth: gmvGrowth,
roi: roi
} as CouponData
// 将原始行数据挂到实例上,方便绘制图表
;(this as any)._typeRows = typeList
;(this as any)._channelRows = channelList
;(this as any)._trendRows = trendList
;(this as any)._conversionRows = conversionList
this.updateTime()
this.buildChartOptions()
} catch (e) {
console.error('loadCouponData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: '优惠券分析数据加载失败', icon: 'none' })
}
},
selectPeriod(p: string) {
@@ -224,11 +375,176 @@ export default {
},
buildChartOptions() {
// TODO: 构建图表配置
this.typeChartOption = {}
this.channelChartOption = {}
this.trendChartOption = {}
this.conversionChartOption = {}
const typeAny = (this as any)._typeRows as any
const channelAny = (this as any)._channelRows as any
const trendAny = (this as any)._trendRows as any
const convAny = (this as any)._conversionRows as any
const typeRows = Array.isArray(typeAny) ? typeAny as Array<UTSJSONObject> : []
const channelRows = Array.isArray(channelAny) ? channelAny as Array<UTSJSONObject> : []
const trendRows = Array.isArray(trendAny) ? trendAny as Array<UTSJSONObject> : []
const convRows = Array.isArray(convAny) ? convAny as Array<UTSJSONObject> : []
// 1) 券类型分析:柱状图(发放/使用/使用率)
const typeNames: string[] = []
const typeIssued: number[] = []
const typeUsed: number[] = []
const typeUsageRate: number[] = []
for (let i = 0; i < typeRows.length; i++) {
const r = typeRows[i]
const t = r.getNumber('coupon_type') ?? 0
// 映射 coupon_type 枚举到中文名称1..8
let label = '未知'
if (t === 1) label = '满减券'
else if (t === 2) label = '折扣券'
else if (t === 3) label = '免运费券'
else if (t === 4) label = '新人券'
else if (t === 5) label = '会员券'
else if (t === 6) label = '品类券'
else if (t === 7) label = '商家券'
else if (t === 8) label = '限时券'
typeNames.push(label)
typeIssued.push(r.getNumber('total_issued') ?? 0)
typeUsed.push(r.getNumber('total_used') ?? 0)
typeUsageRate.push(r.getNumber('usage_rate') ?? 0)
}
this.typeChartOption = {
tooltip: { trigger: 'axis' },
legend: {
data: ['发放数量', '使用数量', '使用率'],
top: 'bottom'
},
// 增加 top 间距,避免左侧“数量”与上方说明文字发生遮挡
grid: { left: 40, right: 40, top: 40, bottom: 60 },
xAxis: {
type: 'category',
data: typeNames,
axisLabel: { interval: 0, rotate: 20 }
},
yAxis: [
{ type: 'value', name: '数量' },
{ type: 'value', name: '使用率', min: 0, max: 100, position: 'right' }
],
series: [
{
name: '发放数量',
type: 'bar',
data: typeIssued,
barMaxWidth: 22,
itemStyle: { color: '#3b82f6' }
},
{
name: '使用数量',
type: 'bar',
data: typeUsed,
barMaxWidth: 22,
itemStyle: { color: '#22c55e' }
},
{
name: '使用率',
type: 'line',
yAxisIndex: 1,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: { width: 2, color: '#111827' },
itemStyle: { color: '#111827' },
z: 5,
data: typeUsageRate
}
]
}
// 2) 发放渠道效果:条形图
const channelNames: string[] = []
const channelIssued: number[] = []
const channelUsed: number[] = []
for (let i = 0; i < channelRows.length; i++) {
const r = channelRows[i]
const ch = r.getString('channel') ?? ''
let chLabel = ch
if (ch === 'manual') chLabel = '主动领取'
else if (ch === 'auto') chLabel = '自动发放'
else if (ch === 'campaign') chLabel = '活动赠送'
else if (ch === 'invite') chLabel = '邀请奖励'
else if (ch === 'cs') chLabel = '客服赠送'
else if (ch === 'points') chLabel = '积分兑换'
else if (ch.trim() === '') chLabel = '未知'
channelNames.push(chLabel)
channelIssued.push(r.getNumber('total_issued') ?? 0)
channelUsed.push(r.getNumber('total_used') ?? 0)
}
this.channelChartOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['发放数量', '使用数量'], top: 'bottom' },
grid: { left: 80, right: 30, top: 20, bottom: 60 },
xAxis: { type: 'value' },
yAxis: { type: 'category', data: channelNames },
series: [
{ name: '发放数量', type: 'bar', data: channelIssued },
{ name: '使用数量', type: 'bar', data: channelUsed }
]
}
// 3) 使用趋势:发放 vs 使用
const trendDays: string[] = []
const trendIssued: number[] = []
const trendUsed: number[] = []
for (let i = 0; i < trendRows.length; i++) {
const r = trendRows[i]
const day = r.getString('day') ?? ''
trendDays.push(day.length >= 10 ? day.substring(5, 10) : day)
trendIssued.push(r.getNumber('issued') ?? 0)
trendUsed.push(r.getNumber('used') ?? 0)
}
this.trendChartOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['发放数量', '使用数量'], top: 'bottom' },
// 增加顶部间距,避免“数量”与上方说明文字遮挡
grid: { left: 40, right: 20, top: 40, bottom: 60 },
xAxis: { type: 'category', data: trendDays },
yAxis: { type: 'value', name: '数量' },
series: [
{ name: '发放数量', type: 'bar', data: trendIssued },
{ name: '使用数量', type: 'line', smooth: true, data: trendUsed }
]
}
// 4) 转化效果:对比有券/无券 GMV & 订单数
const convNames: string[] = []
const convWith: number[] = []
const convWithout: number[] = []
for (let i = 0; i < convRows.length; i++) {
const r = convRows[i]
const metric = r.getString('metric') ?? ''
let metricLabel = metric
if (metric === 'GMV') metricLabel = 'GMV成交额'
else if (metric === 'orders') metricLabel = '订单数'
else if (metric === 'avg_order_amount') metricLabel = '客单价'
else if (metric.trim() === '') metricLabel = '未知'
convNames.push(metricLabel)
convWith.push(r.getNumber('with_coupon') ?? 0)
convWithout.push(r.getNumber('without_coupon') ?? 0)
}
this.conversionChartOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['使用优惠券', '未使用优惠券'], top: 'bottom' },
grid: { left: 40, right: 20, top: 20, bottom: 60 },
xAxis: { type: 'category', data: convNames },
yAxis: { type: 'value' },
series: [
{ name: '使用优惠券', type: 'bar', data: convWith },
{ name: '未使用优惠券', type: 'bar', data: convWithout }
]
}
},
toggleMoreMenu() {

View File

@@ -105,7 +105,7 @@
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import supa, { ensureSupabaseReady } 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'
@@ -160,9 +160,106 @@ export default {
methods: {
async loadDetailData() {
// TODO: 实现详细数据加载
this.updateTime()
this.buildChartOptions()
try {
const now = new Date()
const end = new Date(now.getTime())
const start = new Date(now.getTime())
if (this.timeRangeText === '最近7天') {
start.setDate(start.getDate() - 7)
} else if (this.timeRangeText === '最近30天') {
start.setDate(start.getDate() - 30)
} else if (this.timeRangeText === '最近90天') {
start.setDate(start.getDate() - 90)
} else {
// 自定义暂时按最近30天处理
start.setDate(start.getDate() - 30)
}
const startIso = start.toISOString()
const endIso = end.toISOString()
await ensureSupabaseReady()
// 当前周期明细:复用 rpc_analytics_market_trend_daily按天 GMV / 订单 / 用户)
let currentRows: Array<UTSJSONObject> = []
let compareRows: Array<UTSJSONObject> = []
const curRes = await supa.rpc('rpc_analytics_market_trend_daily', {
p_start: startIso,
p_end: endIso
})
if (curRes.status === 404) {
console.warn('rpc_analytics_market_trend_daily not found, data-detail will be empty')
} else if (curRes.error != null) {
console.error('rpc_analytics_market_trend_daily error:', curRes.error)
} else {
const anyData = curRes.data as any
currentRows = Array.isArray(anyData) ? anyData as Array<UTSJSONObject> : []
}
// 对比周期:与当前周期长度相同的上一段时间
const spanMs = end.getTime() - start.getTime()
const prevEnd = new Date(start.getTime())
const prevStart = new Date(start.getTime() - spanMs)
const prevStartIso = prevStart.toISOString()
const prevEndIso = prevEnd.toISOString()
const prevRes = await supa.rpc('rpc_analytics_market_trend_daily', {
p_start: prevStartIso,
p_end: prevEndIso
})
if (prevRes.status === 404) {
console.warn('rpc_analytics_market_trend_daily not found for compare period')
} else if (prevRes.error != null) {
console.error('rpc_analytics_market_trend_daily (compare) error:', prevRes.error)
} else {
const anyPrev = prevRes.data as any
compareRows = Array.isArray(anyPrev) ? anyPrev as Array<UTSJSONObject> : []
}
// 映射到表格数据
const table: Array<any> = []
for (let i = 0; i < currentRows.length; i++) {
const r = currentRows[i]
const dayStr = r.getString('day') ?? ''
table.push({
id: dayStr + '_' + i.toString(),
date: dayStr,
gmv: r.getNumber('gmv') ?? 0,
orders: r.getNumber('orders') ?? 0,
users: r.getNumber('users') ?? 0
} as any)
}
this.tableData = table
// 简单生成钻取卡片:总 GMV / 总订单 / 总用户
let totalGmv = 0
let totalOrders = 0
let totalUsers = 0
for (let i = 0; i < table.length; i++) {
const row = table[i]
totalGmv += row.gmv as number
totalOrders += row.orders as number
totalUsers += row.users as number
}
this.drillDownItems = [
{ id: 'gmv_total', label: '当前周期 GMV 总计', value: this.formatCellValue(totalGmv, 'money'), type: 'gmv' } as DrillDownItem,
{ id: 'orders_total', label: '当前周期订单总数', value: this.formatCellValue(totalOrders, 'number'), type: 'orders' } as DrillDownItem,
{ id: 'users_total', label: '当前周期下单用户数', value: this.formatCellValue(totalUsers, 'number'), type: 'users' } as DrillDownItem
]
;(this as any)._currentRows = currentRows
;(this as any)._compareRows = compareRows
this.updateTime()
this.buildChartOptions()
} catch (e) {
console.error('loadDetailData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: '详细数据加载失败', icon: 'none' })
}
},
selectTimeRange() {
@@ -201,7 +298,16 @@ export default {
this.sortKey = key
this.sortOrder = 'asc'
}
// TODO: 实现排序逻辑
const data = this.tableData.slice()
data.sort((a: any, b: any) => {
const va = a[key]
const vb = b[key]
const na = typeof va === 'number' ? va : Number(va)
const nb = typeof vb === 'number' ? vb : Number(vb)
if (this.sortOrder === 'asc') return na - nb
return nb - na
})
this.tableData = data
},
formatCellValue(value: any, type: string): string {
@@ -245,8 +351,58 @@ export default {
},
buildChartOptions() {
// TODO: 构建图表配置
this.compareChartOption = {}
const curAny = (this as any)._currentRows as any
const prevAny = (this as any)._compareRows as any
const curRows = Array.isArray(curAny) ? curAny as Array<UTSJSONObject> : []
const prevRows = Array.isArray(prevAny) ? prevAny as Array<UTSJSONObject> : []
if (!this.compareMode || curRows.length === 0) {
this.compareChartOption = {}
return
}
const curDays: string[] = []
const curGmv: number[] = []
const prevGmv: number[] = []
for (let i = 0; i < curRows.length; i++) {
const r = curRows[i]
const dayStr = r.getString('day') ?? ''
curDays.push(dayStr.length >= 10 ? dayStr.substring(5, 10) : dayStr)
curGmv.push(r.getNumber('gmv') ?? 0)
}
// 对比周期的数据按顺序对齐(长度可能不同,超出部分用 0 填充)
for (let i = 0; i < curRows.length; i++) {
const rPrev = i < prevRows.length ? prevRows[i] : null
if (rPrev == null) {
prevGmv.push(0)
} else {
prevGmv.push(rPrev.getNumber('gmv') ?? 0)
}
}
this.compareChartOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['当前周期 GMV', '对比周期 GMV'], top: 'bottom' },
grid: { left: 50, right: 20, top: 30, bottom: 60 },
xAxis: { type: 'category', data: curDays },
yAxis: { type: 'value', name: 'GMV' },
series: [
{
name: '当前周期 GMV',
type: 'line',
smooth: true,
data: curGmv
},
{
name: '对比周期 GMV',
type: 'line',
smooth: true,
data: prevGmv
}
]
}
},
handleMenu() {

View File

@@ -59,16 +59,16 @@
</view>
<view class="kpi-card">
<text class="kpi-label">客户满意度</text>
<text class="kpi-value">{{ deliveryData.satisfaction_rate }}%</text>
<text class="kpi-value">{{ formatScore(deliveryData.satisfaction_rate) }}</text>
<text class="kpi-meta">较上期:{{ formatPct(deliveryData.satisfaction_growth) }}</text>
</view>
</view>
<!-- 配送时效分析 -->
<!-- 配送时效 & 满意度(合并图表) -->
<view class="card card-full">
<view class="card-head">
<text class="card-title">配送时效分析</text>
<text class="card-desc">{{ selectedPeriodText }} · 平均配送时间趋势</text>
<text class="card-title">配送时效 & 满意度</text>
<text class="card-desc">{{ selectedPeriodText }} · 平均配送时间 / 满意度趋势</text>
</view>
<EChartsView class="chart-box" :option="timeChartOption" />
</view>
@@ -88,28 +88,24 @@
<text class="card-title">配送员效率排行 TOP 10</text>
<text class="card-desc">按订单数排序</text>
</view>
<view class="rank-list">
<view v-for="d in topDrivers" :key="d.id" class="rank-item">
<text class="rank-no">{{ d.rank }}</text>
<text class="rank-name">{{ d.name }}</text>
<view class="rank-right">
<text class="rank-val">{{ d.orders }}</text>
<text class="chip" :class="d.rating >= 4.5 ? 'pos' : 'neg'">
⭐{{ d.rating }}
</text>
<view class="rank-scroll" @mouseenter="onRankHover(true)" @mouseleave="onRankHover(false)">
<scroll-view class="rank-scroll-inner" :scroll-y="true" :show-scrollbar="true">
<view class="rank-list">
<view v-for="d in topDrivers" :key="d.id" class="rank-item">
<text class="rank-no">{{ d.rank }}</text>
<text class="rank-name">{{ d.name }}</text>
<view class="rank-right">
<text class="rank-val">{{ d.orders }} 单</text>
<text class="chip" :class="d.rating >= 4.5 ? 'pos' : 'neg'">
⭐{{ d.rating }}
</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
<!-- 客户满意度分析 -->
<view class="card">
<view class="card-head">
<text class="card-title">客户满意度分析</text>
<text class="card-desc">评分分布</text>
</view>
<EChartsView class="chart-box" :option="satisfactionChartOption" />
</view>
<!-- 留白 -->
<view style="height: 24px;"></view>
@@ -121,6 +117,7 @@
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import { ensureSupabaseReady } 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'
@@ -170,7 +167,7 @@ export default {
timeChartOption: {} as any,
feeChartOption: {} as any,
satisfactionChartOption: {} as any
isRankHover: false
}
},
@@ -188,9 +185,220 @@ export default {
methods: {
async loadDeliveryData() {
// TODO: 实现配送数据加载
this.updateTime()
this.buildChartOptions()
try {
// 根据时间维度计算起始时间
const now = new Date()
const start = new Date(now.getTime())
if (this.selectedPeriod === '7d') start.setDate(start.getDate() - 7)
else if (this.selectedPeriod === '30d') start.setDate(start.getDate() - 30)
else if (this.selectedPeriod === '90d') start.setDate(start.getDate() - 90)
else if (this.selectedPeriod === '1y') start.setFullYear(start.getFullYear() - 1)
const startIso = start.toISOString()
await ensureSupabaseReady()
// 1) 查询周期内已送达任务(按 assigned_at 统计周期,口径:接单 assigned_at -> 送达 delivered_at
// 表结构来源doc_mall/database/complete_mall_database.sql
// 1) 趋势RPC数据库侧聚合
const endIso = now.toISOString()
// 优先走 RPC需要在 Supabase 执行 DELIVERY_ANALYSIS_RPCS.sql 创建函数)
let trendList: Array<UTSJSONObject> = []
let topList: Array<UTSJSONObject> = []
const trendRes = await supa.rpc('rpc_delivery_efficiency_daily', {
p_start: startIso,
p_end: endIso
})
if (trendRes.status === 404) {
// RPC 不存在:降级到直查表聚合(测试阶段兜底)
const taskRes = await supa
.from('ml_delivery_tasks')
.select('id,driver_id,assigned_at,delivered_at,delivery_fee', {})
.eq('status', 5)
.gte('assigned_at', startIso)
.order('assigned_at', { ascending: true })
.execute()
if (taskRes.error != null) throw taskRes.error
const rowsAny = (taskRes.data != null ? taskRes.data : []) as any
const tasks = Array.isArray(rowsAny) ? rowsAny as Array<UTSJSONObject> : []
const dayAgg = new Map<string, UTSJSONObject>()
const driverAgg = new Map<string, number>()
for (let i = 0; i < tasks.length; i++) {
const t = tasks[i]
const assignedAt = t.getString('assigned_at') ?? ''
const deliveredAt = t.getString('delivered_at') ?? ''
const driverId = t.getString('driver_id') ?? ''
if (assignedAt.trim() === '' || deliveredAt.trim() === '') continue
const day = assignedAt.length >= 10 ? assignedAt.substring(0, 10) : assignedAt
const a = new Date(assignedAt)
const d = new Date(deliveredAt)
const diffMin = Math.max(0, (d.getTime() - a.getTime()) / 60000)
const fee = t.getNumber('delivery_fee') ?? 0
const old = dayAgg.get(day)
if (old == null) {
const obj = new UTSJSONObject()
obj.set('day', day)
obj.set('completed_orders', 1)
obj.set('sum_minutes', diffMin)
obj.set('total_fee', fee)
dayAgg.set(day, obj)
} else {
old.set('completed_orders', (old.getNumber('completed_orders') ?? 0) + 1)
old.set('sum_minutes', (old.getNumber('sum_minutes') ?? 0) + diffMin)
old.set('total_fee', (old.getNumber('total_fee') ?? 0) + fee)
}
if (driverId.trim() !== '') {
const c = driverAgg.get(driverId) ?? 0
driverAgg.set(driverId, c + 1)
}
}
const keys = Array.from(dayAgg.keys()).sort()
for (let i = 0; i < keys.length; i++) {
const k = keys[i]
const agg = dayAgg.get(k) as UTSJSONObject
const cnt = agg.getNumber('completed_orders') ?? 0
const sumMin = agg.getNumber('sum_minutes') ?? 0
const tFee = agg.getNumber('total_fee') ?? 0
const row = new UTSJSONObject()
row.set('day', k)
row.set('completed_orders', cnt)
row.set('avg_delivery_minutes', cnt > 0 ? (sumMin / cnt) : 0)
row.set('total_fee', tFee)
row.set('avg_fee', cnt > 0 ? (tFee / cnt) : 0)
trendList.push(row)
}
// TOP仅用 driverAgg 计算(姓名/评分未知,先兜底)
const ids = Array.from(driverAgg.keys()).sort((a, b) => (driverAgg.get(b) ?? 0) - (driverAgg.get(a) ?? 0))
const topN = Math.min(10, ids.length)
for (let i = 0; i < topN; i++) {
const id = ids[i]
const row = new UTSJSONObject()
row.set('driver_id', id)
row.set('driver_name', '未知')
row.set('orders', driverAgg.get(id) ?? 0)
row.set('rating_avg', 0)
topList.push(row)
}
} else {
if (trendRes.error != null) throw trendRes.error
const trendAny = (trendRes.data != null ? trendRes.data : []) as any
trendList = Array.isArray(trendAny) ? trendAny as Array<UTSJSONObject> : []
const topRes = await supa.rpc('rpc_delivery_efficiency_top_drivers', {
p_start: startIso,
p_end: endIso,
p_limit: 10
})
if (topRes.error != null) throw topRes.error
const topAny = (topRes.data != null ? topRes.data : []) as any
topList = Array.isArray(topAny) ? topAny as Array<UTSJSONObject> : []
}
// 3) 转成页面内部 trendRows 格式
const trendRows: Array<UTSJSONObject> = []
let totalFee = 0
let totalOrders = 0
for (let i = 0; i < trendList.length; i++) {
const r = trendList[i]
const dayStr = r.getString('day') ?? ''
const orders = r.getNumber('completed_orders') ?? 0
const avgMin = r.getNumber('avg_delivery_minutes') ?? 0
const avgFee = r.getNumber('avg_fee') ?? 0
const tFee = r.getNumber('total_fee') ?? 0
totalOrders += orders
totalFee += tFee
const obj = new UTSJSONObject()
obj.set('day', dayStr)
obj.set('avg_delivery_time', avgMin)
obj.set('avg_fee', avgFee)
// 满意度趋势:目前来源为配送员表 rating_avg后续如有配送评价表可替换
obj.set('satisfaction_rate', 0)
trendRows.push(obj)
}
// 4) 满意度:用 TOP10 里的 rating_avg 做平均(简单可用;也可以后续改为全量司机或配送评价表)
let satisSum = 0
let satisCnt = 0
for (let i = 0; i < topList.length; i++) {
const r = topList[i]
const rating = r.getNumber('rating_avg')
if (rating != null) {
satisSum += rating
satisCnt += 1
}
}
const satisAvg = satisCnt > 0 ? (satisSum / satisCnt) : 0
for (let i = 0; i < trendRows.length; i++) {
trendRows[i].set('satisfaction_rate', satisAvg)
}
// 5) KPI最后一天 vs 前一天环比
const last = trendRows.length > 0 ? trendRows[trendRows.length - 1] : null
const prev = trendRows.length > 1 ? trendRows[trendRows.length - 2] : null
const lastAvgTime = last != null ? (last.getNumber('avg_delivery_time') ?? 0) : 0
const prevAvgTime = prev != null ? (prev.getNumber('avg_delivery_time') ?? 0) : 0
const timeGrowth = prevAvgTime > 0 ? ((lastAvgTime - prevAvgTime) / prevAvgTime) * 100 : 0
const lastSatis = last != null ? (last.getNumber('satisfaction_rate') ?? 0) : 0
const prevSatis = prev != null ? (prev.getNumber('satisfaction_rate') ?? 0) : 0
const satisGrowth = prevSatis > 0 ? ((lastSatis - prevSatis) / prevSatis) * 100 : 0
// 配送员效率:单/人/天(按 TOP10 近似人数 + 趋势天数)
const dayCount = Math.max(1, trendRows.length)
const driverCount = Math.max(1, topList.length)
const avgOrdersPerDriverPerDay = (totalOrders / driverCount) / dayCount
this.deliveryData = {
avg_delivery_time: Math.round(lastAvgTime),
time_growth: timeGrowth,
total_fee: totalFee,
avg_fee: totalOrders > 0 ? (totalFee / totalOrders) : 0,
avg_orders_per_driver: avgOrdersPerDriverPerDay,
satisfaction_rate: lastSatis,
satisfaction_growth: satisGrowth
} as DeliveryData
;(this as any)._trendRows = trendRows
// 6) TOP10 映射
const list: DriverRank[] = []
for (let i = 0; i < topList.length; i++) {
const r = topList[i]
list.push({
id: r.getString('driver_id') ?? String(i),
rank: i + 1,
name: r.getString('driver_name') ?? '未知',
orders: r.getNumber('orders') ?? 0,
rating: r.getNumber('rating_avg') ?? 0
} as DriverRank)
}
this.topDrivers = list
this.updateTime()
this.buildChartOptions()
} catch (e) {
console.error('loadDeliveryData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: '配送分析数据加载失败', icon: 'none' })
}
},
selectPeriod(p: string) {
@@ -235,11 +443,109 @@ export default {
return `${sign}${v.toFixed(1)}%`
},
formatScore(n: number): string {
const v = isFinite(n) ? n : 0
return v.toFixed(1)
},
buildChartOptions() {
// TODO: 构建图表配置
this.timeChartOption = {}
this.feeChartOption = {}
this.satisfactionChartOption = {}
const rowsAny = (this as any)._trendRows as any
const rows = Array.isArray(rowsAny) ? rowsAny as Array<UTSJSONObject> : []
const xAxis: string[] = []
const timeSeries: number[] = []
const feeSeries: number[] = []
const satisSeries: number[] = []
for (let i = 0; i < rows.length; i++) {
const r = rows[i]
const day = r.getString('day') ?? ''
xAxis.push(day.length >= 10 ? day.substring(5, 10) : day)
timeSeries.push(r.getNumber('avg_delivery_time') ?? 0)
feeSeries.push(r.getNumber('avg_fee') ?? 0)
satisSeries.push(r.getNumber('satisfaction_rate') ?? 0)
}
this.timeChartOption = {
tooltip: { trigger: 'axis' },
legend: {
data: ['平均配送时间(分钟)', '满意度(评分)'],
top: 'bottom',
itemGap: 30,
itemWidth: 16,
itemHeight: 16
},
grid: { left: 40, right: 50, top: 30, bottom: 60 },
xAxis: {
type: 'category',
data: xAxis,
axisTick: { alignWithLabel: true }
},
yAxis: [
{
type: 'value',
name: '配送时间',
min: 0,
splitLine: { lineStyle: { color: '#e5e7eb' } }
},
{
type: 'value',
name: '满意度',
min: 0,
max: 5,
position: 'right',
splitLine: { show: false }
}
],
series: [
{
name: '平均配送时间(分钟)',
type: 'line',
smooth: true,
symbolSize: 6,
data: timeSeries,
yAxisIndex: 0
},
{
name: '满意度(评分)',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
data: satisSeries,
yAxisIndex: 1
}
]
}
this.feeChartOption = {
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: xAxis },
yAxis: { type: 'value' },
series: [
{
name: '平均配送费(元)',
type: 'bar',
data: feeSeries
}
]
}
this.satisfactionChartOption = {
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: xAxis },
yAxis: { type: 'value', min: 0, max: 5 },
series: [
{
name: '满意度(评分)',
type: 'line',
smooth: true,
data: satisSeries
}
]
}
},
handleMenu() {
@@ -528,6 +834,36 @@ export default {
height: 360px;
}
/* 排行滚动容器:固定高度(约 5 条) */
.rank-scroll {
/* 5条左右的可视高度5*(10px上下padding + 28px内容 + 10px gap) 约 300 */
height: 320px;
border-radius: 12px;
}
.rank-scroll-inner {
height: 100%;
}
/* H5默认隐藏滚动条悬停时显示 */
.rank-scroll-inner::-webkit-scrollbar {
width: 0;
height: 0;
}
.rank-scroll:hover .rank-scroll-inner::-webkit-scrollbar {
width: 8px;
}
.rank-scroll:hover .rank-scroll-inner::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.25);
border-radius: 999px;
}
.rank-scroll:hover .rank-scroll-inner::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.06);
}
/* 排行列表 */
.rank-list {
display: flex;

View File

@@ -94,7 +94,7 @@
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import supa, { ensureSupabaseReady } 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'
@@ -148,9 +148,108 @@ export default {
methods: {
async loadMarketData() {
// TODO: 实现市场数据加载
this.updateTime()
this.buildChartOptions()
try {
const now = new Date()
const start = new Date(now.getTime())
if (this.selectedPeriod === '7d') start.setDate(start.getDate() - 7)
else if (this.selectedPeriod === '30d') start.setDate(start.getDate() - 30)
else if (this.selectedPeriod === '90d') start.setDate(start.getDate() - 90)
else if (this.selectedPeriod === '1y') start.setFullYear(start.getFullYear() - 1)
const startIso = start.toISOString()
const endIso = now.toISOString()
await ensureSupabaseReady()
// 1) 市场整体趋势(按天 GMV / 订单 / 用户)
let trendRows: Array<UTSJSONObject> = []
let categoryRows: Array<UTSJSONObject> = []
let seasonalRows: Array<UTSJSONObject> = []
let priceRows: Array<UTSJSONObject> = []
let competitionRows: Array<UTSJSONObject> = []
const marketRes = await supa.rpc('rpc_analytics_market_trend_daily', {
p_start: startIso,
p_end: endIso
})
if (marketRes.status === 404) {
console.warn('rpc_analytics_market_trend_daily not found, market trend will be empty')
} else if (marketRes.error != null) {
console.error('rpc_analytics_market_trend_daily error:', marketRes.error)
} else {
const anyData = marketRes.data as any
trendRows = Array.isArray(anyData) ? anyData as Array<UTSJSONObject> : []
}
// 2) 行业对比(按分类 GMV
const catRes = await supa.rpc('rpc_analytics_category_sales', {
p_start_date: startIso.substring(0, 10),
p_end_date: endIso.substring(0, 10)
})
if (catRes.status === 404) {
console.warn('rpc_analytics_category_sales not found, industry comparison will be empty')
} else if (catRes.error != null) {
console.error('rpc_analytics_category_sales error:', catRes.error)
} else {
const cAny = catRes.data as any
categoryRows = Array.isArray(cAny) ? cAny as Array<UTSJSONObject> : []
}
// 3) 季节性趋势(按月 GMV
const seaRes = await supa.rpc('rpc_analytics_seasonal_trend', {
p_start_date: startIso.substring(0, 10),
p_end_date: endIso.substring(0, 10)
})
if (seaRes.status === 404) {
console.warn('rpc_analytics_seasonal_trend not found, seasonal trend will be empty')
} else if (seaRes.error != null) {
console.error('rpc_analytics_seasonal_trend error:', seaRes.error)
} else {
const sAny = seaRes.data as any
seasonalRows = Array.isArray(sAny) ? sAny as Array<UTSJSONObject> : []
}
// 4) 价格趋势(按天平均单价)
const priceRes = await supa.rpc('rpc_analytics_price_trend', {
p_start: startIso,
p_end: endIso
})
if (priceRes.status === 404) {
console.warn('rpc_analytics_price_trend not found, price trend will be empty')
} else if (priceRes.error != null) {
console.error('rpc_analytics_price_trend error:', priceRes.error)
} else {
const pAny = priceRes.data as any
priceRows = Array.isArray(pAny) ? pAny as Array<UTSJSONObject> : []
}
// 5) 竞争分析(商家 GMV 榜单)
const compRes = await supa.rpc('rpc_analytics_competition_share', {
p_start_date: startIso.substring(0, 10),
p_end_date: endIso.substring(0, 10)
})
if (compRes.status === 404) {
console.warn('rpc_analytics_competition_share not found, competition analysis will be empty')
} else if (compRes.error != null) {
console.error('rpc_analytics_competition_share error:', compRes.error)
} else {
const cpAny = compRes.data as any
competitionRows = Array.isArray(cpAny) ? cpAny as Array<UTSJSONObject> : []
}
;(this as any)._marketTrendRows = trendRows
;(this as any)._industryRows = categoryRows
;(this as any)._seasonalRows = seasonalRows
;(this as any)._priceRows = priceRows
;(this as any)._competitionRows = competitionRows
this.updateTime()
this.buildChartOptions()
} catch (e) {
console.error('loadMarketData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: '市场趋势数据加载失败', icon: 'none' })
}
},
selectPeriod(p: string) {
@@ -178,12 +277,162 @@ export default {
},
buildChartOptions() {
// TODO: 构建图表配置
this.marketTrendOption = {}
this.industryCompareOption = {}
this.seasonalTrendOption = {}
this.priceTrendOption = {}
this.competitionOption = {}
const trendAny = (this as any)._marketTrendRows as any
const industryAny = (this as any)._industryRows as any
const seasonalAny = (this as any)._seasonalRows as any
const priceAny = (this as any)._priceRows as any
const compAny = (this as any)._competitionRows as any
const trendRows = Array.isArray(trendAny) ? trendAny as Array<UTSJSONObject> : []
const industryRows = Array.isArray(industryAny) ? industryAny as Array<UTSJSONObject> : []
const seasonalRows = Array.isArray(seasonalAny) ? seasonalAny as Array<UTSJSONObject> : []
const priceRows = Array.isArray(priceAny) ? priceAny as Array<UTSJSONObject> : []
const compRows = Array.isArray(compAny) ? compAny as Array<UTSJSONObject> : []
// 1) 市场整体趋势GMV / 订单数 / 用户数
const mtDays: string[] = []
const mtGmv: number[] = []
const mtOrders: number[] = []
const mtUsers: number[] = []
for (let i = 0; i < trendRows.length; i++) {
const r = trendRows[i]
const dayStr = r.getString('day') ?? ''
mtDays.push(dayStr.length >= 10 ? dayStr.substring(5, 10) : dayStr)
mtGmv.push(r.getNumber('gmv') ?? 0)
mtOrders.push(r.getNumber('orders') ?? 0)
mtUsers.push(r.getNumber('users') ?? 0)
}
this.marketTrendOption = {
tooltip: { trigger: 'axis' },
legend: {
data: ['GMV', '订单数', '用户数'],
top: 'bottom'
},
grid: { left: 50, right: 60, top: 40, bottom: 60 },
xAxis: { type: 'category', data: mtDays },
yAxis: [
{ type: 'value', name: 'GMV', splitLine: { lineStyle: { color: '#e5e7eb' } } },
{ type: 'value', name: '数量', position: 'right', splitLine: { show: false } }
],
series: [
{
name: 'GMV',
type: 'bar',
data: mtGmv,
barMaxWidth: 26,
itemStyle: { color: '#3b82f6' }
},
{
name: '订单数',
type: 'line',
yAxisIndex: 1,
smooth: true,
data: mtOrders
},
{
name: '用户数',
type: 'line',
yAxisIndex: 1,
smooth: true,
data: mtUsers
}
]
}
// 2) 行业对比:分类 GMV
const catNames: string[] = []
const catSales: number[] = []
for (let i = 0; i < industryRows.length; i++) {
const r = industryRows[i]
catNames.push(r.getString('category_name') ?? '未分类')
catSales.push(r.getNumber('total_sales') ?? 0)
}
this.industryCompareOption = {
tooltip: { trigger: 'axis' },
grid: { left: 80, right: 20, top: 30, bottom: 60 },
xAxis: { type: 'value' },
yAxis: { type: 'category', data: catNames },
series: [
{
name: 'GMV',
type: 'bar',
data: catSales
}
]
}
// 3) 季节性趋势:按月 GMV
const seaMonths: string[] = []
const seaGmv: number[] = []
for (let i = 0; i < seasonalRows.length; i++) {
const r = seasonalRows[i]
seaMonths.push(r.getString('month') ?? '')
seaGmv.push(r.getNumber('total_gmv') ?? 0)
}
this.seasonalTrendOption = {
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, top: 30, bottom: 60 },
xAxis: { type: 'category', data: seaMonths },
yAxis: { type: 'value', name: 'GMV' },
series: [
{
name: 'GMV',
type: 'line',
smooth: true,
data: seaGmv
}
]
}
// 4) 价格趋势:按天平均价格
const priceDays: string[] = []
const avgPrices: number[] = []
for (let i = 0; i < priceRows.length; i++) {
const r = priceRows[i]
const d = r.getString('day') ?? ''
priceDays.push(d.length >= 10 ? d.substring(5, 10) : d)
avgPrices.push(r.getNumber('avg_price') ?? 0)
}
this.priceTrendOption = {
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, top: 30, bottom: 60 },
xAxis: { type: 'category', data: priceDays },
yAxis: { type: 'value', name: '平均价格' },
series: [
{
name: '平均价格',
type: 'line',
smooth: true,
data: avgPrices
}
]
}
// 5) 竞争分析:商家 GMV 对比
const merchantNames: string[] = []
const merchantGmv: number[] = []
for (let i = 0; i < compRows.length; i++) {
const r = compRows[i]
merchantNames.push(r.getString('merchant_name') ?? '未知商家')
merchantGmv.push(r.getNumber('gmv') ?? 0)
}
this.competitionOption = {
tooltip: { trigger: 'item' },
legend: { top: 'bottom' },
series: [
{
name: '商家GMV',
type: 'pie',
radius: ['35%', '65%'],
center: ['50%', '50%'],
data: merchantNames.map((n, idx) => {
return { name: n, value: merchantGmv[idx] }
})
}
]
}
},
handleMenu() {

View File

@@ -1,4 +1,3 @@
✅ 明白了!你不需要完整代码,而是希望我**用简洁的 Markdown 文档形式,解释这个 `delivery` 文件夹下的项目结构和每个页面的功能定位**。
---

View File

@@ -25,6 +25,7 @@
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import { IS_TEST_MODE } from '@/ak/config.uts'
export default {
onLoad() {
@@ -37,37 +38,23 @@
methods: {
checkAndRedirect() {
console.log('boot: start redirect check')
try {
const sessionInfo = supa.getSession();
if (sessionInfo != null && sessionInfo.user != null) {
// 已登录 -> 直接进入消费者端首页
console.log('boot: found session, go consumer/index')
uni.reLaunch({
url: '/pages/mall/consumer/index',
success: () => {
console.log('boot: redirect to consumer/index success')
},
fail: (err) => {
console.error('boot: redirect to consumer/index failed', err)
}
});
return;
}
} catch (e) {
console.error('boot: error checking session', e);
if (IS_TEST_MODE) {
// 测试阶段:不做强制重定向,保留你手动输入的 URL / 目标页面
return
}
console.log('boot: no session, go login')
// 未登录 -> 登录页
uni.reLaunch({
url: '/pages/user/login',
success: () => {
console.log('boot: redirect to login success')
},
fail: (err) => {
console.error('boot: redirect to login failed', err)
try {
const sessionInfo = supa.getSession()
if (sessionInfo != null && sessionInfo.user != null) {
uni.reLaunch({ url: '/pages/mall/consumer/index' })
return
}
});
} catch (e) {
console.error('boot: error checking session', e)
}
uni.reLaunch({ url: '/pages/user/login' })
}
}
};

View File

@@ -141,6 +141,7 @@
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
import { IS_TEST_MODE } from '@/ak/config.uts'
import { getCurrentUser, logout } from '@/utils/store.uts'
const cssVars = {
@@ -171,6 +172,7 @@ const codeCountdown = ref<number>(0)
onMounted(() => {
try {
if (IS_TEST_MODE) return
const sessionInfo = supa.getSession()
if (sessionInfo != null && sessionInfo.user != null) {
uni.switchTab({ url: '/pages/mall/consumer/index' })
@@ -298,9 +300,11 @@ const handleLogin = async () => {
}
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}, 500)
if (!IS_TEST_MODE) {
setTimeout(() => {
uni.switchTab({ url: '/pages/mall/consumer/index' })
}, 500)
}
} catch (err) {
console.error('登录错误:', err)
let msg = '登录失败,请重试'

View File

@@ -1,95 +0,0 @@
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

@@ -1,537 +0,0 @@
# 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:

View File

@@ -1,130 +0,0 @@
############
# 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

View File

@@ -220,15 +220,23 @@ export class AkReq {
attempt++;
}
const finalRes = lastRes!!;
// 全局处理 401 未授权:在非 refresh 场景下,清理 token 并跳转登录以避免未捕获错误
// 全局处理 401 未授权:在非 refresh 场景下,清理 token
// 测试模式下不强制跳登录页,避免影响任意跳转调试。
if ((finalRes.status === 401) && (skipRefresh !== true)) {
try {
this.clearToken();
uni.showToast({ title: '未授权或登录已过期,请重新登录', icon: 'none' });
} catch (e) {}
try {
uni.reLaunch({ url: '/pages/user/login' });
} catch (e) {}
// 动态读取配置,避免 ak-req 模块与业务工程强耦合
const cfg = require('@/ak/config.uts') as any
const isTest = cfg != null ? (cfg.IS_TEST_MODE === true) : false
if (!isTest) {
uni.reLaunch({ url: '/pages/user/login' });
}
} catch (e) {
try { uni.reLaunch({ url: '/pages/user/login' }); } catch (e2) {}
}
}
return finalRes;
}