数据分析ui补充完善,接入数据库
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
✅ 明白了!你不需要完整代码,而是希望我**用简洁的 Markdown 文档形式,解释这个 `delivery` 文件夹下的项目结构和每个页面的功能定位**。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 = '登录失败,请重试'
|
||||
|
||||
@@ -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}
|
||||
@@ -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:
|
||||
130
supabase_pro/env
130
supabase_pro/env
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user