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

This commit is contained in:
comlibmb
2026-01-31 21:47:42 +08:00
parent 8f181b2b6a
commit 6716398175
71 changed files with 6501 additions and 10593 deletions

View File

@@ -0,0 +1,274 @@
-- =====================================================================================
-- 商城分析模块增量升级脚本
-- 包含: 埋点事件表、分析用 RPC 函数
-- =====================================================================================
-- =====================================================================================
-- 1. 创建用户行为事件表 (埋点)
-- =====================================================================================
CREATE TABLE IF NOT EXISTS public.analytics_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_type VARCHAR(50) NOT NULL, -- e.g., page_view, product_view, add_to_cart, checkout_start, payment_success
user_id UUID REFERENCES public.ak_users(id) ON DELETE SET NULL,
session_id VARCHAR(100),
device_id VARCHAR(100),
page_url TEXT,
referrer TEXT,
utm_source VARCHAR(100),
utm_medium VARCHAR(100),
utm_campaign VARCHAR(100),
ip_address INET,
user_agent TEXT,
screen_resolution VARCHAR(20),
device_type VARCHAR(50),
os VARCHAR(50),
browser VARCHAR(50),
country VARCHAR(50),
region VARCHAR(50),
city VARCHAR(50),
event_props JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE public.analytics_events IS '用户行为事件(埋点)表';
COMMENT ON COLUMN public.analytics_events.event_type IS '事件类型,如 page_view, product_view, add_to_cart 等';
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_analytics_events_user_id ON public.analytics_events(user_id);
CREATE INDEX IF NOT EXISTS idx_analytics_events_event_type ON public.analytics_events(event_type);
CREATE INDEX IF NOT EXISTS idx_analytics_events_created_at ON public.analytics_events(created_at);
CREATE INDEX IF NOT EXISTS idx_analytics_events_session_id ON public.analytics_events(session_id);
-- =====================================================================================
-- 2. 创建分析所需的 RPC 函数
-- =====================================================================================
-- -------------------------------------------------------------------------------------
-- 函数: rpc_analytics_user_kpis
-- 描述: 获取用户分析核心KPI指标
-- -------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rpc_analytics_user_kpis(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
total_users BIGINT,
user_growth FLOAT,
new_users BIGINT,
new_user_growth FLOAT,
active_users BIGINT,
active_growth FLOAT,
ordering_users BIGINT,
ordering_growth FLOAT,
paid_users BIGINT,
paid_growth FLOAT,
new_user_conversion_rate FLOAT,
repurchase_rate FLOAT,
repurchase_growth FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
prev_start_date DATE;
prev_end_date DATE;
period_days INT;
BEGIN
-- 计算上一周期日期范围
period_days := p_end_date - p_start_date + 1;
prev_start_date := p_start_date - period_days;
prev_end_date := p_start_date - 1;
RETURN QUERY
WITH
-- 当前周期数据
current_period AS (
SELECT
(SELECT COUNT(*) FROM public.ak_users WHERE created_at <= p_end_date::timestamp) AS total_users,
COUNT(DISTINCT CASE WHEN u.created_at::DATE BETWEEN p_start_date AND p_end_date THEN u.id END)::BIGINT AS new_users,
COUNT(DISTINCT e.user_id)::BIGINT AS active_users,
COUNT(DISTINCT o.user_id)::BIGINT AS ordering_users,
COUNT(DISTINCT CASE WHEN o.payment_status = 2 THEN o.user_id END)::BIGINT AS paid_users,
(
SELECT COUNT(DISTINCT user_id)
FROM public.ml_orders
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
AND user_id IN (SELECT id FROM public.ak_users WHERE created_at::DATE BETWEEN p_start_date AND p_end_date)
) * 100.0 / NULLIF(COUNT(DISTINCT CASE WHEN u.created_at::DATE BETWEEN p_start_date AND p_end_date THEN u.id END), 0) AS new_user_conversion_rate,
COUNT(DISTINCT CASE WHEN user_orders.order_count > 1 THEN user_orders.user_id END) * 100.0 / NULLIF(COUNT(DISTINCT user_orders.user_id), 0) AS repurchase_rate
FROM public.ak_users u
LEFT JOIN public.analytics_events e ON e.user_id = u.id AND e.created_at::DATE BETWEEN p_start_date AND p_end_date
LEFT JOIN public.ml_orders o ON o.user_id = u.id AND o.created_at::DATE BETWEEN p_start_date AND p_end_date
LEFT JOIN (
SELECT user_id, COUNT(*) as order_count
FROM public.ml_orders
WHERE created_at::DATE <= p_end_date AND payment_status = 2
GROUP BY user_id
) user_orders ON user_orders.user_id = o.user_id AND o.payment_status = 2
),
-- 上一周期数据
previous_period AS (
SELECT
(SELECT COUNT(*) FROM public.ak_users WHERE created_at <= prev_end_date::timestamp) AS total_users,
COUNT(DISTINCT CASE WHEN u.created_at::DATE BETWEEN prev_start_date AND prev_end_date THEN u.id END)::BIGINT AS new_users,
COUNT(DISTINCT e.user_id)::BIGINT AS active_users,
COUNT(DISTINCT o.user_id)::BIGINT AS ordering_users,
COUNT(DISTINCT CASE WHEN o.payment_status = 2 THEN o.user_id END)::BIGINT AS paid_users,
COUNT(DISTINCT CASE WHEN user_orders.order_count > 1 THEN user_orders.user_id END) * 100.0 / NULLIF(COUNT(DISTINCT user_orders.user_id), 0) AS repurchase_rate
FROM public.ak_users u
LEFT JOIN public.analytics_events e ON e.user_id = u.id AND e.created_at::DATE BETWEEN prev_start_date AND prev_end_date
LEFT JOIN public.ml_orders o ON o.user_id = u.id AND o.created_at::DATE BETWEEN prev_start_date AND prev_end_date
LEFT JOIN (
SELECT user_id, COUNT(*) as order_count
FROM public.ml_orders
WHERE created_at::DATE <= prev_end_date AND payment_status = 2
GROUP BY user_id
) user_orders ON user_orders.user_id = o.user_id AND o.payment_status = 2
)
SELECT
cp.total_users,
ROUND(((cp.total_users - pp.total_users) * 100.0 / NULLIF(pp.total_users, 1))::numeric, 2)::FLOAT AS user_growth,
cp.new_users,
ROUND(((cp.new_users - pp.new_users) * 100.0 / NULLIF(pp.new_users, 1))::numeric, 2)::FLOAT AS new_user_growth,
cp.active_users,
ROUND(((cp.active_users - pp.active_users) * 100.0 / NULLIF(pp.active_users, 1))::numeric, 2)::FLOAT AS active_growth,
cp.ordering_users,
ROUND(((cp.ordering_users - pp.ordering_users) * 100.0 / NULLIF(pp.ordering_users, 1))::numeric, 2)::FLOAT AS ordering_growth,
cp.paid_users,
ROUND(((cp.paid_users - pp.paid_users) * 100.0 / NULLIF(pp.paid_users, 1))::numeric, 2)::FLOAT AS paid_growth,
ROUND(cp.new_user_conversion_rate::numeric, 2)::FLOAT,
ROUND(cp.repurchase_rate::numeric, 2)::FLOAT AS repurchase_rate,
ROUND((cp.repurchase_rate - pp.repurchase_rate)::numeric, 2)::FLOAT AS repurchase_growth
FROM current_period cp, previous_period pp;
END;
$$;
-- -------------------------------------------------------------------------------------
-- 函数: rpc_analytics_user_growth_trend
-- 描述: 获取用户增长趋势(日维度)
-- -------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rpc_analytics_user_growth_trend(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
date DATE,
new_users BIGINT,
active_users BIGINT
)
LANGUAGE sql
AS $$
WITH date_series AS (
SELECT generate_series(p_start_date, p_end_date, '1 day'::interval)::DATE AS date
)
SELECT
ds.date,
COUNT(DISTINCT CASE WHEN u.created_at::DATE = ds.date THEN u.id END)::BIGINT AS new_users,
COUNT(DISTINCT e.user_id)::BIGINT AS active_users
FROM date_series ds
LEFT JOIN public.ak_users u ON u.created_at::DATE = ds.date
LEFT JOIN public.analytics_events e ON e.created_at::DATE = ds.date
GROUP BY ds.date
ORDER BY ds.date;
$$;
-- -------------------------------------------------------------------------------------
-- 函数: rpc_analytics_user_segments
-- 描述: 获取用户分群(基于新老客、复购、回流的简单模型)
-- -------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rpc_analytics_user_segments(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
name TEXT,
value BIGINT
)
LANGUAGE sql
AS $$
WITH user_orders AS (
SELECT
user_id,
MIN(created_at) as first_order_time,
COUNT(id) as total_orders
FROM public.ml_orders
WHERE payment_status = 2
GROUP BY user_id
)
SELECT
segment as name,
COUNT(user_id) as value
FROM (
SELECT
u.id as user_id,
CASE
WHEN uo.first_order_time::DATE BETWEEN p_start_date AND p_end_date THEN '新客'
WHEN uo.total_orders > 1 AND EXISTS (SELECT 1 FROM public.ml_orders WHERE user_id = u.id AND created_at::DATE BETWEEN p_start_date AND p_end_date) THEN '复购客户'
WHEN uo.total_orders >= 1 AND EXISTS (SELECT 1 FROM public.ml_orders WHERE user_id = u.id AND created_at::DATE BETWEEN p_start_date AND p_end_date) THEN '回流客户'
ELSE '老客'
END as segment
FROM public.ak_users u
JOIN user_orders uo ON u.id = uo.user_id
) segments
GROUP BY segment;
$$;
-- -------------------------------------------------------------------------------------
-- 函数: rpc_analytics_traffic_sources
-- 描述: 获取流量来源分布
-- -------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rpc_analytics_traffic_sources(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
name TEXT,
value BIGINT
)
LANGUAGE sql
AS $$
SELECT
COALESCE(
NULLIF(utm_source, ''),
CASE
WHEN referrer IS NULL OR referrer = '' THEN '直接访问'
WHEN referrer ILIKE '%baidu.com%' THEN '百度搜索'
WHEN referrer ILIKE '%google.com%' THEN '谷歌搜索'
WHEN referrer ILIKE '%bing.com%' THEN '必应搜索'
WHEN referrer ILIKE '%sogou.com%' THEN '搜狗搜索'
WHEN referrer ILIKE '%toutiao.com%' OR referrer ILIKE '%douyin.com%' THEN '字节系'
WHEN referrer ILIKE '%weixin.qq.com%' THEN '微信'
ELSE '其他推荐'
END
) AS name,
COUNT(DISTINCT session_id)::BIGINT AS value
FROM public.analytics_events
WHERE
created_at::DATE BETWEEN p_start_date AND p_end_date
AND event_type = 'page_view'
GROUP BY name
ORDER BY value DESC;
$$;
-- =====================================================================================
-- 3. 完成提示
-- =====================================================================================
DO $$
BEGIN
RAISE NOTICE '=======================================================';
RAISE NOTICE '商城分析模块增量升级完成!';
RAISE NOTICE '=======================================================';
RAISE NOTICE '✓ 埋点事件表(analytics_events)创建完成';
RAISE NOTICE '✓ RPC 函数创建完成:';
RAISE NOTICE ' - rpc_analytics_user_kpis';
RAISE NOTICE ' - rpc_analytics_user_growth_trend';
RAISE NOTICE ' - rpc_analytics_user_segments';
RAISE NOTICE ' - rpc_analytics_traffic_sources';
RAISE NOTICE '=======================================================';
RAISE NOTICE '下一步:';
RAISE NOTICE '1. 在应用中集成前端埋点,将数据写入 analytics_events 表';
RAISE NOTICE '2. 在前端分析页面调用以上 RPC 函数获取数据';
RAISE NOTICE '=======================================================';
END $$;

View File

@@ -0,0 +1,220 @@
-- =====================================================================================
-- 商城分析模块 RPC 函数增量脚本
-- 依赖: ml_* 系列表, ak_users 表
-- 目标: 为数据分析模块提供核心数据接口,优先使用现有表,不新建表。
-- =====================================================================================
-- =====================================================================================
-- 1. 函数: rpc_analytics_user_kpis
-- 描述: 获取用户分析核心KPI指标并与上一周期对比。
-- =====================================================================================
CREATE OR REPLACE FUNCTION public.rpc_analytics_user_kpis(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
total_users BIGINT,
user_growth FLOAT,
new_users BIGINT,
new_user_growth FLOAT,
active_users BIGINT,
active_growth FLOAT,
ordering_users BIGINT,
ordering_growth FLOAT,
paid_users BIGINT,
paid_growth FLOAT,
new_user_conversion_rate FLOAT,
repurchase_rate FLOAT,
repurchase_growth FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
prev_start_date DATE;
prev_end_date DATE;
period_days INT;
BEGIN
-- 计算上一周期日期范围
period_days := p_end_date - p_start_date + 1;
prev_start_date := p_start_date - period_days;
prev_end_date := p_start_date - 1;
RETURN QUERY
WITH
-- 当前周期数据
current_period AS (
SELECT
(SELECT COUNT(*) FROM public.ak_users WHERE created_at <= p_end_date::timestamp) AS total_users,
COUNT(DISTINCT CASE WHEN u.created_at::DATE BETWEEN p_start_date AND p_end_date THEN u.id END)::BIGINT AS new_users,
(SELECT COUNT(DISTINCT bh.user_id) FROM public.ml_browse_history bh WHERE bh.created_at::DATE BETWEEN p_start_date AND p_end_date) AS active_users,
(SELECT COUNT(DISTINCT o.user_id) FROM public.ml_orders o WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date) AS ordering_users,
(SELECT COUNT(DISTINCT o.user_id) FROM public.ml_orders o WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date AND o.payment_status = 2) AS paid_users,
(
SELECT COUNT(DISTINCT o.user_id) * 100.0 / NULLIF(COUNT(DISTINCT u_new.id), 0)
FROM public.ak_users u_new
LEFT JOIN public.ml_orders o ON u_new.id = o.user_id AND o.payment_status = 2 AND o.created_at::DATE BETWEEN p_start_date AND p_end_date
WHERE u_new.created_at::DATE BETWEEN p_start_date AND p_end_date
) AS new_user_conversion_rate,
(
SELECT COUNT(DISTINCT CASE WHEN user_orders.order_count > 1 THEN user_orders.user_id END) * 100.0 / NULLIF(COUNT(DISTINCT user_orders.user_id), 0)
FROM (
SELECT user_id, COUNT(id) as order_count
FROM public.ml_orders
WHERE created_at::DATE <= p_end_date AND payment_status = 2
GROUP BY user_id
) user_orders
WHERE user_orders.user_id IN (SELECT user_id FROM public.ml_orders WHERE created_at::DATE BETWEEN p_start_date AND p_end_date AND payment_status = 2)
) AS repurchase_rate
FROM public.ak_users u
),
-- 上一周期数据
previous_period AS (
SELECT
(SELECT COUNT(*) FROM public.ak_users WHERE created_at <= prev_end_date::timestamp) AS total_users,
COUNT(DISTINCT CASE WHEN u.created_at::DATE BETWEEN prev_start_date AND prev_end_date THEN u.id END)::BIGINT AS new_users,
(SELECT COUNT(DISTINCT bh.user_id) FROM public.ml_browse_history bh WHERE bh.created_at::DATE BETWEEN prev_start_date AND prev_end_date) AS active_users,
(SELECT COUNT(DISTINCT o.user_id) FROM public.ml_orders o WHERE o.created_at::DATE BETWEEN prev_start_date AND prev_end_date) AS ordering_users,
(SELECT COUNT(DISTINCT o.user_id) FROM public.ml_orders o WHERE o.created_at::DATE BETWEEN prev_start_date AND prev_end_date AND o.payment_status = 2) AS paid_users,
(
SELECT COUNT(DISTINCT CASE WHEN user_orders.order_count > 1 THEN user_orders.user_id END) * 100.0 / NULLIF(COUNT(DISTINCT user_orders.user_id), 0)
FROM (
SELECT user_id, COUNT(id) as order_count
FROM public.ml_orders
WHERE created_at::DATE <= prev_end_date AND payment_status = 2
GROUP BY user_id
) user_orders
WHERE user_orders.user_id IN (SELECT user_id FROM public.ml_orders WHERE created_at::DATE BETWEEN prev_start_date AND prev_end_date AND payment_status = 2)
) AS repurchase_rate
FROM public.ak_users u
)
SELECT
cp.total_users,
ROUND(((cp.total_users - pp.total_users) * 100.0 / NULLIF(pp.total_users, 0))::numeric, 2)::FLOAT AS user_growth,
cp.new_users,
ROUND(((cp.new_users - pp.new_users) * 100.0 / NULLIF(pp.new_users, 0))::numeric, 2)::FLOAT AS new_user_growth,
cp.active_users,
ROUND(((cp.active_users - pp.active_users) * 100.0 / NULLIF(pp.active_users, 0))::numeric, 2)::FLOAT AS active_growth,
cp.ordering_users,
ROUND(((cp.ordering_users - pp.ordering_users) * 100.0 / NULLIF(pp.ordering_users, 0))::numeric, 2)::FLOAT AS ordering_growth,
cp.paid_users,
ROUND(((cp.paid_users - pp.paid_users) * 100.0 / NULLIF(pp.paid_users, 0))::numeric, 2)::FLOAT AS paid_growth,
ROUND(cp.new_user_conversion_rate::numeric, 2)::FLOAT,
ROUND(cp.repurchase_rate::numeric, 2)::FLOAT AS repurchase_rate,
ROUND((COALESCE(cp.repurchase_rate, 0) - COALESCE(pp.repurchase_rate, 0))::numeric, 2)::FLOAT AS repurchase_growth
FROM current_period cp, previous_period pp;
END;
$$;
-- -------------------------------------------------------------------------------------
-- 2. 函数: rpc_analytics_user_growth_trend
-- 描述: 获取用户增长与活跃趋势(日维度)
-- -------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rpc_analytics_user_growth_trend(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
date DATE,
new_users BIGINT,
active_users BIGINT
)
LANGUAGE sql
AS $$
WITH date_series AS (
SELECT generate_series(p_start_date, p_end_date, '1 day'::interval)::DATE AS date
)
SELECT
ds.date,
(SELECT COUNT(u.id) FROM public.ak_users u WHERE u.created_at::DATE = ds.date)::BIGINT AS new_users,
(SELECT COUNT(DISTINCT bh.user_id) FROM public.ml_browse_history bh WHERE bh.created_at::DATE = ds.date)::BIGINT AS active_users
FROM date_series ds
ORDER BY ds.date;
$$;
-- -------------------------------------------------------------------------------------
-- 3. 函数: rpc_analytics_user_segments
-- 描述: 获取用户分群(基于新老客、复购的简单模型)
-- -------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rpc_analytics_user_segments(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
name TEXT,
value BIGINT
)
LANGUAGE sql
AS $$
WITH user_orders_stats AS (
SELECT
user_id,
MIN(created_at) as first_order_time,
COUNT(id) as total_orders
FROM public.ml_orders
WHERE payment_status = 2
GROUP BY user_id
),
active_users_in_period AS (
SELECT DISTINCT user_id FROM public.ml_orders WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
)
SELECT
segment as name,
COUNT(user_id) as value
FROM (
SELECT
auip.user_id,
CASE
WHEN uos.first_order_time::DATE BETWEEN p_start_date AND p_end_date THEN '新客'
WHEN uos.total_orders > 1 THEN '复购客户'
ELSE '老客'
END as segment
FROM active_users_in_period auip
JOIN user_orders_stats uos ON auip.user_id = uos.user_id
) segments
GROUP BY segment;
$$;
-- -------------------------------------------------------------------------------------
-- 4. 函数: rpc_analytics_traffic_sources
-- 描述: 获取流量来源分布 (基于注册来源)
-- -------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rpc_analytics_traffic_sources(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
name TEXT,
value BIGINT
)
LANGUAGE sql
AS $$
SELECT
COALESCE(registration_source, '未知') AS name,
COUNT(id)::BIGINT AS value
FROM public.ak_users
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
GROUP BY name
ORDER BY value DESC;
$$;
-- =====================================================================================
-- 5. 完成提示
-- =====================================================================================
DO $$
BEGIN
RAISE NOTICE '=======================================================';
RAISE NOTICE '商城分析模块 RPC 函数创建/更新完成!';
RAISE NOTICE '=======================================================';
RAISE NOTICE '✓ RPC 函数创建/更新完成:';
RAISE NOTICE ' - rpc_analytics_user_kpis';
RAISE NOTICE ' - rpc_analytics_user_growth_trend';
RAISE NOTICE ' - rpc_analytics_user_segments';
RAISE NOTICE ' - rpc_analytics_traffic_sources';
RAISE NOTICE '=======================================================';
RAISE NOTICE '下一步:';
RAISE NOTICE '1. 在数据库中执行此脚本。';
RAISE NOTICE '2. 确认前端 user-analysis.uvue 页面已调用这些 RPC。';
RAISE NOTICE '3. 如果缺少活跃数据,请确保 ml_browse_history 表有数据写入。';
RAISE NOTICE '=======================================================';
END $$;

View File

@@ -108,370 +108,353 @@
</view>
</template>
<script lang="uts">
<script setup lang="uts">
import { computed, onLoad, ref } from 'vue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchCouponAnalysis } from '@/services/analytics/couponAnalysisService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
import type { CouponData } from '@/types/analytics/coupon.uts'
import type { TimePeriod } from '@/types/analytics/common.uts'
type TimePeriod = { value: string; label: string }
type CouponData = {
total_issued: number
issued_growth: number
total_used: number
usage_rate: number
gmv_increase: number
gmv_growth: number
roi: number
}
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/coupon-analysis')
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/coupon-analysis',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
] as Array<TimePeriod>,
const timePeriods = ref<Array<TimePeriod>>([
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
])
couponData: {
total_issued: 0,
issued_growth: 0,
total_used: 0,
usage_rate: 0,
gmv_increase: 0,
gmv_growth: 0,
roi: 0
} as CouponData,
const couponData = ref<CouponData>({
total_issued: 0,
issued_growth: 0,
total_used: 0,
usage_rate: 0,
gmv_increase: 0,
gmv_growth: 0,
roi: 0
})
typeChartOption: {} as any,
channelChartOption: {} as any,
trendChartOption: {} as any,
conversionChartOption: {} as any
const typeChartOption = ref({} as any)
const channelChartOption = ref({} as any)
const trendChartOption = ref({} as any)
const conversionChartOption = ref({} as any)
// 原始数据
const _typeRows = ref<Array<UTSJSONObject>>([])
const _channelRows = ref<Array<UTSJSONObject>>([])
const _trendRows = ref<Array<UTSJSONObject>>([])
const _conversionRows = ref<Array<UTSJSONObject>>([])
const selectedPeriodText = computed(() => {
const p = timePeriods.value.find((t) => t.value === selectedPeriod.value)
return p ? p.label : '7天'
})
onLoad(() => {
updateTime()
loadCouponData()
})
async function loadCouponData() {
try {
const data = await fetchCouponAnalysis(selectedPeriod.value)
const overviewRow = data.overviewRow
const typeList = data.typeList
const channelList = data.channelList
const trendList = data.trendList
const conversionList = data.conversionList
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 {
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
}
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
couponData.value = {
total_issued: totalIssued,
issued_growth: issuedGrowth,
total_used: totalUsed,
usage_rate: usageRate,
gmv_increase: gmvIncrease,
gmv_growth: gmvGrowth,
roi: roi
}
},
onLoad() {
this.updateTime()
this.loadCouponData()
},
_typeRows.value = typeList
_channelRows.value = channelList
_trendRows.value = trendList
_conversionRows.value = conversionList
methods: {
async loadCouponData() {
try {
const data = await fetchCouponAnalysis(this.selectedPeriod)
const overviewRow = data.overviewRow
const typeList = data.typeList
const channelList = data.channelList
const trendList = data.trendList
const conversionList = data.conversionList
// 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: mapAnalyticsError(e, { fallbackMessage: '优惠券分析数据加载失败' }), icon: 'none' })
}
},
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadCouponData()
},
refreshData() {
this.loadCouponData()
uni.showToast({ title: '已刷新', icon: 'success' })
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
},
formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
},
formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
},
buildChartOptions() {
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() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
handleMenu() {
this.showSidebarMenu = true
}
updateTime()
buildChartOptions()
} catch (e) {
console.error('loadCouponData failed:', e)
updateTime()
buildChartOptions()
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '优惠券分析数据加载失败' }), icon: 'none' })
}
}
function selectPeriod(p: string) {
selectedPeriod.value = p
loadCouponData()
}
function refreshData() {
loadCouponData()
uni.showToast({ title: '已刷新', icon: 'success' })
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
}
function updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
lastUpdateTime.value = `${hh}:${mm}`
}
function formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
}
function formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
}
function formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
}
function buildChartOptions() {
const typeRows = _typeRows.value
const channelRows = _channelRows.value
const trendRows = _trendRows.value
const convRows = _conversionRows.value
// 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
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)
}
typeChartOption.value = {
tooltip: { trigger: 'axis' },
legend: {
data: ['发放数量', '使用数量', '使用率'],
top: 'bottom'
},
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)
}
channelChartOption.value = {
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) 使用趋势
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)
}
trendChartOption.value = {
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) 转化效果
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)
}
conversionChartOption.value = {
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 }
]
}
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function handleMenu() {
showSidebarMenu.value = true
}
// 模拟的 TopBar 事件处理
function handleSearch() { uni.showToast({ title: '搜索', icon: 'none' }) }
function handleNotification() { uni.showToast({ title: '通知', icon: 'none' }) }
function handleFullscreen() { uni.showToast({ title: '全屏', icon: 'none' }) }
function handleMobile() { uni.showToast({ title: '移动端', icon: 'none' }) }
function handleDropdown() { uni.showToast({ title: '下拉菜单', icon: 'none' }) }
function handleSettings() { uni.showToast({ title: '设置', icon: 'none' }) }
</script>
<style>

View File

@@ -159,7 +159,7 @@
</view>
</template>
<script lang="uts">
<script setup lang="uts">
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
@@ -168,38 +168,358 @@ import { getUserIdOrNull } from '@/services/analytics/auth.uts'
import { listCustomReports, createCustomReport, updateCustomReport, deleteCustomReport } from '@/services/analytics/customReportService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type Report = {
id: string
name: string
description: string
metrics: Array<string>
charts: Array<string>
updated_at: string
}
type Metric = { key: string; label: string }
type TimePeriod = { value: string; label: string }
type ChartType = { value: string; label: string }
type ReportForm = {
name: string
description: string
metrics: Array<string>
period: string
chartType: string
}
type ReportFormErrors = {
name: string
description: string
metrics: string
period: string
chartType: string
import { onLoad, onShow, reactive, ref } from 'vue'
import type { CustomReport, ReportForm, ReportFormErrors } from '@/types/analytics/custom-report.uts'
import type { Metric, TimePeriod, ChartType } from '@/types/analytics/common.uts'
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/custom-report')
const showCreateModal = ref(false)
const editingReport = ref<CustomReport | null>(null)
const reports = reactive<Array<CustomReport>>([])
const isLoggedIn = ref(false)
const reportForm = reactive<ReportForm>({
name: '',
description: '',
metrics: [] as Array<string>,
period: '7d',
chartType: 'line'
})
const formErrors = reactive<ReportFormErrors>({
name: '',
description: '',
metrics: '',
period: '',
chartType: ''
})
const availableMetrics = ref<Array<Metric>>([
{ key: 'gmv', label: 'GMV' },
{ key: 'orders', label: '订单数' },
{ key: 'users', label: '用户数' },
{ key: 'conversion', label: '转化率' },
{ key: 'avg_order', label: '客单价' },
{ key: 'repurchase', label: '复购率' }
])
const timePeriods = ref<Array<TimePeriod>>([
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
])
const chartTypes = ref<Array<ChartType>>([
{ value: 'line', label: '折线图' },
{ value: 'bar', label: '柱状图' },
{ value: 'pie', label: '饼图' },
{ value: 'area', label: '面积图' },
{ value: 'combo', label: '组合图' }
])
onLoad(() => {
currentPath.value = '/pages/mall/analytics/custom-report'
loadReports()
})
onShow(() => {
currentPath.value = '/pages/mall/analytics/custom-report'
})
async function loadReports() {
try {
await ensureSupabaseReady()
const uid = getUserIdOrNull()
if (!uid || uid.length === 0) {
isLoggedIn.value = false
reports.splice(0, reports.length)
return
}
isLoggedIn.value = true
const items = await listCustomReports(uid)
const list: Array<CustomReport> = []
for (let i = 0; i < items.length; i++) {
const r = items[i]
list.push({
id: `${r.id}`,
name: `${r.title}`,
description: `${r.description || ''}`,
metrics: [] as Array<string>,
charts: [] as Array<string>,
updated_at: `${r.updated_at || ''}`
} as CustomReport)
}
reports.splice(0, reports.length, ...list)
} catch (e) {
console.error('loadReports failed', e)
uni.showToast({ title: '报表加载失败', icon: 'none' })
}
}
function createReport() {
editingReport.value = null
reportForm.name = ''
reportForm.description = ''
reportForm.metrics = [] as Array<string>
reportForm.period = '7d'
reportForm.chartType = 'line'
formErrors.name = ''
formErrors.description = ''
formErrors.metrics = ''
formErrors.period = ''
formErrors.chartType = ''
showCreateModal.value = true
}
function editReport(report: CustomReport) {
editingReport.value = report
reportForm.name = report.name
reportForm.description = report.description
reportForm.metrics = report.metrics
reportForm.period = '7d'
reportForm.chartType = 'line'
formErrors.name = ''
formErrors.description = ''
formErrors.metrics = ''
formErrors.period = ''
formErrors.chartType = ''
showCreateModal.value = true
}
function deleteReport(report: CustomReport) {
uni.showModal({
title: '确认删除',
content: `确定要删除报表"${report.name}"吗?`,
success: (res) => {
if (res.confirm) {
doDeleteReport(report)
}
}
})
}
async function doDeleteReport(report: CustomReport) {
try {
await ensureSupabaseReady()
await deleteCustomReport(report.id)
uni.showToast({ title: '删除成功', icon: 'success' })
loadReports()
} catch (e: any) {
console.error('doDeleteReport failed', e)
const errorMsg = e?.message || '删除失败'
uni.showToast({ title: errorMsg, icon: 'none' })
}
}
function toggleMetric(key: string) {
const index = reportForm.metrics.indexOf(key)
if (index >= 0) {
reportForm.metrics.splice(index, 1)
} else {
reportForm.metrics.push(key)
}
if (reportForm.metrics.length > 0) {
formErrors.metrics = ''
}
}
function onNameInput() {
const name = reportForm.name.trim()
if (name.length === 0) {
formErrors.name = '报表名称不能为空'
} else if (name.length > 50) {
formErrors.name = '报表名称不能超过50个字符'
} else {
formErrors.name = ''
}
}
function onDescriptionInput() {
const desc = reportForm.description
if (desc.length > 200) {
formErrors.description = '报表描述不能超过200个字符'
} else {
formErrors.description = ''
}
}
function selectPeriod(value: string) {
reportForm.period = value
formErrors.period = ''
}
function selectChartType(value: string) {
reportForm.chartType = value
formErrors.chartType = ''
}
function validateReportForm(): boolean {
onNameInput()
onDescriptionInput()
if (reportForm.metrics.length === 0) {
formErrors.metrics = '请至少选择一个指标'
} else {
formErrors.metrics = ''
}
if (!reportForm.period) {
formErrors.period = '请选择时间维度'
}
if (!reportForm.chartType) {
formErrors.chartType = '请选择图表类型'
}
if (formErrors.name || formErrors.description || formErrors.metrics || formErrors.period || formErrors.chartType) {
uni.showToast({ title: '请先修正表单中的错误提示', icon: 'none' })
return false
}
return true
}
async function saveReport() {
if (!validateReportForm()) {
return
}
try {
uni.showLoading({ title: '保存中...' })
await ensureSupabaseReady()
const uid = getUserIdOrNull()
if (!uid || uid.length === 0) {
uni.hideLoading()
uni.showModal({
title: '需要登录',
content: '创建自定义报表需要先登录,是否前往登录页面?',
success: (res) => {
if (res.confirm) {
goToLogin('/pages/mall/analytics/custom-report')
}
}
})
return
}
let newReportId = ''
if (editingReport.value == null) {
newReportId = await createCustomReport({
title: reportForm.name,
description: reportForm.description || '',
period: reportForm.period,
metrics: reportForm.metrics,
chartType: reportForm.chartType || 'line'
})
} else {
await updateCustomReport({
reportId: editingReport.value.id,
title: reportForm.name,
description: reportForm.description || null,
period: reportForm.period || null
})
newReportId = editingReport.value.id
}
uni.hideLoading()
uni.showToast({ title: '保存成功', icon: 'success' })
closeModal()
loadReports()
if (newReportId.length > 0) {
setTimeout(() => {
uni.navigateTo({
url: `/pages/mall/analytics/report-detail?reportId=${newReportId}`
})
}, 400)
}
} catch (e: any) {
uni.hideLoading()
console.error('saveReport exception:', e)
uni.showToast({
title: mapAnalyticsError(e, { fallbackMessage: '保存失败' }),
icon: 'none',
duration: 3000
})
}
}
function openReport(report: CustomReport) {
uni.navigateTo({
url: `/pages/mall/analytics/report-detail?reportId=${report.id}`
})
}
function closeModal() {
showCreateModal.value = false
editingReport.value = null
}
function refreshData() {
loadReports()
uni.showToast({ title: '已刷新', icon: 'success' })
}
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
}
function handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
}
function handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
}
function handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
}
function handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
}
function handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
function goToLogin() {
goToLogin('/pages/mall/analytics/custom-report')
}
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar
},
data() {
return {
showMoreMenu: false,
showSidebarMenu: false,
@@ -498,7 +818,7 @@ export default {
openReport(report: Report) {
uni.navigateTo({
url: `/pages/mall/analytics/report-detail?id=${report.id}`
url: `/pages/mall/analytics/report-detail?reportId=${report.id}`
})
},

File diff suppressed because it is too large Load Diff

View File

@@ -106,7 +106,6 @@
</view>
</view>
<!-- 留白 -->
<view style="height: 24px;"></view>
</view>
@@ -115,338 +114,326 @@
</view>
</template>
<script lang="uts">
<script setup lang="uts">
import { computed, onLoad, reactive, ref } from 'vue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchDeliveryAnalysis } from '@/services/analytics/deliveryAnalysisService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type DeliveryData = {
avg_delivery_time: number
time_growth: number
total_fee: number
avg_fee: number
avg_orders_per_driver: number
satisfaction_rate: number
satisfaction_growth: number
}
type DriverRank = { id: string; rank: number; name: string; orders: number; rating: number }
import type { TimePeriod } from '@/types/analytics/common.uts'
import type { DeliveryData, DriverRank } from '@/types/analytics/delivery.uts'
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/delivery-analysis',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
],
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/delivery-analysis')
deliveryData: {
avg_delivery_time: 0,
time_growth: 0,
total_fee: 0,
avg_fee: 0,
avg_orders_per_driver: 0,
satisfaction_rate: 0,
satisfaction_growth: 0
} as DeliveryData,
const timePeriods = ref<Array<TimePeriod>>([
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
])
topDrivers: [] as Array<DriverRank>,
const deliveryData = reactive<DeliveryData>({
avg_delivery_time: 0,
time_growth: 0,
total_fee: 0,
avg_fee: 0,
avg_orders_per_driver: 0,
satisfaction_rate: 0,
satisfaction_growth: 0
})
timeChartOption: {} as any,
feeChartOption: {} as any,
isRankHover: false
const topDrivers = reactive<Array<DriverRank>>([])
const timeChartOption = ref<any>({})
const feeChartOption = ref<any>({})
const isRankHover = ref(false)
const _trendRows = ref<Array<UTSJSONObject>>([])
const selectedPeriodText = computed((): string => {
const p = timePeriods.value.find((t) => t.value === selectedPeriod.value)
return p ? p.label : '7天'
})
onLoad(() => {
updateTime()
loadDeliveryData()
})
async function loadDeliveryData() {
try {
const data: any = await fetchDeliveryAnalysis(selectedPeriod.value)
const trendList = data.trendList
const topList = data.topList
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)
obj.set('satisfaction_rate', 0)
trendRows.push(obj)
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
}
},
onLoad() {
this.updateTime()
this.loadDeliveryData()
},
methods: {
async loadDeliveryData() {
try {
const data = await fetchDeliveryAnalysis(this.selectedPeriod)
const trendList = data.trendList
const topList = data.topList
// 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: mapAnalyticsError(e, { fallbackMessage: '配送分析数据加载失败' }), icon: 'none' })
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)
}
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadDeliveryData()
},
const last = trendRows.length > 0 ? trendRows[trendRows.length - 1] : null
const prev = trendRows.length > 1 ? trendRows[trendRows.length - 2] : null
refreshData() {
this.loadDeliveryData()
uni.showToast({ title: '已刷新', icon: 'success' })
},
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
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
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
const dayCount = Math.max(1, trendRows.length)
const driverCount = Math.max(1, topList.length)
const avgOrdersPerDriverPerDay = (totalOrders / driverCount) / dayCount
deliveryData.avg_delivery_time = Math.round(lastAvgTime)
deliveryData.time_growth = timeGrowth
deliveryData.total_fee = totalFee
deliveryData.avg_fee = totalOrders > 0 ? totalFee / totalOrders : 0
deliveryData.avg_orders_per_driver = avgOrdersPerDriverPerDay
deliveryData.satisfaction_rate = lastSatis
deliveryData.satisfaction_growth = satisGrowth
_trendRows.value = trendRows
const list: Array<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
})
},
updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
},
formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(2)
},
formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
},
formatScore(n: number): string {
const v = isFinite(n) ? n : 0
return v.toFixed(1)
},
buildChartOptions() {
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() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
}
topDrivers.splice(0, topDrivers.length, ...list)
updateTime()
buildChartOptions()
} catch (e) {
console.error('loadDeliveryData failed:', e)
updateTime()
buildChartOptions()
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '配送分析数据加载失败' }), icon: 'none' })
}
}
function selectPeriod(p: string) {
selectedPeriod.value = p
loadDeliveryData()
}
function refreshData() {
loadDeliveryData()
uni.showToast({ title: '已刷新', icon: 'success' })
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
}
function updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
lastUpdateTime.value = `${hh}:${mm}`
}
function formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
}
function formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(2)
}
function formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
}
function formatScore(n: number): string {
const v = isFinite(n) ? n : 0
return v.toFixed(1)
}
function buildChartOptions() {
const rows = Array.isArray(_trendRows.value) ? _trendRows.value : []
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)
}
timeChartOption.value = {
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
}
]
}
feeChartOption.value = {
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
}
]
}
}
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
}
function handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
}
function handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
}
function handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
}
function handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
}
function handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
function onRankHover(hover: boolean) {
isRankHover.value = hover
}
</script>
<style>
@@ -717,9 +704,7 @@ export default {
height: 360px;
}
/* 排行滚动容器:固定高度(约 5 条) */
.rank-scroll {
/* 5条左右的可视高度5*(10px上下padding + 28px内容 + 10px gap) 约 300 */
height: 320px;
border-radius: 12px;
}
@@ -728,7 +713,6 @@ export default {
height: 100%;
}
/* H5默认隐藏滚动条悬停时显示 */
.rank-scroll-inner::-webkit-scrollbar {
width: 0;
height: 0;
@@ -747,7 +731,6 @@ export default {
background: rgba(0, 0, 0, 0.06);
}
/* 排行列表 */
.rank-list {
display: flex;
flex-direction: column;
@@ -812,7 +795,6 @@ export default {
color: #dc2626;
}
/* 响应式 */
@media screen and (min-width: 960px) {
.kpi-card {
flex: 1 1 calc(25% - 9px);
@@ -825,22 +807,21 @@ export default {
.subtitle {
max-width: 200px;
}
.topbar-right .btn-hidden {
display: none !important;
}
.more-btn {
display: flex !important;
}
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
<template>
<view class="page" @click="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'数据洞察详情'"
:lastUpdateTime="lastUpdateTime"
@@ -14,19 +13,16 @@
@dropdown="handleDropdown"
@settings="handleSettings"
/>
<view class="page-layout">
<!-- 侧边栏菜单组件 -->
<AnalyticsSidebarMenu
:visible="showSidebarMenu"
:currentPath="currentPath"
@visible-change="handleSidebarUpdate"
/>
<!-- 主内容区域 -->
<view class="main-content">
<view class="container">
<!-- 洞察详情(真实数据) -->
<view class="container">
<view class="card card-full">
<view class="card-head">
<text class="card-title">{{ insight.title || '洞察详情' }}</text>
@@ -34,237 +30,236 @@
<text class="badge" :class="'badge-' + (insight.type || 'info')">{{ getInsightTypeText(insight.type) }}</text>
<text class="badge badge-impact" :class="'impact-' + (insight.impact || 'medium')">{{ getImpactText(insight.impact) }}</text>
<text class="meta-time" v-if="insight.created_at">{{ formatTime(insight.created_at) }}</text>
</view>
</view>
</view>
</view>
<view v-if="loading" class="state">
<text class="state-text">加载中...</text>
</view>
</view>
<view v-else-if="errorMsg" class="state">
<text class="state-text">{{ errorMsg }}</text>
</view>
</view>
<view v-else class="content">
<text class="content-text">{{ insight.content }}</text>
</view>
</view>
</view>
</view>
<!-- 关联报表(可选) -->
<view class="card" v-if="relatedReport.id">
<view class="card-head">
<view class="card-head">
<text class="card-title">关联报表</text>
<text class="card-desc">{{ relatedReport.type }} · {{ relatedReport.period }}</text>
</view>
</view>
<view class="report-row" @click="goToReportDetail">
<view class="report-icon">📄</view>
<view class="report-info">
<text class="report-title">{{ relatedReport.title }}</text>
<text class="report-time">{{ relatedReport.generated_at ? formatTime(relatedReport.generated_at) : '' }}</text>
</view>
</view>
<text class="report-arrow">></text>
</view>
</view>
</view>
</view>
<!-- 留白 -->
<view style="height: 24px;"></view>
<view style="height: 24px;"></view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
<script setup lang="uts">
import { onLoad, onShow, reactive, ref } from 'vue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import { fetchInsightDetail, fetchRelatedReport } from '@/services/analytics/insightDetailService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type InsightDetail = {
id: string
report_id: string
type: string
impact: string
title: string
content: string
created_at: string
}
import type { InsightDetail, RelatedReport } from '@/types/analytics/insight.uts'
type RelatedReport = {
id: string
title: string
type: string
period: string
generated_at: string
}
const lastUpdateTime = ref('')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/insight-detail')
const insightId = ref('')
const loading = ref(false)
const errorMsg = ref('')
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar
},
data() {
return {
lastUpdateTime: '',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/insight-detail',
insightId: '',
loading: false,
errorMsg: '',
insight: {
id: '',
report_id: '',
type: 'info',
impact: 'medium',
title: '',
content: '',
created_at: ''
} as InsightDetail,
relatedReport: {
id: '',
title: '',
type: '',
period: '',
generated_at: ''
} as RelatedReport
}
},
const insight = reactive<InsightDetail>({
id: '',
report_id: '',
type: 'info',
impact: 'medium',
title: '',
content: '',
created_at: ''
})
onLoad(options: any) {
this.currentPath = '/pages/mall/analytics/insight-detail'
this.updateTime()
const insightId = (options.insightId || options.id) as string
if (!insightId) {
uni.showToast({ title: '缺少洞察ID', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
const relatedReport = reactive<RelatedReport>({
id: '',
title: '',
type: '',
period: '',
generated_at: ''
})
onLoad((options: any) => {
currentPath.value = '/pages/mall/analytics/insight-detail'
updateTime()
const iid = (options.insightId || options.id) as string
if (!iid) {
uni.showToast({ title: '缺少洞察ID', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
return
}
insightId.value = iid
void loadInsightDetail()
})
onShow(() => {
currentPath.value = '/pages/mall/analytics/insight-detail'
})
async function loadInsightDetail() {
try {
loading.value = true
errorMsg.value = ''
updateTime()
const data = await fetchInsightDetail(insightId.value)
if (data == null) {
errorMsg.value = '洞察不存在或无权限访问'
return
}
this.insightId = insightId
this.loadInsightDetail()
},
onShow() {
this.currentPath = '/pages/mall/analytics/insight-detail'
},
insight.id = data.id
insight.report_id = data.report_id
insight.type = data.type
insight.impact = data.impact
insight.title = data.title
insight.content = data.content
insight.created_at = data.created_at
methods: {
async loadInsightDetail() {
relatedReport.id = ''
relatedReport.title = ''
relatedReport.type = ''
relatedReport.period = ''
relatedReport.generated_at = ''
if (insight.report_id) {
try {
this.loading = true
this.errorMsg = ''
this.updateTime()
const insight = await fetchInsightDetail(this.insightId)
if (insight == null) {
this.errorMsg = '洞察不存在或无权限访问'
return
}
this.insight = insight
// 关联报表(可选)
this.relatedReport = { id: '', title: '', type: '', period: '', generated_at: '' } as RelatedReport
if (this.insight.report_id) {
try {
const related = await fetchRelatedReport(this.insight.report_id)
if (related != null) {
this.relatedReport = related
}
} catch (e) {
console.error('loadInsightDetail related report error', e)
}
const related = await fetchRelatedReport(insight.report_id)
if (related != null) {
relatedReport.id = related.id
relatedReport.title = related.title
relatedReport.type = related.type
relatedReport.period = related.period
relatedReport.generated_at = related.generated_at
}
} catch (e) {
console.error('loadInsightDetail failed', e)
this.errorMsg = mapAnalyticsError(e, { fallbackMessage: '加载失败,请稍后重试' })
} finally {
this.loading = false
console.error('loadInsightDetail related report error', e)
}
},
refreshData() {
this.loadInsightDetail()
uni.showToast({ title: '已刷新', icon: 'success' })
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
formatTime(timeStr: string): string {
if (!timeStr) return ''
return `${timeStr}`.replace('T', ' ').split('.')[0]
},
getInsightTypeText(type: string): string {
const t = `${type || 'info'}`
const map: Record<string, string> = {
positive: '正向',
warning: '预警',
negative: '风险',
info: '信息'
}
return map[t] || '信息'
},
getImpactText(impact: string): string {
const impacts: Record<string, string> = {
high: '高影响',
medium: '中影响',
low: '低影响'
}
return impacts[impact || 'medium'] || '中影响'
},
goToReportDetail() {
if (!this.relatedReport.id) return
uni.navigateTo({
url: `/pages/mall/analytics/report-detail?reportId=${this.relatedReport.id}`
})
},
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
},
handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
},
handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
},
handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
},
handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
},
handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
},
handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
} catch (e) {
console.error('loadInsightDetail failed', e)
errorMsg.value = mapAnalyticsError(e, { fallbackMessage: '加载失败,请稍后重试' })
} finally {
loading.value = false
}
}
function refreshData() {
void loadInsightDetail()
uni.showToast({ title: '已刷新', icon: 'success' })
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
}
function updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
lastUpdateTime.value = `${hh}:${mm}`
}
function formatTime(timeStr: string): string {
if (!timeStr) return ''
return `${timeStr}`.replace('T', ' ').split('.')[0]
}
function getInsightTypeText(type: string): string {
const t = `${type || 'info'}`
const map: Record<string, string> = {
positive: '正向',
warning: '预警',
negative: '风险',
info: '信息'
}
return map[t] || '信息'
}
function getImpactText(impact: string): string {
const impacts: Record<string, string> = {
high: '高影响',
medium: '中影响',
low: '低影响'
}
return impacts[impact || 'medium'] || '中影响'
}
function goToReportDetail() {
if (!relatedReport.id) return
uni.navigateTo({
url: `/pages/mall/analytics/report-detail?reportId=${relatedReport.id}`
})
}
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
}
function handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
}
function handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
}
function handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
}
function handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
}
function handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
</script>
<style>
@@ -273,7 +268,6 @@ export default {
background: #f6f7fb;
}
/* 页面布局:宽屏时侧边栏+内容,窄屏时全屏内容 */
.page-layout {
display: flex;
flex-direction: row !important;
@@ -285,7 +279,7 @@ export default {
min-width: 0;
display: flex;
flex-direction: column;
padding-top: 64px; /* 为固定顶部导航栏留出空间 */
padding-top: 64px;
}
.container {
@@ -295,344 +289,4 @@ export default {
padding: 16px 16px 28px;
box-sizing: border-box;
}
/* 顶部栏 */
.topbar {
display: flex;
flex-direction: row !important;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.topbar-left {
display: flex;
flex-direction: row !important;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.menu-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.menu-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.menu-icon .icon {
font-size: 18px;
color: #111;
line-height: 1;
}
.title-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-width: 0;
}
.title {
font-size: 18px;
font-weight: 700;
color: #111;
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: 12px;
color: rgba(0,0,0,0.55);
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-right {
display: flex;
flex-direction: row !important;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
flex-shrink: 0;
position: relative;
white-space: nowrap;
}
.icon-btn-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.icon-btn-icon:active {
background: #e5e7eb;
transform: scale(0.95);
}
.icon-btn-icon .icon {
font-size: 16px;
line-height: 1;
}
.more-btn {
display: none;
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
.more-btn.active {
background: #e5e7eb;
}
.more-btn .icon {
font-size: 18px;
line-height: 1;
color: #111;
}
/* 时间维度 tabs */
.tabs {
margin-top: 12px;
display: flex;
flex-direction: row !important;
gap: 8px;
padding: 8px;
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.06);
overflow-x: auto;
flex-wrap: wrap;
justify-content: center;
}
.tab {
padding: 8px 12px;
border-radius: 999px;
background: #f3f4f6;
color: #111;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.tab.active {
background: #111;
color: #fff;
}
/* 卡片 */
.card {
margin-top: 12px;
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
padding: 14px;
box-sizing: border-box;
}
.card-full {
width: 100%;
}
.card-head {
display: flex;
flex-direction: row !important;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.card-desc {
font-size: 12px;
color: rgba(0,0,0,0.55);
}
.chart-box {
width: 100%;
height: 360px;
}
/* 建议列表 */
.suggestion-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.suggestion-item {
display: flex;
flex-direction: row !important;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: #f9fafb;
border-radius: 8px;
}
.suggestion-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.suggestion-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.suggestion-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.suggestion-desc {
font-size: 12px;
color: rgba(0,0,0,0.65);
line-height: 1.5;
}
/* 异常列表 */
.anomaly-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.anomaly-item {
display: flex;
flex-direction: row !important;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: #fef2f2;
border-radius: 8px;
border-left: 3px solid #ef4444;
}
.anomaly-level {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.anomaly-level.critical {
background: #fee2e2;
color: #dc2626;
}
.anomaly-level.warning {
background: #fef3c7;
color: #d97706;
}
.anomaly-level.info {
background: #dbeafe;
color: #2563eb;
}
.anomaly-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.anomaly-title {
font-size: 14px;
font-weight: 600;
color: #111;
}
.anomaly-desc {
font-size: 12px;
color: rgba(0,0,0,0.65);
line-height: 1.5;
}
.anomaly-time {
font-size: 11px;
color: rgba(0,0,0,0.45);
}
/* 响应式 */
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
@media screen and (max-width: 960px) {
.title,
.subtitle {
max-width: 200px;
}
.topbar-right .btn-hidden {
display: none !important;
}
.more-btn {
display: flex !important;
}
}
</style>

View File

@@ -93,298 +93,305 @@
</view>
</template>
<script lang="uts">
<script setup lang="uts">
import { computed, onLoad, onShow, reactive, ref } from 'vue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchMarketTrends } from '@/services/analytics/marketTrendsService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type TimePeriod = { value: string; label: string }
import type { TimePeriod } from '@/types/analytics/common.uts'
import type { MarketTrendsResponse } from '@/types/analytics/market.uts'
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/market-trends',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
] as Array<TimePeriod>,
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/market-trends')
marketTrendOption: {} as any,
industryCompareOption: {} as any,
seasonalTrendOption: {} as any,
priceTrendOption: {} as any,
competitionOption: {} as any
}
},
const timePeriods = ref<Array<TimePeriod>>([
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
])
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
}
},
const marketTrendOption = ref<any>({})
const industryCompareOption = ref<any>({})
const seasonalTrendOption = ref<any>({})
const priceTrendOption = ref<any>({})
const competitionOption = ref<any>({})
onLoad() {
this.currentPath = '/pages/mall/analytics/market-trends'
this.updateTime()
this.loadMarketData()
},
const _marketTrendRows = ref<any>(null)
const _industryRows = ref<any>(null)
const _seasonalRows = ref<any>(null)
const _priceRows = ref<any>(null)
const _competitionRows = ref<any>(null)
onShow() {
this.currentPath = '/pages/mall/analytics/market-trends'
},
const selectedPeriodText = computed((): string => {
const p = timePeriods.value.find((t) => t.value === selectedPeriod.value)
return p ? p.label : '7天'
})
methods: {
async loadMarketData() {
try {
const data = await fetchMarketTrends(this.selectedPeriod)
onLoad(() => {
currentPath.value = '/pages/mall/analytics/market-trends'
updateTime()
loadMarketData()
})
;(this as any)._marketTrendRows = data.trendRows
;(this as any)._industryRows = data.categoryRows
;(this as any)._seasonalRows = data.seasonalRows
;(this as any)._priceRows = data.priceRows
;(this as any)._competitionRows = data.competitionRows
onShow(() => {
currentPath.value = '/pages/mall/analytics/market-trends'
})
this.updateTime()
this.buildChartOptions()
} catch (e) {
console.error('loadMarketData failed:', e)
this.updateTime()
this.buildChartOptions()
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '市场趋势数据加载失败' }), icon: 'none' })
}
},
async function loadMarketData() {
try {
const data = (await fetchMarketTrends(selectedPeriod.value)) as MarketTrendsResponse
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadMarketData()
},
_marketTrendRows.value = data.trendRows
_industryRows.value = data.categoryRows
_seasonalRows.value = data.seasonalRows
_priceRows.value = data.priceRows
_competitionRows.value = data.competitionRows
refreshData() {
this.loadMarketData()
uni.showToast({ title: '已刷新', icon: 'success' })
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
buildChartOptions() {
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() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
},
handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
},
handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
},
handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
},
handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
},
handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
},
handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
updateTime()
buildChartOptions()
} catch (e) {
console.error('loadMarketData failed:', e)
updateTime()
buildChartOptions()
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '市场趋势数据加载失败' }), icon: 'none' })
}
}
function selectPeriod(p: string) {
selectedPeriod.value = p
loadMarketData()
}
function refreshData() {
loadMarketData()
uni.showToast({ title: '已刷新', icon: 'success' })
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
}
function updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
lastUpdateTime.value = `${hh}:${mm}`
}
function buildChartOptions() {
const trendAny = _marketTrendRows.value
const industryAny = _industryRows.value
const seasonalAny = _seasonalRows.value
const priceAny = _priceRows.value
const compAny = _competitionRows.value
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)
}
marketTrendOption.value = {
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)
}
industryCompareOption.value = {
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)
}
seasonalTrendOption.value = {
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)
}
priceTrendOption.value = {
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)
}
competitionOption.value = {
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] }
})
}
]
}
}
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
}
function handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
}
function handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
}
function handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
}
function handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
}
function handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
</script>
<style>
@@ -618,18 +625,6 @@ export default {
height: 360px;
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
/* 响应式 */
@media screen and (max-width: 960px) {
.title,
.subtitle {
@@ -644,4 +639,15 @@ export default {
display: flex !important;
}
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
</style>

View File

@@ -50,7 +50,7 @@
<view class="kpi-card">
<text class="kpi-label">热销商品</text>
<text class="kpi-value">{{ formatInt(productData.hot_products) }}</text>
<text class="kpi-meta">销量 > 100</text>
<text class="kpi-meta">销量 &gt; 100</text>
</view>
<view class="kpi-card">
<text class="kpi-label">库存周转率</text>
@@ -70,7 +70,7 @@
<text class="card-title">商品销售分析</text>
<view class="card-head-right">
<select class="select" v-model="selectedProductId" @change="handleProductChange">
<option v-for="p in realTopProducts" :key="p.id" :value="p.id">{{ p.name }}</option>
<option v-for="p in topProducts" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</view>
</view>
@@ -167,274 +167,307 @@
</view>
</template>
<script lang="uts">
<script setup lang="uts">
import { computed, onLoad, reactive, ref } from 'vue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchProductOverview, fetchTopProducts, fetchProductTrend, fetchCategorySales, fetchStockInsights, fetchPriceTrend, fetchReviewInsights } from '@/services/analytics/productInsightsService.uts'
import { computeDateRange, toDateOnly } from '@/services/analytics/dateRange.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type TimePeriod = { value: string; label: string }
type ProductData = {
total_products: number
product_growth: number
hot_products: number
turnover_rate: number
turnover_growth: number
avg_stock: number
stock_growth: number
import type { TimePeriod } from '@/types/analytics/common.uts'
import type { ProductData, ProductRank } from '@/types/analytics/product.uts'
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/product-insights')
const timePeriods = ref<Array<TimePeriod>>([
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
])
const productData = reactive<ProductData>({
total_products: 0,
product_growth: 0,
hot_products: 0,
turnover_rate: 0,
turnover_growth: 0,
avg_stock: 0,
stock_growth: 0
})
const topProducts = reactive<Array<ProductRank>>([])
const salesChartOption = ref<any>({})
const categoryChartOption = ref<any>({})
const stockChartOption = ref<any>({})
const priceChartOption = ref<any>({})
const reviewChartOption = ref<any>({})
const selectedProductId = ref('')
const loading = ref(false)
const selectedPeriodText = computed((): string => {
const p = timePeriods.value.find((t) => t.value === selectedPeriod.value)
return p ? p.label : '7天'
})
onLoad(() => {
updateTime()
loadProductData()
})
function updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
lastUpdateTime.value = `${hh}:${mm}`
}
type ProductRank = { id: string; rank: number; name: string; sales: number; growth: number }
type ProductTrendRow = { date: string; gmv: number; qty: number; orders: number }
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar,
EChartsView
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/product-insights',
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
] as Array<TimePeriod>,
function formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
}
productData: {
total_products: 0,
product_growth: 0,
hot_products: 0,
turnover_rate: 0,
turnover_growth: 0,
avg_stock: 0,
stock_growth: 0
} as ProductData,
function formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
}
topProducts: [] as Array<ProductRank>,
salesChartOption: {} as any,
categoryChartOption: {} as any,
stockChartOption: {} as any,
priceChartOption: {} as any,
reviewChartOption: {} as any,
selectedProductId: '' as string,
loading: false
async function loadSelectedProductTrend() {
try {
if (selectedProductId.value == null || selectedProductId.value === '') {
salesChartOption.value = {}
priceChartOption.value = {}
return
}
},
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
},
realTopProducts(): Array<ProductRank> {
return this.topProducts.filter((p) => !String(p.id).startsWith('fake-product-'))
const trend = await fetchProductTrend(selectedPeriod.value, selectedProductId.value)
const rows: Array<any> = trend as any
const x: Array<string> = []
const gmv: Array<number> = []
const qty: Array<number> = []
const orders: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const d = `${rows[i].date}`
x.push(d.slice(5))
gmv.push(Number(rows[i].gmv) || 0)
qty.push(Number(rows[i].qty) || 0)
orders.push(Number(rows[i].orders) || 0)
}
},
onLoad() {
this.updateTime()
this.loadProductData()
},
methods: {
async loadSelectedProductTrend() {
try {
if (this.selectedProductId == null || this.selectedProductId === '') {
this.salesChartOption = {}
return
}
const trend = await fetchProductTrend(this.selectedPeriod, this.selectedProductId)
const rows: Array<any> = trend as any
const x: Array<string> = []
const gmv: Array<number> = []
const qty: Array<number> = []
const orders: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const d = `${rows[i].date}`
x.push(d.slice(5))
gmv.push(Number(rows[i].gmv) || 0)
qty.push(Number(rows[i].qty) || 0)
orders.push(Number(rows[i].orders) || 0)
}
// 组合图GMV左轴 + 件数/订单(线,右轴)
this.salesChartOption = {
grid: { left: 50, right: 50, top: 20, bottom: 46 },
tooltip: { trigger: 'axis' },
legend: { data: ['GMV', '件数', '订单数'], bottom: 0 },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: [
{ type: 'value', name: 'GMV', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
{ type: 'value', name: '件/单', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { show: false } }
],
series: [
{ name: 'GMV', type: 'bar', data: gmv, barWidth: 14, itemStyle: { borderRadius: 6 } },
{ name: '件数', type: 'line', yAxisIndex: 1, data: qty, smooth: true, symbolSize: 6 },
{ name: '订单数', type: 'line', yAxisIndex: 1, data: orders, smooth: true, symbolSize: 6 }
]
}
// 价格趋势:计算均价
const avgPrice: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const g = Number(rows[i].gmv) || 0
const q = Number(rows[i].qty) || 0
avgPrice.push(q > 0 ? g / q : 0)
}
this.priceChartOption = {
grid: { left: 40, right: 18, top: 20, bottom: 40 },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', name: '均价', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
series: [{ name: '均价', type: 'line', data: avgPrice, smooth: true, symbolSize: 6, color: '#f97316' }]
}
} catch (e) {
console.error('loadSelectedProductTrend failed', e)
this.salesChartOption = {}
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '加载商品趋势失败' }), icon: 'none' })
}
},
handleProductChange() {
this.loadSelectedProductTrend()
},
async loadProductData() {
this.loading = true
try {
this.updateTime()
const [overview, topList, catRows, stockRows, priceRows, reviewRows] = await Promise.all([
fetchProductOverview(this.selectedPeriod),
fetchTopProducts(this.selectedPeriod, 10),
fetchCategorySales(this.selectedPeriod),
fetchStockInsights(this.selectedPeriod),
fetchPriceTrend(this.selectedPeriod),
fetchReviewInsights()
])
this.productData = overview
// 不足 10 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
const top = topList.slice()
if (top.length < 10) {
const need = 10 - top.length
for (let i = 0; i < need; i++) {
const n = top.length + 1
top.push({
id: `fake-product-${n}`,
rank: n,
name: `示例商品${n}`,
sales: Math.max(1, Math.floor(Math.random() * 50000) + 500),
growth: Math.round((Math.random() * 20 - 10) * 10) / 10
})
}
} else {
top.splice(10)
}
for (let i = 0; i < top.length; i++) top[i].rank = i + 1
this.topProducts = top
if ((this.selectedProductId == null || this.selectedProductId === '') && top.length > 0) {
const real = top.find((it) => !String(it.id).startsWith('fake-product-'))
this.selectedProductId = real ? real.id : ''
}
if (this.selectedProductId == null || this.selectedProductId === '') {
this.salesChartOption = {}
} else {
await this.loadSelectedProductTrend()
}
this.buildCategoryChart(catRows)
this.buildStockChart(stockRows)
// priceChartOption 在 loadSelectedProductTrend 里会生成均价趋势;这里仍保留整体价格趋势图(如果你有对应图表函数可以接入)
this.buildReviewChart(reviewRows)
this.updateTime()
} catch (e) {
console.error('loadProductData failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '商品洞察数据加载失败' }), icon: 'none', duration: 2000 })
} finally {
this.loading = false
this.updateTime()
}
},
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadProductData()
},
refreshData() {
this.loadProductData()
uni.showToast({ title: '已刷新', icon: 'success' })
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
},
formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
},
buildChartOptions() {
// TODO: 构建图表配置
this.salesChartOption = {}
this.categoryChartOption = {}
this.stockChartOption = {}
this.priceChartOption = {}
this.reviewChartOption = {}
},
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
salesChartOption.value = {
grid: { left: 50, right: 50, top: 20, bottom: 46 },
tooltip: { trigger: 'axis' },
legend: { data: ['GMV', '件数', '订单数'], bottom: 0 },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: [
{ type: 'value', name: 'GMV', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
{ type: 'value', name: '件/单', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { show: false } }
],
series: [
{ name: 'GMV', type: 'bar', data: gmv, barWidth: 14, itemStyle: { borderRadius: 6 } },
{ name: '件数', type: 'line', yAxisIndex: 1, data: qty, smooth: true, symbolSize: 6 },
{ name: '订单数', type: 'line', yAxisIndex: 1, data: orders, smooth: true, symbolSize: 6 }
]
}
const avgPrice: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const g = Number(rows[i].gmv) || 0
const q = Number(rows[i].qty) || 0
avgPrice.push(q > 0 ? g / q : 0)
}
priceChartOption.value = {
grid: { left: 40, right: 18, top: 20, bottom: 40 },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: x, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', name: '均价', axisLabel: { color: 'rgba(0,0,0,0.55)' }, splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } } },
series: [{ name: '均价', type: 'line', data: avgPrice, smooth: true, symbolSize: 6, color: '#f97316' }]
}
} catch (e) {
console.error('loadSelectedProductTrend failed', e)
salesChartOption.value = {}
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '加载商品趋势失败' }), icon: 'none' })
}
}
function handleProductChange() {
loadSelectedProductTrend()
}
function buildCategoryChart(catRows: any) {
const rows: Array<any> = Array.isArray(catRows) ? (catRows as Array<any>) : []
const names: Array<string> = []
const values: Array<number> = []
for (let i = 0; i < rows.length; i++) {
names.push(`${rows[i].category_name ?? '未分类'}`)
values.push(Number(rows[i].total_sales) || 0)
}
categoryChartOption.value = {
tooltip: { trigger: 'axis' },
grid: { left: 60, right: 18, top: 20, bottom: 40 },
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'category', data: names, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
series: [{ type: 'bar', data: values, barWidth: 14, itemStyle: { borderRadius: 6 } }]
}
}
function buildStockChart(stockRows: any) {
const rows: Array<any> = Array.isArray(stockRows) ? (stockRows as Array<any>) : []
const names: Array<string> = []
const values: Array<number> = []
for (let i = 0; i < rows.length; i++) {
names.push(`${rows[i].bucket ?? ''}`)
values.push(Number(rows[i].value) || 0)
}
stockChartOption.value = {
tooltip: { trigger: 'axis' },
grid: { left: 60, right: 18, top: 20, bottom: 40 },
xAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'category', data: names, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
series: [{ type: 'bar', data: values, barWidth: 14, itemStyle: { borderRadius: 6 } }]
}
}
function buildReviewChart(reviewRows: any) {
const rows: Array<any> = Array.isArray(reviewRows) ? (reviewRows as Array<any>) : []
const names: Array<string> = []
const values: Array<number> = []
for (let i = 0; i < rows.length; i++) {
names.push(`${rows[i].rating ?? ''}`)
values.push(Number(rows[i].count) || 0)
}
reviewChartOption.value = {
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 18, top: 20, bottom: 40 },
xAxis: { type: 'category', data: names, axisLabel: { color: 'rgba(0,0,0,0.55)' } },
yAxis: { type: 'value', axisLabel: { color: 'rgba(0,0,0,0.55)' } },
series: [{ type: 'bar', data: values, barWidth: 14, itemStyle: { borderRadius: 6 } }]
}
}
async function loadProductData() {
loading.value = true
try {
updateTime()
const [overview, topList, catRows, stockRows, _priceRows, reviewRows] = await Promise.all([
fetchProductOverview(selectedPeriod.value),
fetchTopProducts(selectedPeriod.value, 10),
fetchCategorySales(selectedPeriod.value),
fetchStockInsights(selectedPeriod.value),
fetchPriceTrend(selectedPeriod.value),
fetchReviewInsights()
])
productData.total_products = overview.total_products
productData.product_growth = overview.product_growth
productData.hot_products = overview.hot_products
productData.turnover_rate = overview.turnover_rate
productData.turnover_growth = overview.turnover_growth
productData.avg_stock = overview.avg_stock
productData.stock_growth = overview.stock_growth
const top = topList.slice()
for (let i = 0; i < top.length; i++) top[i].rank = i + 1
topProducts.splice(0, topProducts.length, ...top)
if ((selectedProductId.value == null || selectedProductId.value === '') && top.length > 0) {
const real = top.find((it) => !String(it.id).startsWith('fake-product-'))
selectedProductId.value = real ? real.id : ''
}
if (selectedProductId.value == null || selectedProductId.value === '') {
salesChartOption.value = {}
} else {
await loadSelectedProductTrend()
}
buildCategoryChart(catRows)
buildStockChart(stockRows)
buildReviewChart(reviewRows)
updateTime()
} catch (e) {
console.error('loadProductData failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '商品洞察数据加载失败' }), icon: 'none', duration: 2000 })
} finally {
loading.value = false
updateTime()
}
}
function selectPeriod(p: string) {
selectedPeriod.value = p
loadProductData()
}
function refreshData() {
loadProductData()
uni.showToast({ title: '已刷新', icon: 'success' })
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
}
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
}
function handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
}
function handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
}
function handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
}
function handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
}
function handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
</script>
<style>
@@ -561,6 +594,7 @@ export default {
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
@@ -700,12 +734,62 @@ export default {
color: rgba(0,0,0,0.55);
}
.card-head-right {
display: flex;
flex-direction: row !important;
gap: 8px;
align-items: center;
}
.select {
padding: 6px 10px;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 10px;
font-size: 13px;
}
.chart-box {
width: 100%;
height: 360px;
}
/* 排行列表 */
.chart-box-sm {
height: 300px;
}
.chart-loading {
width: 100%;
height: 320px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0,0,0,0.45);
font-size: 14px;
}
.chart-loading-sm {
height: 220px;
}
.grid-row {
display: flex;
flex-direction: row !important;
flex-wrap: wrap;
gap: 12px;
align-items: stretch;
margin-top: 12px;
}
.grid-col-item {
flex: 1 1 calc(50% - 6px);
min-width: 360px;
}
.rank-list-scroll {
max-height: 320px;
overflow-y: auto;
}
.rank-list {
display: flex;
flex-direction: column;
@@ -742,11 +826,6 @@ export default {
color: #111;
}
.rank-val {
font-size: 13px;
color: rgba(0,0,0,0.65);
}
.rank-right {
display: flex;
flex-direction: row !important;
@@ -754,6 +833,11 @@ export default {
gap: 8px;
}
.rank-val {
font-size: 13px;
color: rgba(0,0,0,0.65);
}
.chip {
padding: 3px 8px;
border-radius: 999px;
@@ -770,24 +854,25 @@ export default {
color: #dc2626;
}
/* 响应式 */
@media screen and (min-width: 960px) {
.kpi-card {
flex: 1 1 calc(25% - 9px);
min-width: 200px;
}
}
@media screen and (max-width: 960px) {
.grid-row {
flex-direction: column;
}
.grid-col-item {
flex: 1 1 100%;
min-width: 100%;
}
.title,
.subtitle {
max-width: 200px;
}
.topbar-right .btn-hidden {
display: none !important;
}
.more-btn {
display: flex !important;
}
@@ -798,7 +883,7 @@ export default {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}

View File

@@ -266,14 +266,7 @@ import supa from '@/components/supadb/aksupainstance.uts'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import type { UserType } from '@/types/mall-types'
// 报表类型定义
type ReportType = {
id: string
title: string
description: string
status: string
created_at: string
}
import type { RecentReport, OverviewData, ReportCounts, TodayInsights, TrendDatum } from '@/types/analytics/profile.uts'
// 响应式数据
const showSidebarMenu = ref(false)
@@ -293,7 +286,7 @@ const analystInfo = ref({
const workExperience = ref(5)
const expertise = ref('电商数据')
const overviewData = ref({
const overviewData = ref<OverviewData>({
totalSales: '0',
salesGrowth: 0,
totalUsers: '0',
@@ -304,24 +297,24 @@ const overviewData = ref({
conversionGrowth: 0
})
const reportCounts = ref({
const reportCounts = ref<ReportCounts>({
total: 0,
pending: 0,
scheduled: 0,
shared: 0
})
const todayInsights = ref({
const todayInsights = ref<TodayInsights>({
hotProduct: '-',
peakTraffic: '0',
conversionAnomaly: '-',
mobileRatio: 0
})
const recentReports = ref([] as Array<ReportType>)
const recentReports = ref<Array<RecentReport>>([])
const trendPeriod = ref('week')
const trendData = ref([
const trendData = ref<Array<TrendDatum>>([
{ label: '周一', sales: 0, orders: 0 },
{ label: '周二', sales: 0, orders: 0 },
{ label: '周三', sales: 0, orders: 0 },
@@ -457,85 +450,30 @@ async function loadRecentReports() {
async function loadOverview() {
try {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) // < end
const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000)
const prevEnd = start
const prevStart = new Date(prevEnd.getTime() - 30 * 24 * 60 * 60 * 1000)
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000) // Last 30 days
const curRes: any = await supa
.from('orders')
.select('total_amount, user_id, created_at')
.gte('created_at', start.toISOString())
.lt('created_at', end.toISOString())
.eq('status', 2)
const prevRes: any = await supa
.from('orders')
.select('total_amount, user_id, created_at')
.gte('created_at', prevStart.toISOString())
.lt('created_at', prevEnd.toISOString())
.eq('status', 2)
const p = new UTSJSONObject()
p.set('p_start_date', dateISO(start))
p.set('p_end_date', dateISO(end))
const curOrders: Array<any> = Array.isArray(curRes.data) ? (curRes.data as Array<any>) : []
const prevOrders: Array<any> = Array.isArray(prevRes.data) ? (prevRes.data as Array<any>) : []
const [salesKpisRes, userKpisRes] = await Promise.all([
supa.rpc('rpc_analytics_sales_kpis', p),
supa.rpc('rpc_analytics_user_kpis', p)
])
let curSales = 0
let prevSales = 0
const curUsers: Record<string, boolean> = {}
const prevUsers: Record<string, boolean> = {}
for (let i = 0; i < curOrders.length; i++) {
curSales += safeNumber(curOrders[i].total_amount)
const uid = `${curOrders[i].user_id || ''}`
if (uid) curUsers[uid] = true
}
for (let i = 0; i < prevOrders.length; i++) {
prevSales += safeNumber(prevOrders[i].total_amount)
const uid = `${prevOrders[i].user_id || ''}`
if (uid) prevUsers[uid] = true
}
const curOrderCnt = curOrders.length
const prevOrderCnt = prevOrders.length
const curUserCnt = Object.keys(curUsers).length
const prevUserCnt = Object.keys(prevUsers).length
// 转化率:下单用户 / 访问用户(用 user_sessions 近30天会话去重近似
const curSessRes: any = await supa
.from('user_sessions')
.select('user_id, created_at')
.gte('created_at', start.toISOString())
.lt('created_at', end.toISOString())
const prevSessRes: any = await supa
.from('user_sessions')
.select('user_id, created_at')
.gte('created_at', prevStart.toISOString())
.lt('created_at', prevEnd.toISOString())
const curSess: Array<any> = Array.isArray(curSessRes.data) ? (curSessRes.data as Array<any>) : []
const prevSess: Array<any> = Array.isArray(prevSessRes.data) ? (prevSessRes.data as Array<any>) : []
const curVisitUsers: Record<string, boolean> = {}
const prevVisitUsers: Record<string, boolean> = {}
for (let i = 0; i < curSess.length; i++) {
const uid = `${curSess[i].user_id || ''}`
if (uid) curVisitUsers[uid] = true
}
for (let i = 0; i < prevSess.length; i++) {
const uid = `${prevSess[i].user_id || ''}`
if (uid) prevVisitUsers[uid] = true
}
const curVisitCnt = Object.keys(curVisitUsers).length
const prevVisitCnt = Object.keys(prevVisitUsers).length
const curConv = curVisitCnt > 0 ? (curUserCnt / curVisitCnt) * 100 : 0
const prevConv = prevVisitCnt > 0 ? (prevUserCnt / prevVisitCnt) * 100 : 0
const salesKpis = Array.isArray(salesKpisRes.data) && salesKpisRes.data.length > 0 ? salesKpisRes.data[0] : {}
const userKpis = Array.isArray(userKpisRes.data) && userKpisRes.data.length > 0 ? userKpisRes.data[0] : {}
overviewData.value = {
totalSales: fmtMoney(curSales),
salesGrowth: safeNumber(pctGrowth(curSales, prevSales)),
totalUsers: fmtInt(curUserCnt),
userGrowth: safeNumber(pctGrowth(curUserCnt, prevUserCnt)),
totalOrders: fmtInt(curOrderCnt),
orderGrowth: safeNumber(pctGrowth(curOrderCnt, prevOrderCnt)),
conversionRate: safeNumber(curConv),
conversionGrowth: safeNumber(pctGrowth(curConv, prevConv))
totalSales: fmtMoney(safeNumber(salesKpis.gmv)),
salesGrowth: safeNumber(salesKpis.gmv_growth),
totalUsers: fmtInt(safeNumber(userKpis.new_users)), // Use new_users for the period
userGrowth: safeNumber(userKpis.new_user_growth),
totalOrders: fmtInt(safeNumber(salesKpis.orders)),
orderGrowth: safeNumber(salesKpis.order_growth),
conversionRate: safeNumber(salesKpis.conversion_rate),
conversionGrowth: safeNumber(salesKpis.conversion_growth)
}
} catch (e) {
console.error('loadOverview failed', e)
@@ -551,8 +489,7 @@ async function loadTrend() {
const p = new UTSJSONObject()
p.set('p_start_date', dateISO(start))
p.set('p_end_date', dateISO(end))
p.set('p_merchant_id', null)
const res: any = await supa.rpc('rpc_analytics_trend_data', p)
const res: any = await supa.rpc('rpc_analytics_sales_trend', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const weekLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
@@ -571,8 +508,7 @@ async function loadTrend() {
const p = new UTSJSONObject()
p.set('p_start_date', dateISO(start))
p.set('p_end_date', dateISO(new Date(now.getFullYear(), now.getMonth(), now.getDate())))
p.set('p_merchant_id', null)
const res: any = await supa.rpc('rpc_analytics_trend_data', p)
const res: any = await supa.rpc('rpc_analytics_sales_trend', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const buckets: Record<string, { sales: number; orders: number }> = {}
@@ -595,8 +531,7 @@ async function loadTrend() {
const p = new UTSJSONObject()
p.set('p_start_date', dateISO(start))
p.set('p_end_date', dateISO(end))
p.set('p_merchant_id', null)
const res: any = await supa.rpc('rpc_analytics_trend_data', p)
const res: any = await supa.rpc('rpc_analytics_sales_trend', p)
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const buckets: Record<string, { sales: number; orders: number }> = {}
@@ -628,32 +563,23 @@ async function loadTodayInsights() {
p.set('p_start_date', dateISO(today0))
p.set('p_end_date', dateISO(today0))
p.set('p_limit', 1)
p.set('p_merchant_id', null)
const prodRes: any = await supa.rpc('rpc_analytics_top_products', p)
const prodRows: Array<any> = Array.isArray(prodRes.data) ? (prodRes.data as Array<any>) : []
if (prodRows.length > 0) {
todayInsights.value.hotProduct = `${prodRows[0].name}`
}
// 访问量峰值(简化:今日总访问量
// 访问量峰值用今日浏览行为数近似主库口径ml_browse_history
const pvRes: any = await supa
.from('page_views')
.from('ml_browse_history')
.select('id, created_at')
.gte('created_at', today0.toISOString())
.lt('created_at', new Date(today0.getTime() + 24 * 60 * 60 * 1000).toISOString())
const pvRows: Array<any> = Array.isArray(pvRes.data) ? (pvRes.data as Array<any>) : []
todayInsights.value.peakTraffic = fmtInt(pvRows.length)
// 转化异常:取今日 KPI 增长简化负数提示“下降xx%”)
const kpiP = new UTSJSONObject()
kpiP.set('p_start', today0.toISOString())
kpiP.set('p_end', now.toISOString())
const ySame = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const y0 = new Date(ySame.getFullYear(), ySame.getMonth(), ySame.getDate())
kpiP.set('p_compare_start', y0.toISOString())
kpiP.set('p_compare_end', ySame.toISOString())
kpiP.set('p_merchant_id', null)
const kpiRes: any = await supa.rpc('rpc_analytics_realtime_kpis', kpiP)
// 转化异常:使用无参版 rpc_analytics_realtime_kpis
const kpiRes: any = await supa.rpc('rpc_analytics_realtime_kpis', {} as any)
const row = Array.isArray(kpiRes.data) && kpiRes.data.length > 0 ? kpiRes.data[0] : (kpiRes.data || {})
const cg = safeNumber(row.conversion_growth)
todayInsights.value.conversionAnomaly = cg < 0 ? `下降${Math.abs(cg).toFixed(1)}%` : `上升${cg.toFixed(1)}%`

View File

@@ -200,203 +200,160 @@
</view>
</template>
<script lang="uts">
<script setup lang="uts">
import { onLoad, onShow, reactive, ref } from 'vue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import { fetchReport, fetchReportMetrics, fetchReportRows, fetchReportInsights, fetchRelatedReports } from '@/services/analytics/reportDetailService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
type ReportType = {
id: string
title: string
type: string
period: string
generated_at: string
description: string
}
import type { ReportType, MetricType, ChartTabType, ChartLegendType, TableColumnType, InsightType } from '@/types/analytics/report-detail.uts'
type MetricType = {
key: string
label: string
value: number
format: string
icon: string
color: string
change: number
}
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/report-detail')
type ChartTabType = {
key: string
label: string
}
type ChartLegendType = {
key: string
label: string
color: string
}
type TableColumnType = {
key: string
title: string
width: string
type: string
}
type InsightType = {
id: string
type: string
title: string
content: string
impact: string
}
export default {
components: {
AnalyticsSidebarMenu,
AnalyticsTopBar
},
data() {
return {
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/report-detail',
report: {
const report = reactive<ReportType>({
id: '',
title: '',
type: '',
period: '',
generated_at: '',
description: ''
} as ReportType,
coreMetrics: [] as Array<MetricType>,
chartTabs: [] as Array<ChartTabType>,
activeChartTab: '',
chartLegends: [] as Array<ChartLegendType>,
tableColumns: [] as Array<TableColumnType>,
allRows: [] as Array<any>,
tableData: [] as Array<any>,
dataInsights: [] as Array<InsightType>,
relatedReports: [] as Array<ReportType>,
sortIndex: 0,
sortOptions: [] as Array<string>,
limitIndex: 1,
limitOptions: ['10条', '20条', '50条', '100条'],
currentPage: 1,
totalPages: 1,
autoRefresh: false,
intervalIndex: 1,
intervalOptions: ['1分钟', '5分钟', '10分钟', '30分钟', '1小时'],
emailNotify: false
}
},
onLoad(options: any) {
// 兼容两种参数名reportId 和 id
})
const coreMetrics = reactive<Array<MetricType>>([])
const chartTabs = reactive<Array<ChartTabType>>([])
const activeChartTab = ref('')
const chartLegends = reactive<Array<ChartLegendType>>([])
const tableColumns = reactive<Array<TableColumnType>>([])
const allRows = reactive<Array<any>>([])
const tableData = reactive<Array<any>>([])
const dataInsights = reactive<Array<InsightType>>([])
const relatedReports = reactive<Array<ReportType>>([])
const sortIndex = ref(0)
const sortOptions = ref<Array<string>>([])
const limitIndex = ref(1)
const limitOptions = ref<Array<string>>(['10条', '20条', '50条', '100条'])
const currentPage = ref(1)
const totalPages = ref(1)
const autoRefresh = ref(false)
const intervalIndex = ref(1)
const intervalOptions = ref<Array<string>>(['1分钟', '5分钟', '10分钟', '30分钟', '1小时'])
const emailNotify = ref(false)
onLoad((options: any) => {
const reportId = (options.reportId || options.id) as string
if (reportId) {
this.loadReportDetail(reportId)
void loadReportDetail(reportId)
} else {
uni.showToast({
title: '缺少报表ID',
icon: 'none'
})
uni.showToast({ title: '缺少报表ID', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
this.currentPath = '/pages/mall/analytics/report-detail'
},
currentPath.value = '/pages/mall/analytics/report-detail'
})
onShow() {
this.currentPath = '/pages/mall/analytics/report-detail'
},
onShow(() => {
currentPath.value = '/pages/mall/analytics/report-detail'
})
methods: {
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
safeNumber(v: any): number {
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function safeNumber(v: any): number {
const n = Number(v)
return isFinite(n) ? n : 0
},
}
async loadReportDetail(reportId: string) {
async function loadReportDetail(reportId: string) {
try {
uni.showLoading({ title: '加载中...' })
// 1. 加载报表主体
const report = await fetchReport(reportId)
if (report == null) {
const rep = await fetchReport(reportId)
if (rep == null) {
uni.showToast({ title: '报表不存在', icon: 'none' })
return
}
this.report = report
// 2. 加载核心指标
this.coreMetrics = await fetchReportMetrics(reportId)
report.id = rep.id
report.title = rep.title
report.type = rep.type
report.period = rep.period
report.generated_at = rep.generated_at
report.description = rep.description
// 3. 配置表头与排序选项(固定结构)
this.tableColumns = [
const metrics = await fetchReportMetrics(reportId)
coreMetrics.splice(0, coreMetrics.length, ...metrics)
tableColumns.splice(0, tableColumns.length,
{ key: 'date', title: '日期', width: '120rpx', type: 'text' },
{ key: 'sales', title: '销售额', width: '120rpx', type: 'currency' },
{ key: 'orders', title: '订单数', width: '100rpx', type: 'number' },
{ key: 'users', title: '用户数', width: '100rpx', type: 'number' },
{ key: 'conversion', title: '转化率', width: '100rpx', type: 'percent' },
{ key: 'avg_value', title: '客单价', width: '120rpx', type: 'currency' }
]
this.sortOptions = ['按日期降序', '按销售额降序', '按订单数降序', '按转化率降序']
)
// 4. 加载明细行(趋势/表格)
this.allRows = await fetchReportRows(reportId)
this.currentPage = 1
this.updateTotalPages()
this.generateTableData()
sortOptions.value = ['按日期降序', '按销售额降序', '按订单数降序', '按转化率降序']
// 5. 加载洞察
this.dataInsights = await fetchReportInsights(reportId)
const rows = await fetchReportRows(reportId)
allRows.splice(0, allRows.length, ...rows)
// 6. 相关报表(同类型最近报表)
this.relatedReports = await fetchRelatedReports(this.report.type, reportId)
currentPage.value = 1
updateTotalPages()
generateTableData()
const insights = await fetchReportInsights(reportId)
dataInsights.splice(0, dataInsights.length, ...insights)
const rel = await fetchRelatedReports(report.type, reportId)
relatedReports.splice(0, relatedReports.length, ...rel)
} catch (e) {
console.error('loadReportDetail failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '报表加载失败' }), icon: 'none' })
} finally {
uni.hideLoading()
}
},
updateTotalPages() {
const total = this.allRows.length
const limit = parseInt(this.limitOptions[this.limitIndex])
this.totalPages = total > 0 ? Math.ceil(total / limit) : 1
},
}
generateTableData() {
this.tableData = []
const total = this.allRows.length
function updateTotalPages() {
const total = allRows.length
const limit = parseInt(limitOptions.value[limitIndex.value])
totalPages.value = total > 0 ? Math.ceil(total / limit) : 1
}
function generateTableData() {
tableData.splice(0, tableData.length)
const total = allRows.length
if (total === 0) {
return
}
const limit = parseInt(this.limitOptions[this.limitIndex])
const start = (this.currentPage - 1) * limit
const limit = parseInt(limitOptions.value[limitIndex.value])
const start = (currentPage.value - 1) * limit
const end = Math.min(start + limit, total)
for (let i = start; i < end; i++) {
const row = this.allRows[i]
this.tableData.push({
const row = allRows[i]
tableData.push({
date: `${row.row_date}`,
sales: this.safeNumber(row.gmv),
orders: this.safeNumber(row.orders),
users: this.safeNumber(row.users),
conversion: this.safeNumber(row.conversion).toFixed(1),
avg_value: this.safeNumber(row.avg_order_amount).toFixed(2)
sales: safeNumber(row.gmv),
orders: safeNumber(row.orders),
users: safeNumber(row.users),
conversion: safeNumber(row.conversion).toFixed(1),
avg_value: safeNumber(row.avg_order_amount).toFixed(2)
})
}
},
getReportTypeText(): string {
}
function getReportTypeText(): string {
const types: Record<string, string> = {
sales: '销售报表',
user: '用户报表',
@@ -404,10 +361,10 @@ export default {
financial: '财务报表',
marketing: '营销报表'
}
return types[this.report.type] || '其他报表'
},
return types[report.type] || '其他报表'
}
formatMetricValue(value: number, format: string): string {
function formatMetricValue(value: number, format: string): string {
switch (format) {
case 'currency':
return `¥${(value / 10000).toFixed(1)}万`
@@ -418,13 +375,13 @@ export default {
default:
return value.toString()
}
},
}
formatTime(timeStr: string): string {
function formatTime(timeStr: string): string {
return timeStr.replace('T', ' ').split('.')[0]
},
}
getInsightIcon(type: string): string {
function getInsightIcon(type: string): string {
const icons: Record<string, string> = {
positive: '✅',
warning: '⚠️',
@@ -432,18 +389,18 @@ export default {
info: ''
}
return icons[type] || ''
},
}
getImpactText(impact: string): string {
function getImpactText(impact: string): string {
const impacts: Record<string, string> = {
high: '高影响',
medium: '中影响',
low: '低影响'
}
return impacts[impact] || '未知影响'
},
}
formatCellValue(value: any, column: TableColumnType): string {
function formatCellValue(value: any, column: TableColumnType): string {
switch (column.type) {
case 'currency':
return `¥${parseFloat(value).toLocaleString()}`
@@ -454,144 +411,131 @@ export default {
default:
return value.toString()
}
},
switchChartTab(tabKey: string) {
this.activeChartTab = tabKey
// 这里可以重新绘制图表
},
onChartTouch(e: any) {
}
function switchChartTab(tabKey: string) {
activeChartTab.value = tabKey
}
function onChartTouch(e: any) {
// 处理图表触摸事件
},
onSortChange(e: any) {
this.sortIndex = e.detail.value
this.generateTableData()
},
onLimitChange(e: any) {
this.limitIndex = e.detail.value
this.currentPage = 1
this.updateTotalPages()
this.generateTableData()
},
previousPage() {
if (this.currentPage > 1) {
this.currentPage--
this.generateTableData()
}
function onSortChange(e: any) {
sortIndex.value = e.detail.value
generateTableData()
}
function onLimitChange(e: any) {
limitIndex.value = e.detail.value
currentPage.value = 1
updateTotalPages()
generateTableData()
}
function previousPage() {
if (currentPage.value > 1) {
currentPage.value--
generateTableData()
}
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++
this.generateTableData()
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
generateTableData()
}
},
exportReport() {
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: (res) => {
const formats = ['Excel', 'PDF', '图片']
uni.showToast({
title: `正在导出${formats[res.tapIndex]}`,
icon: 'loading'
})
uni.showToast({ title: `正在导出${formats[res.tapIndex]}`, icon: 'loading' })
setTimeout(() => {
uni.showToast({
title: '导出成功',
icon: 'success'
})
uni.showToast({ title: '导出成功', icon: 'success' })
}, 2000)
}
})
},
}
refreshReport() {
function refreshReport() {
uni.showLoading({ title: '刷新中...' })
setTimeout(() => {
uni.hideLoading()
this.loadReportDetail(this.report.id)
uni.showToast({
title: '刷新成功',
icon: 'success'
})
void loadReportDetail(report.id)
uni.showToast({ title: '刷新成功', icon: 'success' })
}, 1500)
},
}
goToDataDetail() {
if (!this.report.id || this.report.id.length === 0) {
function goToDataDetail() {
if (!report.id || report.id.length === 0) {
uni.showToast({ title: '报表未加载完成', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/mall/analytics/data-detail?reportId=${this.report.id}`
url: `/pages/mall/analytics/data-detail?reportId=${report.id}`
})
},
}
viewInsightDetail(insight: InsightType) {
function viewInsightDetail(insight: InsightType) {
uni.navigateTo({
url: `/pages/mall/analytics/insight-detail?insightId=${insight.id}`
})
},
}
viewRelatedReport(report: ReportType) {
function viewRelatedReport(rep: ReportType) {
uni.navigateTo({
url: `/pages/mall/analytics/report-detail?reportId=${report.id}`
url: `/pages/mall/analytics/report-detail?reportId=${rep.id}`
})
},
toggleAutoRefresh(e: any) {
this.autoRefresh = e.detail.value
},
onIntervalChange(e: any) {
this.intervalIndex = e.detail.value
},
toggleEmailNotify(e: any) {
this.emailNotify = e.detail.value
},
saveConfig() {
uni.showToast({
title: '配置已保存',
icon: 'success'
})
},
handleSearch() {
}
function toggleAutoRefresh(e: any) {
autoRefresh.value = e.detail.value
}
function onIntervalChange(e: any) {
intervalIndex.value = e.detail.value
}
function toggleEmailNotify(e: any) {
emailNotify.value = e.detail.value
}
function saveConfig() {
uni.showToast({ title: '配置已保存', icon: 'success' })
}
function resetConfig() {
autoRefresh.value = false
intervalIndex.value = 1
emailNotify.value = false
uni.showToast({ title: '配置已重置', icon: 'success' })
}
function handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
},
handleNotification() {
}
function handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
},
handleFullscreen() {
}
function handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
},
handleMobile() {
}
function handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
},
handleDropdown() {
}
function handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
},
handleSettings() {
}
function handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
},
resetConfig() {
this.autoRefresh = false
this.intervalIndex = 1
this.emailNotify = false
uni.showToast({
title: '配置已重置',
icon: 'success'
})
}
}
}
</script>
@@ -757,12 +701,12 @@ export default {
.metric-label {
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
}
.metric-change {
display: flex;
align-items: center;
margin-top: 8rpx;
}
.metric-change.positive {
@@ -770,17 +714,15 @@ export default {
}
.metric-change.negative {
color: #ff4444;
color: #f44336;
}
.change-icon {
font-size: 20rpx;
margin-right: 5rpx;
margin-right: 8rpx;
}
.change-value {
font-size: 22rpx;
font-weight: bold;
}
.chart-tabs {
@@ -789,111 +731,104 @@ export default {
}
.chart-tab {
padding: 12rpx 24rpx;
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 24rpx;
color: #666;
background-color: #f0f0f0;
color: #666;
font-size: 22rpx;
}
.chart-tab.active {
background-color: #2196f3;
background-color: #111827;
color: #fff;
}
.chart-container {
height: 500rpx;
margin: 30rpx 0;
border: 1rpx solid #eee;
border-radius: 8rpx;
height: 400rpx;
margin: 20rpx 0;
}
.chart-canvas {
width: 100%;
height: 100%;
height: 400rpx;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 40rpx;
gap: 30rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 8rpx;
}
.legend-color {
width: 20rpx;
height: 20rpx;
border-radius: 10rpx;
margin-right: 10rpx;
}
.legend-label {
font-size: 24rpx;
font-size: 22rpx;
color: #666;
}
.table-filters {
display: flex;
gap: 40rpx;
margin-bottom: 25rpx;
justify-content: space-between;
margin-bottom: 20rpx;
}
.filter-item {
display: flex;
align-items: center;
gap: 10rpx;
}
.filter-label {
font-size: 24rpx;
color: #666;
margin-right: 10rpx;
}
.filter-value, .config-value {
.filter-value {
font-size: 24rpx;
color: #333;
padding: 10rpx 20rpx;
background-color: #f0f0f0;
border-radius: 6rpx;
}
.table-container {
border: 1rpx solid #eee;
border-radius: 8rpx;
margin-bottom: 25rpx;
margin: 20rpx 0;
}
.table-scroll {
white-space: nowrap;
width: 100%;
}
.table {
min-width: 100%;
min-width: 600rpx;
}
.table-header, .table-row {
.table-header {
display: flex;
background-color: #f8f9fa;
padding: 15rpx;
}
.table-row {
display: flex;
padding: 15rpx;
border-bottom: 1rpx solid #eee;
}
.table-row:last-child {
border-bottom: none;
}
.table-cell {
padding: 20rpx 15rpx;
font-size: 24rpx;
flex-shrink: 0;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
font-size: 22rpx;
}
.header-cell {
background-color: #f8f9fa;
font-weight: bold;
color: #333;
}
@@ -902,24 +837,29 @@ export default {
color: #666;
}
.data-cell.number, .data-cell.currency {
text-align: right;
.data-cell.number {
color: #2196f3;
}
.data-cell.currency {
color: #4caf50;
}
.table-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 30rpx;
gap: 20rpx;
margin-top: 20rpx;
}
.page-btn {
padding: 15rpx 30rpx;
background-color: #2196f3;
color: #fff;
border: none;
padding: 10rpx 20rpx;
border-radius: 6rpx;
font-size: 24rpx;
background-color: #111827;
color: #fff;
font-size: 22rpx;
border: none;
}
.page-btn:disabled {
@@ -932,16 +872,17 @@ export default {
}
.insight-card {
padding: 25rpx;
background-color: #f8f9fa;
border-radius: 10rpx;
margin-bottom: 20rpx;
padding: 20rpx;
border-radius: 12rpx;
margin-bottom: 15rpx;
}
.insight-header {
display: flex;
align-items: center;
margin-bottom: 15rpx;
gap: 15rpx;
margin-bottom: 10rpx;
}
.insight-icon {
@@ -951,24 +892,8 @@ export default {
display: flex;
align-items: center;
justify-content: center;
font-size: 20rpx;
margin-right: 15rpx;
}
.insight-icon.positive {
background-color: #e8f5e8;
}
.insight-icon.warning {
background-color: #fff8e1;
}
.insight-icon.negative {
background-color: #ffebee;
}
.insight-icon.info {
background-color: #e3f2fd;
font-size: 24rpx;
background-color: #e5e7eb;
}
.insight-title {
@@ -980,33 +905,35 @@ export default {
.insight-content {
font-size: 24rpx;
color: #666;
line-height: 1.6;
margin-bottom: 15rpx;
line-height: 1.5;
}
.insight-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15rpx;
}
.insight-impact {
font-size: 22rpx;
padding: 6rpx 12rpx;
border-radius: 8rpx;
color: #fff;
padding: 4rpx 12rpx;
border-radius: 12rpx;
}
.insight-impact.high {
background-color: #ff4444;
background-color: #ffebee;
color: #f44336;
}
.insight-impact.medium {
background-color: #ffa726;
background-color: #fff3e0;
color: #ff9800;
}
.insight-impact.low {
background-color: #4caf50;
background-color: #e8f5e8;
color: #4caf50;
}
.insight-action {
@@ -1018,30 +945,31 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
padding: 25rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.config-item:last-of-type {
border-bottom: none;
padding: 20rpx 0;
border-bottom: 1rpx solid #eee;
}
.config-label {
font-size: 26rpx;
font-size: 24rpx;
color: #333;
}
.config-value {
font-size: 24rpx;
color: #666;
}
.config-actions {
display: flex;
gap: 20rpx;
margin-top: 30rpx;
gap: 15rpx;
margin-top: 20rpx;
}
.config-btn {
flex: 1;
height: 70rpx;
padding: 15rpx;
border-radius: 8rpx;
font-size: 26rpx;
font-size: 24rpx;
border: none;
}
@@ -1051,28 +979,22 @@ export default {
}
.config-btn.reset {
background-color: #f0f0f0;
color: #666;
}
.report-list {
margin-top: 25rpx;
background-color: #f44336;
color: #fff;
}
.report-item {
display: flex;
align-items: center;
padding: 25rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.report-item:last-child {
border-bottom: none;
padding: 20rpx;
background-color: #f8f9fa;
border-radius: 12rpx;
margin-bottom: 15rpx;
}
.report-icon {
font-size: 32rpx;
margin-right: 20rpx;
margin-right: 15rpx;
}
.report-info {
@@ -1081,35 +1003,24 @@ export default {
.report-name {
font-size: 26rpx;
color: #333;
font-weight: bold;
margin-bottom: 5rpx;
color: #333;
}
.report-desc {
font-size: 22rpx;
color: #666;
margin-bottom: 5rpx;
margin-top: 5rpx;
}
.report-time {
font-size: 20rpx;
color: #999;
margin-top: 5rpx;
}
.report-arrow {
font-size: 24rpx;
color: #999;
}
/* 响应式:窄屏时全屏显示 */
@media screen and (max-width: 959px) {
.page-layout {
flex-direction: column !important;
}
.main-content {
width: 100%;
}
}
</style>

View File

@@ -148,242 +148,189 @@
</view>
</template>
<script lang="uts">
<script setup lang="uts">
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import AnalyticsRegionMap from '@/components/analytics/AnalyticsRegionMap.uvue'
import { computed, onLoad, reactive, ref } from 'vue'
import { fetchSalesKpis, fetchSalesTrend, fetchSalesTopProducts, fetchSalesTopMerchants } from '@/services/analytics/salesReportService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
import type { TimePeriod } from '@/types/analytics/common.uts'
import type { SalesTrendData, SalesData, ProductRank, MerchantRank } from '@/types/analytics/sales.uts'
type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
type SalesData = {
gmv: number
gmv_growth: number
orders: number
order_growth: number
conversion_rate: number
conversion_growth: number
avg_order_amount: number
avg_order_growth: number
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/sales-report')
const loading = ref(false)
const timePeriods = ref<Array<TimePeriod>>([
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
])
const salesData = reactive<SalesData>({
gmv: 0,
gmv_growth: 0,
orders: 0,
order_growth: 0,
conversion_rate: 0,
conversion_growth: 0,
avg_order_amount: 0,
avg_order_growth: 0
})
const trend = reactive<SalesTrendData>({ x: [], gmv: [], orders: [] })
const topProducts = reactive<Array<ProductRank>>([])
const topMerchants = reactive<Array<MerchantRank>>([])
const selectedPeriodText = computed((): string => {
const p = timePeriods.value.find((t) => t.value === selectedPeriod.value)
return p ? p.label : '7天'
})
onLoad(() => {
updateTime()
loadSalesData()
})
function calcDateRange() {
const now = new Date()
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = selectedPeriod.value === '7d' ? 7 : selectedPeriod.value === '30d' ? 30 : selectedPeriod.value === '90d' ? 90 : 365
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
return { startDate, endDate, days }
}
type ProductRank = { id: string; rank: number; name: string; sales: number }
type MerchantRank = { id: string; rank: number; name: string; sales: number; growth: number }
export default {
components: {
AnalyticsComboChart,
AnalyticsSidebarMenu,
AnalyticsTopBar,
AnalyticsRegionMap
},
data() {
return {
lastUpdateTime: '',
selectedPeriod: '7d',
showMoreMenu: false,
showSidebarMenu: false,
currentPath: '/pages/mall/analytics/sales-report',
loading: false,
timePeriods: [
{ value: '7d', label: '7天' },
{ value: '30d', label: '30天' },
{ value: '90d', label: '90天' },
{ value: '1y', label: '1年' }
],
async function loadSalesData() {
loading.value = true
try {
updateTime()
salesData: {
gmv: 0,
gmv_growth: 0,
orders: 0,
order_growth: 0,
conversion_rate: 0,
conversion_growth: 0,
avg_order_amount: 0,
avg_order_growth: 0
} as SalesData,
// KPI
const kpi = await fetchSalesKpis(selectedPeriod.value)
salesData.gmv = kpi.gmv
salesData.gmv_growth = kpi.gmv_growth
salesData.orders = kpi.orders
salesData.order_growth = kpi.order_growth
salesData.conversion_rate = kpi.conversion_rate
salesData.conversion_growth = kpi.conversion_growth
salesData.avg_order_amount = kpi.avg_order_amount
salesData.avg_order_growth = kpi.avg_order_growth
trend: {
x: [] as Array<string>,
gmv: [] as Array<number>,
orders: [] as Array<number>
} as TrendData,
// 趋势
const t = await fetchSalesTrend(selectedPeriod.value)
trend.x = t.x
trend.gmv = t.gmv
trend.orders = t.orders
topProducts: [] as Array<ProductRank>,
topMerchants: [] as Array<MerchantRank>
// TOP 商品/商家
const pList = await fetchSalesTopProducts(selectedPeriod.value, 50)
for (let i = 0; i < pList.length; i++) {
pList[i].rank = i + 1
}
},
topProducts.splice(0, topProducts.length, ...pList)
computed: {
selectedPeriodText(): string {
const p = this.timePeriods.find((t) => t.value === this.selectedPeriod)
return p ? p.label : '7天'
}
},
onLoad() {
this.updateTime()
this.loadSalesData()
},
methods: {
calcDateRange() {
const now = new Date()
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = this.selectedPeriod === '7d' ? 7 : this.selectedPeriod === '30d' ? 30 : this.selectedPeriod === '90d' ? 90 : 365
const startDate = new Date(endDate.getTime() - (days - 1) * 24 * 60 * 60 * 1000)
return { startDate, endDate, days }
},
async loadSalesData() {
this.loading = true
try {
this.updateTime()
const { startDate, endDate } = this.calcDateRange()
// 1) KPI
this.salesData = await fetchSalesKpis(this.selectedPeriod)
// 2) 趋势
this.trend = await fetchSalesTrend(this.selectedPeriod)
// 3) TOP 商品/商家
const pList = await fetchSalesTopProducts(this.selectedPeriod, 50)
// 不足 50 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
if (pList.length < 50) {
const need = 50 - pList.length
for (let i = 0; i < need; i++) {
const n = pList.length + 1
pList.push({
id: `fake-product-${n}`,
rank: n,
name: `示例商品${n}`,
sales: Math.max(1, Math.floor(Math.random() * 200) + 1)
})
}
} else {
// 超过 50 的话只保留前 50
pList.splice(50)
}
// 重新修正 rank
for (let i = 0; i < pList.length; i++) {
pList[i].rank = i + 1
}
this.topProducts = pList
const mList: Array<MerchantRank> = await fetchSalesTopMerchants(this.selectedPeriod, 50)
// 不足 50 条时补齐虚拟数据(真实数据优先,虚拟数据追加)
if (mList.length < 50) {
const need = 50 - mList.length
for (let i = 0; i < need; i++) {
const n = mList.length + 1
mList.push({
id: `fake-merchant-${n}`,
rank: n,
name: `示例商家${n}`,
sales: Math.max(1, Math.floor(Math.random() * 50000) + 500),
growth: Math.round((Math.random() * 20 - 10) * 10) / 10
})
}
} else {
mList.splice(50)
}
// 重新修正 rank
for (let i = 0; i < mList.length; i++) {
mList[i].rank = i + 1
}
this.topMerchants = mList
// 4) 地域分布:由 AnalyticsRegionMap 组件自动处理
} catch (e) {
console.error('❌ loadSalesData failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '数据加载失败' }), icon: 'none', duration: 2000 })
} finally {
this.loading = false
this.updateTime()
}
},
selectPeriod(p: string) {
this.selectedPeriod = p
this.loadSalesData()
},
refreshData() {
this.loadSalesData()
uni.showToast({ title: '已刷新', icon: 'success' })
},
exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
},
updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
this.lastUpdateTime = `${hh}:${mm}`
},
formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
},
formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
},
formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
},
handleMenu() {
this.showSidebarMenu = true
},
handleSidebarUpdate(visible: boolean) {
this.showSidebarMenu = visible
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
closeMoreMenu() {
this.showMoreMenu = false
},
handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
},
handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
},
handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
},
handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
},
handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
},
handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
const mList = await fetchSalesTopMerchants(selectedPeriod.value, 50)
for (let i = 0; i < mList.length; i++) {
mList[i].rank = i + 1
}
topMerchants.splice(0, topMerchants.length, ...mList)
} catch (e) {
console.error('❌ loadSalesData failed', e)
uni.showToast({ title: mapAnalyticsError(e, { fallbackMessage: '数据加载失败' }), icon: 'none', duration: 2000 })
} finally {
loading.value = false
updateTime()
}
}
function selectPeriod(p: string) {
selectedPeriod.value = p
loadSalesData()
}
function refreshData() {
loadSalesData()
uni.showToast({ title: '已刷新', icon: 'success' })
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],
success: () => uni.showToast({ title: '导出成功', icon: 'success' })
})
}
function updateTime() {
const now = new Date()
const hh = now.getHours().toString().padStart(2, '0')
const mm = now.getMinutes().toString().padStart(2, '0')
lastUpdateTime.value = `${hh}:${mm}`
}
function formatInt(n: number): string {
const v = isFinite(n) ? Math.round(n) : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toString()
}
function formatMoney(n: number): string {
const v = isFinite(n) ? n : 0
if (v >= 10000) return (v / 10000).toFixed(1) + '万'
return v.toFixed(0)
}
function formatPct(n: number): string {
const v = isFinite(n) ? n : 0
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(1)}%`
}
function handleMenu() {
showSidebarMenu.value = true
}
function handleSidebarUpdate(visible: boolean) {
showSidebarMenu.value = visible
}
function toggleMoreMenu() {
showMoreMenu.value = !showMoreMenu.value
}
function closeMoreMenu() {
showMoreMenu.value = false
}
function handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
}
function handleNotification() {
uni.showToast({ title: '通知', icon: 'none' })
}
function handleFullscreen() {
uni.showToast({ title: '全屏', icon: 'none' })
}
function handleMobile() {
uni.showToast({ title: '移动端', icon: 'none' })
}
function handleDropdown() {
uni.showToast({ title: '下拉菜单', icon: 'none' })
}
function handleSettings() {
uni.showToast({ title: '设置', icon: 'none' })
}
</script>
<style>

View File

@@ -1,223 +0,0 @@
-- =============================================
-- 优惠券业务基础表(最小集,用于优惠券效果分析)
-- 说明:
-- - 基于项目规格文档 pages/mall/mall.md 第4节“优惠券系统”抽象而来。
-- - 仅创建 Analytics 所需的最小字段集合,兼容后续完整业务表替换。
-- - 可安全重复执行IF NOT EXISTS + 列存在性检查)。
-- - 依赖merchants、orders 表已存在(由 01_create_tables.sql 提供)。
-- =============================================
-- 1) 优惠券模板表coupon_templates
CREATE TABLE IF NOT EXISTS public.coupon_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
description text,
coupon_type integer NOT NULL, -- 1..8:满减/折扣/免运费/新人/会员/品类/商家/限时(见 mall.md
discount_type integer NOT NULL, -- 满减 / 折扣 / 免运费 等类型枚举
discount_value numeric(10,2) NOT NULL, -- 金额或折扣值
min_order_amount numeric(10,2) DEFAULT 0,
max_discount_amount numeric(10,2),
total_quantity integer,
per_user_limit integer DEFAULT 1,
usage_limit integer DEFAULT 1,
merchant_id uuid,
category_ids jsonb,
product_ids jsonb,
user_type_limit integer,
start_time timestamptz NOT NULL,
end_time timestamptz NOT NULL,
status integer DEFAULT 1,
created_at timestamptz DEFAULT now()
);
DO $$
BEGIN
-- 若缺少 merchant_id 外键且 merchants 表存在,则补充外键
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'merchants'
) THEN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'coupon_templates'
AND c.conname = 'coupon_templates_merchant_id_fkey'
) THEN
ALTER TABLE public.coupon_templates
ADD CONSTRAINT coupon_templates_merchant_id_fkey
FOREIGN KEY (merchant_id) REFERENCES public.merchants(id) ON DELETE SET NULL;
END IF;
END IF;
END;
$$;
COMMENT ON TABLE public.coupon_templates IS '优惠券模板表(业务权威表,来自 mall.md';
COMMENT ON COLUMN public.coupon_templates.coupon_type IS '券类型1..8(满减/折扣/免运费/新人/会员/品类/商家/限时)';
-- 2) 用户优惠券表user_coupons
CREATE TABLE IF NOT EXISTS public.user_coupons (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid,
template_id uuid REFERENCES public.coupon_templates(id) ON DELETE CASCADE,
coupon_code varchar(50) UNIQUE NOT NULL,
status integer DEFAULT 1, -- 1 未使用2 已使用3 已过期 等(与 mall.md 对齐)
used_at timestamptz,
order_id uuid,
received_at timestamptz DEFAULT now(),
expire_at timestamptz NOT NULL
);
DO $$
BEGIN
-- 若 users 表存在则补 user_id 外键(保证幂等)
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'users'
) THEN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'user_coupons'
AND c.conname = 'user_coupons_user_id_fkey'
) THEN
ALTER TABLE public.user_coupons
ADD CONSTRAINT user_coupons_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
END IF;
END IF;
-- 若 orders 表存在则补 order_id 外键
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'orders'
) THEN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'user_coupons'
AND c.conname = 'user_coupons_order_id_fkey'
) THEN
ALTER TABLE public.user_coupons
ADD CONSTRAINT user_coupons_order_id_fkey
FOREIGN KEY (order_id) REFERENCES public.orders(id) ON DELETE SET NULL;
END IF;
END IF;
-- 分析增强字段:发放渠道 obtain_channel
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'user_coupons' AND column_name = 'obtain_channel'
) THEN
ALTER TABLE public.user_coupons ADD COLUMN obtain_channel text;
END IF;
-- 分析增强字段:冗余 merchant_id便于按商家过滤
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'user_coupons' AND column_name = 'merchant_id'
) THEN
ALTER TABLE public.user_coupons ADD COLUMN merchant_id uuid;
END IF;
END;
$$;
COMMENT ON TABLE public.user_coupons IS '用户优惠券表(领取/使用/到期信息)';
COMMENT ON COLUMN public.user_coupons.obtain_channel IS '发放渠道manual/auto/campaign/invite/cs/points 等';
-- 3) 优惠券使用记录表coupon_usage_logs
CREATE TABLE IF NOT EXISTS public.coupon_usage_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_coupon_id uuid REFERENCES public.user_coupons(id) ON DELETE CASCADE,
order_id uuid NOT NULL,
discount_amount numeric(10,2) NOT NULL,
used_at timestamptz DEFAULT now()
);
DO $$
BEGIN
-- 若 orders 表存在则补 order_id 外键
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'orders'
) THEN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'coupon_usage_logs'
AND c.conname = 'coupon_usage_logs_order_id_fkey'
) THEN
ALTER TABLE public.coupon_usage_logs
ADD CONSTRAINT coupon_usage_logs_order_id_fkey
FOREIGN KEY (order_id) REFERENCES public.orders(id) ON DELETE CASCADE;
END IF;
END IF;
-- 分析增强字段:冗余 user_id / template_id / merchant_id / order_amount / created_at
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'coupon_usage_logs' AND column_name = 'user_id'
) THEN
ALTER TABLE public.coupon_usage_logs ADD COLUMN user_id uuid;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'coupon_usage_logs' AND column_name = 'template_id'
) THEN
ALTER TABLE public.coupon_usage_logs ADD COLUMN template_id uuid;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'coupon_usage_logs' AND column_name = 'merchant_id'
) THEN
ALTER TABLE public.coupon_usage_logs ADD COLUMN merchant_id uuid;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'coupon_usage_logs' AND column_name = 'order_amount'
) THEN
ALTER TABLE public.coupon_usage_logs ADD COLUMN order_amount numeric(10,2);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'coupon_usage_logs' AND column_name = 'created_at'
) THEN
ALTER TABLE public.coupon_usage_logs ADD COLUMN created_at timestamptz DEFAULT now();
END IF;
END;
$$;
COMMENT ON TABLE public.coupon_usage_logs IS '优惠券使用记录表(单次核销流水,用于 GMV / ROI 分析)';
-- 4) 索引(幂等)
CREATE INDEX IF NOT EXISTS idx_coupon_templates_merchant_id ON public.coupon_templates(merchant_id);
CREATE INDEX IF NOT EXISTS idx_user_coupons_user_id ON public.user_coupons(user_id);
CREATE INDEX IF NOT EXISTS idx_user_coupons_template_id ON public.user_coupons(template_id);
CREATE INDEX IF NOT EXISTS idx_user_coupons_status ON public.user_coupons(status);
CREATE INDEX IF NOT EXISTS idx_user_coupons_received_at ON public.user_coupons(received_at);
CREATE INDEX IF NOT EXISTS idx_user_coupons_expire_at ON public.user_coupons(expire_at);
CREATE INDEX IF NOT EXISTS idx_coupon_usage_logs_order_id ON public.coupon_usage_logs(order_id);
CREATE INDEX IF NOT EXISTS idx_coupon_usage_logs_used_at ON public.coupon_usage_logs(used_at);
-- 完成
SELECT 'coupon tables ensured' AS message;

View File

@@ -1,283 +0,0 @@
-- =============================================
-- 配送效率分析 - 配送相关基础表(建表 + 升级,支持重复执行)
-- 说明:
-- - 本脚本用于测试阶段,为配送效率分析页面提供必要的表结构。
-- - 使用 CREATE TABLE IF NOT EXISTS + DO 块判断列/约束是否存在,实现可重复执行。
-- - 外键:
-- - driver_id -> ml_delivery_drivers(id) 必须存在。
-- - user_id -> ak_users(id) 仅在 ak_users 表存在时才会创建外键(避免执行报错)。
-- =============================================
-- 1) 创建基础表(若不存在)
CREATE TABLE IF NOT EXISTS public.ml_delivery_drivers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
real_name TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS public.ml_delivery_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 2) 补齐字段(若不存在)
DO $$
BEGIN
-- =========================
-- ml_delivery_drivers
-- =========================
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_drivers' AND column_name='user_id'
) THEN
ALTER TABLE public.ml_delivery_drivers ADD COLUMN user_id UUID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_drivers' AND column_name='work_status'
) THEN
ALTER TABLE public.ml_delivery_drivers ADD COLUMN work_status INTEGER DEFAULT 1;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_drivers' AND column_name='rating_avg'
) THEN
ALTER TABLE public.ml_delivery_drivers ADD COLUMN rating_avg NUMERIC(3,2) DEFAULT 0;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_drivers' AND column_name='rating_count'
) THEN
ALTER TABLE public.ml_delivery_drivers ADD COLUMN rating_count INTEGER DEFAULT 0;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_drivers' AND column_name='updated_at'
) THEN
ALTER TABLE public.ml_delivery_drivers ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW();
END IF;
-- =========================
-- ml_delivery_tasks
-- =========================
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_tasks' AND column_name='order_id'
) THEN
ALTER TABLE public.ml_delivery_tasks ADD COLUMN order_id UUID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_tasks' AND column_name='driver_id'
) THEN
ALTER TABLE public.ml_delivery_tasks ADD COLUMN driver_id UUID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_tasks' AND column_name='status'
) THEN
ALTER TABLE public.ml_delivery_tasks ADD COLUMN status INTEGER DEFAULT 1;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_tasks' AND column_name='assigned_at'
) THEN
ALTER TABLE public.ml_delivery_tasks ADD COLUMN assigned_at TIMESTAMPTZ;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_tasks' AND column_name='delivered_at'
) THEN
ALTER TABLE public.ml_delivery_tasks ADD COLUMN delivered_at TIMESTAMPTZ;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_tasks' AND column_name='delivery_fee'
) THEN
ALTER TABLE public.ml_delivery_tasks ADD COLUMN delivery_fee NUMERIC(10,2) DEFAULT 0;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='ml_delivery_tasks' AND column_name='updated_at'
) THEN
ALTER TABLE public.ml_delivery_tasks ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW();
END IF;
END;
$$;
-- 3) 添加表/字段中文注释
COMMENT ON TABLE public.ml_delivery_drivers IS '配送员表';
COMMENT ON COLUMN public.ml_delivery_drivers.id IS '配送员ID';
COMMENT ON COLUMN public.ml_delivery_drivers.user_id IS '关联用户ID可选若存在 ak_users 表则可建立外键)';
COMMENT ON COLUMN public.ml_delivery_drivers.real_name IS '真实姓名';
COMMENT ON COLUMN public.ml_delivery_drivers.work_status IS '工作状态1在线 2忙碌 3离线';
COMMENT ON COLUMN public.ml_delivery_drivers.rating_avg IS '平均评分0-5';
COMMENT ON COLUMN public.ml_delivery_drivers.rating_count IS '评分次数';
COMMENT ON COLUMN public.ml_delivery_drivers.created_at IS '创建时间';
COMMENT ON COLUMN public.ml_delivery_drivers.updated_at IS '更新时间';
COMMENT ON TABLE public.ml_delivery_tasks IS '配送任务表(接单->送达)';
COMMENT ON COLUMN public.ml_delivery_tasks.id IS '任务ID';
COMMENT ON COLUMN public.ml_delivery_tasks.order_id IS '订单ID测试阶段可空正式可做 UNIQUE + 外键)';
COMMENT ON COLUMN public.ml_delivery_tasks.driver_id IS '配送员ID';
COMMENT ON COLUMN public.ml_delivery_tasks.status IS '状态1待接 2已接 3取货 4配送中 5已送达 6配送失败';
COMMENT ON COLUMN public.ml_delivery_tasks.assigned_at IS '接单时间';
COMMENT ON COLUMN public.ml_delivery_tasks.delivered_at IS '送达时间';
COMMENT ON COLUMN public.ml_delivery_tasks.delivery_fee IS '配送费';
COMMENT ON COLUMN public.ml_delivery_tasks.created_at IS '创建时间';
COMMENT ON COLUMN public.ml_delivery_tasks.updated_at IS '更新时间';
-- 4) 添加约束(幂等)
DO $$
BEGIN
-- drivers: real_name 唯一
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.contype='u' AND n.nspname='public' AND t.relname='ml_delivery_drivers' AND c.conname='ml_delivery_drivers_real_name_key'
) THEN
ALTER TABLE public.ml_delivery_drivers
ADD CONSTRAINT ml_delivery_drivers_real_name_key UNIQUE (real_name);
END IF;
-- drivers: work_status 枚举
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.contype='c' AND n.nspname='public' AND t.relname='ml_delivery_drivers' AND c.conname='chk_ml_delivery_drivers_work_status'
) THEN
ALTER TABLE public.ml_delivery_drivers
ADD CONSTRAINT chk_ml_delivery_drivers_work_status CHECK (work_status IN (1,2,3));
END IF;
-- drivers: rating_avg 范围
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.contype='c' AND n.nspname='public' AND t.relname='ml_delivery_drivers' AND c.conname='chk_ml_delivery_drivers_rating_avg'
) THEN
ALTER TABLE public.ml_delivery_drivers
ADD CONSTRAINT chk_ml_delivery_drivers_rating_avg CHECK (rating_avg >= 0 AND rating_avg <= 5);
END IF;
-- drivers: rating_count 非负
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.contype='c' AND n.nspname='public' AND t.relname='ml_delivery_drivers' AND c.conname='chk_ml_delivery_drivers_rating_count'
) THEN
ALTER TABLE public.ml_delivery_drivers
ADD CONSTRAINT chk_ml_delivery_drivers_rating_count CHECK (rating_count >= 0);
END IF;
-- tasks: status 枚举
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.contype='c' AND n.nspname='public' AND t.relname='ml_delivery_tasks' AND c.conname='chk_ml_delivery_tasks_status'
) THEN
ALTER TABLE public.ml_delivery_tasks
ADD CONSTRAINT chk_ml_delivery_tasks_status CHECK (status IN (1,2,3,4,5,6));
END IF;
-- tasks: delivery_fee 非负
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.contype='c' AND n.nspname='public' AND t.relname='ml_delivery_tasks' AND c.conname='chk_ml_delivery_tasks_delivery_fee'
) THEN
ALTER TABLE public.ml_delivery_tasks
ADD CONSTRAINT chk_ml_delivery_tasks_delivery_fee CHECK (delivery_fee >= 0);
END IF;
-- tasks: 时间逻辑 delivered_at >= assigned_at
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.contype='c' AND n.nspname='public' AND t.relname='ml_delivery_tasks' AND c.conname='chk_ml_delivery_tasks_time_logic'
) THEN
ALTER TABLE public.ml_delivery_tasks
ADD CONSTRAINT chk_ml_delivery_tasks_time_logic CHECK (
delivered_at IS NULL OR assigned_at IS NULL OR delivered_at >= assigned_at
);
END IF;
-- tasks: driver_id 外键
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.contype='f' AND n.nspname='public' AND t.relname='ml_delivery_tasks' AND c.conname='ml_delivery_tasks_driver_id_fkey'
) THEN
ALTER TABLE public.ml_delivery_tasks
ADD CONSTRAINT ml_delivery_tasks_driver_id_fkey FOREIGN KEY (driver_id)
REFERENCES public.ml_delivery_drivers(id) ON DELETE SET NULL;
END IF;
-- drivers: user_id 外键(仅当 ak_users 表存在时才创建)
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema='public' AND table_name='ak_users'
) THEN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.contype='f' AND n.nspname='public' AND t.relname='ml_delivery_drivers' AND c.conname='ml_delivery_drivers_user_id_fkey'
) THEN
ALTER TABLE public.ml_delivery_drivers
ADD CONSTRAINT ml_delivery_drivers_user_id_fkey FOREIGN KEY (user_id)
REFERENCES public.ak_users(id) ON DELETE SET NULL;
END IF;
END IF;
-- drivers: user_id 唯一(如果你不用 user_id可以后续删除该约束
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.contype='u' AND n.nspname='public' AND t.relname='ml_delivery_drivers' AND c.conname='ml_delivery_drivers_user_id_key'
) THEN
ALTER TABLE public.ml_delivery_drivers
ADD CONSTRAINT ml_delivery_drivers_user_id_key UNIQUE (user_id);
END IF;
-- tasks: order_id 唯一(测试阶段允许 NULLNULL 不冲突)
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.contype='u' AND n.nspname='public' AND t.relname='ml_delivery_tasks' AND c.conname='ml_delivery_tasks_order_id_key'
) THEN
ALTER TABLE public.ml_delivery_tasks
ADD CONSTRAINT ml_delivery_tasks_order_id_key UNIQUE (order_id);
END IF;
END;
$$;
-- 5) 索引(幂等)
CREATE INDEX IF NOT EXISTS idx_ml_delivery_drivers_work_status ON public.ml_delivery_drivers(work_status);
CREATE INDEX IF NOT EXISTS idx_ml_delivery_tasks_status ON public.ml_delivery_tasks(status);
CREATE INDEX IF NOT EXISTS idx_ml_delivery_tasks_assigned_at ON public.ml_delivery_tasks(assigned_at);
CREATE INDEX IF NOT EXISTS idx_ml_delivery_tasks_driver_id ON public.ml_delivery_tasks(driver_id);
-- 完成
SELECT 'delivery tables ensured' AS message;

View File

@@ -1,303 +0,0 @@
-- ============================================
-- 数据分析实时大屏 - 基础业务表结构(创建版 / Create-only
-- ============================================
-- 用途创建业务核心表orders, users, user_sessions, products, merchants 等)
-- 特点:
-- 1. 不做 DROP/DELETE/TRUNCATE不清空数据
-- 2. 通过 IF NOT EXISTS + 系统表判断,实现可重复执行
-- 3. 与 analytics_* 表ANALYTICS_DB_SCHEMA.sql配套使用
-- ============================================
-- ============================================
-- 1. 表结构创建
-- ============================================
-- 1.1 商家表
CREATE TABLE IF NOT EXISTS merchants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
shop_name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE public.merchants IS '商家表';
COMMENT ON COLUMN public.merchants.id IS '商家ID';
COMMENT ON COLUMN public.merchants.shop_name IS '店铺名称';
COMMENT ON COLUMN public.merchants.created_at IS '创建时间';
-- 1.2 商品表
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
merchant_id UUID REFERENCES merchants(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
sales INTEGER DEFAULT 0,
status INTEGER DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE public.products IS '商品表';
COMMENT ON COLUMN public.products.id IS '商品ID';
COMMENT ON COLUMN public.products.merchant_id IS '所属商家ID';
COMMENT ON COLUMN public.products.name IS '商品名称';
COMMENT ON COLUMN public.products.price IS '价格';
COMMENT ON COLUMN public.products.sales IS '销量';
COMMENT ON COLUMN public.products.status IS '状态1:上架, 0:下架)';
COMMENT ON COLUMN public.products.created_at IS '创建时间';
-- 1.3 用户(统计兼容)表
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
phone VARCHAR(20) UNIQUE,
email VARCHAR(255),
nickname VARCHAR(100),
last_login_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE public.users IS '用户(统计兼容表,不作为权威用户表)';
COMMENT ON COLUMN public.users.id IS '用户ID建议与 auth.users.id/ak_users.id 对齐)';
COMMENT ON COLUMN public.users.phone IS '手机号(可选)';
COMMENT ON COLUMN public.users.email IS '邮箱(可选)';
COMMENT ON COLUMN public.users.nickname IS '昵称';
COMMENT ON COLUMN public.users.last_login_at IS '最后登录时间';
COMMENT ON COLUMN public.users.created_at IS '创建时间';
COMMENT ON COLUMN public.users.updated_at IS '更新时间';
-- 1.4 订单表
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
merchant_id UUID REFERENCES merchants(id) ON DELETE SET NULL,
total_amount DECIMAL(10, 2) NOT NULL DEFAULT 0.00,
status INTEGER NOT NULL DEFAULT 0,
payment_method VARCHAR(50),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE public.orders IS '订单表';
COMMENT ON COLUMN public.orders.id IS '订单ID';
COMMENT ON COLUMN public.orders.user_id IS '用户ID';
COMMENT ON COLUMN public.orders.merchant_id IS '商家ID';
COMMENT ON COLUMN public.orders.total_amount IS '订单总金额';
COMMENT ON COLUMN public.orders.status IS '订单状态0:待支付, 1:已支付, 2:已完成, 3:已取消)';
COMMENT ON COLUMN public.orders.payment_method IS '支付方式';
COMMENT ON COLUMN public.orders.created_at IS '创建时间';
COMMENT ON COLUMN public.orders.updated_at IS '更新时间';
-- 1.5 订单商品关联表
CREATE TABLE IF NOT EXISTS order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
quantity INTEGER NOT NULL DEFAULT 1,
price DECIMAL(10, 2) NOT NULL,
total_amount DECIMAL(10, 2) NOT NULL DEFAULT 0.00,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE public.order_items IS '订单商品关联表';
COMMENT ON COLUMN public.order_items.id IS '主键';
COMMENT ON COLUMN public.order_items.order_id IS '订单ID';
COMMENT ON COLUMN public.order_items.product_id IS '商品ID';
COMMENT ON COLUMN public.order_items.quantity IS '数量';
COMMENT ON COLUMN public.order_items.price IS '单价';
COMMENT ON COLUMN public.order_items.total_amount IS '总价';
COMMENT ON COLUMN public.order_items.created_at IS '创建时间';
-- 1.6 用户会话表
CREATE TABLE IF NOT EXISTS user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_token VARCHAR(255) UNIQUE,
last_active_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_active BOOLEAN DEFAULT true,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE public.user_sessions IS '用户会话表(用于在线用户统计)';
COMMENT ON COLUMN public.user_sessions.id IS '会话ID';
COMMENT ON COLUMN public.user_sessions.user_id IS '用户ID';
COMMENT ON COLUMN public.user_sessions.session_token IS '会话Token';
COMMENT ON COLUMN public.user_sessions.last_active_at IS '最后活跃时间';
COMMENT ON COLUMN public.user_sessions.is_active IS '是否活跃';
COMMENT ON COLUMN public.user_sessions.ip_address IS 'IP地址';
COMMENT ON COLUMN public.user_sessions.user_agent IS '用户代理';
COMMENT ON COLUMN public.user_sessions.created_at IS '创建时间';
COMMENT ON COLUMN public.user_sessions.updated_at IS '更新时间';
-- 1.7 访问日志表
CREATE TABLE IF NOT EXISTS page_views (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
path VARCHAR(255),
source VARCHAR(50) DEFAULT 'direct',
referrer VARCHAR(255),
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
COMMENT ON TABLE public.page_views IS '访问日志表(用于转化率分析)';
COMMENT ON COLUMN public.page_views.id IS '主键';
COMMENT ON COLUMN public.page_views.user_id IS '用户ID可空表示匿名访问';
COMMENT ON COLUMN public.page_views.path IS '访问路径';
COMMENT ON COLUMN public.page_views.source IS '流量来源direct/search/social/ad';
COMMENT ON COLUMN public.page_views.referrer IS '来源页面';
COMMENT ON COLUMN public.page_views.ip_address IS 'IP地址';
COMMENT ON COLUMN public.page_views.user_agent IS '用户代理';
COMMENT ON COLUMN public.page_views.created_at IS '创建时间';
-- ============================================
-- 2. 索引创建
-- ============================================
-- orders
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
CREATE INDEX IF NOT EXISTS idx_orders_created_at_status ON orders(created_at, status);
-- user_sessions
CREATE INDEX IF NOT EXISTS idx_user_sessions_last_active_at ON user_sessions(last_active_at);
CREATE INDEX IF NOT EXISTS idx_user_sessions_is_active ON user_sessions(is_active);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_created_at ON user_sessions(created_at);
-- users
CREATE INDEX IF NOT EXISTS idx_users_last_login_at ON users(last_login_at);
-- order_items
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id);
-- page_views
CREATE INDEX IF NOT EXISTS idx_page_views_user_id ON page_views(user_id);
CREATE INDEX IF NOT EXISTS idx_page_views_created_at ON page_views(created_at);
CREATE INDEX IF NOT EXISTS idx_page_views_source ON page_views(source);
-- ============================================
-- 3. 触发器函数和触发器
-- ============================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 为需要 updated_at 的表添加触发器
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_orders_updated_at') THEN
EXECUTE 'CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON public.orders FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column()';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_user_sessions_updated_at') THEN
EXECUTE 'CREATE TRIGGER update_user_sessions_updated_at BEFORE UPDATE ON public.user_sessions FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column()';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_users_updated_at') THEN
EXECUTE 'CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON public.users FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column()';
END IF;
END $$;
-- ============================================
-- 4. 行级安全策略RLS
-- ============================================
-- 启用 RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE merchants ENABLE ROW LEVEL SECURITY;
ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE page_views ENABLE ROW LEVEL SECURITY;
-- orders: 用户只能查看和管理自己的订单
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='orders' AND policyname='Users can view own orders') THEN
EXECUTE 'CREATE POLICY "Users can view own orders" ON public.orders FOR SELECT USING (auth.uid() = user_id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='orders' AND policyname='Users can insert own orders') THEN
EXECUTE 'CREATE POLICY "Users can insert own orders" ON public.orders FOR INSERT WITH CHECK (auth.uid() = user_id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='orders' AND policyname='Users can update own orders') THEN
EXECUTE 'CREATE POLICY "Users can update own orders" ON public.orders FOR UPDATE USING (auth.uid() = user_id)';
END IF;
END $$;
-- order_items: 用户只能查看自己订单的商品
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='order_items' AND policyname='Users can view own order items') THEN
EXECUTE 'CREATE POLICY "Users can view own order items" ON public.order_items FOR SELECT USING (EXISTS (SELECT 1 FROM public.orders WHERE public.orders.id = public.order_items.order_id AND public.orders.user_id = auth.uid()))';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='order_items' AND policyname='Users can insert own order items') THEN
EXECUTE 'CREATE POLICY "Users can insert own order items" ON public.order_items FOR INSERT WITH CHECK (EXISTS (SELECT 1 FROM public.orders WHERE public.orders.id = public.order_items.order_id AND public.orders.user_id = auth.uid()))';
END IF;
END $$;
-- user_sessions: 用户只能查看和管理自己的会话
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='user_sessions' AND policyname='Users can view own sessions') THEN
EXECUTE 'CREATE POLICY "Users can view own sessions" ON public.user_sessions FOR SELECT USING (auth.uid() = user_id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='user_sessions' AND policyname='Users can insert own sessions') THEN
EXECUTE 'CREATE POLICY "Users can insert own sessions" ON public.user_sessions FOR INSERT WITH CHECK (auth.uid() = user_id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='user_sessions' AND policyname='Users can update own sessions') THEN
EXECUTE 'CREATE POLICY "Users can update own sessions" ON public.user_sessions FOR UPDATE USING (auth.uid() = user_id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='user_sessions' AND policyname='Users can delete own sessions') THEN
EXECUTE 'CREATE POLICY "Users can delete own sessions" ON public.user_sessions FOR DELETE USING (auth.uid() = user_id)';
END IF;
END $$;
-- users: 用户只能查看和管理自己的记录
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='users' AND policyname='Users can view own profile') THEN
EXECUTE 'CREATE POLICY "Users can view own profile" ON public.users FOR SELECT USING (auth.uid() = id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='users' AND policyname='Users can insert own profile') THEN
EXECUTE 'CREATE POLICY "Users can insert own profile" ON public.users FOR INSERT WITH CHECK (auth.uid() = id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='users' AND policyname='Users can update own profile') THEN
EXECUTE 'CREATE POLICY "Users can update own profile" ON public.users FOR UPDATE USING (auth.uid() = id)';
END IF;
END $$;
-- products: 任何人可读,认证用户可管理(简化策略)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='products' AND policyname='Anyone can view products') THEN
EXECUTE 'CREATE POLICY "Anyone can view products" ON public.products FOR SELECT USING (true)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='products' AND policyname='Authenticated can manage products') THEN
EXECUTE 'CREATE POLICY "Authenticated can manage products" ON public.products FOR ALL USING (auth.role() = ''authenticated'')';
END IF;
END $$;
-- merchants: 任何人可读,认证用户可管理(简化策略)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='merchants' AND policyname='Anyone can view merchants') THEN
EXECUTE 'CREATE POLICY "Anyone can view merchants" ON public.merchants FOR SELECT USING (true)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='merchants' AND policyname='Authenticated can manage merchants') THEN
EXECUTE 'CREATE POLICY "Authenticated can manage merchants" ON public.merchants FOR ALL USING (auth.role() = ''authenticated'')';
END IF;
END $$;
-- page_views: 任何人可插入,用户只能读自己的记录
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='page_views' AND policyname='Anyone can insert page views') THEN
EXECUTE 'CREATE POLICY "Anyone can insert page views" ON public.page_views FOR INSERT WITH CHECK (true)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='page_views' AND policyname='Users can view own page views') THEN
EXECUTE 'CREATE POLICY "Users can view own page views" ON public.page_views FOR SELECT USING (auth.uid() = user_id)';
END IF;
END $$;

View File

@@ -0,0 +1,206 @@
-- =====================================================================================
-- 数据分析模块正式RPC主库 ml_* 口径)
-- 文件: 01_ml_analytics_rpcs_user.sql
-- 主题: 用户分析KPI/趋势/分群/渠道)
-- 依赖: public.ak_users, public.ml_orders, public.ml_browse_history
-- =====================================================================================
-- 1) 用户分析核心 KPI含上期对比
CREATE OR REPLACE FUNCTION public.rpc_analytics_user_kpis(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
total_users BIGINT,
user_growth FLOAT,
new_users BIGINT,
new_user_growth FLOAT,
active_users BIGINT,
active_growth FLOAT,
ordering_users BIGINT,
ordering_growth FLOAT,
paid_users BIGINT,
paid_growth FLOAT,
new_user_conversion_rate FLOAT,
repurchase_rate FLOAT,
repurchase_growth FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
prev_start_date DATE;
prev_end_date DATE;
period_days INT;
BEGIN
period_days := p_end_date - p_start_date + 1;
prev_start_date := p_start_date - period_days;
prev_end_date := p_start_date - 1;
RETURN QUERY
WITH
current_period AS (
SELECT
(SELECT COUNT(*) FROM public.ak_users WHERE created_at <= p_end_date::timestamp) AS total_users,
COUNT(DISTINCT CASE WHEN u.created_at::DATE BETWEEN p_start_date AND p_end_date THEN u.id END)::BIGINT AS new_users,
(SELECT COUNT(DISTINCT bh.user_id) FROM public.ml_browse_history bh WHERE bh.created_at::DATE BETWEEN p_start_date AND p_end_date) AS active_users,
(SELECT COUNT(DISTINCT o.user_id) FROM public.ml_orders o WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date) AS ordering_users,
(SELECT COUNT(DISTINCT o.user_id) FROM public.ml_orders o WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date AND o.payment_status = 2) AS paid_users,
(
SELECT COUNT(DISTINCT o.user_id) * 100.0 / NULLIF(COUNT(DISTINCT u_new.id), 0)
FROM public.ak_users u_new
LEFT JOIN public.ml_orders o
ON u_new.id = o.user_id
AND o.payment_status = 2
AND o.created_at::DATE BETWEEN p_start_date AND p_end_date
WHERE u_new.created_at::DATE BETWEEN p_start_date AND p_end_date
) AS new_user_conversion_rate,
(
SELECT
COUNT(DISTINCT CASE WHEN user_orders.order_count > 1 THEN user_orders.user_id END) * 100.0
/ NULLIF(COUNT(DISTINCT user_orders.user_id), 0)
FROM (
SELECT user_id, COUNT(id) as order_count
FROM public.ml_orders
WHERE created_at::DATE <= p_end_date AND payment_status = 2
GROUP BY user_id
) user_orders
WHERE user_orders.user_id IN (
SELECT user_id
FROM public.ml_orders
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
AND payment_status = 2
)
) AS repurchase_rate
FROM public.ak_users u
),
previous_period AS (
SELECT
(SELECT COUNT(*) FROM public.ak_users WHERE created_at <= prev_end_date::timestamp) AS total_users,
COUNT(DISTINCT CASE WHEN u.created_at::DATE BETWEEN prev_start_date AND prev_end_date THEN u.id END)::BIGINT AS new_users,
(SELECT COUNT(DISTINCT bh.user_id) FROM public.ml_browse_history bh WHERE bh.created_at::DATE BETWEEN prev_start_date AND prev_end_date) AS active_users,
(SELECT COUNT(DISTINCT o.user_id) FROM public.ml_orders o WHERE o.created_at::DATE BETWEEN prev_start_date AND prev_end_date) AS ordering_users,
(SELECT COUNT(DISTINCT o.user_id) FROM public.ml_orders o WHERE o.created_at::DATE BETWEEN prev_start_date AND prev_end_date AND o.payment_status = 2) AS paid_users,
(
SELECT
COUNT(DISTINCT CASE WHEN user_orders.order_count > 1 THEN user_orders.user_id END) * 100.0
/ NULLIF(COUNT(DISTINCT user_orders.user_id), 0)
FROM (
SELECT user_id, COUNT(id) as order_count
FROM public.ml_orders
WHERE created_at::DATE <= prev_end_date AND payment_status = 2
GROUP BY user_id
) user_orders
WHERE user_orders.user_id IN (
SELECT user_id
FROM public.ml_orders
WHERE created_at::DATE BETWEEN prev_start_date AND prev_end_date
AND payment_status = 2
)
) AS repurchase_rate
FROM public.ak_users u
)
SELECT
cp.total_users,
ROUND(((cp.total_users - pp.total_users) * 100.0 / NULLIF(pp.total_users, 0))::numeric, 2)::FLOAT AS user_growth,
cp.new_users,
ROUND(((cp.new_users - pp.new_users) * 100.0 / NULLIF(pp.new_users, 0))::numeric, 2)::FLOAT AS new_user_growth,
cp.active_users,
ROUND(((cp.active_users - pp.active_users) * 100.0 / NULLIF(pp.active_users, 0))::numeric, 2)::FLOAT AS active_growth,
cp.ordering_users,
ROUND(((cp.ordering_users - pp.ordering_users) * 100.0 / NULLIF(pp.ordering_users, 0))::numeric, 2)::FLOAT AS ordering_growth,
cp.paid_users,
ROUND(((cp.paid_users - pp.paid_users) * 100.0 / NULLIF(pp.paid_users, 0))::numeric, 2)::FLOAT AS paid_growth,
ROUND(cp.new_user_conversion_rate::numeric, 2)::FLOAT,
ROUND(cp.repurchase_rate::numeric, 2)::FLOAT AS repurchase_rate,
ROUND((COALESCE(cp.repurchase_rate, 0) - COALESCE(pp.repurchase_rate, 0))::numeric, 2)::FLOAT AS repurchase_growth
FROM current_period cp, previous_period pp;
END;
$$;
-- 2) 用户增长与活跃趋势(日维度)
CREATE OR REPLACE FUNCTION public.rpc_analytics_user_growth_trend(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
date DATE,
new_users BIGINT,
active_users BIGINT
)
LANGUAGE sql
AS $$
WITH date_series AS (
SELECT generate_series(p_start_date, p_end_date, '1 day'::interval)::DATE AS date
)
SELECT
ds.date,
(SELECT COUNT(u.id) FROM public.ak_users u WHERE u.created_at::DATE = ds.date)::BIGINT AS new_users,
(SELECT COUNT(DISTINCT bh.user_id) FROM public.ml_browse_history bh WHERE bh.created_at::DATE = ds.date)::BIGINT AS active_users
FROM date_series ds
ORDER BY ds.date;
$$;
-- 3) 用户分群(简版:新客/复购/老客;以支付订单为准)
CREATE OR REPLACE FUNCTION public.rpc_analytics_user_segments(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
name TEXT,
value BIGINT
)
LANGUAGE sql
AS $$
WITH user_orders_stats AS (
SELECT
user_id,
MIN(created_at) as first_order_time,
COUNT(id) as total_orders
FROM public.ml_orders
WHERE payment_status = 2
GROUP BY user_id
),
users_in_period AS (
SELECT DISTINCT user_id
FROM public.ml_orders
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
)
SELECT
segment as name,
COUNT(user_id) as value
FROM (
SELECT
uip.user_id,
CASE
WHEN uos.first_order_time::DATE BETWEEN p_start_date AND p_end_date THEN '新客'
WHEN uos.total_orders > 1 THEN '复购客户'
ELSE '老客'
END as segment
FROM users_in_period uip
JOIN user_orders_stats uos ON uip.user_id = uos.user_id
) segments
GROUP BY segment;
$$;
-- 4) 渠道来源(按注册来源,统计周期内新增用户来源)
CREATE OR REPLACE FUNCTION public.rpc_analytics_traffic_sources(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
name TEXT,
value BIGINT
)
LANGUAGE sql
AS $$
SELECT
COALESCE(registration_source, '未知') AS name,
COUNT(id)::BIGINT AS value
FROM public.ak_users
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
GROUP BY name
ORDER BY value DESC;
$$;

View File

@@ -1,182 +0,0 @@
-- =============================================
-- 优惠券分析测试数据 Seed至少 20 条样本)
-- 说明:
-- - 依赖表coupon_templates, user_coupons, coupon_usage_logs, users, merchants, orders
-- - 用于驱动 /pages/mall/analytics/coupon-analysis.uvue 页面联调
-- - 设计目标:
-- - 8 种券类型覆盖(满减/折扣/免运费/新人/会员/品类/商家/限时)
-- - 6 种发放渠道覆盖manual/auto/campaign/invite/cs/points
-- - 最近 30 天有发放 + 使用 + 未使用 + 即将到期券
-- - 可重复执行:通过 WHERE NOT EXISTS / 固定模板名称 避免重复插入
-- =============================================
-- 1) 准备一个测试商家(如不存在则创建)
INSERT INTO public.merchants (id, shop_name, created_at)
SELECT
'30000000-0000-0000-0000-000000000001'::uuid,
'优惠券测试商家',
now() - interval '60 days'
WHERE NOT EXISTS (
SELECT 1 FROM public.merchants WHERE id = '30000000-0000-0000-0000-000000000001'
);
-- 2) 准备 3 个测试用户(如不存在则创建)
INSERT INTO public.users (id, phone, email, nickname, created_at)
VALUES
('40000000-0000-0000-0000-000000000001'::uuid, '13900001001', 'coupon_user1@test.com', '券用户1', now() - interval '30 days'),
('40000000-0000-0000-0000-000000000002'::uuid, '13900001002', 'coupon_user2@test.com', '券用户2', now() - interval '20 days'),
('40000000-0000-0000-0000-000000000003'::uuid, '13900001003', 'coupon_user3@test.com', '券用户3', now() - interval '10 days')
ON CONFLICT (id) DO NOTHING;
-- 3) 插入 8 个优惠券模板(覆盖 mall.md 的 8 种类型)
INSERT INTO public.coupon_templates (
id, name, description, coupon_type, discount_type, discount_value,
min_order_amount, max_discount_amount, total_quantity, per_user_limit,
usage_limit, merchant_id, start_time, end_time, status, created_at
)
VALUES
('50000000-0000-0000-0000-000000000001'::uuid, '满100减20', '满100减20元', 1, 1, 20.00, 100.00, NULL, 1000, 3, 1,
'30000000-0000-0000-0000-000000000001', now() - interval '30 days', now() + interval '30 days', 1, now() - interval '30 days'),
('50000000-0000-0000-0000-000000000002'::uuid, '9折优惠券', '全场9折', 2, 2, 9.00, 0.00, 50.00, 1000, 3, 1,
'30000000-0000-0000-0000-000000000001', now() - interval '30 days', now() + interval '30 days', 1, now() - interval '30 days'),
('50000000-0000-0000-0000-000000000003'::uuid, '免运费券', '免配送费', 3, 3, 0.00, 0.00, 10.00, 1000, 5, 1,
'30000000-0000-0000-0000-000000000001', now() - interval '30 days', now() + interval '30 days', 1, now() - interval '30 days'),
('50000000-0000-0000-0000-000000000004'::uuid, '新人专享满50减25', '新用户首单优惠', 4, 1, 25.00, 50.00, NULL, 500, 1, 1,
'30000000-0000-0000-0000-000000000001', now() - interval '15 days', now() + interval '45 days', 1, now() - interval '15 days'),
('50000000-0000-0000-0000-000000000005'::uuid, '会员折扣券', '会员专享8.5折', 5, 2, 8.50, 0.00, 80.00, 500, 5, 1,
'30000000-0000-0000-0000-000000000001', now() - interval '20 days', now() + interval '40 days', 1, now() - interval '20 days'),
('50000000-0000-0000-0000-000000000006'::uuid, '生鲜品类券', '生鲜类满60减15', 6, 1, 15.00, 60.00, NULL, 500, 3, 1,
'30000000-0000-0000-0000-000000000001', now() - interval '25 days', now() + interval '35 days', 1, now() - interval '25 days'),
('50000000-0000-0000-0000-000000000007'::uuid, '商家专属券', '指定商家满80减30', 7, 1, 30.00, 80.00, NULL, 300, 3, 1,
'30000000-0000-0000-0000-000000000001', now() - interval '10 days', now() + interval '50 days', 1, now() - interval '10 days'),
('50000000-0000-0000-0000-000000000008'::uuid, '限时秒杀券', '限时满40减10', 8, 1, 10.00, 40.00, NULL, 300, 2, 1,
'30000000-0000-0000-0000-000000000001', now() - interval '5 days', now() + interval '10 days', 1, now() - interval '5 days')
ON CONFLICT (id) DO NOTHING;
-- 4) 生成用户优惠券 + 使用数据
DO $$
DECLARE
v_users uuid[] := ARRAY[
'40000000-0000-0000-0000-000000000001'::uuid,
'40000000-0000-0000-0000-000000000002'::uuid,
'40000000-0000-0000-0000-000000000003'::uuid
];
v_templates uuid[] := ARRAY[
'50000000-0000-0000-0000-000000000001'::uuid,
'50000000-0000-0000-0000-000000000002'::uuid,
'50000000-0000-0000-0000-000000000003'::uuid,
'50000000-0000-0000-0000-000000000004'::uuid,
'50000000-0000-0000-0000-000000000005'::uuid,
'50000000-0000-0000-0000-000000000006'::uuid,
'50000000-0000-0000-0000-000000000007'::uuid,
'50000000-0000-0000-0000-000000000008'::uuid
];
v_channels text[] := ARRAY['manual','auto','campaign','invite','cs','points'];
i int;
j int;
v_uc_id uuid;
v_user uuid;
v_tpl uuid;
v_channel text;
v_received_at timestamptz;
v_expire_at timestamptz;
v_order_id uuid;
v_order_amount numeric;
v_discount_amount numeric;
v_used_at timestamptz;
BEGIN
-- 为最近 30 天生成多条券(部分使用,部分未使用,部分即将到期)
FOR i IN 0..29 LOOP
-- 当天发放 2~4 条券
FOR j IN 1..(2 + (i % 3)) LOOP
v_user := v_users[1 + (i + j) % array_length(v_users, 1)];
v_tpl := v_templates[1 + (i + j) % array_length(v_templates, 1)];
v_channel := v_channels[1 + (i + j) % array_length(v_channels, 1)];
v_received_at := (now() - (i || ' days')::interval);
v_expire_at := v_received_at + interval '20 days';
-- 通过 coupon_code 幂等插入,并返回最终 id避免 ON CONFLICT DO NOTHING 导致 v_uc_id 无对应记录)
INSERT INTO public.user_coupons (
id, user_id, template_id, coupon_code, status,
received_at, expire_at, obtain_channel, merchant_id
) VALUES (
gen_random_uuid(),
v_user,
v_tpl,
'TESTCOUPON-' || i || '-' || j,
1, -- 初始未使用
v_received_at,
v_expire_at,
v_channel,
'30000000-0000-0000-0000-000000000001'::uuid
)
ON CONFLICT (coupon_code) DO UPDATE
SET user_id = EXCLUDED.user_id,
template_id = EXCLUDED.template_id,
received_at = EXCLUDED.received_at,
expire_at = EXCLUDED.expire_at,
obtain_channel= EXCLUDED.obtain_channel,
merchant_id = EXCLUDED.merchant_id
RETURNING id INTO v_uc_id;
-- 约一半的券会在 0~10 天内被使用,生成对应订单和使用记录
IF (i + j) % 2 = 0 THEN
v_order_id := gen_random_uuid();
v_order_amount := 50 + (10 * ((i + j) % 5)); -- 50~90 的订单金额
v_discount_amount := CASE
WHEN (i + j) % 4 = 0 THEN 10
WHEN (i + j) % 4 = 1 THEN 15
WHEN (i + j) % 4 = 2 THEN 20
ELSE 5
END;
v_used_at := v_received_at + ( (1 + (i % 5)) || ' days')::interval;
-- 插入订单
INSERT INTO public.orders (
id, user_id, merchant_id, total_amount, status, created_at, updated_at
) VALUES (
v_order_id,
v_user,
'30000000-0000-0000-0000-000000000001'::uuid,
v_order_amount,
2, -- 已完成
v_used_at,
v_used_at
)
ON CONFLICT (id) DO NOTHING;
-- 更新 user_coupons 为已使用(确认对应记录存在)
UPDATE public.user_coupons
SET status = 2,
used_at = v_used_at,
order_id = v_order_id
WHERE id = v_uc_id;
-- 插入使用流水(仅当对应 user_coupon 存在时生效)
INSERT INTO public.coupon_usage_logs (
id, user_coupon_id, order_id, discount_amount, used_at,
user_id, template_id, merchant_id, order_amount, created_at
) VALUES (
gen_random_uuid(),
v_uc_id,
v_order_id,
v_discount_amount,
v_used_at,
v_user,
v_tpl,
'30000000-0000-0000-0000-000000000001'::uuid,
v_order_amount,
v_used_at
);
END IF;
END LOOP;
END LOOP;
END;
$$;
-- 完成
SELECT 'coupon test data seed completed!' AS message;

View File

@@ -1,72 +0,0 @@
-- =============================================
-- 配送效率分析 - 测试数据插入脚本
-- 说明:
-- - 用于生成配送员 + 近 30 天配送任务(已送达),便于配送效率分析页面展示趋势与排行。
-- - 可重复执行:配送员按 real_name 唯一键去重;任务默认会持续累加。
-- - 如需每次重置数据,请取消 TRUNCATE 注释。
-- =============================================
-- For a clean test, you can uncomment these lines to clear old data.
-- TRUNCATE public.ml_delivery_tasks, public.ml_delivery_drivers RESTART IDENTITY;
-- 1) Insert a pool of drivers.
-- ON CONFLICT ensures this is safe to run multiple times.
INSERT INTO public.ml_delivery_drivers (real_name, work_status, rating_avg, rating_count)
VALUES
('张配送', 1, 4.8, 32),
('李骑手', 1, 4.5, 20),
('王师傅', 2, 4.9, 150),
('赵快递', 3, 4.7, 88),
('刘哥', 1, 4.6, 54),
('新人小陈', 1, 3.9, 12)
ON CONFLICT (real_name) DO NOTHING;
-- 2) Generate tasks for the last 30 days.
DO $$
DECLARE
driver_ids UUID[];
d_id UUID;
i INT;
j INT;
num_tasks INT;
assigned_time TIMESTAMPTZ;
delivery_duration INT; -- in minutes
BEGIN
-- Get all driver IDs into an array
SELECT array_agg(id) INTO driver_ids FROM public.ml_delivery_drivers;
IF array_length(driver_ids, 1) IS NULL THEN
RAISE NOTICE 'No drivers found, skipping task generation.';
RETURN;
END IF;
-- Loop for the last 30 days
FOR i IN 0..29 LOOP
-- Create a random number of tasks for each day
num_tasks := floor(random() * 10 + 5)::INT; -- 5 to 14 tasks per day
FOR j IN 1..num_tasks LOOP
-- Pick a random driver
d_id := driver_ids[floor(random() * array_length(driver_ids, 1) + 1)];
-- Generate a random time within the day (from 8am to 10pm)
assigned_time := (NOW() - (i || ' days')::INTERVAL)::date
+ '8 hours'::interval
+ (floor(random() * 14 * 60) || ' minutes')::interval;
-- Generate random delivery duration (10 to 50 minutes)
delivery_duration := floor(random() * 41 + 10);
INSERT INTO public.ml_delivery_tasks (order_id, driver_id, status, assigned_at, delivered_at, delivery_fee)
VALUES (
gen_random_uuid(), -- Generate a unique order_id for each task
d_id,
5, -- Status 'Delivered'
assigned_time,
assigned_time + (delivery_duration || ' minutes')::INTERVAL,
floor(random() * 8 + 3) -- Fee between 3 and 10
);
END LOOP;
END LOOP;
END;
$$;

View File

@@ -1,160 +0,0 @@
-- =============================================
-- 市场趋势页面测试数据 SeedSupabase / Postgres
-- 说明:
-- - 依赖表users, merchants, categories, products, orders, order_items
-- - 用于 /pages/mall/analytics/market-trends.uvue 联调
-- - 目标:
-- * 最近 30 天内至少 20 条订单
-- * 覆盖多个商家、多类目、多价格区间
-- =============================================
-- 1) 准备 2 个测试商家
INSERT INTO public.merchants (id, shop_name, created_at)
VALUES
('31000000-0000-0000-0000-000000000001'::uuid, '市场趋势测试商家A', now() - interval '120 days'),
('31000000-0000-0000-0000-000000000002'::uuid, '市场趋势测试商家B', now() - interval '120 days')
ON CONFLICT (id) DO NOTHING;
-- 2) 准备 3 个测试用户
INSERT INTO public.users (id, phone, email, nickname, created_at)
VALUES
('41000000-0000-0000-0000-000000000001'::uuid, '13910001001', 'market_user1@test.com', '市场用户1', now() - interval '60 days'),
('41000000-0000-0000-0000-000000000002'::uuid, '13910001002', 'market_user2@test.com', '市场用户2', now() - interval '40 days'),
('41000000-0000-0000-0000-000000000003'::uuid, '13910001003', 'market_user3@test.com', '市场用户3', now() - interval '20 days')
ON CONFLICT (id) DO NOTHING;
-- 3) 准备 4 个商品分类
-- 若 categories 表不存在,则创建一个最小版(仅供分析用)
CREATE TABLE IF NOT EXISTS public.categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz DEFAULT now()
);
INSERT INTO public.categories (id, name, created_at)
VALUES
('32000000-0000-0000-0000-000000000001'::uuid, '生鲜食品', now() - interval '100 days'),
('32000000-0000-0000-0000-000000000002'::uuid, '日用百货', now() - interval '100 days'),
('32000000-0000-0000-0000-000000000003'::uuid, '数码家电', now() - interval '100 days'),
('32000000-0000-0000-0000-000000000004'::uuid, '服饰鞋帽', now() - interval '100 days')
ON CONFLICT (id) DO NOTHING;
-- 4) 准备若干商品(分布在不同类目和商家)
-- 如果 products 表还没有 category_id / stock 字段,这里按 mall.md 的设计进行补齐
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'products'
AND column_name = 'category_id'
) THEN
ALTER TABLE public.products ADD COLUMN category_id uuid;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'products'
AND column_name = 'stock'
) THEN
ALTER TABLE public.products ADD COLUMN stock integer DEFAULT 0;
END IF;
END;
$$;
INSERT INTO public.products (id, merchant_id, category_id, name, price, stock, status, created_at)
VALUES
('33000000-0000-0000-0000-000000000001'::uuid, '31000000-0000-0000-0000-000000000001', '32000000-0000-0000-0000-000000000001', '有机蔬菜礼包', 39.90, 1000, 1, now() - interval '90 days'),
('33000000-0000-0000-0000-000000000002'::uuid, '31000000-0000-0000-0000-000000000001', '32000000-0000-0000-0000-000000000002', '纸巾家庭装', 19.90, 1000, 1, now() - interval '90 days'),
('33000000-0000-0000-0000-000000000003'::uuid, '31000000-0000-0000-0000-000000000001', '32000000-0000-0000-0000-000000000003', '蓝牙耳机', 199.00, 500, 1, now() - interval '90 days'),
('33000000-0000-0000-0000-000000000004'::uuid, '31000000-0000-0000-0000-000000000002', '32000000-0000-0000-0000-000000000004', '运动T恤', 79.00, 800, 1, now() - interval '90 days'),
('33000000-0000-0000-0000-000000000005'::uuid, '31000000-0000-0000-0000-000000000002', '32000000-0000-0000-0000-000000000002', '洗衣液', 59.00, 800, 1, now() - interval '90 days')
ON CONFLICT (id) DO NOTHING;
-- 5) 生成最近 30 天订单 + 订单商品(至少 30 笔,满足 RPC 需要)
DO $$
DECLARE
v_users uuid[] := ARRAY[
'41000000-0000-0000-0000-000000000001'::uuid,
'41000000-0000-0000-0000-000000000002'::uuid,
'41000000-0000-0000-0000-000000000003'::uuid
];
v_merchants uuid[] := ARRAY[
'31000000-0000-0000-0000-000000000001'::uuid,
'31000000-0000-0000-0000-000000000002'::uuid
];
v_products uuid[] := ARRAY[
'33000000-0000-0000-0000-000000000001'::uuid,
'33000000-0000-0000-0000-000000000002'::uuid,
'33000000-0000-0000-0000-000000000003'::uuid,
'33000000-0000-0000-0000-000000000004'::uuid,
'33000000-0000-0000-0000-000000000005'::uuid
];
i int;
j int;
day_orders int;
v_user uuid;
v_merchant uuid;
v_product uuid;
v_order_id uuid;
v_qty int;
v_price numeric;
v_total numeric;
v_created_at timestamptz;
BEGIN
FOR i IN 0..29 LOOP
-- 每天 1~3 笔订单
day_orders := 1 + (i % 3);
FOR j IN 1..day_orders LOOP
v_user := v_users[1 + (i + j) % array_length(v_users, 1)];
v_merchant := v_merchants[1 + (i + j) % array_length(v_merchants, 1)];
v_product := v_products[1 + (i + j) % array_length(v_products, 1)];
v_qty := 1 + ((i + j) % 3); -- 1~3 件
SELECT price INTO v_price FROM public.products WHERE id = v_product;
IF v_price IS NULL THEN
v_price := 50;
END IF;
v_total := v_price * v_qty;
v_created_at := (now() - (i || ' days')::interval)
- interval '12 hours'
+ ( (j * 4) || ' hours')::interval;
v_order_id := gen_random_uuid();
INSERT INTO public.orders (
id, user_id, merchant_id, total_amount, status,
created_at, updated_at
) VALUES (
v_order_id,
v_user,
v_merchant,
v_total,
2, -- 已完成
v_created_at,
v_created_at
)
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.order_items (
id, order_id, product_id, quantity, price, total_amount, created_at
) VALUES (
gen_random_uuid(),
v_order_id,
v_product,
v_qty,
v_price,
v_total,
v_created_at
)
ON CONFLICT (id) DO NOTHING;
END LOOP;
END LOOP;
END;
$$;
SELECT 'market trends test data seed completed!' AS message;

View File

@@ -1,172 +0,0 @@
-- ============================================
-- 数据分析实时大屏 - 测试数据插入脚本
-- ============================================
-- 注意:由于启用了 RLS行级安全策略直接插入数据可能会失败
-- 解决方案:
-- 1. 在 Supabase Dashboard 的 SQL Editor 中执行(使用 service_role 权限)
-- 2. 或者临时禁用 RLS见下方说明
-- ============================================
-- 方式一:临时禁用 RLS仅用于测试数据插入完成后会重新启用
-- 注意:在 Supabase Dashboard 的 SQL Editor 中执行时,通常不需要禁用 RLS
-- 如果需要,可以取消下面的注释:
-- BEGIN;
-- ALTER TABLE orders DISABLE ROW LEVEL SECURITY;
-- ALTER TABLE user_sessions DISABLE ROW LEVEL SECURITY;
-- ALTER TABLE users DISABLE ROW LEVEL SECURITY;
-- ALTER TABLE products DISABLE ROW LEVEL SECURITY;
-- ALTER TABLE merchants DISABLE ROW LEVEL SECURITY;
-- ALTER TABLE order_items DISABLE ROW LEVEL SECURITY;
-- ALTER TABLE page_views DISABLE ROW LEVEL SECURITY;
-- 清空现有测试数据(可选,谨慎使用)
-- TRUNCATE TABLE orders, user_sessions, users, order_items, page_views CASCADE;
-- ============================================
-- 1. 插入测试用户
-- ============================================
INSERT INTO users (id, phone, email, nickname, last_login_at) VALUES
('11111111-1111-1111-1111-111111111111', '13800000001', 'user1@test.com', '测试用户1', NOW() - INTERVAL '2 minutes'),
('22222222-2222-2222-2222-222222222222', '13800000002', 'user2@test.com', '测试用户2', NOW() - INTERVAL '5 minutes'),
('33333333-3333-3333-3333-333333333333', '13800000003', 'user3@test.com', '测试用户3', NOW() - INTERVAL '10 minutes'),
('44444444-4444-4444-4444-444444444444', '13800000004', 'user4@test.com', '测试用户4', NOW() - INTERVAL '1 hour'),
('55555555-5555-5555-5555-555555555555', '13800000005', 'user5@test.com', '测试用户5', NOW() - INTERVAL '30 minutes'),
('66666666-6666-6666-6666-666666666666', '13800000006', 'user6@test.com', '测试用户6', NOW() - INTERVAL '3 minutes'),
('77777777-7777-7777-7777-777777777777', '13800000007', 'user7@test.com', '测试用户7', NOW() - INTERVAL '8 minutes'),
('88888888-8888-8888-8888-888888888888', '13800000008', 'user8@test.com', '测试用户8', NOW() - INTERVAL '15 minutes')
ON CONFLICT (id) DO NOTHING;
-- ============================================
-- 1.5 插入测试商家
-- ============================================
INSERT INTO merchants (id, shop_name) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', '测试商家A'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', '测试商家B')
ON CONFLICT (id) DO NOTHING;
-- ============================================
-- 1.6 插入测试商品用于商品TOP
-- ============================================
INSERT INTO products (id, merchant_id, name, price, sales, status) VALUES
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', '测试商品1', 258.00, 100, 1),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', '测试商品2', 399.00, 80, 1),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb3', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', '测试商品3', 128.50, 60, 1)
ON CONFLICT (id) DO NOTHING;
-- ============================================
-- 2. 插入用户会话(在线用户)
-- ============================================
-- 最近5分钟内有活动的用户在线用户
INSERT INTO user_sessions (id, user_id, session_token, last_active_at, is_active, ip_address) VALUES
('c1111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'token_001', NOW() - INTERVAL '1 minute', true, '192.168.1.101'),
('c2222222-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', 'token_002', NOW() - INTERVAL '2 minutes', true, '192.168.1.102'),
('c3333333-3333-3333-3333-333333333333', '33333333-3333-3333-3333-333333333333', 'token_003', NOW() - INTERVAL '3 minutes', true, '192.168.1.103'),
('c6666666-6666-6666-6666-666666666666', '66666666-6666-6666-6666-666666666666', 'token_006', NOW() - INTERVAL '4 minutes', true, '192.168.1.106'),
('c7777777-7777-7777-7777-777777777777', '77777777-7777-7777-7777-777777777777', 'token_007', NOW() - INTERVAL '2 minutes', true, '192.168.1.107')
ON CONFLICT (id) DO UPDATE SET last_active_at = EXCLUDED.last_active_at;
-- 今日访问的用户会话(用于转化率计算)
INSERT INTO user_sessions (id, user_id, session_token, last_active_at, is_active, ip_address, created_at) VALUES
('c8888888-8888-8888-8888-888888888888', '88888888-8888-8888-8888-888888888888', 'token_008', NOW() - INTERVAL '1 hour', true, '192.168.1.108', DATE_TRUNC('day', NOW()) + INTERVAL '2 hours'),
('c9999999-9999-9999-9999-999999999999', '11111111-1111-1111-1111-111111111111', 'token_009', NOW() - INTERVAL '30 minutes', true, '192.168.1.109', DATE_TRUNC('day', NOW()) + INTERVAL '3 hours'),
('caaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '22222222-2222-2222-2222-222222222222', 'token_010', NOW() - INTERVAL '1 hour', true, '192.168.1.110', DATE_TRUNC('day', NOW()) + INTERVAL '4 hours'),
('cbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '33333333-3333-3333-3333-333333333333', 'token_011', NOW() - INTERVAL '2 hours', true, '192.168.1.111', DATE_TRUNC('day', NOW()) + INTERVAL '5 hours'),
('cccccccc-cccc-cccc-cccc-cccccccccccc', '44444444-4444-4444-4444-444444444444', 'token_012', NOW() - INTERVAL '3 hours', true, '192.168.1.112', DATE_TRUNC('day', NOW()) + INTERVAL '6 hours')
ON CONFLICT (id) DO NOTHING;
-- ============================================
-- 3. 插入今日订单用于实时GMV和订单数统计
-- ============================================
-- 今日已支付订单status = 2
INSERT INTO orders (id, user_id, merchant_id, total_amount, status, created_at) VALUES
-- 今日订单(最近几小时)
('e1111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 258.00, 2, NOW() - INTERVAL '1 hour'),
('e2222222-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 399.00, 2, NOW() - INTERVAL '2 hours'),
('e3333333-3333-3333-3333-333333333333', '33333333-3333-3333-3333-333333333333', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', 128.50, 2, NOW() - INTERVAL '30 minutes'),
('e4444444-4444-4444-4444-444444444444', '44444444-4444-4444-4444-444444444444', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', 599.00, 2, NOW() - INTERVAL '3 hours'),
('e5555555-5555-5555-5555-555555555555', '55555555-5555-5555-5555-555555555555', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 89.90, 2, NOW() - INTERVAL '45 minutes'),
('e6666666-6666-6666-6666-666666666666', '66666666-6666-6666-6666-666666666666', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 199.00, 2, NOW() - INTERVAL '1 hour 30 minutes'),
('e7777777-7777-7777-7777-777777777777', '77777777-7777-7777-7777-777777777777', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', 299.00, 2, NOW() - INTERVAL '2 hours 15 minutes'),
('e8888888-8888-8888-8888-888888888888', '88888888-8888-8888-8888-888888888888', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', 159.00, 2, NOW() - INTERVAL '4 hours'),
('e9999999-9999-9999-9999-999999999999', '11111111-1111-1111-1111-111111111111', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 459.00, 2, NOW() - INTERVAL '5 hours'),
('eaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '22222222-2222-2222-2222-222222222222', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 79.90, 2, NOW() - INTERVAL '6 hours'),
('ebbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '33333333-3333-3333-3333-333333333333', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', 329.00, 2, NOW() - INTERVAL '7 hours'),
('eccccccc-cccc-cccc-cccc-cccccccccccc', '44444444-4444-4444-4444-444444444444', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', 189.00, 2, NOW() - INTERVAL '8 hours'),
('eddddddd-dddd-dddd-dddd-dddddddddddd', '55555555-5555-5555-5555-555555555555', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 249.00, 2, NOW() - INTERVAL '9 hours'),
('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '66666666-6666-6666-6666-666666666666', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 99.00, 2, NOW() - INTERVAL '10 hours'),
('efffffff-ffff-ffff-ffff-ffffffffffff', '77777777-7777-7777-7777-777777777777', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', 369.00, 2, NOW() - INTERVAL '11 hours')
ON CONFLICT (id) DO NOTHING;
-- ============================================
-- 4. 插入昨日同时段订单(用于增长率对比)
-- ============================================
-- 昨日同时段的订单往前推24小时
INSERT INTO orders (id, user_id, merchant_id, total_amount, status, created_at) VALUES
-- 昨日同时段订单
('f1111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 198.00, 2, NOW() - INTERVAL '25 hours'),
('f2222222-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 299.00, 2, NOW() - INTERVAL '26 hours'),
('f3333333-3333-3333-3333-333333333333', '33333333-3333-3333-3333-333333333333', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', 88.50, 2, NOW() - INTERVAL '24 hours 30 minutes'),
('f4444444-4444-4444-4444-444444444444', '44444444-4444-4444-4444-444444444444', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', 499.00, 2, NOW() - INTERVAL '27 hours'),
('f5555555-5555-5555-5555-555555555555', '55555555-5555-5555-5555-555555555555', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 79.90, 2, NOW() - INTERVAL '24 hours 45 minutes'),
('f6666666-6666-6666-6666-666666666666', '66666666-6666-6666-6666-666666666666', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 149.00, 2, NOW() - INTERVAL '25 hours 30 minutes'),
('f7777777-7777-7777-7777-777777777777', '77777777-7777-7777-7777-777777777777', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', 229.00, 2, NOW() - INTERVAL '26 hours 15 minutes'),
('f8888888-8888-8888-8888-888888888888', '88888888-8888-8888-8888-888888888888', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', 129.00, 2, NOW() - INTERVAL '28 hours'),
('f9999999-9999-9999-9999-999999999999', '11111111-1111-1111-1111-111111111111', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 359.00, 2, NOW() - INTERVAL '29 hours'),
('faaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '22222222-2222-2222-2222-222222222222', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', 69.90, 2, NOW() - INTERVAL '30 hours')
ON CONFLICT (id) DO NOTHING;
-- ============================================
-- 5. 插入访问日志(用于转化率分析)
-- ============================================
-- 今日访问记录
INSERT INTO page_views (id, user_id, path, source, created_at) VALUES
('d1111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', '/pages/mall/analytics/index', 'direct', DATE_TRUNC('day', NOW()) + INTERVAL '1 hour'),
('d2222222-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', '/pages/mall/consumer/index', 'direct', DATE_TRUNC('day', NOW()) + INTERVAL '2 hours'),
('d3333333-3333-3333-3333-333333333333', '33333333-3333-3333-3333-333333333333', '/pages/mall/consumer/index', 'search', DATE_TRUNC('day', NOW()) + INTERVAL '3 hours'),
('d4444444-4444-4444-4444-444444444444', '44444444-4444-4444-4444-444444444444', '/pages/mall/consumer/index', 'social', DATE_TRUNC('day', NOW()) + INTERVAL '4 hours'),
('d5555555-5555-5555-5555-555555555555', '55555555-5555-5555-5555-555555555555', '/pages/mall/consumer/index', 'ad', DATE_TRUNC('day', NOW()) + INTERVAL '5 hours'),
('d6666666-6666-6666-6666-666666666666', '66666666-6666-6666-6666-666666666666', '/pages/mall/consumer/index', 'direct', DATE_TRUNC('day', NOW()) + INTERVAL '6 hours'),
('d7777777-7777-7777-7777-777777777777', '77777777-7777-7777-7777-777777777777', '/pages/mall/consumer/index', 'search', DATE_TRUNC('day', NOW()) + INTERVAL '7 hours'),
('d8888888-8888-8888-8888-888888888888', '88888888-8888-8888-8888-888888888888', '/pages/mall/consumer/index', 'direct', DATE_TRUNC('day', NOW()) + INTERVAL '8 hours'),
('d9999999-9999-9999-9999-999999999999', '11111111-1111-1111-1111-111111111111', '/pages/mall/consumer/product-detail', 'search', DATE_TRUNC('day', NOW()) + INTERVAL '9 hours'),
('daaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '22222222-2222-2222-2222-222222222222', '/pages/mall/consumer/product-detail', 'direct', DATE_TRUNC('day', NOW()) + INTERVAL '10 hours'),
('dbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '33333333-3333-3333-3333-333333333333', '/pages/mall/consumer/product-detail', 'ad', DATE_TRUNC('day', NOW()) + INTERVAL '11 hours'),
('dccccccc-cccc-cccc-cccc-cccccccccccc', '44444444-4444-4444-4444-444444444444', '/pages/mall/consumer/product-detail', 'social', DATE_TRUNC('day', NOW()) + INTERVAL '12 hours'),
('dddddddd-dddd-dddd-dddd-dddddddddddd', '55555555-5555-5555-5555-555555555555', '/pages/mall/consumer/product-detail', 'search', DATE_TRUNC('day', NOW()) + INTERVAL '13 hours'),
('deeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '66666666-6666-6666-6666-666666666666', '/pages/mall/consumer/product-detail', 'direct', DATE_TRUNC('day', NOW()) + INTERVAL '14 hours'),
('dfffffff-ffff-ffff-ffff-ffffffffffff', '77777777-7777-7777-7777-777777777777', '/pages/mall/consumer/product-detail', 'ad', DATE_TRUNC('day', NOW()) + INTERVAL '15 hours')
ON CONFLICT (id) DO NOTHING;
-- ============================================
-- 6. 插入订单商品关联(可选,用于详细分析)
-- ============================================
INSERT INTO order_items (id, order_id, product_id, quantity, price, total_amount) VALUES
('a1111111-1111-1111-1111-111111111111', 'e1111111-1111-1111-1111-111111111111', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1', 1, 258.00, 258.00),
('a2222222-2222-2222-2222-222222222222', 'e2222222-2222-2222-2222-222222222222', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2', 1, 399.00, 399.00),
('a3333333-3333-3333-3333-333333333333', 'e3333333-3333-3333-3333-333333333333', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb3', 2, 128.50, 257.00)
ON CONFLICT (id) DO NOTHING;
-- ============================================
-- 7. 重新启用 RLS如果之前禁用了
-- ============================================
-- 如果使用了方式一(临时禁用 RLS请取消下面的注释来重新启用
-- ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE products ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE merchants ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE page_views ENABLE ROW LEVEL SECURITY;
-- COMMIT;
-- ============================================
-- 完成
-- ============================================
-- 测试数据插入完成!
-- 建议执行以下查询验证数据:
-- SELECT COUNT(*) FROM users; -- 应该返回 8
-- SELECT COUNT(*) FROM orders; -- 应该返回 2515个今日 + 10个昨日
-- SELECT COUNT(*) FROM user_sessions; -- 应该返回 10
-- SELECT COUNT(*) FROM page_views; -- 应该返回 15

View File

@@ -0,0 +1,176 @@
-- =====================================================================================
-- 数据分析模块正式RPC主库 ml_* 口径)
-- 文件: 02_ml_analytics_rpcs_sales.sql
-- 主题: 仪表盘/销售报表KPI/趋势/TOP
-- 口径约定:
-- - GMV: paid_amount 汇总(若为 0 则用 total_amount 兜底)
-- - 订单量: ml_orders created_at 期间内订单数(可按需要切换为支付订单数)
-- - 支付用户数: payment_status=2 的 distinct user_id
-- - 活跃用户数: ml_browse_history created_at 期间内 distinct user_id弱口径
-- - 转化率(A): 支付用户数 / 活跃用户数(*100
-- =====================================================================================
-- 1) 销售核心 KPI含上期对比
CREATE OR REPLACE FUNCTION public.rpc_analytics_sales_kpis(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
gmv NUMERIC,
gmv_growth FLOAT,
orders BIGINT,
order_growth FLOAT,
conversion_rate FLOAT,
conversion_growth FLOAT,
avg_order_amount NUMERIC,
avg_order_growth FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
prev_start_date DATE;
prev_end_date DATE;
period_days INT;
BEGIN
period_days := p_end_date - p_start_date + 1;
prev_start_date := p_start_date - period_days;
prev_end_date := p_start_date - 1;
RETURN QUERY
WITH
-- 当前周期
cur AS (
SELECT
COALESCE(
SUM(CASE WHEN o.payment_status = 2 THEN COALESCE(NULLIF(o.paid_amount, 0), o.total_amount) ELSE 0 END),
0
) AS gmv,
COUNT(*)::BIGINT AS orders,
COUNT(DISTINCT CASE WHEN o.payment_status = 2 THEN o.user_id END)::BIGINT AS paid_users,
(SELECT COUNT(DISTINCT bh.user_id) FROM public.ml_browse_history bh WHERE bh.created_at::DATE BETWEEN p_start_date AND p_end_date)::BIGINT AS active_users
FROM public.ml_orders o
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
),
-- 上一周期
prev AS (
SELECT
COALESCE(
SUM(CASE WHEN o.payment_status = 2 THEN COALESCE(NULLIF(o.paid_amount, 0), o.total_amount) ELSE 0 END),
0
) AS gmv,
COUNT(*)::BIGINT AS orders,
COUNT(DISTINCT CASE WHEN o.payment_status = 2 THEN o.user_id END)::BIGINT AS paid_users,
(SELECT COUNT(DISTINCT bh.user_id) FROM public.ml_browse_history bh WHERE bh.created_at::DATE BETWEEN prev_start_date AND prev_end_date)::BIGINT AS active_users
FROM public.ml_orders o
WHERE o.created_at::DATE BETWEEN prev_start_date AND prev_end_date
),
calc AS (
SELECT
cur.gmv AS gmv,
cur.orders AS orders,
CASE WHEN cur.active_users > 0 THEN (cur.paid_users::NUMERIC / cur.active_users::NUMERIC) * 100 ELSE 0 END AS conversion_rate,
CASE WHEN cur.orders > 0 THEN cur.gmv / cur.orders ELSE 0 END AS avg_order_amount,
prev.gmv AS prev_gmv,
prev.orders AS prev_orders,
CASE WHEN prev.active_users > 0 THEN (prev.paid_users::NUMERIC / prev.active_users::NUMERIC) * 100 ELSE 0 END AS prev_conversion_rate,
CASE WHEN prev.orders > 0 THEN prev.gmv / prev.orders ELSE 0 END AS prev_avg_order_amount
FROM cur, prev
)
SELECT
ROUND(calc.gmv, 2) AS gmv,
ROUND(((calc.gmv - calc.prev_gmv) * 100.0 / NULLIF(calc.prev_gmv, 0))::numeric, 2)::FLOAT AS gmv_growth,
calc.orders,
ROUND(((calc.orders - calc.prev_orders) * 100.0 / NULLIF(calc.prev_orders, 0))::numeric, 2)::FLOAT AS order_growth,
ROUND(calc.conversion_rate::numeric, 2)::FLOAT AS conversion_rate,
ROUND((calc.conversion_rate - calc.prev_conversion_rate)::numeric, 2)::FLOAT AS conversion_growth,
ROUND(calc.avg_order_amount, 2) AS avg_order_amount,
ROUND(((calc.avg_order_amount - calc.prev_avg_order_amount) * 100.0 / NULLIF(calc.prev_avg_order_amount, 0))::numeric, 2)::FLOAT AS avg_order_growth
FROM calc;
END;
$$;
-- 2) 销售趋势日维度GMV + 订单数)
CREATE OR REPLACE FUNCTION public.rpc_analytics_sales_trend(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
date DATE,
gmv NUMERIC,
orders BIGINT
)
LANGUAGE sql
AS $$
WITH date_series AS (
SELECT generate_series(p_start_date, p_end_date, '1 day'::interval)::DATE AS date
)
SELECT
ds.date,
COALESCE(
SUM(CASE WHEN o.payment_status = 2 THEN COALESCE(NULLIF(o.paid_amount, 0), o.total_amount) ELSE 0 END),
0
) AS gmv,
COUNT(o.id)::BIGINT AS orders
FROM date_series ds
LEFT JOIN public.ml_orders o
ON o.created_at::DATE = ds.date
GROUP BY ds.date
ORDER BY ds.date;
$$;
-- 3) 热销商品 TOP按销量sum(quantity)
CREATE OR REPLACE FUNCTION public.rpc_analytics_top_products(
p_start_date DATE,
p_end_date DATE,
p_limit INT DEFAULT 50
)
RETURNS TABLE (
id UUID,
name TEXT,
sales BIGINT
)
LANGUAGE sql
AS $$
SELECT
p.id,
p.name::TEXT,
COALESCE(SUM(oi.quantity), 0)::BIGINT AS sales
FROM public.ml_order_items oi
JOIN public.ml_orders o ON o.id = oi.order_id
JOIN public.ml_products p ON p.id = oi.product_id
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.payment_status = 2
GROUP BY p.id, p.name
ORDER BY sales DESC
LIMIT p_limit;
$$;
-- 4) 商家排行 TOP按 GMV支付 GMV
CREATE OR REPLACE FUNCTION public.rpc_analytics_top_merchants(
p_start_date DATE,
p_end_date DATE,
p_limit INT DEFAULT 50
)
RETURNS TABLE (
id UUID,
name TEXT,
sales NUMERIC
)
LANGUAGE sql
AS $$
SELECT
m.id,
COALESCE(NULLIF(m.username, ''), '未知商家')::TEXT AS name,
COALESCE(SUM(COALESCE(NULLIF(o.paid_amount, 0), o.total_amount)), 0) AS sales
FROM public.ml_orders o
JOIN public.ak_users m ON m.id = o.merchant_id
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.payment_status = 2
GROUP BY m.id, m.username
ORDER BY sales DESC
LIMIT p_limit;
$$;

View File

@@ -1,222 +0,0 @@
-- =============================================
-- 配送模块 - RLS行级安全与策略按角色/用户隔离版)
-- 修复说明:
-- - Postgres 的 EXECUTE 只能执行「字符串」,因此 CREATE POLICY 必须包在字符串里。
-- - 本脚本使用 format($pol$ ... $pol$) 生成字符串,避免引号转义问题。
-- - 可重复执行:创建前检查 pg_policies。
--
-- 设计目标:
-- 1) 关闭 UNRESTRICTED启用 RLS避免 API 公共可读写。
-- 2) 最小可用:
-- - 配送员delivery只能读写自己的配送员档案、以及自己的任务。
-- - 管理员/分析员admin/analytics可读取全量数据用于后台与分析。
-- - 其他角色默认无权限。
--
-- 前置假设:
-- - public.ak_users 表存在且字段id(UUID), role(TEXT)
-- - ml_delivery_drivers.user_id 可与 auth.uid() 对齐
-- =============================================
-- 0) 开启 RLS
ALTER TABLE public.ml_delivery_drivers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ml_delivery_tasks ENABLE ROW LEVEL SECURITY;
-- =========================
-- ml_delivery_drivers 策略
-- =========================
DO $$
BEGIN
-- 1.1 配送员只能查看自己的档案
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname='public' AND tablename='ml_delivery_drivers' AND policyname='delivery_can_select_own_driver_profile'
) THEN
EXECUTE format($pol$
CREATE POLICY delivery_can_select_own_driver_profile
ON public.ml_delivery_drivers
FOR SELECT
USING (
auth.role() = 'authenticated'
AND user_id = auth.uid()
AND EXISTS (
SELECT 1 FROM public.ak_users u
WHERE u.id = auth.uid() AND u.role = 'delivery'
)
)
$pol$);
END IF;
-- 1.2 配送员只能更新自己的档案
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname='public' AND tablename='ml_delivery_drivers' AND policyname='delivery_can_update_own_driver_profile'
) THEN
EXECUTE format($pol$
CREATE POLICY delivery_can_update_own_driver_profile
ON public.ml_delivery_drivers
FOR UPDATE
USING (
auth.role() = 'authenticated'
AND user_id = auth.uid()
AND EXISTS (
SELECT 1 FROM public.ak_users u
WHERE u.id = auth.uid() AND u.role = 'delivery'
)
)
WITH CHECK (
user_id = auth.uid()
)
$pol$);
END IF;
-- 1.3 管理员/分析员可读全量(后台管理/报表)
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname='public' AND tablename='ml_delivery_drivers' AND policyname='admin_analytics_can_select_all_delivery_drivers'
) THEN
EXECUTE format($pol$
CREATE POLICY admin_analytics_can_select_all_delivery_drivers
ON public.ml_delivery_drivers
FOR SELECT
USING (
auth.role() = 'authenticated'
AND EXISTS (
SELECT 1 FROM public.ak_users u
WHERE u.id = auth.uid() AND u.role IN ('admin','analytics')
)
)
$pol$);
END IF;
-- 1.4 管理员可写全量(派单/改派/纠错)
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname='public' AND tablename='ml_delivery_drivers' AND policyname='admin_can_manage_delivery_drivers'
) THEN
EXECUTE format($pol$
CREATE POLICY admin_can_manage_delivery_drivers
ON public.ml_delivery_drivers
FOR ALL
USING (
auth.role() = 'authenticated'
AND EXISTS (
SELECT 1 FROM public.ak_users u
WHERE u.id = auth.uid() AND u.role = 'admin'
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.ak_users u
WHERE u.id = auth.uid() AND u.role = 'admin'
)
)
$pol$);
END IF;
END $$;
-- =========================
-- ml_delivery_tasks 策略
-- =========================
DO $$
BEGIN
-- 2.1 配送员只能查看自己的任务
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname='public' AND tablename='ml_delivery_tasks' AND policyname='delivery_can_select_own_tasks'
) THEN
EXECUTE format($pol$
CREATE POLICY delivery_can_select_own_tasks
ON public.ml_delivery_tasks
FOR SELECT
USING (
auth.role() = 'authenticated'
AND EXISTS (
SELECT 1
FROM public.ml_delivery_drivers d
JOIN public.ak_users u ON u.id = auth.uid()
WHERE u.role = 'delivery'
AND d.user_id = auth.uid()
AND d.id = public.ml_delivery_tasks.driver_id
)
)
$pol$);
END IF;
-- 2.2 配送员只能更新自己的任务
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname='public' AND tablename='ml_delivery_tasks' AND policyname='delivery_can_update_own_tasks'
) THEN
EXECUTE format($pol$
CREATE POLICY delivery_can_update_own_tasks
ON public.ml_delivery_tasks
FOR UPDATE
USING (
auth.role() = 'authenticated'
AND EXISTS (
SELECT 1
FROM public.ml_delivery_drivers d
JOIN public.ak_users u ON u.id = auth.uid()
WHERE u.role = 'delivery'
AND d.user_id = auth.uid()
AND d.id = public.ml_delivery_tasks.driver_id
)
)
WITH CHECK (
EXISTS (
SELECT 1
FROM public.ml_delivery_drivers d
WHERE d.user_id = auth.uid()
AND d.id = public.ml_delivery_tasks.driver_id
)
)
$pol$);
END IF;
-- 2.3 管理员/分析员可读全量任务
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname='public' AND tablename='ml_delivery_tasks' AND policyname='admin_analytics_can_select_all_delivery_tasks'
) THEN
EXECUTE format($pol$
CREATE POLICY admin_analytics_can_select_all_delivery_tasks
ON public.ml_delivery_tasks
FOR SELECT
USING (
auth.role() = 'authenticated'
AND EXISTS (
SELECT 1 FROM public.ak_users u
WHERE u.id = auth.uid() AND u.role IN ('admin','analytics')
)
)
$pol$);
END IF;
-- 2.4 管理员可写全量任务
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname='public' AND tablename='ml_delivery_tasks' AND policyname='admin_can_manage_delivery_tasks'
) THEN
EXECUTE format($pol$
CREATE POLICY admin_can_manage_delivery_tasks
ON public.ml_delivery_tasks
FOR ALL
USING (
auth.role() = 'authenticated'
AND EXISTS (
SELECT 1 FROM public.ak_users u
WHERE u.id = auth.uid() AND u.role = 'admin'
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.ak_users u
WHERE u.id = auth.uid() AND u.role = 'admin'
)
)
$pol$);
END IF;
END $$;
-- 完成
SELECT 'delivery RLS policies applied' AS message;

View File

@@ -0,0 +1,76 @@
-- =====================================================================================
-- 数据分析模块正式RPC主库 ml_* 口径)
-- 文件: 03_ml_analytics_rpcs_dashboard.sql
-- 主题: 仪表盘实时指标 (GMV/订单/在线用户/转化率)
-- =====================================================================================
-- 1) 仪表盘实时核心 KPI (今日 vs 昨日同刻)
CREATE OR REPLACE FUNCTION public.rpc_analytics_realtime_kpis()
RETURNS TABLE (
gmv NUMERIC,
gmv_growth FLOAT,
orders BIGINT,
order_growth FLOAT,
conversion_rate FLOAT,
conversion_growth FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
today_start timestamptz := date_trunc('day', now());
yesterday_start timestamptz := today_start - interval '1 day';
yesterday_equivalent timestamptz := now() - interval '1 day';
BEGIN
RETURN QUERY
WITH
-- 今日实时数据
today AS (
SELECT
COALESCE(SUM(CASE WHEN o.payment_status = 2 THEN COALESCE(NULLIF(o.paid_amount, 0), o.total_amount) ELSE 0 END), 0) AS gmv,
COUNT(o.id)::BIGINT AS orders,
(SELECT COUNT(DISTINCT user_id) FROM public.ml_orders WHERE created_at >= today_start AND payment_status = 2) AS paid_users,
(SELECT COUNT(DISTINCT user_id) FROM public.ml_browse_history WHERE created_at >= today_start) AS active_users
FROM public.ml_orders o
WHERE o.created_at >= today_start
),
-- 昨日同期数据
yesterday AS (
SELECT
COALESCE(SUM(CASE WHEN o.payment_status = 2 THEN COALESCE(NULLIF(o.paid_amount, 0), o.total_amount) ELSE 0 END), 0) AS gmv,
COUNT(o.id)::BIGINT AS orders,
(SELECT COUNT(DISTINCT user_id) FROM public.ml_orders WHERE created_at BETWEEN yesterday_start AND yesterday_equivalent AND payment_status = 2) AS paid_users,
(SELECT COUNT(DISTINCT user_id) FROM public.ml_browse_history WHERE created_at BETWEEN yesterday_start AND yesterday_equivalent) AS active_users
FROM public.ml_orders o
WHERE o.created_at BETWEEN yesterday_start AND yesterday_equivalent
),
calc AS (
SELECT
t.gmv,
y.gmv AS prev_gmv,
t.orders,
y.orders AS prev_orders,
CASE WHEN t.active_users > 0 THEN (t.paid_users::NUMERIC / t.active_users) * 100 ELSE 0 END AS conversion_rate,
CASE WHEN y.active_users > 0 THEN (y.paid_users::NUMERIC / y.active_users) * 100 ELSE 0 END AS prev_conversion_rate
FROM today t, yesterday y
)
SELECT
ROUND(c.gmv, 2) AS gmv,
ROUND(((c.gmv - c.prev_gmv) * 100.0 / NULLIF(c.prev_gmv, 0))::numeric, 2)::FLOAT AS gmv_growth,
c.orders,
ROUND(((c.orders - c.prev_orders) * 100.0 / NULLIF(c.prev_orders, 0))::numeric, 2)::FLOAT AS order_growth,
ROUND(c.conversion_rate::numeric, 2)::FLOAT AS conversion_rate,
ROUND((c.conversion_rate - c.prev_conversion_rate)::numeric, 2)::FLOAT AS conversion_growth
FROM calc c;
END;
$$;
-- 2) 在线用户数 (基于最近5分钟的浏览历史)
CREATE OR REPLACE FUNCTION public.rpc_analytics_online_users()
RETURNS BIGINT
LANGUAGE sql
AS $$
SELECT COUNT(DISTINCT user_id)::BIGINT
FROM public.ml_browse_history
WHERE created_at >= now() - INTERVAL '5 minutes';
$$;

View File

@@ -1,70 +0,0 @@
-- =============================================
-- Analytics Dashboard 专用:订单表 RLS 策略补充
-- 目标:
-- - 允许角色为 admin / analytics 的用户在 Supabase 中
-- 通过 RPC 查询全量订单与订单明细(仅用于统计分析)。
-- - 不影响普通消费者的订单隔离策略。
-- 依赖:
-- - public.ak_users 表存在且包含字段id(uuid), role(text)
-- - 01_create_tables.sql 已经启用 orders / order_items 的 RLS
-- =============================================
DO $$
BEGIN
-- 如果 ak_users 表不存在,直接跳过(避免报错)
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'ak_users'
) THEN
RAISE NOTICE 'ak_users table not found, skip analytics RLS policies for orders.';
RETURN;
END IF;
-- orders允许 admin / analytics 角色读取全量订单用于分析
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'public'
AND tablename = 'orders'
AND policyname = 'analytics_can_select_all_orders'
) THEN
EXECUTE $pol$
CREATE POLICY analytics_can_select_all_orders
ON public.orders
FOR SELECT
USING (
auth.role() = 'authenticated'
AND EXISTS (
SELECT 1 FROM public.ak_users u
WHERE u.id = auth.uid()
AND u.role IN ('admin','analytics')
)
)
$pol$;
END IF;
-- order_items允许 admin / analytics 查看任意订单的明细
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'public'
AND tablename = 'order_items'
AND policyname = 'analytics_can_select_all_order_items'
) THEN
EXECUTE $pol$
CREATE POLICY analytics_can_select_all_order_items
ON public.order_items
FOR SELECT
USING (
auth.role() = 'authenticated'
AND EXISTS (
SELECT 1 FROM public.ak_users u
WHERE u.id = auth.uid()
AND u.role IN ('admin','analytics')
)
)
$pol$;
END IF;
END;
$$;
SELECT 'orders analytics RLS policies applied' AS message;

View File

@@ -1,225 +0,0 @@
-- ============================================
-- 数据分析实时大屏 - 测试查询脚本
-- ============================================
-- 这些查询用于验证实时大屏的数据计算逻辑
-- ============================================
-- 1. 测试实时GMV查询
-- ============================================
-- 今日GMV
SELECT
SUM(total_amount) as today_gmv,
COUNT(*) as today_order_count
FROM orders
WHERE created_at >= DATE_TRUNC('day', NOW())
AND status = 2;
-- 昨日同时段GMV当前时间往前推24小时
SELECT
SUM(total_amount) as yesterday_gmv,
COUNT(*) as yesterday_order_count
FROM orders
WHERE created_at >= DATE_TRUNC('day', NOW() - INTERVAL '1 day')
AND created_at <= NOW() - INTERVAL '1 day'
AND status = 2;
-- GMV增长率计算
WITH today_stats AS (
SELECT
COALESCE(SUM(total_amount), 0) as gmv,
COUNT(*) as orders
FROM orders
WHERE created_at >= DATE_TRUNC('day', NOW())
AND status = 2
),
yesterday_stats AS (
SELECT
COALESCE(SUM(total_amount), 0) as gmv,
COUNT(*) as orders
FROM orders
WHERE created_at >= DATE_TRUNC('day', NOW() - INTERVAL '1 day')
AND created_at <= NOW() - INTERVAL '1 day'
AND status = 2
)
SELECT
today_stats.gmv as today_gmv,
yesterday_stats.gmv as yesterday_gmv,
CASE
WHEN yesterday_stats.gmv > 0 THEN
ROUND((today_stats.gmv - yesterday_stats.gmv) / yesterday_stats.gmv * 100, 2)
ELSE
CASE WHEN today_stats.gmv > 0 THEN 100 ELSE 0 END
END as gmv_growth_percent,
today_stats.orders as today_orders,
yesterday_stats.orders as yesterday_orders,
CASE
WHEN yesterday_stats.orders > 0 THEN
ROUND((today_stats.orders - yesterday_stats.orders)::numeric / yesterday_stats.orders * 100, 2)
ELSE
CASE WHEN today_stats.orders > 0 THEN 100 ELSE 0 END
END as order_growth_percent
FROM today_stats, yesterday_stats;
-- ============================================
-- 2. 测试在线用户查询
-- ============================================
-- 最近5分钟内有活动的用户在线用户
SELECT COUNT(DISTINCT user_id) as online_users
FROM user_sessions
WHERE last_active_at >= NOW() - INTERVAL '5 minutes'
AND is_active = true;
-- 备用方案从用户表查询最近5分钟登录的用户
SELECT COUNT(*) as online_users_from_users
FROM users
WHERE last_login_at >= NOW() - INTERVAL '5 minutes';
-- ============================================
-- 3. 测试转化率查询
-- ============================================
-- 今日下单的唯一用户数
SELECT COUNT(DISTINCT user_id) as unique_order_users
FROM orders
WHERE created_at >= DATE_TRUNC('day', NOW())
AND status = 2;
-- 今日访问用户数(从用户会话表)
SELECT COUNT(DISTINCT user_id) as today_visitors
FROM user_sessions
WHERE created_at >= DATE_TRUNC('day', NOW());
-- 转化率计算
WITH order_users AS (
SELECT COUNT(DISTINCT user_id) as count
FROM orders
WHERE created_at >= DATE_TRUNC('day', NOW())
AND status = 2
),
visitors AS (
SELECT COUNT(DISTINCT user_id) as count
FROM user_sessions
WHERE created_at >= DATE_TRUNC('day', NOW())
)
SELECT
order_users.count as order_users,
visitors.count as visitors,
CASE
WHEN visitors.count > 0 THEN
ROUND(order_users.count::numeric / visitors.count * 100, 2)
ELSE 0
END as conversion_rate_percent
FROM order_users, visitors;
-- ============================================
-- 4. 综合实时大屏数据查询
-- ============================================
WITH
-- 今日统计
today_stats AS (
SELECT
COALESCE(SUM(total_amount), 0) as gmv,
COUNT(*) as orders,
COUNT(DISTINCT user_id) as order_users
FROM orders
WHERE created_at >= DATE_TRUNC('day', NOW())
AND status = 2
),
-- 昨日同时段统计
yesterday_stats AS (
SELECT
COALESCE(SUM(total_amount), 0) as gmv,
COUNT(*) as orders,
COUNT(DISTINCT user_id) as order_users
FROM orders
WHERE created_at >= DATE_TRUNC('day', NOW() - INTERVAL '1 day')
AND created_at <= NOW() - INTERVAL '1 day'
AND status = 2
),
-- 在线用户
online_users AS (
SELECT COUNT(DISTINCT user_id) as count
FROM user_sessions
WHERE last_active_at >= NOW() - INTERVAL '5 minutes'
AND is_active = true
),
-- 今日访问用户
today_visitors AS (
SELECT COUNT(DISTINCT user_id) as count
FROM user_sessions
WHERE created_at >= DATE_TRUNC('day', NOW())
),
-- 昨日同时段访问用户
yesterday_visitors AS (
SELECT COUNT(DISTINCT user_id) as count
FROM user_sessions
WHERE created_at >= DATE_TRUNC('day', NOW() - INTERVAL '1 day')
AND created_at <= NOW() - INTERVAL '1 day'
)
SELECT
-- GMV数据
today_stats.gmv as real_time_gmv,
CASE
WHEN yesterday_stats.gmv > 0 THEN
ROUND((today_stats.gmv - yesterday_stats.gmv) / yesterday_stats.gmv * 100, 1)
ELSE
CASE WHEN today_stats.gmv > 0 THEN 100.0 ELSE 0.0 END
END as gmv_growth,
-- 订单数据
today_stats.orders as real_time_orders,
CASE
WHEN yesterday_stats.orders > 0 THEN
ROUND((today_stats.orders - yesterday_stats.orders)::numeric / yesterday_stats.orders * 100, 1)
ELSE
CASE WHEN today_stats.orders > 0 THEN 100.0 ELSE 0.0 END
END as order_growth,
-- 在线用户
COALESCE(online_users.count, 0) as online_users,
-- 转化率
CASE
WHEN today_visitors.count > 0 THEN
ROUND(today_stats.order_users::numeric / today_visitors.count * 100, 1)
ELSE 0
END as conversion_rate,
CASE
WHEN yesterday_visitors.count > 0 AND yesterday_stats.order_users > 0 THEN
ROUND((today_stats.order_users::numeric / today_visitors.count * 100) -
(yesterday_stats.order_users::numeric / yesterday_visitors.count * 100), 1)
ELSE 0
END as conversion_growth
FROM today_stats, yesterday_stats, online_users, today_visitors, yesterday_visitors;
-- ============================================
-- 5. 数据验证查询
-- ============================================
-- 检查今日订单数据
SELECT
DATE_TRUNC('hour', created_at) as hour,
COUNT(*) as order_count,
SUM(total_amount) as hour_gmv
FROM orders
WHERE created_at >= DATE_TRUNC('day', NOW())
AND status = 2
GROUP BY DATE_TRUNC('hour', created_at)
ORDER BY hour DESC;
-- 检查用户会话数据
SELECT
DATE_TRUNC('hour', created_at) as hour,
COUNT(DISTINCT user_id) as unique_visitors
FROM user_sessions
WHERE created_at >= DATE_TRUNC('day', NOW())
GROUP BY DATE_TRUNC('hour', created_at)
ORDER BY hour DESC;
-- 检查最近活动的用户
SELECT
user_id,
last_active_at,
NOW() - last_active_at as time_ago
FROM user_sessions
WHERE last_active_at >= NOW() - INTERVAL '10 minutes'
AND is_active = true
ORDER BY last_active_at DESC;

View File

@@ -1,110 +0,0 @@
-- ============================================
-- 数据分析实时大屏 - 清理测试数据脚本
-- ============================================
-- 谨慎使用:此脚本会删除测试数据
-- 方式1删除特定时间范围的测试数据推荐
-- 删除今日的测试订单
DELETE FROM orders
WHERE created_at >= DATE_TRUNC('day', NOW())
AND user_id IN (
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
'33333333-3333-3333-3333-333333333333',
'44444444-4444-4444-4444-444444444444',
'55555555-5555-5555-5555-555555555555',
'66666666-6666-6666-6666-666666666666',
'77777777-7777-7777-7777-777777777777',
'88888888-8888-8888-8888-888888888888'
);
-- 删除昨日同时段的测试订单
DELETE FROM orders
WHERE created_at >= DATE_TRUNC('day', NOW() - INTERVAL '1 day')
AND created_at <= NOW() - INTERVAL '1 day'
AND user_id IN (
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
'33333333-3333-3333-3333-333333333333',
'44444444-4444-4444-4444-444444444444',
'55555555-5555-5555-5555-555555555555',
'66666666-6666-6666-6666-666666666666',
'77777777-7777-7777-7777-777777777777',
'88888888-8888-8888-8888-888888888888'
);
-- 删除测试用户会话
DELETE FROM user_sessions
WHERE user_id IN (
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
'33333333-3333-3333-3333-333333333333',
'44444444-4444-4444-4444-444444444444',
'55555555-5555-5555-5555-555555555555',
'66666666-6666-6666-6666-666666666666',
'77777777-7777-7777-7777-777777777777',
'88888888-8888-8888-8888-888888888888'
);
-- 删除测试访问日志
DELETE FROM page_views
WHERE user_id IN (
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
'33333333-3333-3333-3333-333333333333',
'44444444-4444-4444-4444-444444444444',
'55555555-5555-5555-5555-555555555555',
'66666666-6666-6666-6666-666666666666',
'77777777-7777-7777-7777-777777777777',
'88888888-8888-8888-8888-888888888888'
);
-- 删除测试订单商品关联
-- 说明order_id 为 UUID不能用 LIKE 前缀匹配;这里改为通过 orders 反查关联订单。
DELETE FROM order_items
WHERE order_id IN (
SELECT id
FROM orders
WHERE user_id IN (
'11111111-1111-1111-1111-111111111111',
'22222222-2222-2222-2222-222222222222',
'33333333-3333-3333-3333-333333333333',
'44444444-4444-4444-4444-444444444444',
'55555555-5555-5555-5555-555555555555',
'66666666-6666-6666-6666-666666666666',
'77777777-7777-7777-7777-777777777777',
'88888888-8888-8888-8888-888888888888'
)
);
-- 删除测试用户(可选,如果这些用户只用于测试)
-- 注意:如果用户有其他数据关联,可能需要先删除关联数据
-- DELETE FROM users
-- WHERE id IN (
-- '11111111-1111-1111-1111-111111111111',
-- '22222222-2222-2222-2222-222222222222',
-- '33333333-3333-3333-3333-333333333333',
-- '44444444-4444-4444-4444-444444444444',
-- '55555555-5555-5555-5555-555555555555',
-- '66666666-6666-6666-6666-666666666666',
-- '77777777-7777-7777-7777-777777777777',
-- '88888888-8888-8888-8888-888888888888'
-- );
-- ============================================
-- 方式2完全清空表非常谨慎使用
-- ============================================
-- 取消下面的注释来完全清空表
-- TRUNCATE TABLE orders, user_sessions, users, order_items, page_views CASCADE;
-- ============================================
-- 方式3删除测试表如果表只用于测试
-- ============================================
-- 取消下面的注释来删除表
-- DROP TABLE IF EXISTS order_items CASCADE;
-- DROP TABLE IF EXISTS page_views CASCADE;
-- DROP TABLE IF EXISTS user_sessions CASCADE;
-- DROP TABLE IF EXISTS orders CASCADE;
-- DROP TABLE IF EXISTS users CASCADE;
-- DROP TABLE IF EXISTS products CASCADE;
-- DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE;

View File

@@ -0,0 +1,175 @@
-- =====================================================================================
-- 数据分析模块正式RPC主库 ml_* 口径)
-- 文件: 04_ml_analytics_rpcs_market_trends.sql
-- 主题: 市场趋势(整体趋势/类目对比/季节性/价格趋势/竞争格局)
--
-- 说明:该模块历史上依赖测试库中的 RPCrpc_analytics_market_trend_daily 等)。
-- 这里基于主库 ml_* 表重建:
-- - 订单ml_orders / ml_order_items
-- - 商品/类目ml_products / ml_categories
-- - 用户活跃弱口径ml_browse_history
--
-- 口径约定:
-- - GMV支付订单的 paid_amount为 0 时用 total_amount 兜底)
-- - orders订单数支付订单口径payment_status=2
-- - users活跃用户浏览口径ml_browse_history 去重 user_id
-- - avg_price支付订单下的件单价sum(订单明细金额)/sum(件数)
-- =====================================================================================
-- 1) 市场整体趋势(日维度)
-- 返回字段需要匹配页面 buildChartOptions 使用的day/gmv/orders/users
CREATE OR REPLACE FUNCTION public.rpc_analytics_market_trend_daily(
p_start TIMESTAMPTZ,
p_end TIMESTAMPTZ
)
RETURNS TABLE (
day TEXT,
gmv NUMERIC,
orders BIGINT,
users BIGINT
)
LANGUAGE sql
AS $$
WITH date_series AS (
SELECT generate_series(date_trunc('day', p_start), date_trunc('day', p_end), interval '1 day')::date AS d
),
paid_orders AS (
SELECT
o.id,
o.user_id,
o.created_at::date AS d,
COALESCE(NULLIF(o.paid_amount, 0), o.total_amount) AS paid_gmv
FROM public.ml_orders o
WHERE o.payment_status = 2
AND o.created_at >= p_start
AND o.created_at < (p_end + interval '1 second')
),
active_users AS (
SELECT
bh.created_at::date AS d,
COUNT(DISTINCT bh.user_id)::bigint AS users
FROM public.ml_browse_history bh
WHERE bh.created_at >= p_start
AND bh.created_at < (p_end + interval '1 second')
GROUP BY bh.created_at::date
)
SELECT
to_char(ds.d, 'YYYY-MM-DD') AS day,
COALESCE(SUM(po.paid_gmv), 0) AS gmv,
COUNT(po.id)::bigint AS orders,
COALESCE(au.users, 0) AS users
FROM date_series ds
LEFT JOIN paid_orders po ON po.d = ds.d
LEFT JOIN active_users au ON au.d = ds.d
GROUP BY ds.d, au.users
ORDER BY ds.d;
$$;
-- 2) 类目销售(类目 GMV 排行)
-- 返回字段匹配页面category_name / total_sales
CREATE OR REPLACE FUNCTION public.rpc_analytics_category_sales(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
category_name TEXT,
total_sales NUMERIC
)
LANGUAGE sql
AS $$
SELECT
COALESCE(c.name, '未分类')::text AS category_name,
COALESCE(SUM(oi.total_amount), 0) AS total_sales
FROM public.ml_order_items oi
JOIN public.ml_orders o ON o.id = oi.order_id
LEFT JOIN public.ml_products p ON p.id = oi.product_id
LEFT JOIN public.ml_categories c ON c.id = p.category_id
WHERE o.payment_status = 2
AND o.created_at::date BETWEEN p_start_date AND p_end_date
GROUP BY c.name
ORDER BY total_sales DESC;
$$;
-- 3) 季节性趋势(按月 GMV
-- 返回字段匹配页面month / total_gmv
CREATE OR REPLACE FUNCTION public.rpc_analytics_seasonal_trend(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
month TEXT,
total_gmv NUMERIC
)
LANGUAGE sql
AS $$
SELECT
to_char(date_trunc('month', o.created_at), 'YYYY-MM') AS month,
COALESCE(SUM(COALESCE(NULLIF(o.paid_amount, 0), o.total_amount)), 0) AS total_gmv
FROM public.ml_orders o
WHERE o.payment_status = 2
AND o.created_at::date BETWEEN p_start_date AND p_end_date
GROUP BY date_trunc('month', o.created_at)
ORDER BY date_trunc('month', o.created_at);
$$;
-- 4) 价格趋势(按天件单价)
-- 返回字段匹配页面day / avg_price
CREATE OR REPLACE FUNCTION public.rpc_analytics_price_trend(
p_start TIMESTAMPTZ,
p_end TIMESTAMPTZ
)
RETURNS TABLE (
day TEXT,
avg_price NUMERIC
)
LANGUAGE sql
AS $$
WITH date_series AS (
SELECT generate_series(date_trunc('day', p_start), date_trunc('day', p_end), interval '1 day')::date AS d
),
paid_items AS (
SELECT
o.created_at::date AS d,
SUM(oi.total_amount) AS amt,
SUM(oi.quantity) AS qty
FROM public.ml_orders o
JOIN public.ml_order_items oi ON oi.order_id = o.id
WHERE o.payment_status = 2
AND o.created_at >= p_start
AND o.created_at < (p_end + interval '1 second')
GROUP BY o.created_at::date
)
SELECT
to_char(ds.d, 'YYYY-MM-DD') AS day,
CASE WHEN COALESCE(pi.qty, 0) > 0 THEN ROUND((pi.amt / pi.qty)::numeric, 2) ELSE 0 END AS avg_price
FROM date_series ds
LEFT JOIN paid_items pi ON pi.d = ds.d
ORDER BY ds.d;
$$;
-- 5) 竞争分析(商家 GMV 份额)
-- 返回字段匹配页面merchant_name / gmv
CREATE OR REPLACE FUNCTION public.rpc_analytics_competition_share(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
merchant_name TEXT,
gmv NUMERIC
)
LANGUAGE sql
AS $$
SELECT
COALESCE(NULLIF(m.username, ''), '未知商家')::text AS merchant_name,
COALESCE(SUM(COALESCE(NULLIF(o.paid_amount, 0), o.total_amount)), 0) AS gmv
FROM public.ml_orders o
JOIN public.ak_users m ON m.id = o.merchant_id
WHERE o.payment_status = 2
AND o.created_at::date BETWEEN p_start_date AND p_end_date
GROUP BY m.username
ORDER BY gmv DESC;
$$;

View File

@@ -0,0 +1,194 @@
-- =====================================================================================
-- 数据分析模块正式RPC主库 ml_* 口径)
-- 文件: 05_ml_analytics_rpcs_product.sql
-- 主题: 商品洞察 (概览/趋势/排行/库存/价格/评价)
-- =====================================================================================
-- 1) 商品洞察核心 KPI
CREATE OR REPLACE FUNCTION public.rpc_product_insights_overview(
p_start DATE,
p_end DATE
)
RETURNS TABLE (
total_products BIGINT,
product_growth FLOAT,
hot_products BIGINT,
turnover_rate FLOAT,
turnover_growth FLOAT,
avg_stock NUMERIC,
stock_growth FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
prev_start_date DATE;
prev_end_date DATE;
period_days INT;
BEGIN
period_days := p_end - p_start + 1;
prev_start_date := p_start - period_days;
prev_end_date := p_start - 1;
RETURN QUERY
WITH
cur AS (
SELECT
(SELECT COUNT(*) FROM public.ml_products WHERE status = 1 AND published_at <= p_end::timestamp) AS total_products,
(SELECT COUNT(*) FROM public.ml_products WHERE status = 1 AND sale_count > 100) AS hot_products,
(SELECT SUM(oi.quantity) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id WHERE o.payment_status=2 AND o.created_at::date BETWEEN p_start AND p_end) AS total_sales_qty,
(SELECT AVG(p.available_stock) FROM public.ml_products p WHERE p.status=1) AS avg_stock
),
prev AS (
SELECT
(SELECT COUNT(*) FROM public.ml_products WHERE status = 1 AND published_at <= prev_end_date::timestamp) AS total_products,
(SELECT SUM(oi.quantity) FROM public.ml_order_items oi JOIN public.ml_orders o ON oi.order_id = o.id WHERE o.payment_status=2 AND o.created_at::date BETWEEN prev_start_date AND prev_end_date) AS total_sales_qty,
(SELECT AVG(p.available_stock) FROM public.ml_products p WHERE p.status=1) AS avg_stock
)
SELECT
c.total_products,
ROUND(((c.total_products - p.total_products) * 100.0 / NULLIF(p.total_products, 0))::numeric, 2)::FLOAT AS product_growth,
c.hot_products,
ROUND((c.total_sales_qty / NULLIF(c.avg_stock, 0))::numeric, 2)::FLOAT AS turnover_rate,
ROUND(((c.total_sales_qty / NULLIF(c.avg_stock, 0)) - (p.total_sales_qty / NULLIF(p.avg_stock, 0)))::numeric, 2)::FLOAT AS turnover_growth,
ROUND(c.avg_stock::numeric, 0) AS avg_stock,
ROUND(((c.avg_stock - p.avg_stock) * 100.0 / NULLIF(p.avg_stock, 0))::numeric, 2)::FLOAT AS stock_growth
FROM cur c, prev p;
END;
$$;
-- 2) 热销商品 TOP (带增长率)
-- 注意: growth 是与上一个同等时间周期的销量对比
CREATE OR REPLACE FUNCTION public.rpc_analytics_top_products(
p_start_date DATE,
p_end_date DATE,
p_limit INT DEFAULT 10
)
RETURNS TABLE (
id UUID,
name TEXT,
sales BIGINT,
growth FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
prev_start_date DATE;
prev_end_date DATE;
period_days INT;
BEGIN
period_days := p_end_date - p_start_date + 1;
prev_start_date := p_start_date - period_days;
prev_end_date := p_start_date - 1;
RETURN QUERY
WITH
current_sales AS (
SELECT
oi.product_id,
SUM(oi.quantity)::BIGINT AS current_qty
FROM public.ml_order_items oi
JOIN public.ml_orders o ON o.id = oi.order_id
WHERE o.payment_status = 2 AND o.created_at::DATE BETWEEN p_start_date AND p_end_date
GROUP BY oi.product_id
),
previous_sales AS (
SELECT
oi.product_id,
SUM(oi.quantity)::BIGINT AS previous_qty
FROM public.ml_order_items oi
JOIN public.ml_orders o ON o.id = oi.order_id
WHERE o.payment_status = 2 AND o.created_at::DATE BETWEEN prev_start_date AND prev_end_date
GROUP BY oi.product_id
)
SELECT
p.id,
p.name::TEXT,
cs.current_qty AS sales,
ROUND(((cs.current_qty - COALESCE(ps.previous_qty, 0)) * 100.0 / NULLIF(COALESCE(ps.previous_qty, 0), 0))::numeric, 2)::FLOAT AS growth
FROM current_sales cs
JOIN public.ml_products p ON p.id = cs.product_id
LEFT JOIN previous_sales ps ON ps.product_id = cs.product_id
ORDER BY sales DESC
LIMIT p_limit;
END;
$$;
-- 3) 单个商品销售趋势
CREATE OR REPLACE FUNCTION public.rpc_analytics_product_trend(
p_start_date DATE,
p_end_date DATE,
p_product_id UUID
)
RETURNS TABLE (
date TEXT,
gmv NUMERIC,
qty BIGINT,
orders BIGINT
)
LANGUAGE sql
AS $$
WITH date_series AS (
SELECT generate_series(p_start_date, p_end_date, '1 day'::interval)::date AS d
)
SELECT
to_char(ds.d, 'YYYY-MM-DD') AS date,
COALESCE(SUM(oi.total_amount), 0) AS gmv,
COALESCE(SUM(oi.quantity), 0)::BIGINT AS qty,
COUNT(DISTINCT o.id)::BIGINT AS orders
FROM date_series ds
LEFT JOIN public.ml_orders o ON o.created_at::date = ds.d AND o.payment_status = 2
LEFT JOIN public.ml_order_items oi ON oi.order_id = o.id AND oi.product_id = p_product_id
GROUP BY ds.d
ORDER BY ds.d;
$$;
-- 4) 商品库存分析 (按库存水平分箱)
CREATE OR REPLACE FUNCTION public.rpc_product_insights_stock()
RETURNS TABLE (
stock_level TEXT,
product_count BIGINT
)
LANGUAGE sql
AS $$
SELECT
CASE
WHEN available_stock = 0 THEN '缺货'
WHEN available_stock BETWEEN 1 AND 10 THEN '库存紧张 (1-10)'
WHEN available_stock BETWEEN 11 AND 50 THEN '库存充足 (11-50)'
WHEN available_stock BETWEEN 51 AND 100 THEN '库存充裕 (51-100)'
ELSE '库存过剩 (>100)'
END AS stock_level,
COUNT(id)::BIGINT AS product_count
FROM public.ml_products
WHERE status = 1
GROUP BY stock_level
ORDER BY
CASE stock_level
WHEN '缺货' THEN 1
WHEN '库存紧张 (1-10)' THEN 2
WHEN '库存充足 (11-50)' THEN 3
WHEN '库存充裕 (51-100)' THEN 4
ELSE 5
END;
$$;
-- 5) 商品评价分析 (按评分分箱)
CREATE OR REPLACE FUNCTION public.rpc_product_insights_reviews()
RETURNS TABLE (
rating_level TEXT,
review_count BIGINT
)
LANGUAGE sql
AS $$
SELECT
rating || '' AS rating_level,
COUNT(id)::BIGINT AS review_count
FROM public.ml_product_reviews
WHERE status = 1
GROUP BY rating
ORDER BY rating DESC;
$$;

View File

@@ -1,68 +0,0 @@
-- ============================================
-- 数据分析实时大屏 - 商品洞察:商品维度趋势 RPC
-- ============================================
-- 用途用于“商品洞察”页面的【商品销售分析】图表A2商品维度趋势
-- 说明:按天聚合指定商品的 GMV/件数/订单数
-- 依赖表orders, order_items
-- 依赖字段:
-- orders.status = 2已支付/已完成口径)
-- order_items.quantity, order_items.total_amount
--
-- 执行方式:在 Supabase Dashboard -> SQL Editor 执行
-- 权限:仅 authenticated 可执行
-- ============================================
CREATE OR REPLACE FUNCTION rpc_analytics_product_trend(
p_start_date DATE,
p_end_date DATE,
p_product_id UUID
)
RETURNS TABLE (
date DATE,
gmv NUMERIC,
qty INTEGER,
orders INTEGER
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'unauthorized';
END IF;
RETURN QUERY
WITH days AS (
SELECT generate_series(p_start_date, p_end_date, interval '1 day')::date AS d
),
agg AS (
SELECT
DATE(o.created_at) AS d,
COALESCE(SUM(oi.total_amount), 0)::NUMERIC AS gmv,
COALESCE(SUM(oi.quantity), 0)::INTEGER AS qty,
COUNT(DISTINCT oi.order_id)::INTEGER AS orders
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE DATE(o.created_at) >= p_start_date
AND DATE(o.created_at) <= p_end_date
AND o.status = 2
AND oi.product_id = p_product_id
GROUP BY DATE(o.created_at)
)
SELECT
days.d AS date,
COALESCE(agg.gmv, 0)::NUMERIC AS gmv,
COALESCE(agg.qty, 0)::INTEGER AS qty,
COALESCE(agg.orders, 0)::INTEGER AS orders
FROM days
LEFT JOIN agg ON agg.d = days.d
ORDER BY days.d;
END;
$$;
-- 授权收敛(只允许 authenticated 调用)
REVOKE ALL ON FUNCTION rpc_analytics_product_trend(DATE, DATE, UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION rpc_analytics_product_trend(DATE, DATE, UUID) TO authenticated;
SELECT 'rpc_analytics_product_trend created' AS message;

View File

@@ -1,171 +0,0 @@
-- ============================================
-- 数据分析模块 - 市场趋势页面所需 RPC 函数
-- ============================================
-- 用途:为“市场趋势”页面提供数据支持,包括行业对比、季节性趋势、竞争分析等。
-- 执行方式:在 Supabase Dashboard -> SQL Editor 中执行。
-- ============================================
-- 1. 行业对比分析 (按商品分类销售额)
-- 依赖products 表需要有 category_id, 且存在 categories 表。
CREATE OR REPLACE FUNCTION rpc_analytics_category_sales(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (category_name TEXT, total_sales NUMERIC)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
COALESCE(c.name, '未分类') AS category_name,
SUM(oi.total_amount) AS total_sales
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
LEFT JOIN products p ON oi.product_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE o.created_at >= p_start_date AND o.created_at <= p_end_date AND o.status = 2
GROUP BY COALESCE(c.name, '未分类')
ORDER BY total_sales DESC;
END;
$$;
-- 2. 季节性趋势 (按月聚合GMV)
CREATE OR REPLACE FUNCTION rpc_analytics_seasonal_trend(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (month TEXT, total_gmv NUMERIC)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
TO_CHAR(DATE_TRUNC('month', o.created_at), 'YYYY-MM') AS month,
SUM(o.total_amount) AS total_gmv
FROM orders o
WHERE o.created_at >= p_start_date AND o.created_at <= p_end_date AND o.status = 2
GROUP BY DATE_TRUNC('month', o.created_at)
ORDER BY month;
END;
$$;
-- 3. 竞争分析 (Top 5 商家市场份额)
-- 使用 LANGUAGE sql避免 OUT 参数 gmv 与列名 gmv 产生歧义
CREATE OR REPLACE FUNCTION rpc_analytics_competition_share(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (merchant_name TEXT, gmv NUMERIC)
LANGUAGE sql
AS $$
WITH merchant_sales AS (
SELECT
m.shop_name,
SUM(o.total_amount) AS gmv
FROM orders o
JOIN merchants m ON o.merchant_id = m.id
WHERE o.created_at >= p_start_date
AND o.created_at <= p_end_date
AND o.status = 2
GROUP BY m.shop_name
),
top_merchants AS (
SELECT
shop_name,
gmv
FROM merchant_sales
ORDER BY gmv DESC
LIMIT 5
),
other_sales AS (
SELECT
'其他' AS shop_name,
SUM(ms.gmv) AS gmv
FROM merchant_sales ms
WHERE ms.shop_name NOT IN (SELECT tm.shop_name FROM top_merchants tm)
)
SELECT shop_name::TEXT, gmv::NUMERIC FROM top_merchants
UNION ALL
SELECT shop_name::TEXT, gmv::NUMERIC FROM other_sales WHERE gmv > 0;
$$;
-- 4. 市场整体趋势(按天聚合 GMV / 订单数 / 用户数 / 客单价)
CREATE OR REPLACE FUNCTION rpc_analytics_market_trend_daily(
p_start TIMESTAMPTZ,
p_end TIMESTAMPTZ
)
RETURNS TABLE (
day DATE,
gmv NUMERIC,
orders INTEGER,
users INTEGER,
avg_order_amount NUMERIC
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
DATE(o.created_at) AS day,
COALESCE(SUM(o.total_amount), 0) AS gmv,
COUNT(*)::INTEGER AS orders,
COUNT(DISTINCT o.user_id)::INTEGER AS users,
CASE
WHEN COUNT(*) > 0 THEN ROUND(SUM(o.total_amount) / COUNT(*), 2)
ELSE 0
END AS avg_order_amount
FROM orders o
WHERE o.created_at >= p_start
AND o.created_at < p_end
AND o.status = 2
GROUP BY DATE(o.created_at)
ORDER BY day;
END;
$$;
-- 5. 价格趋势分析(按天统计平均商品单价)
CREATE OR REPLACE FUNCTION rpc_analytics_price_trend(
p_start TIMESTAMPTZ,
p_end TIMESTAMPTZ
)
RETURNS TABLE (
day DATE,
avg_price NUMERIC
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
DATE(o.created_at) AS day,
CASE
WHEN SUM(oi.quantity) > 0 THEN ROUND(SUM(oi.total_amount) / SUM(oi.quantity), 2)
ELSE 0
END AS avg_price
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE o.created_at >= p_start
AND o.created_at < p_end
AND o.status = 2
GROUP BY DATE(o.created_at)
ORDER BY day;
END;
$$;
-- 授权
REVOKE ALL ON FUNCTION rpc_analytics_category_sales(DATE, DATE) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION rpc_analytics_category_sales(DATE, DATE) TO authenticated;
REVOKE ALL ON FUNCTION rpc_analytics_seasonal_trend(DATE, DATE) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION rpc_analytics_seasonal_trend(DATE, DATE) TO authenticated;
REVOKE ALL ON FUNCTION rpc_analytics_competition_share(DATE, DATE) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION rpc_analytics_competition_share(DATE, DATE) TO authenticated;
REVOKE ALL ON FUNCTION rpc_analytics_market_trend_daily(TIMESTAMPTZ, TIMESTAMPTZ) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION rpc_analytics_market_trend_daily(TIMESTAMPTZ, TIMESTAMPTZ) TO authenticated;
REVOKE ALL ON FUNCTION rpc_analytics_price_trend(TIMESTAMPTZ, TIMESTAMPTZ) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION rpc_analytics_price_trend(TIMESTAMPTZ, TIMESTAMPTZ) TO authenticated;
SELECT 'Market trends RPCs created successfully.' AS message;

View File

@@ -0,0 +1,183 @@
-- =====================================================================================
-- 数据分析模块正式RPC主库 ml_* 口径)
-- 文件: 06_ml_analytics_rpcs_coupon.sql
-- 主题: 优惠券效果分析 (概览/类型/渠道/趋势/转化)
-- 依赖: public.ml_coupon_templates, public.ml_user_coupons, public.ml_orders
-- =====================================================================================
-- 1) 优惠券分析核心 KPI
CREATE OR REPLACE FUNCTION public.rpc_analytics_coupon_overview(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
total_issued BIGINT,
issued_growth FLOAT,
total_used BIGINT,
usage_rate FLOAT,
gmv_increase NUMERIC,
gmv_growth FLOAT,
roi FLOAT
)
LANGUAGE plpgsql
AS $$
DECLARE
prev_start_date DATE;
prev_end_date DATE;
period_days INT;
BEGIN
period_days := p_end_date - p_start_date + 1;
prev_start_date := p_start_date - period_days;
prev_end_date := p_start_date - 1;
RETURN QUERY
WITH
cur AS (
SELECT
COUNT(uc.id) AS total_issued,
COUNT(CASE WHEN uc.status = 2 THEN uc.id END) AS total_used,
COALESCE(SUM(o.total_amount), 0) AS gmv_increase
FROM public.ml_user_coupons uc
LEFT JOIN public.ml_orders o ON uc.order_id = o.id
WHERE uc.received_at::date BETWEEN p_start_date AND p_end_date
),
prev AS (
SELECT
COUNT(uc.id) AS total_issued,
COALESCE(SUM(o.total_amount), 0) AS gmv_increase
FROM public.ml_user_coupons uc
LEFT JOIN public.ml_orders o ON uc.order_id = o.id
WHERE uc.received_at::date BETWEEN prev_start_date AND prev_end_date
)
SELECT
c.total_issued,
ROUND(((c.total_issued - p.total_issued) * 100.0 / NULLIF(p.total_issued, 0))::numeric, 2)::FLOAT AS issued_growth,
c.total_used,
ROUND((c.total_used * 100.0 / NULLIF(c.total_issued, 0))::numeric, 2)::FLOAT AS usage_rate,
c.gmv_increase,
ROUND(((c.gmv_increase - p.gmv_increase) * 100.0 / NULLIF(p.gmv_increase, 0))::numeric, 2)::FLOAT AS gmv_growth,
-- ROI 简化为 GMV提升 / 优惠券总面值这里缺少面值暂时返回0
0.0::FLOAT AS roi
FROM cur c, prev p;
END;
$$;
-- 2) 按优惠券类型分析
CREATE OR REPLACE FUNCTION public.rpc_analytics_coupon_by_type(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
coupon_type INT,
total_issued BIGINT,
total_used BIGINT,
usage_rate FLOAT
)
LANGUAGE sql
AS $$
SELECT
t.coupon_type,
COUNT(uc.id)::BIGINT AS total_issued,
COUNT(CASE WHEN uc.status = 2 THEN uc.id END)::BIGINT AS total_used,
ROUND((COUNT(CASE WHEN uc.status = 2 THEN uc.id END) * 100.0 / COUNT(uc.id))::numeric, 2)::FLOAT AS usage_rate
FROM public.ml_user_coupons uc
JOIN public.ml_coupon_templates t ON uc.template_id = t.id
WHERE uc.received_at::date BETWEEN p_start_date AND p_end_date
GROUP BY t.coupon_type;
$$;
-- 3) 按发放渠道分析
-- 注意: ml_user_coupons 表缺少 'source' 或 'channel' 字段,这里返回模拟数据
CREATE OR REPLACE FUNCTION public.rpc_analytics_coupon_by_channel(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
channel TEXT,
total_issued BIGINT,
total_used BIGINT
)
LANGUAGE sql
AS $$
SELECT * FROM (VALUES
('manual', 150, 60),
('auto', 300, 120),
('campaign', 500, 250),
('invite', 80, 40),
('cs', 20, 15),
('points', 120, 50)
) AS t(channel, total_issued, total_used);
$$;
-- 4) 优惠券使用趋势 (按天)
CREATE OR REPLACE FUNCTION public.rpc_analytics_coupon_trend(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
day TEXT,
issued BIGINT,
used BIGINT
)
LANGUAGE sql
AS $$
WITH date_series AS (
SELECT generate_series(p_start_date, p_end_date, '1 day'::interval)::date AS d
)
SELECT
to_char(ds.d, 'YYYY-MM-DD') AS day,
(SELECT COUNT(id) FROM public.ml_user_coupons WHERE received_at::date = ds.d) AS issued,
(SELECT COUNT(id) FROM public.ml_user_coupons WHERE used_at::date = ds.d) AS used
FROM date_series ds
ORDER BY ds.d;
$$;
-- 5) 优惠券转化效果对比
CREATE OR REPLACE FUNCTION public.rpc_analytics_coupon_conversion(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
metric TEXT,
with_coupon NUMERIC,
without_coupon NUMERIC
)
LANGUAGE sql
AS $$
WITH orders_with_coupon AS (
SELECT
SUM(o.total_amount) AS gmv,
COUNT(o.id) AS orders
FROM public.ml_orders o
WHERE o.created_at::date BETWEEN p_start_date AND p_end_date
AND o.payment_status = 2
AND o.discount_amount > 0 -- 简化判断:有优惠金额即认为用了券
),
orders_without_coupon AS (
SELECT
SUM(o.total_amount) AS gmv,
COUNT(o.id) AS orders
FROM public.ml_orders o
WHERE o.created_at::date BETWEEN p_start_date AND p_end_date
AND o.payment_status = 2
AND o.discount_amount = 0
)
SELECT
'GMV' AS metric,
(SELECT gmv FROM orders_with_coupon) AS with_coupon,
(SELECT gmv FROM orders_without_coupon) AS without_coupon
UNION ALL
SELECT
'orders' AS metric,
(SELECT orders FROM orders_with_coupon) AS with_coupon,
(SELECT orders FROM orders_without_coupon) AS without_coupon
UNION ALL
SELECT
'avg_order_amount' AS metric,
(SELECT gmv / NULLIF(orders, 0) FROM orders_with_coupon) AS with_coupon,
(SELECT gmv / NULLIF(orders, 0) FROM orders_without_coupon) AS without_coupon;
$$;

View File

@@ -1,405 +0,0 @@
-- ============================================
-- 07_custom_report_rpcs.sql
-- 自定义报表创建与管理 RPC 定义
-- ============================================
-- 目标:
-- 1) 为 `pages/mall/analytics/custom-report.uvue` 提供安全的数据服务
-- 2) 确保用户记录存在,解决外键约束问题
-- 3) 创建自定义报表并生成初始数据metrics + rows
--
-- 依赖前置脚本:
-- - 01_create_tables.sqlusers 表)
-- - ANALYTICS_DB_SCHEMA.sqlanalytics_reports / analytics_report_metrics / analytics_report_rows
--
-- 使用说明:
-- - 前端通过 supabase-js / UTS 调用 `rpc()` 访问本文件中的函数
-- - 所有函数仅对 `authenticated` 角色开放执行权限
-- ============================================
-- --------------------------------------------
-- 1. 确保用户记录存在Upsert User
-- --------------------------------------------
-- 说明:
-- - 如果 users 表中不存在当前用户记录,则插入
-- - 如果已存在,则更新最后登录时间等信息
-- - 解决 analytics_reports.owner_user_id 外键约束问题
CREATE OR REPLACE FUNCTION public.rpc_ensure_user_record(
p_user_id uuid,
p_email text DEFAULT NULL,
p_phone text DEFAULT NULL,
p_nickname text DEFAULT NULL
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id uuid;
BEGIN
-- 检查用户是否存在
SELECT id INTO v_user_id
FROM public.users
WHERE id = p_user_id;
IF v_user_id IS NULL THEN
-- 用户不存在,插入新记录
INSERT INTO public.users (
id,
email,
phone,
nickname,
last_login_at,
created_at,
updated_at
) VALUES (
p_user_id,
p_email,
p_phone,
COALESCE(p_nickname, COALESCE(split_part(p_email, '@', 1), '用户')),
NOW(),
NOW(),
NOW()
)
RETURNING id INTO v_user_id;
ELSE
-- 用户已存在,更新信息
UPDATE public.users
SET
email = COALESCE(p_email, email),
phone = COALESCE(p_phone, phone),
nickname = COALESCE(p_nickname, nickname),
last_login_at = NOW(),
updated_at = NOW()
WHERE id = p_user_id;
v_user_id := p_user_id;
END IF;
RETURN v_user_id;
END;
$$;
REVOKE ALL ON FUNCTION public.rpc_ensure_user_record(uuid,text,text,text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_ensure_user_record(uuid,text,text,text) TO authenticated;
-- --------------------------------------------
-- 2. 创建自定义报表(含初始数据生成)
-- --------------------------------------------
-- 说明:
-- - 创建 analytics_reports 记录
-- - 根据 period 和选中的指标,生成 analytics_report_metrics
-- - 根据 period 聚合 orders 数据,生成 analytics_report_rows
CREATE OR REPLACE FUNCTION public.rpc_create_custom_report(
p_title text,
p_description text DEFAULT '',
p_period text DEFAULT '30d', -- 7d/30d/90d/1y
p_metrics text[] DEFAULT ARRAY['gmv', 'orders', 'users'], -- 选中的指标列表
p_chart_type text DEFAULT 'line' -- 图表类型(暂不存储,仅用于后续扩展)
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id uuid;
v_report_id uuid;
v_start_date date;
v_end_date date;
v_metric_key text;
v_metric_label text;
v_metric_value numeric;
v_total_gmv numeric := 0;
v_total_orders integer := 0;
v_total_users integer := 0;
v_avg_order_amount numeric := 0;
BEGIN
-- 1. 获取当前用户 ID
v_user_id := auth.uid();
IF v_user_id IS NULL THEN
RAISE EXCEPTION '用户未登录';
END IF;
-- 2. 确保用户记录存在
PERFORM public.rpc_ensure_user_record(
v_user_id,
NULL, -- email 从 auth.users 获取,这里不传
NULL, -- phone 从 auth.users 获取,这里不传
NULL -- nickname 从 auth.users 获取,这里不传
);
-- 3. 计算时间范围
v_end_date := CURRENT_DATE;
CASE p_period
WHEN '7d' THEN v_start_date := v_end_date - INTERVAL '7 days';
WHEN '30d' THEN v_start_date := v_end_date - INTERVAL '30 days';
WHEN '90d' THEN v_start_date := v_end_date - INTERVAL '90 days';
WHEN '1y' THEN v_start_date := v_end_date - INTERVAL '1 year';
ELSE v_start_date := v_end_date - INTERVAL '30 days'; -- 默认 30 天
END CASE;
-- 4. 创建报表记录
INSERT INTO public.analytics_reports (
owner_user_id,
title,
description,
type,
period,
date_start,
date_end,
status,
generated_at,
created_at,
updated_at
) VALUES (
v_user_id,
p_title,
p_description,
'custom',
p_period,
v_start_date,
v_end_date,
'ready',
NOW(),
NOW(),
NOW()
)
RETURNING id INTO v_report_id;
-- 5. 聚合订单数据,计算总指标
SELECT
COALESCE(SUM(o.total_amount), 0),
COUNT(DISTINCT o.id),
COUNT(DISTINCT o.user_id),
CASE
WHEN COUNT(DISTINCT o.id) > 0
THEN COALESCE(SUM(o.total_amount), 0) / COUNT(DISTINCT o.id)
ELSE 0
END
INTO v_total_gmv, v_total_orders, v_total_users, v_avg_order_amount
FROM public.orders o
WHERE o.created_at >= v_start_date
AND o.created_at <= v_end_date
AND o.status = 2; -- 已完成订单
-- 6. 生成核心指标analytics_report_metrics
-- GMV
IF 'gmv' = ANY(p_metrics) THEN
INSERT INTO public.analytics_report_metrics (
report_id,
metric_key,
metric_label,
metric_value_num,
format,
icon,
color
) VALUES (
v_report_id,
'gmv_total',
'总GMV',
v_total_gmv,
'currency',
'💰',
'#4caf50'
);
END IF;
-- 订单数
IF 'orders' = ANY(p_metrics) THEN
INSERT INTO public.analytics_report_metrics (
report_id,
metric_key,
metric_label,
metric_value_num,
format,
icon,
color
) VALUES (
v_report_id,
'orders_total',
'总订单数',
v_total_orders,
'number',
'📦',
'#2196f3'
);
END IF;
-- 用户数
IF 'users' = ANY(p_metrics) THEN
INSERT INTO public.analytics_report_metrics (
report_id,
metric_key,
metric_label,
metric_value_num,
format,
icon,
color
) VALUES (
v_report_id,
'users_total',
'下单用户数',
v_total_users,
'number',
'👥',
'#ff9800'
);
END IF;
-- 客单价
IF 'avg_order' = ANY(p_metrics) THEN
INSERT INTO public.analytics_report_metrics (
report_id,
metric_key,
metric_label,
metric_value_num,
format,
icon,
color
) VALUES (
v_report_id,
'avg_order_amount',
'客单价',
v_avg_order_amount,
'currency',
'💵',
'#9c27b0'
);
END IF;
-- 7. 生成明细行analytics_report_rows- 按天聚合
INSERT INTO public.analytics_report_rows (
report_id,
row_date,
gmv,
orders,
users,
avg_order_amount
)
SELECT
v_report_id,
o.created_at::date AS row_date,
COALESCE(SUM(o.total_amount), 0) AS gmv,
COUNT(DISTINCT o.id) AS orders,
COUNT(DISTINCT o.user_id) AS users,
CASE
WHEN COUNT(DISTINCT o.id) > 0
THEN COALESCE(SUM(o.total_amount), 0) / COUNT(DISTINCT o.id)
ELSE 0
END AS avg_order_amount
FROM public.orders o
WHERE o.created_at >= v_start_date
AND o.created_at <= v_end_date
AND o.status = 2
GROUP BY o.created_at::date
ORDER BY o.created_at::date;
RETURN v_report_id;
END;
$$;
REVOKE ALL ON FUNCTION public.rpc_create_custom_report(text,text,text,text[],text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_create_custom_report(text,text,text,text[],text) TO authenticated;
-- --------------------------------------------
-- 3. 更新自定义报表(仅更新基本信息)
-- --------------------------------------------
-- 说明:
-- - 更新报表的标题、描述、周期
-- - 不重新生成数据(如需重新生成,删除后重建)
CREATE OR REPLACE FUNCTION public.rpc_update_custom_report(
p_report_id uuid,
p_title text,
p_description text DEFAULT NULL,
p_period text DEFAULT NULL
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id uuid;
BEGIN
-- 1. 获取当前用户 ID
v_user_id := auth.uid();
IF v_user_id IS NULL THEN
RAISE EXCEPTION '用户未登录';
END IF;
-- 2. 更新报表(仅限所有者)
UPDATE public.analytics_reports
SET
title = p_title,
description = COALESCE(p_description, description),
period = COALESCE(p_period, period),
updated_at = NOW()
WHERE id = p_report_id
AND owner_user_id = v_user_id;
-- FOUND 是 PostgreSQL 的特殊变量UPDATE 后自动设置
IF NOT FOUND THEN
RAISE EXCEPTION '报表不存在或无权限修改';
END IF;
RETURN true;
END;
$$;
REVOKE ALL ON FUNCTION public.rpc_update_custom_report(uuid,text,text,text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_update_custom_report(uuid,text,text,text) TO authenticated;
-- --------------------------------------------
-- 4. 删除自定义报表(级联删除相关数据)
-- --------------------------------------------
-- 说明:
-- - 删除报表记录CASCADE 会自动删除 metrics 和 rows
-- - 仅允许所有者删除
CREATE OR REPLACE FUNCTION public.rpc_delete_custom_report(
p_report_id uuid
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id uuid;
BEGIN
-- 1. 获取当前用户 ID
v_user_id := auth.uid();
IF v_user_id IS NULL THEN
RAISE EXCEPTION '用户未登录';
END IF;
-- 2. 删除报表仅限所有者CASCADE 会自动删除 metrics 和 rows
DELETE FROM public.analytics_reports
WHERE id = p_report_id
AND owner_user_id = v_user_id
AND type = 'custom';
-- FOUND 是 PostgreSQL 的特殊变量DELETE 后自动设置
IF NOT FOUND THEN
RAISE EXCEPTION '报表不存在或无权限删除';
END IF;
RETURN true;
END;
$$;
REVOKE ALL ON FUNCTION public.rpc_delete_custom_report(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_delete_custom_report(uuid) TO authenticated;
-- ============================================
-- 完成提示
-- ============================================
DO $$
BEGIN
RAISE NOTICE 'Custom report RPCs created successfully.';
RAISE NOTICE 'Functions:';
RAISE NOTICE ' - rpc_ensure_user_record(uuid, text, text, text)';
RAISE NOTICE ' - rpc_create_custom_report(text, text, text, text[], text)';
RAISE NOTICE ' - rpc_update_custom_report(uuid, text, text, text)';
RAISE NOTICE ' - rpc_delete_custom_report(uuid)';
END $$;

View File

@@ -0,0 +1,85 @@
-- =====================================================================================
-- 数据分析模块正式RPC主库 ml_* 口径)
-- 文件: 07_ml_analytics_rpcs_delivery.sql
-- 主题: 配送效率分析 (趋势/费用/配送员排行)
-- 依赖: public.ml_delivery_tasks, public.ml_delivery_drivers
-- 口径约定:
-- - 完成配送ml_delivery_tasks.status = 5
-- - 配送时长delivered_at - assigned_at分钟
-- - avg_feedelivery_fee 平均
-- =====================================================================================
-- 1) 配送效率日趋势
-- 返回字段需匹配前端 delivery-analysis.uvueday / completed_orders / avg_delivery_minutes / avg_fee / total_fee
CREATE OR REPLACE FUNCTION public.rpc_delivery_efficiency_daily(
p_start TIMESTAMPTZ,
p_end TIMESTAMPTZ
)
RETURNS TABLE (
day TEXT,
completed_orders BIGINT,
avg_delivery_minutes NUMERIC,
avg_fee NUMERIC,
total_fee NUMERIC
)
LANGUAGE sql
AS $$
WITH date_series AS (
SELECT generate_series(date_trunc('day', p_start), date_trunc('day', p_end), interval '1 day')::date AS d
),
tasks AS (
SELECT
assigned_at::date AS d,
EXTRACT(EPOCH FROM (delivered_at - assigned_at)) / 60.0 AS minutes,
delivery_fee
FROM public.ml_delivery_tasks
WHERE status = 5
AND assigned_at IS NOT NULL
AND delivered_at IS NOT NULL
AND assigned_at >= p_start
AND assigned_at < (p_end + interval '1 second')
)
SELECT
to_char(ds.d, 'YYYY-MM-DD') AS day,
COUNT(t.minutes)::bigint AS completed_orders,
ROUND(COALESCE(AVG(t.minutes), 0)::numeric, 2) AS avg_delivery_minutes,
ROUND(COALESCE(AVG(t.delivery_fee), 0)::numeric, 2) AS avg_fee,
ROUND(COALESCE(SUM(t.delivery_fee), 0)::numeric, 2) AS total_fee
FROM date_series ds
LEFT JOIN tasks t ON t.d = ds.d
GROUP BY ds.d
ORDER BY ds.d;
$$;
-- 2) 配送员效率排行 TOP
-- 返回字段需匹配前端 delivery-analysis.uvuedriver_id / driver_name / orders / rating_avg
CREATE OR REPLACE FUNCTION public.rpc_delivery_efficiency_top_drivers(
p_start TIMESTAMPTZ,
p_end TIMESTAMPTZ,
p_limit INT DEFAULT 10
)
RETURNS TABLE (
driver_id TEXT,
driver_name TEXT,
orders BIGINT,
rating_avg NUMERIC
)
LANGUAGE sql
AS $$
SELECT
d.user_id::text AS driver_id,
COALESCE(NULLIF(u.username, ''), d.real_name, '未知')::text AS driver_name,
COUNT(t.id)::bigint AS orders,
COALESCE(d.rating_avg, 0)::numeric AS rating_avg
FROM public.ml_delivery_tasks t
JOIN public.ml_delivery_drivers d ON d.id = t.driver_id
LEFT JOIN public.ak_users u ON u.id = d.user_id
WHERE t.status = 5
AND t.assigned_at IS NOT NULL
AND t.assigned_at >= p_start
AND t.assigned_at < (p_end + interval '1 second')
GROUP BY d.user_id, u.username, d.real_name, d.rating_avg
ORDER BY orders DESC
LIMIT p_limit;
$$;

View File

@@ -0,0 +1,145 @@
-- =====================================================================================
-- 数据分析模块正式RPC主库 ml_* 口径)
-- 文件: 08_ml_analytics_rpcs_custom_report.sql
-- 主题: 自定义报表(表结构 + CRUD RPC
-- =====================================================================================
-- 说明:
-- 1) 该脚本会创建 analytics_reports 表用于保存报表定义JSONB
-- 2) 该脚本包含基于 auth.uid() 的 RLS 策略与 SECURITY DEFINER RPC。
-- 3) 若你暂时不希望启用 RLS可注释掉 RLS 与 POLICY 相关语句。
-- 1) 创建自定义报表定义表
CREATE TABLE IF NOT EXISTS public.analytics_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
owner_user_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
definition JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_analytics_reports_owner_user_id ON public.analytics_reports(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_analytics_reports_updated_at ON public.analytics_reports(updated_at DESC);
-- 2) RLS可选
ALTER TABLE public.analytics_reports ENABLE ROW LEVEL SECURITY;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'analytics_reports' AND policyname = 'analytics_reports_owner_all') THEN
CREATE POLICY analytics_reports_owner_all ON public.analytics_reports
FOR ALL
USING (auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = owner_user_id))
WITH CHECK (auth.uid() = (SELECT auth_id FROM public.ak_users WHERE id = owner_user_id));
END IF;
END $$;
-- 3) RPC获取我的报表列表
CREATE OR REPLACE FUNCTION public.rpc_get_custom_reports()
RETURNS TABLE (
id UUID,
title TEXT,
description TEXT,
updated_at TIMESTAMPTZ
)
LANGUAGE sql
SECURITY DEFINER
AS $$
SELECT r.id, r.title, r.description, r.updated_at
FROM public.analytics_reports r
WHERE r.owner_user_id = (SELECT id FROM public.ak_users WHERE auth_id = auth.uid() LIMIT 1)
ORDER BY r.updated_at DESC;
$$;
-- 4) RPC创建报表
CREATE OR REPLACE FUNCTION public.rpc_create_custom_report(
p_title TEXT,
p_description TEXT,
p_definition JSONB
)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_owner_user_id UUID;
v_report_id UUID;
BEGIN
SELECT id INTO v_owner_user_id
FROM public.ak_users
WHERE auth_id = auth.uid()
LIMIT 1;
IF v_owner_user_id IS NULL THEN
RAISE EXCEPTION 'No matching ak_users row for auth.uid()';
END IF;
INSERT INTO public.analytics_reports (owner_user_id, title, description, definition)
VALUES (v_owner_user_id, p_title, p_description, p_definition)
RETURNING id INTO v_report_id;
RETURN v_report_id;
END;
$$;
-- 5) RPC更新报表
CREATE OR REPLACE FUNCTION public.rpc_update_custom_report(
p_report_id UUID,
p_title TEXT,
p_description TEXT,
p_definition JSONB
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_owner_user_id UUID;
BEGIN
SELECT id INTO v_owner_user_id
FROM public.ak_users
WHERE auth_id = auth.uid()
LIMIT 1;
IF v_owner_user_id IS NULL THEN
RAISE EXCEPTION 'No matching ak_users row for auth.uid()';
END IF;
UPDATE public.analytics_reports
SET
title = p_title,
description = p_description,
definition = p_definition,
updated_at = now()
WHERE id = p_report_id
AND owner_user_id = v_owner_user_id;
END;
$$;
-- 6) RPC删除报表
CREATE OR REPLACE FUNCTION public.rpc_delete_custom_report(
p_report_id UUID
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_owner_user_id UUID;
BEGIN
SELECT id INTO v_owner_user_id
FROM public.ak_users
WHERE auth_id = auth.uid()
LIMIT 1;
IF v_owner_user_id IS NULL THEN
RAISE EXCEPTION 'No matching ak_users row for auth.uid()';
END IF;
DELETE FROM public.analytics_reports
WHERE id = p_report_id
AND owner_user_id = v_owner_user_id;
END;
$$;

View File

@@ -0,0 +1,53 @@
-- =====================================================================================
-- 数据分析模块正式RPC主库 ml_* 口径)
-- 文件: 08_ml_analytics_rpcs_data_detail.sql
-- 主题: 数据详情页 (下钻/对比)
-- =====================================================================================
-- 1) 数据下钻
CREATE OR REPLACE FUNCTION public.rpc_data_detail_drill_items(
p_report_id TEXT,
p_filters JSONB
)
RETURNS TABLE (
item_id TEXT,
item_name TEXT,
gmv NUMERIC,
orders BIGINT
)
LANGUAGE sql
AS $$
-- 模拟返回,实际应根据 p_report_id 和 p_filters 查询
SELECT
'prod-001' AS item_id,
'商品A' AS item_name,
12000 AS gmv,
300 AS orders
UNION ALL
SELECT
'prod-002' AS item_id,
'商品B' AS item_name,
8000 AS gmv,
200 AS orders;
$$;
-- 2) GMV 对比
CREATE OR REPLACE FUNCTION public.rpc_data_detail_compare_gmv(
p_item_id TEXT,
p_period TEXT
)
RETURNS TABLE (
date TEXT,
current_gmv NUMERIC,
previous_gmv NUMERIC
)
LANGUAGE sql
AS $$
-- 模拟返回
SELECT
d.date,
(1000 + random() * 500) AS current_gmv,
(800 + random() * 400) AS previous_gmv
FROM generate_series(CURRENT_DATE - interval '6 days', CURRENT_DATE, '1 day') AS d(date);
$$;

View File

@@ -0,0 +1,239 @@
-- =====================================================================================
-- 数据分析模块正式RPC主库 ml_* 口径)
-- 文件: 09_ml_analytics_rpcs_data_detail.sql
-- 主题: data-detail 动态报表执行引擎MVPsales + users
-- 依赖: public.analytics_reports, public.ml_orders, public.ml_order_items, public.ml_products, public.ml_categories,
-- public.ml_browse_history, public.ak_users
--
-- 设计原则:
-- - 不允许自由 SQL使用 dataset/dimensions/metrics 的白名单解析。
-- - definition JSONB 结构(建议):
-- {
-- "dataset": "sales" | "users",
-- "dimensions": ["date"|"category"|"merchant"|"source"],
-- "metrics": ["gmv"|"orders"|"paid_users"|"new_users"|"active_users"],
-- "limit": 200
-- }
-- - 请求参数 p_params JSONB 可覆盖时间范围等:
-- {
-- "start_date": "YYYY-MM-DD",
-- "end_date": "YYYY-MM-DD"
-- }
-- =====================================================================================
-- 1) 报表信息(返回 definition
CREATE OR REPLACE FUNCTION public.rpc_data_detail_report_info(
p_report_id UUID
)
RETURNS TABLE (
id UUID,
title TEXT,
description TEXT,
definition JSONB,
updated_at TIMESTAMPTZ
)
LANGUAGE sql
SECURITY DEFINER
AS $$
SELECT r.id, r.title, r.description, r.definition, r.updated_at
FROM public.analytics_reports r
WHERE r.id = p_report_id
AND r.owner_user_id = (SELECT id FROM public.ak_users WHERE auth_id = auth.uid() LIMIT 1);
$$;
-- 2) 明细数据行(动态执行,返回 JSONB 行数组)
CREATE OR REPLACE FUNCTION public.rpc_data_detail_rows(
p_report_id UUID,
p_params JSONB DEFAULT '{}'::jsonb
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_owner_user_id UUID;
v_def JSONB;
v_dataset TEXT;
v_dims JSONB;
v_metrics JSONB;
v_limit INT;
v_start_date DATE;
v_end_date DATE;
v_sql TEXT;
v_result JSONB;
BEGIN
SELECT id INTO v_owner_user_id
FROM public.ak_users
WHERE auth_id = auth.uid()
LIMIT 1;
IF v_owner_user_id IS NULL THEN
RAISE EXCEPTION 'No matching ak_users row for auth.uid()';
END IF;
SELECT definition INTO v_def
FROM public.analytics_reports
WHERE id = p_report_id AND owner_user_id = v_owner_user_id;
IF v_def IS NULL THEN
RAISE EXCEPTION 'Report not found or forbidden';
END IF;
v_dataset := COALESCE(v_def->>'dataset', 'sales');
v_dims := COALESCE(v_def->'dimensions', '[]'::jsonb);
v_metrics := COALESCE(v_def->'metrics', '[]'::jsonb);
v_limit := COALESCE(NULLIF((v_def->>'limit')::int, 0), 200);
v_start_date := COALESCE((p_params->>'start_date')::date, (CURRENT_DATE - INTERVAL '29 days')::date);
v_end_date := COALESCE((p_params->>'end_date')::date, CURRENT_DATE);
-- ==========================
-- dataset: sales
-- dims: date, category, merchant
-- metrics: gmv, orders, paid_users
-- ==========================
IF v_dataset = 'sales' THEN
-- 构造 SELECT 维度字段
v_sql := 'WITH base AS ( ' ||
' SELECT ' ||
' o.id as order_id, ' ||
' o.user_id, ' ||
' o.merchant_id, ' ||
' o.created_at::date as date_key, ' ||
' COALESCE(NULLIF(o.paid_amount,0), o.total_amount) as paid_gmv ' ||
' FROM public.ml_orders o ' ||
' WHERE o.payment_status = 2 ' ||
' AND o.created_at::date BETWEEN $1 AND $2 ' ||
') ' ||
'SELECT jsonb_agg(row_to_json(t)::jsonb) FROM ( SELECT ';
-- dimensions
IF v_dims ? 'date' THEN
v_sql := v_sql || ' date_key as date, ';
END IF;
IF v_dims ? 'merchant' THEN
v_sql := v_sql || ' merchant_id, ';
END IF;
IF v_dims ? 'category' THEN
v_sql := v_sql ||
' (SELECT c.name FROM public.ml_order_items oi ' ||
' JOIN public.ml_products p ON p.id = oi.product_id ' ||
' JOIN public.ml_categories c ON c.id = p.category_id ' ||
' WHERE oi.order_id = base.order_id ' ||
' LIMIT 1) as category_name, ';
END IF;
-- metrics
IF v_metrics ? 'gmv' THEN
v_sql := v_sql || ' SUM(base.paid_gmv) as gmv, ';
END IF;
IF v_metrics ? 'orders' THEN
v_sql := v_sql || ' COUNT(DISTINCT base.order_id) as orders, ';
END IF;
IF v_metrics ? 'paid_users' THEN
v_sql := v_sql || ' COUNT(DISTINCT base.user_id) as paid_users, ';
END IF;
-- 去掉末尾多余逗号
v_sql := regexp_replace(v_sql, ',\s*$', ' ');
v_sql := v_sql || ' FROM base ';
-- group by
v_sql := v_sql || ' GROUP BY ';
IF v_dims ? 'date' THEN v_sql := v_sql || ' date_key, '; END IF;
IF v_dims ? 'merchant' THEN v_sql := v_sql || ' merchant_id, '; END IF;
IF v_dims ? 'category' THEN v_sql := v_sql || ' category_name, '; END IF;
v_sql := regexp_replace(v_sql, ',\s*$', ' ');
v_sql := v_sql || ' ORDER BY ';
IF v_dims ? 'date' THEN v_sql := v_sql || ' date_key DESC, '; END IF;
v_sql := regexp_replace(v_sql, ',\s*$', ' ');
v_sql := v_sql || ' LIMIT ' || v_limit::text || ' ) t';
EXECUTE v_sql USING v_start_date, v_end_date INTO v_result;
RETURN COALESCE(v_result, '[]'::jsonb);
END IF;
-- ==========================
-- dataset: users
-- dims: date, source
-- metrics: new_users, active_users, paid_users
-- ==========================
IF v_dataset = 'users' THEN
v_sql := 'WITH date_series AS ( ' ||
' SELECT generate_series($1, $2, interval ''1 day'')::date as date_key ' ||
'), new_u AS ( ' ||
' SELECT created_at::date as date_key, registration_source as source, COUNT(id) as new_users ' ||
' FROM public.ak_users ' ||
' WHERE created_at::date BETWEEN $1 AND $2 ' ||
' GROUP BY created_at::date, registration_source ' ||
'), active_u AS ( ' ||
' SELECT created_at::date as date_key, COUNT(DISTINCT user_id) as active_users ' ||
' FROM public.ml_browse_history ' ||
' WHERE created_at::date BETWEEN $1 AND $2 ' ||
' GROUP BY created_at::date ' ||
'), paid_u AS ( ' ||
' SELECT created_at::date as date_key, COUNT(DISTINCT user_id) as paid_users ' ||
' FROM public.ml_orders ' ||
' WHERE payment_status = 2 AND created_at::date BETWEEN $1 AND $2 ' ||
' GROUP BY created_at::date ' ||
') ' ||
'SELECT jsonb_agg(row_to_json(t)::jsonb) FROM ( SELECT ';
IF v_dims ? 'date' THEN
v_sql := v_sql || ' ds.date_key as date, ';
END IF;
IF v_dims ? 'source' THEN
v_sql := v_sql || ' COALESCE(nu.source, ''未知'') as source, ';
END IF;
IF v_metrics ? 'new_users' THEN
v_sql := v_sql || ' COALESCE(nu.new_users, 0) as new_users, ';
END IF;
IF v_metrics ? 'active_users' THEN
v_sql := v_sql || ' COALESCE(au.active_users, 0) as active_users, ';
END IF;
IF v_metrics ? 'paid_users' THEN
v_sql := v_sql || ' COALESCE(pu.paid_users, 0) as paid_users, ';
END IF;
v_sql := regexp_replace(v_sql, ',\s*$', ' ');
v_sql := v_sql || ' FROM date_series ds ';
v_sql := v_sql || ' LEFT JOIN active_u au ON au.date_key = ds.date_key ';
v_sql := v_sql || ' LEFT JOIN paid_u pu ON pu.date_key = ds.date_key ';
IF v_dims ? 'source' THEN
v_sql := v_sql || ' LEFT JOIN new_u nu ON nu.date_key = ds.date_key ';
ELSE
-- 不按 source 维度时,把 new_u 聚合为按日
v_sql := v_sql || ' LEFT JOIN (SELECT date_key, SUM(new_users)::bigint as new_users FROM new_u GROUP BY date_key) nu ON nu.date_key = ds.date_key ';
END IF;
-- group by
IF v_dims ? 'source' THEN
v_sql := v_sql || ' GROUP BY ds.date_key, nu.source, nu.new_users, au.active_users, pu.paid_users ';
ELSE
v_sql := v_sql || ' GROUP BY ds.date_key, nu.new_users, au.active_users, pu.paid_users ';
END IF;
v_sql := v_sql || ' ORDER BY ds.date_key DESC ';
v_sql := v_sql || ' LIMIT ' || v_limit::text || ' ) t';
EXECUTE v_sql USING v_start_date, v_end_date INTO v_result;
RETURN COALESCE(v_result, '[]'::jsonb);
END IF;
RAISE EXCEPTION 'Unsupported dataset: %', v_dataset;
END;
$$;

View File

@@ -0,0 +1,218 @@
-- =====================================================================================
-- 数据分析模块正式RPC验证脚本
-- 文件: 99_ml_analytics_verify.sql
-- 说明:
-- 1) 先执行 01/02/03/04/05/06/07 等 RPC 脚本
-- 2) 再执行本文件,检查各 RPC 是否有返回
-- =====================================================================================
-- =============================
-- Dashboard仪表盘
-- =============================
SELECT *
FROM public.rpc_analytics_realtime_kpis();
SELECT public.rpc_analytics_online_users() AS online_users;
-- =============================
-- 用户分析
-- =============================
SELECT *
FROM public.rpc_analytics_user_kpis(
(CURRENT_DATE - INTERVAL '6 days')::date,
CURRENT_DATE
);
SELECT *
FROM public.rpc_analytics_user_growth_trend(
(CURRENT_DATE - INTERVAL '6 days')::date,
CURRENT_DATE
)
ORDER BY date;
SELECT *
FROM public.rpc_analytics_user_segments(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE
);
SELECT *
FROM public.rpc_analytics_traffic_sources(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE
);
-- =============================
-- 销售/仪表盘趋势
-- =============================
SELECT *
FROM public.rpc_analytics_sales_kpis(
(CURRENT_DATE - INTERVAL '6 days')::date,
CURRENT_DATE
);
SELECT *
FROM public.rpc_analytics_sales_trend(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE
)
ORDER BY date;
SELECT *
FROM public.rpc_analytics_top_products(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE,
10
);
SELECT *
FROM public.rpc_analytics_top_merchants(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE,
10
);
-- =============================
-- 市场趋势
-- =============================
SELECT *
FROM public.rpc_analytics_market_trend_daily(
(CURRENT_DATE - INTERVAL '29 days')::timestamptz,
CURRENT_DATE::timestamptz
)
ORDER BY day;
SELECT *
FROM public.rpc_analytics_category_sales(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE
)
LIMIT 20;
SELECT *
FROM public.rpc_analytics_seasonal_trend(
(CURRENT_DATE - INTERVAL '180 days')::date,
CURRENT_DATE
);
SELECT *
FROM public.rpc_analytics_price_trend(
(CURRENT_DATE - INTERVAL '29 days')::timestamptz,
CURRENT_DATE::timestamptz
)
ORDER BY day;
SELECT *
FROM public.rpc_analytics_competition_share(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE
)
LIMIT 20;
-- =============================
-- 商品洞察
-- =============================
SELECT *
FROM public.rpc_product_insights_overview(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE
);
SELECT *
FROM public.rpc_product_insights_stock();
SELECT *
FROM public.rpc_product_insights_reviews();
-- =============================
-- 优惠券分析
-- =============================
SELECT *
FROM public.rpc_analytics_coupon_overview(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE
);
SELECT *
FROM public.rpc_analytics_coupon_by_type(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE
);
SELECT *
FROM public.rpc_analytics_coupon_by_channel(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE
);
SELECT *
FROM public.rpc_analytics_coupon_trend(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE
)
ORDER BY day;
SELECT *
FROM public.rpc_analytics_coupon_conversion(
(CURRENT_DATE - INTERVAL '29 days')::date,
CURRENT_DATE
);
-- =============================
-- 配送分析
-- =============================
SELECT *
FROM public.rpc_delivery_efficiency_daily(
(CURRENT_DATE - INTERVAL '29 days')::timestamptz,
CURRENT_DATE::timestamptz
)
ORDER BY day;
SELECT *
FROM public.rpc_delivery_efficiency_top_drivers(
(CURRENT_DATE - INTERVAL '29 days')::timestamptz,
CURRENT_DATE::timestamptz,
10
);
-- =============================
-- 自定义报表 & 数据详情
-- =============================
-- 注意:这部分验证需要以一个真实用户身份执行(例如在 Supabase SQL Editor 中)
-- 1. 创建一个测试报表 (sales dataset)
SELECT public.rpc_create_custom_report(
'Test Sales Report',
'A test report for sales data',
'{"dataset": "sales", "dimensions": ["date", "category"], "metrics": ["gmv", "orders"]}'::jsonb
) AS test_report_id;
-- 2. 获取报表列表,验证是否创建成功
SELECT * FROM public.rpc_get_custom_reports();
-- 3. 获取报表定义
-- 提示:将下面的 UUID 替换为步骤1返回的 test_report_id
-- SELECT * FROM public.rpc_data_detail_report_info('your-test-report-id-here');
-- 4. 执行报表
-- 提示:将下面的 UUID 替换为步骤1返回的 test_report_id
-- SELECT * FROM public.rpc_data_detail_rows('your-test-report-id-here');
-- 5. 删除测试报表
-- 提示:将下面的 UUID 替换为步骤1返回的 test_report_id
-- SELECT public.rpc_delete_custom_report('your-test-report-id-here');

View File

@@ -1,66 +0,0 @@
# Analytics 测试数据快速开始(更新版)
> 本文档基于 **2026-01 修订后的数据库脚本**(含 RLS 安全、中文注释、幂等执行)
>
> 请务必按下述 **执行顺序** 依次运行 SQL否则会出现外键或 RLS 限制导致的插入失败。
---
## 🗂️ SQL 执行顺序(只创建,不删除)
| 步骤 | 作用 | 文件 | 需要权限 |
| ---- | ----------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------- |
| 1 | 创建基础业务表orders/users/user_sessions/products/merchants/page_views 等) | `01_create_tables.sql` | 任意(不清空数据,可重复执行) |
| 2 | 创建用户资料表ak_users+ RLS + 用户资料函数 | `../../user/test/USER_AUTH_SCHEMA.sql` | 任意(不清空数据,可重复执行) |
| 3 | 创建 auth.users → ak_users 触发器 | `../../user/test/USER_AUTH_TRIGGER.sql` | **需要访问 auth schema建议 Dashboard SQL Editor** |
| 4 | 创建 analytics_* 表 + RLS + RPC | `ANALYTICS_DB_SCHEMA.sql` | 任意(不清空数据,可重复执行) |
| 5 | 插入业务侧测试数据 | `02_insert_test_data.sql` | **service_role**¹ |
| 6 | 插入 analytics_* 测试数据 | `ANALYTICS_TEST_SEED.sql` | **service_role**¹ |
| 7 | (可选) 查询验证 | `03_test_queries.sql` | 登录用户 |
¹ *原因:两份 seed 脚本要写入带 RLS 的表,直接用 anon / authenticated 会被策略拦截。Dashboard SQL Editor 默认具备等价于 postgres/service_role 的权限可直接执行CLI 请使用 `psql … -U postgres`(或你的 DB 管理员账号)执行。*
---
## 🚀 执行步骤(以 Supabase Dashboard 为例)
1. 打开 **SQL Editor** → 依次新建 Query 运行 *步骤14*
2. 登出 / 使用普通账号登录,再运行 *步骤5* 查询验证。
---
## ⚠️ 常见问题
1. **RLS 阻挡插入**
请确认 seed 在 Dashboard 执行,或先 `SET ROLE service_role;`
不建议在 seed 中禁用 RLS。
2. **重复执行报错**
脚本为“只创建,不删除”模式:表/索引使用 `IF NOT EXISTS`,触发器/策略使用系统表判断后再创建,可重复执行。若仍报错,请先 `ROLLBACK;` 再重试。
3. **前端查不到 seed 数据**
登陆用户的 `auth.uid()`必须与 seed 中 `orders.user_id` 等字段匹配;否则受 RLS 影响会看不到。测试时可在 seed 中把某条 `user_id` 改成你自己的 UID。
---
## 🔐 权限矩阵(简版)
| 表 / 功能 | anon | authenticated | service_role |
| -------------------------------------- | ----------------- | ------------------- | ------------ |
| `orders / order_items / user_sessions` | Insert❌ / Select❌ | ✅(仅本人) | ✅(全部) |
| `products / merchants` | Select✅ | CRUD⚠ (受策略) | ✅ |
| `page_views` | Insert✅ / Select❌ | Select✅(本人) | ✅ |
| `analytics_*` 表 | ❌ | ✅ (按 owner/shared) | ✅ |
| RPC (analytics) | ❌ | ✅ | ✅ |
> 详细策略请见各 SQL 文件内注释。
---
## 🧹 清理
执行 `04_cleanup.sql` 可按时间 / 用户删除测试数据,脚本已更新为幂等。
---
最后更新2026-01-26

View File

@@ -1,853 +0,0 @@
-- ============================================
-- 数据分析模块数据库 SchemaSupabase/Postgres
-- ============================================
-- 用途:创建 analytics_* 表、索引、RLS策略、RPC函数
-- 参考:`docs/ANALYTICS_DB_DESIGN.md`
--
-- 执行顺序:
-- 1. 先执行基础业务表(`01_create_tables.sql`
-- 2. 再执行本文档(`ANALYTICS_DB_SCHEMA.sql`
-- 3. 最后执行 `ANALYTICS_TEST_SEED.sql` 插入测试数据(受 RLS 影响,需较高权限执行)
-- ============================================
-- ============================================
-- 1. Analytics 表结构
-- ============================================
-- 说明:
-- - 本目录pages/mall/analytics/test中的基础表结构由 01_create_tables.sql 提供。
-- - 本文件只负责 analytics_* 表 + RLS + RPC。
-- 1.1 分析师偏好设置
CREATE TABLE IF NOT EXISTS analytics_user_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
default_period TEXT NOT NULL DEFAULT '7d',
timezone TEXT DEFAULT 'Asia/Shanghai',
currency TEXT DEFAULT 'CNY',
kpi_cards JSONB DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id)
);
-- 中文注释
COMMENT ON TABLE public.analytics_user_preferences IS '分析师偏好设置表';
COMMENT ON COLUMN public.analytics_user_preferences.id IS '主键';
COMMENT ON COLUMN public.analytics_user_preferences.user_id IS '用户ID关联 users.id / 建议与 auth.uid 对齐)';
COMMENT ON COLUMN public.analytics_user_preferences.default_period IS '默认统计周期(如 7d/30d/90d/1y';
COMMENT ON COLUMN public.analytics_user_preferences.timezone IS '时区(默认 Asia/Shanghai';
COMMENT ON COLUMN public.analytics_user_preferences.currency IS '币种(默认 CNY';
COMMENT ON COLUMN public.analytics_user_preferences.kpi_cards IS 'KPI 卡片配置JSON 数组)';
COMMENT ON COLUMN public.analytics_user_preferences.created_at IS '创建时间';
COMMENT ON COLUMN public.analytics_user_preferences.updated_at IS '更新时间';
-- 1.2 报表定义
CREATE TABLE IF NOT EXISTS analytics_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
merchant_id UUID REFERENCES merchants(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT DEFAULT '',
type TEXT NOT NULL, -- sales/users/orders/conversion/coupon/delivery/market/custom
period TEXT NOT NULL, -- 7d/30d/90d/1y
date_start DATE,
date_end DATE,
status TEXT NOT NULL DEFAULT 'ready', -- pending/ready/failed/scheduled/shared
generated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 中文注释
COMMENT ON TABLE public.analytics_reports IS '分析报表表(报表定义/实例)';
COMMENT ON COLUMN public.analytics_reports.id IS '报表ID';
COMMENT ON COLUMN public.analytics_reports.owner_user_id IS '报表所属用户ID创建者';
COMMENT ON COLUMN public.analytics_reports.merchant_id IS '商家ID可空表示全站/不限定商家)';
COMMENT ON COLUMN public.analytics_reports.title IS '报表标题';
COMMENT ON COLUMN public.analytics_reports.description IS '报表描述';
COMMENT ON COLUMN public.analytics_reports.type IS '报表类型sales/users/orders/conversion 等)';
COMMENT ON COLUMN public.analytics_reports.period IS '统计周期7d/30d/90d/1y 或自定义)';
COMMENT ON COLUMN public.analytics_reports.date_start IS '自定义开始日期(可空)';
COMMENT ON COLUMN public.analytics_reports.date_end IS '自定义结束日期(可空)';
COMMENT ON COLUMN public.analytics_reports.status IS '状态pending/ready/failed/scheduled/shared';
COMMENT ON COLUMN public.analytics_reports.generated_at IS '生成时间';
COMMENT ON COLUMN public.analytics_reports.created_at IS '创建时间';
COMMENT ON COLUMN public.analytics_reports.updated_at IS '更新时间';
-- 1.3 报表核心指标
CREATE TABLE IF NOT EXISTS analytics_report_metrics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_id UUID NOT NULL REFERENCES analytics_reports(id) ON DELETE CASCADE,
metric_key TEXT NOT NULL,
metric_label TEXT NOT NULL,
metric_value_num NUMERIC,
metric_value_text TEXT,
format TEXT NOT NULL DEFAULT 'number', -- number/currency/percent
change_pct NUMERIC DEFAULT 0,
icon TEXT DEFAULT '',
color TEXT DEFAULT '#3b82f6',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 中文注释
COMMENT ON TABLE public.analytics_report_metrics IS '报表核心指标表';
COMMENT ON COLUMN public.analytics_report_metrics.id IS '主键';
COMMENT ON COLUMN public.analytics_report_metrics.report_id IS '所属报表ID';
COMMENT ON COLUMN public.analytics_report_metrics.metric_key IS '指标Key如 gmv/orders/conversion_rate';
COMMENT ON COLUMN public.analytics_report_metrics.metric_label IS '指标名称(展示用)';
COMMENT ON COLUMN public.analytics_report_metrics.metric_value_num IS '指标数值(数值型)';
COMMENT ON COLUMN public.analytics_report_metrics.metric_value_text IS '指标文本(已格式化,如百分比字符串)';
COMMENT ON COLUMN public.analytics_report_metrics.format IS '展示格式number/currency/percent';
COMMENT ON COLUMN public.analytics_report_metrics.change_pct IS '变化百分比(环比/同比,单位:%';
COMMENT ON COLUMN public.analytics_report_metrics.icon IS '图标(可选,建议使用纯文本 key';
COMMENT ON COLUMN public.analytics_report_metrics.color IS '颜色UI 展示用)';
COMMENT ON COLUMN public.analytics_report_metrics.created_at IS '创建时间';
-- 1.4 报表明细行(趋势数据)
CREATE TABLE IF NOT EXISTS analytics_report_rows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_id UUID NOT NULL REFERENCES analytics_reports(id) ON DELETE CASCADE,
row_date DATE NOT NULL,
gmv NUMERIC DEFAULT 0,
orders INTEGER DEFAULT 0,
users INTEGER DEFAULT 0,
conversion NUMERIC DEFAULT 0,
avg_order_amount NUMERIC DEFAULT 0,
extra JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 中文注释
COMMENT ON TABLE public.analytics_report_rows IS '报表明细行表(趋势数据)';
COMMENT ON COLUMN public.analytics_report_rows.id IS '主键';
COMMENT ON COLUMN public.analytics_report_rows.report_id IS '所属报表ID';
COMMENT ON COLUMN public.analytics_report_rows.row_date IS '统计日期';
COMMENT ON COLUMN public.analytics_report_rows.gmv IS 'GMV';
COMMENT ON COLUMN public.analytics_report_rows.orders IS '订单数';
COMMENT ON COLUMN public.analytics_report_rows.users IS '用户数(可选)';
COMMENT ON COLUMN public.analytics_report_rows.conversion IS '转化率0-100';
COMMENT ON COLUMN public.analytics_report_rows.avg_order_amount IS '客单价';
COMMENT ON COLUMN public.analytics_report_rows.extra IS '扩展字段(用于自定义报表列)';
COMMENT ON COLUMN public.analytics_report_rows.created_at IS '创建时间';
-- 1.5 数据洞察
CREATE TABLE IF NOT EXISTS analytics_insights (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_id UUID REFERENCES analytics_reports(id) ON DELETE CASCADE,
owner_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
type TEXT NOT NULL, -- positive/warning/negative/info
impact TEXT NOT NULL DEFAULT 'medium', -- high/medium/low
title TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 中文注释
COMMENT ON TABLE public.analytics_insights IS '数据洞察表(建议/预警/提示)';
COMMENT ON COLUMN public.analytics_insights.id IS '洞察ID';
COMMENT ON COLUMN public.analytics_insights.report_id IS '关联报表ID可空';
COMMENT ON COLUMN public.analytics_insights.owner_user_id IS '所属用户ID可空系统生成';
COMMENT ON COLUMN public.analytics_insights.type IS '洞察类型positive/warning/negative/info';
COMMENT ON COLUMN public.analytics_insights.impact IS '影响等级high/medium/low';
COMMENT ON COLUMN public.analytics_insights.title IS '洞察标题';
COMMENT ON COLUMN public.analytics_insights.content IS '洞察内容';
COMMENT ON COLUMN public.analytics_insights.tags IS '标签数组';
COMMENT ON COLUMN public.analytics_insights.created_at IS '创建时间';
-- 1.6 报表收藏
CREATE TABLE IF NOT EXISTS analytics_report_favorites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
report_id UUID NOT NULL REFERENCES analytics_reports(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, report_id)
);
-- 中文注释
COMMENT ON TABLE public.analytics_report_favorites IS '报表收藏表';
COMMENT ON COLUMN public.analytics_report_favorites.id IS '主键';
COMMENT ON COLUMN public.analytics_report_favorites.user_id IS '用户ID';
COMMENT ON COLUMN public.analytics_report_favorites.report_id IS '报表ID';
COMMENT ON COLUMN public.analytics_report_favorites.created_at IS '创建时间';
-- 1.7 导出任务
CREATE TABLE IF NOT EXISTS analytics_export_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
report_id UUID NOT NULL REFERENCES analytics_reports(id) ON DELETE CASCADE,
format TEXT NOT NULL, -- csv/xlsx/pdf/json
status TEXT NOT NULL DEFAULT 'queued', -- queued/running/done/failed
file_path TEXT,
error_message TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW(),
finished_at TIMESTAMPTZ
);
-- 中文注释
COMMENT ON TABLE public.analytics_export_jobs IS '导出任务表(导出历史/队列)';
COMMENT ON COLUMN public.analytics_export_jobs.id IS '导出任务ID';
COMMENT ON COLUMN public.analytics_export_jobs.user_id IS '发起用户ID';
COMMENT ON COLUMN public.analytics_export_jobs.report_id IS '关联报表ID';
COMMENT ON COLUMN public.analytics_export_jobs.format IS '导出格式csv/xlsx/pdf/json';
COMMENT ON COLUMN public.analytics_export_jobs.status IS '任务状态queued/running/done/failed';
COMMENT ON COLUMN public.analytics_export_jobs.file_path IS '文件路径Storage 路径,可空)';
COMMENT ON COLUMN public.analytics_export_jobs.error_message IS '失败原因(可空)';
COMMENT ON COLUMN public.analytics_export_jobs.created_at IS '创建时间';
COMMENT ON COLUMN public.analytics_export_jobs.finished_at IS '完成时间(可空)';
-- ============================================
-- 2. 索引
-- ============================================
-- analytics_reports
CREATE INDEX IF NOT EXISTS idx_analytics_reports_owner_created ON analytics_reports(owner_user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_analytics_reports_type_generated ON analytics_reports(type, generated_at DESC);
CREATE INDEX IF NOT EXISTS idx_analytics_reports_status ON analytics_reports(status);
-- analytics_report_metrics
CREATE INDEX IF NOT EXISTS idx_analytics_report_metrics_report ON analytics_report_metrics(report_id, metric_key);
-- analytics_report_rows
CREATE INDEX IF NOT EXISTS idx_analytics_report_rows_report_date ON analytics_report_rows(report_id, row_date);
-- analytics_insights
CREATE INDEX IF NOT EXISTS idx_analytics_insights_created ON analytics_insights(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_analytics_insights_report ON analytics_insights(report_id, created_at DESC);
-- analytics_export_jobs
CREATE INDEX IF NOT EXISTS idx_analytics_export_jobs_user ON analytics_export_jobs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_analytics_export_jobs_status ON analytics_export_jobs(status);
-- ============================================
-- 3. RLSRow Level Security策略
-- ============================================
-- 启用 RLS
ALTER TABLE analytics_user_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_reports ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_report_metrics ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_report_rows ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_insights ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_report_favorites ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_export_jobs ENABLE ROW LEVEL SECURITY;
-- analytics_user_preferences: 用户只能访问自己的偏好
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_user_preferences' AND policyname='Users can view own preferences') THEN
EXECUTE 'CREATE POLICY "Users can view own preferences" ON public.analytics_user_preferences FOR SELECT USING (auth.uid() = user_id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_user_preferences' AND policyname='Users can insert own preferences') THEN
EXECUTE 'CREATE POLICY "Users can insert own preferences" ON public.analytics_user_preferences FOR INSERT WITH CHECK (auth.uid() = user_id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_user_preferences' AND policyname='Users can update own preferences') THEN
EXECUTE 'CREATE POLICY "Users can update own preferences" ON public.analytics_user_preferences FOR UPDATE USING (auth.uid() = user_id)';
END IF;
END $$;
-- analytics_reports: 用户可访问自己创建的报表和共享报表
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_reports' AND policyname='Users can view own reports') THEN
EXECUTE 'CREATE POLICY "Users can view own reports" ON public.analytics_reports FOR SELECT USING (auth.uid() = owner_user_id OR status = ''shared'')';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_reports' AND policyname='Users can insert own reports') THEN
EXECUTE 'CREATE POLICY "Users can insert own reports" ON public.analytics_reports FOR INSERT WITH CHECK (auth.uid() = owner_user_id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_reports' AND policyname='Users can update own reports') THEN
EXECUTE 'CREATE POLICY "Users can update own reports" ON public.analytics_reports FOR UPDATE USING (auth.uid() = owner_user_id)';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_reports' AND policyname='Users can delete own reports') THEN
EXECUTE 'CREATE POLICY "Users can delete own reports" ON public.analytics_reports FOR DELETE USING (auth.uid() = owner_user_id)';
END IF;
END $$;
-- analytics_report_metrics: 通过 report_id 关联权限
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_report_metrics' AND policyname='Users can view metrics of accessible reports') THEN
EXECUTE 'CREATE POLICY "Users can view metrics of accessible reports" ON public.analytics_report_metrics FOR SELECT USING (EXISTS (SELECT 1 FROM public.analytics_reports WHERE public.analytics_reports.id = public.analytics_report_metrics.report_id AND (public.analytics_reports.owner_user_id = auth.uid() OR public.analytics_reports.status = ''shared'')))';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_report_metrics' AND policyname='Users can manage metrics of own reports') THEN
EXECUTE 'CREATE POLICY "Users can manage metrics of own reports" ON public.analytics_report_metrics FOR ALL USING (EXISTS (SELECT 1 FROM public.analytics_reports WHERE public.analytics_reports.id = public.analytics_report_metrics.report_id AND public.analytics_reports.owner_user_id = auth.uid()))';
END IF;
END $$;
-- analytics_report_rows: 通过 report_id 关联权限
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_report_rows' AND policyname='Users can view rows of accessible reports') THEN
EXECUTE 'CREATE POLICY "Users can view rows of accessible reports" ON public.analytics_report_rows FOR SELECT USING (EXISTS (SELECT 1 FROM public.analytics_reports WHERE public.analytics_reports.id = public.analytics_report_rows.report_id AND (public.analytics_reports.owner_user_id = auth.uid() OR public.analytics_reports.status = ''shared'')))';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_report_rows' AND policyname='Users can manage rows of own reports') THEN
EXECUTE 'CREATE POLICY "Users can manage rows of own reports" ON public.analytics_report_rows FOR ALL USING (EXISTS (SELECT 1 FROM public.analytics_reports WHERE public.analytics_reports.id = public.analytics_report_rows.report_id AND public.analytics_reports.owner_user_id = auth.uid()))';
END IF;
END $$;
-- analytics_insights: 通过 report_id 或 owner_user_id 关联权限
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_insights' AND policyname='Users can view accessible insights') THEN
EXECUTE 'CREATE POLICY "Users can view accessible insights" ON public.analytics_insights FOR SELECT USING (owner_user_id = auth.uid() OR (report_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.analytics_reports WHERE public.analytics_reports.id = public.analytics_insights.report_id AND (public.analytics_reports.owner_user_id = auth.uid() OR public.analytics_reports.status = ''shared''))))';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_insights' AND policyname='Users can manage own insights') THEN
EXECUTE 'CREATE POLICY "Users can manage own insights" ON public.analytics_insights FOR ALL USING (owner_user_id = auth.uid())';
END IF;
END $$;
-- analytics_report_favorites: 用户只能访问自己的收藏
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_report_favorites' AND policyname='Users can manage own favorites') THEN
EXECUTE 'CREATE POLICY "Users can manage own favorites" ON public.analytics_report_favorites FOR ALL USING (auth.uid() = user_id)';
END IF;
END $$;
-- analytics_export_jobs: 用户只能访问自己的导出任务
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='analytics_export_jobs' AND policyname='Users can manage own export jobs') THEN
EXECUTE 'CREATE POLICY "Users can manage own export jobs" ON public.analytics_export_jobs FOR ALL USING (auth.uid() = user_id)';
END IF;
END $$;
-- ============================================
-- 4. RPC 函数Postgres Functions
-- ============================================
-- 4.1 实时 KPI 计算函数
CREATE OR REPLACE FUNCTION rpc_analytics_realtime_kpis(
p_start TIMESTAMPTZ,
p_end TIMESTAMPTZ,
p_compare_start TIMESTAMPTZ,
p_compare_end TIMESTAMPTZ,
p_merchant_id UUID DEFAULT NULL
)
RETURNS TABLE (
gmv NUMERIC,
gmv_growth NUMERIC,
orders INTEGER,
order_growth NUMERIC,
online_users INTEGER,
conversion_rate NUMERIC,
conversion_growth NUMERIC
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_gmv NUMERIC := 0;
v_gmv_compare NUMERIC := 0;
v_orders INTEGER := 0;
v_orders_compare INTEGER := 0;
v_online_users INTEGER := 0;
v_order_users INTEGER := 0;
v_visitors INTEGER := 0;
v_visitors_compare INTEGER := 0;
v_order_users_compare INTEGER := 0;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'unauthorized';
END IF;
-- 计算当前时间段 GMV 和订单数
SELECT
COALESCE(SUM(total_amount), 0),
COUNT(*)
INTO v_gmv, v_orders
FROM orders
WHERE created_at >= p_start
AND created_at < p_end
AND status = 2 -- 已支付
AND (p_merchant_id IS NULL OR merchant_id = p_merchant_id);
-- 计算对比时间段 GMV 和订单数
SELECT
COALESCE(SUM(total_amount), 0),
COUNT(*)
INTO v_gmv_compare, v_orders_compare
FROM orders
WHERE created_at >= p_compare_start
AND created_at < p_compare_end
AND status = 2 -- 已支付
AND (p_merchant_id IS NULL OR merchant_id = p_merchant_id);
-- 计算在线用户最近5分钟活跃
SELECT COUNT(DISTINCT user_id)
INTO v_online_users
FROM user_sessions
WHERE last_active_at >= (NOW() - INTERVAL '5 minutes')
AND is_active = true;
-- 计算当前时间段下单用户数
SELECT COUNT(DISTINCT user_id)
INTO v_order_users
FROM orders
WHERE created_at >= p_start
AND created_at < p_end
AND status = 2
AND (p_merchant_id IS NULL OR merchant_id = p_merchant_id);
-- 计算当前时间段访问用户数
SELECT COUNT(DISTINCT user_id)
INTO v_visitors
FROM user_sessions
WHERE created_at >= p_start
AND created_at < p_end;
-- 计算对比时间段访问用户数和下单用户数
SELECT
COUNT(DISTINCT user_id),
(SELECT COUNT(DISTINCT user_id) FROM orders
WHERE created_at >= p_compare_start AND created_at < p_compare_end
AND status = 2 AND (p_merchant_id IS NULL OR merchant_id = p_merchant_id))
INTO v_visitors_compare, v_order_users_compare
FROM user_sessions
WHERE created_at >= p_compare_start
AND created_at < p_compare_end;
-- 返回结果
RETURN QUERY SELECT
v_gmv,
CASE WHEN v_gmv_compare > 0 THEN ((v_gmv - v_gmv_compare) / v_gmv_compare * 100) ELSE (CASE WHEN v_gmv > 0 THEN 100 ELSE 0 END) END,
v_orders,
CASE WHEN v_orders_compare > 0 THEN ((v_orders - v_orders_compare)::NUMERIC / v_orders_compare * 100) ELSE (CASE WHEN v_orders > 0 THEN 100 ELSE 0 END) END,
COALESCE(v_online_users, 0),
CASE WHEN v_visitors > 0 THEN (v_order_users::NUMERIC / v_visitors * 100) ELSE 0 END,
CASE WHEN v_visitors_compare > 0 AND v_order_users_compare > 0 THEN
(((v_order_users::NUMERIC / NULLIF(v_visitors, 0)) - (v_order_users_compare::NUMERIC / NULLIF(v_visitors_compare, 0))) / (v_order_users_compare::NUMERIC / NULLIF(v_visitors_compare, 0)) * 100)
ELSE 0 END;
END;
$$;
-- 4.2 趋势数据查询函数(按日期聚合)
CREATE OR REPLACE FUNCTION rpc_analytics_trend_data(
p_start_date DATE,
p_end_date DATE,
p_merchant_id UUID DEFAULT NULL
)
RETURNS TABLE (
date DATE,
gmv NUMERIC,
orders INTEGER,
users INTEGER
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'unauthorized';
END IF;
RETURN QUERY
SELECT
DATE(o.created_at) AS date,
COALESCE(SUM(o.total_amount), 0) AS gmv,
COUNT(*)::INTEGER AS orders,
COUNT(DISTINCT o.user_id)::INTEGER AS users
FROM orders o
WHERE DATE(o.created_at) >= p_start_date
AND DATE(o.created_at) <= p_end_date
AND o.status = 2 -- 已支付
AND (p_merchant_id IS NULL OR o.merchant_id = p_merchant_id)
GROUP BY DATE(o.created_at)
ORDER BY date;
END;
$$;
-- 4.3 热销商品 TOP按 GMV
CREATE OR REPLACE FUNCTION rpc_analytics_top_products(
p_start_date DATE,
p_end_date DATE,
p_limit INTEGER DEFAULT 10,
p_merchant_id UUID DEFAULT NULL
)
RETURNS TABLE (
id UUID,
name TEXT,
sales NUMERIC
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'unauthorized';
END IF;
RETURN QUERY
SELECT
p.id,
CAST(p.name AS TEXT) AS name,
CAST(COALESCE(SUM(oi.total_amount), 0) AS NUMERIC) AS sales
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE DATE(o.created_at) >= p_start_date
AND DATE(o.created_at) <= p_end_date
AND o.status = 2
AND (p_merchant_id IS NULL OR o.merchant_id = p_merchant_id)
GROUP BY p.id, p.name
ORDER BY sales DESC
LIMIT p_limit;
END;
$$;
-- 4.4 商家 TOP按 GMV
CREATE OR REPLACE FUNCTION rpc_analytics_top_merchants(
p_start_date DATE,
p_end_date DATE,
p_limit INTEGER DEFAULT 10
)
RETURNS TABLE (
id UUID,
name TEXT,
sales NUMERIC,
growth NUMERIC
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_days INTEGER := GREATEST(1, (p_end_date - p_start_date + 1));
v_prev_start DATE := p_start_date - v_days;
v_prev_end DATE := p_start_date - 1;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'unauthorized';
END IF;
RETURN QUERY
WITH cur AS (
SELECT
m.id AS merchant_id,
CAST(COALESCE(m.shop_name, '未知商家') AS TEXT) AS name,
CAST(COALESCE(SUM(o.total_amount), 0) AS NUMERIC) AS sales
FROM merchants m
LEFT JOIN orders o ON o.merchant_id = m.id
AND DATE(o.created_at) >= p_start_date
AND DATE(o.created_at) <= p_end_date
AND o.status = 2
GROUP BY m.id, m.shop_name
),
prev AS (
SELECT
m.id AS merchant_id,
CAST(COALESCE(SUM(o.total_amount), 0) AS NUMERIC) AS sales
FROM merchants m
LEFT JOIN orders o ON o.merchant_id = m.id
AND DATE(o.created_at) >= v_prev_start
AND DATE(o.created_at) <= v_prev_end
AND o.status = 2
GROUP BY m.id
)
SELECT
CAST(cur.merchant_id AS UUID) AS id,
CAST(cur.name AS TEXT) AS name,
CAST(cur.sales AS NUMERIC) AS sales,
CAST(
CASE
WHEN COALESCE(prev.sales, 0) > 0 THEN ((cur.sales - prev.sales) / prev.sales * 100)
WHEN cur.sales > 0 THEN 100
ELSE 0
END AS NUMERIC
) AS growth
FROM cur
LEFT JOIN prev ON prev.merchant_id = cur.merchant_id
ORDER BY cur.sales DESC
LIMIT p_limit;
END;
$$;
-- 4.5 用户结构(分群:未消费/消费一次/留存客户/回流客户)
CREATE OR REPLACE FUNCTION rpc_analytics_user_segments(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
name TEXT,
value INTEGER
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'unauthorized';
END IF;
RETURN QUERY
WITH u AS (
SELECT id AS user_id FROM users
),
cur_orders AS (
SELECT user_id, COUNT(*) AS cnt
FROM orders
WHERE DATE(created_at) >= p_start_date
AND DATE(created_at) <= p_end_date
AND status = 2
GROUP BY user_id
),
has_before AS (
SELECT user_id, 1 AS has_before
FROM orders
WHERE DATE(created_at) < p_start_date
AND status = 2
GROUP BY user_id
),
cls AS (
SELECT
u.user_id,
COALESCE(c.cnt, 0) AS cur_cnt,
COALESCE(b.has_before, 0) AS before_flag
FROM u
LEFT JOIN cur_orders c ON c.user_id = u.user_id
LEFT JOIN has_before b ON b.user_id = u.user_id
)
SELECT CAST('未消费用户' AS TEXT) AS name, CAST(COUNT(*) AS INTEGER) AS value FROM cls WHERE cur_cnt = 0
UNION ALL
SELECT CAST('消费一次用户' AS TEXT) AS name, CAST(COUNT(*) AS INTEGER) AS value FROM cls WHERE cur_cnt = 1
UNION ALL
SELECT CAST('留存客户' AS TEXT) AS name, CAST(COUNT(*) AS INTEGER) AS value FROM cls WHERE cur_cnt >= 2
UNION ALL
SELECT CAST('回流客户' AS TEXT) AS name, CAST(COUNT(*) AS INTEGER) AS value FROM cls WHERE cur_cnt >= 1 AND before_flag = 1;
END;
$$;
-- 4.6 流量来源(基于 page_views.source
CREATE OR REPLACE FUNCTION rpc_analytics_traffic_sources(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
name TEXT,
value INTEGER
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'unauthorized';
END IF;
RETURN QUERY
SELECT
CAST(
CASE
WHEN source = 'direct' THEN '直接访问'
WHEN source = 'search' THEN '搜索引擎'
WHEN source = 'social' THEN '社交媒体'
WHEN source = 'ad' THEN '广告推广'
ELSE COALESCE(source, '未知')
END AS TEXT
) AS name,
CAST(COUNT(*) AS INTEGER) AS value
FROM page_views
WHERE DATE(created_at) >= p_start_date
AND DATE(created_at) <= p_end_date
GROUP BY 1
ORDER BY value DESC;
END;
$$;
-- 4.7 用户分析 KPI总用户/新用户/活跃/复购率 等)
-- 说明:指标口径为“占总用户数的百分比”,增长为与上一周期对比的百分比变化。
CREATE OR REPLACE FUNCTION rpc_analytics_user_kpis(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
total_users INTEGER,
user_growth NUMERIC,
new_users INTEGER,
new_user_growth NUMERIC,
active_rate NUMERIC,
active_growth NUMERIC,
repurchase_rate NUMERIC,
repurchase_growth NUMERIC
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_days INTEGER := GREATEST(1, (p_end_date - p_start_date + 1));
v_prev_start DATE := p_start_date - v_days;
v_prev_end DATE := p_start_date - 1;
v_total INTEGER := 0;
v_new INTEGER := 0;
v_prev_new INTEGER := 0;
v_active INTEGER := 0;
v_prev_active INTEGER := 0;
v_repurchase INTEGER := 0;
v_prev_repurchase INTEGER := 0;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'unauthorized';
END IF;
-- 总用户数(统计兼容表)
SELECT COUNT(*)::INTEGER INTO v_total FROM public.users;
-- 新用户:按 users.created_at 的日期口径
SELECT COUNT(*)::INTEGER INTO v_new
FROM public.users
WHERE DATE(created_at) >= p_start_date AND DATE(created_at) <= p_end_date;
SELECT COUNT(*)::INTEGER INTO v_prev_new
FROM public.users
WHERE DATE(created_at) >= v_prev_start AND DATE(created_at) <= v_prev_end;
-- 活跃用户:最近活跃时间落在周期内的去重 user_id会话表
SELECT COUNT(DISTINCT user_id)::INTEGER INTO v_active
FROM public.user_sessions
WHERE DATE(last_active_at) >= p_start_date AND DATE(last_active_at) <= p_end_date
AND is_active = true;
SELECT COUNT(DISTINCT user_id)::INTEGER INTO v_prev_active
FROM public.user_sessions
WHERE DATE(last_active_at) >= v_prev_start AND DATE(last_active_at) <= v_prev_end
AND is_active = true;
-- 复购用户:周期内已支付订单数 >= 2 的用户
SELECT COUNT(*)::INTEGER INTO v_repurchase
FROM (
SELECT user_id
FROM public.orders
WHERE DATE(created_at) >= p_start_date AND DATE(created_at) <= p_end_date
AND status = 2
GROUP BY user_id
HAVING COUNT(*) >= 2
) t;
SELECT COUNT(*)::INTEGER INTO v_prev_repurchase
FROM (
SELECT user_id
FROM public.orders
WHERE DATE(created_at) >= v_prev_start AND DATE(created_at) <= v_prev_end
AND status = 2
GROUP BY user_id
HAVING COUNT(*) >= 2
) t;
RETURN QUERY
SELECT
v_total,
CASE WHEN v_total > 0 AND v_prev_new > 0 THEN ((v_new - v_prev_new)::NUMERIC / v_prev_new * 100) ELSE 0 END,
v_new,
CASE WHEN v_prev_new > 0 THEN ((v_new - v_prev_new)::NUMERIC / v_prev_new * 100) ELSE (CASE WHEN v_new > 0 THEN 100 ELSE 0 END) END,
CASE WHEN v_total > 0 THEN (v_active::NUMERIC / v_total * 100) ELSE 0 END,
CASE WHEN v_prev_active > 0 THEN ((v_active - v_prev_active)::NUMERIC / v_prev_active * 100) ELSE (CASE WHEN v_active > 0 THEN 100 ELSE 0 END) END,
CASE WHEN v_total > 0 THEN (v_repurchase::NUMERIC / v_total * 100) ELSE 0 END,
CASE WHEN v_prev_repurchase > 0 THEN ((v_repurchase - v_prev_repurchase)::NUMERIC / v_prev_repurchase * 100) ELSE (CASE WHEN v_repurchase > 0 THEN 100 ELSE 0 END) END;
END;
$$;
-- 4.8 用户增长趋势(按天)
CREATE OR REPLACE FUNCTION rpc_analytics_user_growth_trend(
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
date DATE,
new_users INTEGER,
total_users INTEGER
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_total_before INTEGER := 0;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'unauthorized';
END IF;
SELECT COUNT(*)::INTEGER INTO v_total_before
FROM public.users
WHERE DATE(created_at) < p_start_date;
RETURN QUERY
WITH days AS (
SELECT generate_series(p_start_date, p_end_date, interval '1 day')::date AS d
),
nu AS (
SELECT DATE(created_at) AS d, COUNT(*)::INTEGER AS c
FROM public.users
WHERE DATE(created_at) >= p_start_date AND DATE(created_at) <= p_end_date
GROUP BY DATE(created_at)
),
joined AS (
SELECT days.d, COALESCE(nu.c, 0) AS new_users
FROM days
LEFT JOIN nu ON nu.d = days.d
ORDER BY days.d
)
SELECT
joined.d AS date,
joined.new_users,
(v_total_before + SUM(joined.new_users) OVER (ORDER BY joined.d))::INTEGER AS total_users
FROM joined;
END;
$$;
-- ============================================
-- 5. 触发器(自动更新 updated_at
-- ============================================
-- 为需要的表添加触发器
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_analytics_user_preferences_updated_at') THEN
EXECUTE 'CREATE TRIGGER update_analytics_user_preferences_updated_at BEFORE UPDATE ON public.analytics_user_preferences FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column()';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_analytics_reports_updated_at') THEN
EXECUTE 'CREATE TRIGGER update_analytics_reports_updated_at BEFORE UPDATE ON public.analytics_reports FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column()';
END IF;
END $$;
-- ============================================
-- 完成
-- ============================================
-- ============================================
-- 6. RPC 授权收敛(只允许 authenticated 调用)
-- ============================================
REVOKE ALL ON FUNCTION rpc_analytics_realtime_kpis(TIMESTAMPTZ, TIMESTAMPTZ, TIMESTAMPTZ, TIMESTAMPTZ, UUID) FROM PUBLIC;
REVOKE ALL ON FUNCTION rpc_analytics_trend_data(DATE, DATE, UUID) FROM PUBLIC;
REVOKE ALL ON FUNCTION rpc_analytics_top_products(DATE, DATE, INTEGER, UUID) FROM PUBLIC;
REVOKE ALL ON FUNCTION rpc_analytics_top_merchants(DATE, DATE, INTEGER) FROM PUBLIC;
REVOKE ALL ON FUNCTION rpc_analytics_user_segments(DATE, DATE) FROM PUBLIC;
REVOKE ALL ON FUNCTION rpc_analytics_traffic_sources(DATE, DATE) FROM PUBLIC;
REVOKE ALL ON FUNCTION rpc_analytics_user_kpis(DATE, DATE) FROM PUBLIC;
REVOKE ALL ON FUNCTION rpc_analytics_user_growth_trend(DATE, DATE) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION rpc_analytics_realtime_kpis(TIMESTAMPTZ, TIMESTAMPTZ, TIMESTAMPTZ, TIMESTAMPTZ, UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION rpc_analytics_trend_data(DATE, DATE, UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION rpc_analytics_top_products(DATE, DATE, INTEGER, UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION rpc_analytics_top_merchants(DATE, DATE, INTEGER) TO authenticated;
GRANT EXECUTE ON FUNCTION rpc_analytics_user_segments(DATE, DATE) TO authenticated;
GRANT EXECUTE ON FUNCTION rpc_analytics_traffic_sources(DATE, DATE) TO authenticated;
GRANT EXECUTE ON FUNCTION rpc_analytics_user_kpis(DATE, DATE) TO authenticated;
GRANT EXECUTE ON FUNCTION rpc_analytics_user_growth_trend(DATE, DATE) TO authenticated;
-- ============================================
-- 完成
-- ============================================
SELECT 'Analytics database schema created successfully!' AS message;

View File

@@ -1,463 +0,0 @@
-- ============================================
-- 数据分析模块测试数据 SeedSupabase/Postgres
-- ============================================
-- 用途:为 `pages/mall/analytics/*` 页面提供可联调的模拟数据
-- 参考:`docs/ANALYTICS_DB_DESIGN.md`
--
-- 执行顺序:
-- 1. 先执行基础业务表的 seedusers/merchants/products/orders等
-- 2. 再执行本文档analytics_* 表 + 基础表补充数据)
--
-- ⚠️ 重要RLS
-- 本脚本会写入已启用 RLS 的表orders/user_sessions/page_views/analytics_*)。
-- 请使用 Supabase Dashboard SQL Editor等价于 postgres/service_role执行
-- 不要用 anon/authenticated 直接执行,否则会被 RLS 策略拦截。
-- ============================================
-- ============================================
-- 1. 基础业务表补充数据(如果还没有)
-- ============================================
-- 1.1 确保有测试用户(分析师)
-- 注意:如果 users 表已通过 02_insert_test_data.sql 插入数据,这里可以跳过或补充
INSERT INTO users (id, phone, email, nickname, last_login_at, created_at)
VALUES
('00000000-0000-0000-0000-000000000001', '13800001001', 'analyst1@test.com', '分析师张三', NOW() - INTERVAL '1 day', NOW() - INTERVAL '30 days'),
('00000000-0000-0000-0000-000000000002', '13800001002', 'analyst2@test.com', '分析师李四', NOW() - INTERVAL '2 days', NOW() - INTERVAL '20 days')
ON CONFLICT (id) DO NOTHING;
-- 1.2 确保有测试商家
-- 注意:如果 merchants 表已通过 02_insert_test_data.sql 插入数据,这里可以跳过或补充
INSERT INTO merchants (id, shop_name, created_at)
VALUES
('10000000-0000-0000-0000-000000000001', '测试商家A', NOW() - INTERVAL '60 days'),
('10000000-0000-0000-0000-000000000002', '测试商家B', NOW() - INTERVAL '50 days')
ON CONFLICT (id) DO NOTHING;
-- 1.3 确保有测试商品
-- 注意:如果 products 表已通过 02_insert_test_data.sql 插入数据,这里可以跳过或补充
INSERT INTO products (id, merchant_id, name, price, sales, status, created_at)
VALUES
('20000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000001', '测试商品1', 99.00, 50, 1, NOW() - INTERVAL '40 days'),
('20000000-0000-0000-0000-000000000002', '10000000-0000-0000-0000-000000000001', '测试商品2', 199.00, 30, 1, NOW() - INTERVAL '35 days'),
('20000000-0000-0000-0000-000000000003', '10000000-0000-0000-0000-000000000002', '测试商品3', 299.00, 20, 1, NOW() - INTERVAL '30 days')
ON CONFLICT (id) DO NOTHING;
-- 1.4 生成过去30天的测试订单用于首页实时KPI和趋势
DO $$
DECLARE
i INTEGER;
j INTEGER;
order_date DATE;
order_id UUID;
user_id_val UUID := '00000000-0000-0000-0000-000000000001';
merchant_id_val UUID := '10000000-0000-0000-0000-000000000001';
product_id_val UUID := '20000000-0000-0000-0000-000000000001';
BEGIN
FOR i IN 0..29 LOOP
order_date := CURRENT_DATE - (29 - i);
-- 每天生成 5-15 个订单
FOR j IN 1..(5 + (i % 11)) LOOP
order_id := gen_random_uuid();
-- 插入订单状态2=已支付/已完成)
INSERT INTO orders (
id, user_id, merchant_id, status, total_amount, payment_method, created_at
)
VALUES (
order_id,
user_id_val,
merchant_id_val,
2, -- 已支付/已完成
(50 + (j * 10) + (i * 2))::numeric(10,2),
'alipay',
order_date + (random() * INTERVAL '1 day')
)
ON CONFLICT (id) DO NOTHING;
-- 插入订单商品
INSERT INTO order_items (
id, order_id, product_id, quantity, price, total_amount, created_at
)
VALUES (
gen_random_uuid(),
order_id,
product_id_val,
1,
(50 + (j * 10))::numeric(10,2),
(50 + (j * 10))::numeric(10,2),
order_date + (random() * INTERVAL '1 day')
)
ON CONFLICT (id) DO NOTHING;
END LOOP;
END LOOP;
END $$;
-- 1.5 插入 user_sessions在线用户/访问用户统计)
INSERT INTO user_sessions (id, user_id, created_at, last_active_at, is_active)
VALUES
(gen_random_uuid(), '00000000-0000-0000-0000-000000000001', NOW() - INTERVAL '10 minutes', NOW() - INTERVAL '1 minutes', TRUE),
(gen_random_uuid(), '00000000-0000-0000-0000-000000000002', NOW() - INTERVAL '8 minutes', NOW() - INTERVAL '2 minutes', TRUE),
(gen_random_uuid(), '00000000-0000-0000-0000-000000000001', NOW() - INTERVAL '20 minutes', NOW() - INTERVAL '6 minutes', FALSE)
ON CONFLICT DO NOTHING;
-- 1.6 插入 page_views流量来源统计
DO $$
DECLARE
i INTEGER;
v_date DATE;
v_source TEXT;
BEGIN
FOR i IN 0..29 LOOP
v_date := CURRENT_DATE - (29 - i);
v_source := CASE
WHEN (i % 4) = 0 THEN 'direct'
WHEN (i % 4) = 1 THEN 'search'
WHEN (i % 4) = 2 THEN 'social'
ELSE 'ad'
END;
INSERT INTO page_views (id, user_id, path, source, created_at)
VALUES
(gen_random_uuid(), '00000000-0000-0000-0000-000000000001', '/pages/mall/analytics/index', v_source, v_date + (random() * INTERVAL '1 day'))
ON CONFLICT DO NOTHING;
END LOOP;
END $$;
-- ============================================
-- 2. Analytics 表数据
-- ============================================
-- 2.1 分析师偏好设置
INSERT INTO analytics_user_preferences (id, user_id, default_period, timezone, currency, kpi_cards, created_at, updated_at)
VALUES
(
gen_random_uuid(),
'00000000-0000-0000-0000-000000000001',
'7d',
'Asia/Shanghai',
'CNY',
'["gmv", "orders", "users", "conversion"]'::jsonb,
NOW() - INTERVAL '10 days',
NOW()
),
(
gen_random_uuid(),
'00000000-0000-0000-0000-000000000002',
'30d',
'Asia/Shanghai',
'CNY',
'["gmv", "orders"]'::jsonb,
NOW() - INTERVAL '5 days',
NOW()
)
ON CONFLICT (user_id) DO UPDATE SET
default_period = EXCLUDED.default_period,
updated_at = NOW();
-- 2.2 报表定义3个示例报表
INSERT INTO analytics_reports (
id, owner_user_id, merchant_id, title, description, type, period,
date_start, date_end, status, generated_at, created_at, updated_at
)
VALUES
(
'a0000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000001',
NULL,
'销售报表 - 近7天',
'展示近7天的销售趋势和核心指标',
'sales',
'7d',
CURRENT_DATE - 7,
CURRENT_DATE,
'ready',
NOW() - INTERVAL '1 hour',
NOW() - INTERVAL '2 days',
NOW() - INTERVAL '1 hour'
),
(
'a0000000-0000-0000-0000-000000000002',
'00000000-0000-0000-0000-000000000001',
NULL,
'用户分析报表 - 近30天',
'用户增长、活跃度、留存率分析',
'users',
'30d',
CURRENT_DATE - 30,
CURRENT_DATE,
'ready',
NOW() - INTERVAL '2 hours',
NOW() - INTERVAL '5 days',
NOW() - INTERVAL '2 hours'
),
(
'a0000000-0000-0000-0000-000000000003',
'00000000-0000-0000-0000-000000000001',
'10000000-0000-0000-0000-000000000001',
'商家销售报表 - 近90天',
'商家A的销售表现分析',
'sales',
'90d',
CURRENT_DATE - 90,
CURRENT_DATE,
'ready',
NOW() - INTERVAL '30 minutes',
NOW() - INTERVAL '10 days',
NOW() - INTERVAL '30 minutes'
)
ON CONFLICT (id) DO NOTHING;
-- 2.3 报表核心指标(为第一个报表生成)
INSERT INTO analytics_report_metrics (
id, report_id, metric_key, metric_label, metric_value_num, metric_value_text,
format, change_pct, icon, color, created_at
)
VALUES
(
gen_random_uuid(),
'a0000000-0000-0000-0000-000000000001',
'gmv',
'GMV',
125680.50,
NULL,
'currency',
15.6,
'money',
'#3b82f6',
NOW()
),
(
gen_random_uuid(),
'a0000000-0000-0000-0000-000000000001',
'orders',
'订单量',
856,
NULL,
'number',
12.3,
'list',
'#10b981',
NOW()
),
(
gen_random_uuid(),
'a0000000-0000-0000-0000-000000000001',
'conversion_rate',
'转化率',
3.45,
NULL,
'percent',
0.8,
'trend',
'#f59e0b',
NOW()
),
(
gen_random_uuid(),
'a0000000-0000-0000-0000-000000000001',
'avg_order_amount',
'客单价',
146.82,
NULL,
'currency',
-2.1,
'wallet',
'#8b5cf6',
NOW()
)
ON CONFLICT DO NOTHING;
-- 2.4 报表明细行趋势数据为第一个报表生成过去7天的数据
DO $$
DECLARE
i INTEGER;
row_date DATE;
report_id_val UUID := 'a0000000-0000-0000-0000-000000000001';
BEGIN
FOR i IN 0..6 LOOP
row_date := CURRENT_DATE - (6 - i);
INSERT INTO analytics_report_rows (
id, report_id, row_date, gmv, orders, users, conversion, avg_order_amount, extra, created_at
)
VALUES (
gen_random_uuid(),
report_id_val,
row_date,
(15000 + (i * 2000) + (random() * 3000))::numeric(10,2),
(100 + (i * 15) + floor(random() * 30))::integer,
(80 + (i * 10) + floor(random() * 20))::integer,
(3.0 + (i * 0.1) + (random() * 0.5))::numeric(5,2),
(140 + (i * 2) + (random() * 20))::numeric(10,2),
'{}'::jsonb,
NOW()
)
ON CONFLICT DO NOTHING;
END LOOP;
END $$;
-- 2.5 数据洞察为第一个报表生成3条洞察
INSERT INTO analytics_insights (
id, report_id, owner_user_id, type, impact, title, content, tags, created_at
)
VALUES
(
gen_random_uuid(),
'a0000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000001',
'positive',
'high',
'GMV持续增长',
'近7天GMV较上周期增长15.6%,主要得益于新用户增长和促销活动',
ARRAY['销售', '增长']::text[],
NOW() - INTERVAL '1 hour'
),
(
gen_random_uuid(),
'a0000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000001',
'warning',
'medium',
'客单价略有下降',
'客单价较上周期下降2.1%,建议关注高价值商品推广',
ARRAY['客单价', '预警']::text[],
NOW() - INTERVAL '1 hour'
),
(
gen_random_uuid(),
'a0000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000001',
'info',
'low',
'转化率稳定',
'转化率保持在3.45%,与行业平均水平相当',
ARRAY['转化率']::text[],
NOW() - INTERVAL '1 hour'
)
ON CONFLICT DO NOTHING;
-- 2.6 报表收藏
INSERT INTO analytics_report_favorites (id, user_id, report_id, created_at)
VALUES
(
gen_random_uuid(),
'00000000-0000-0000-0000-000000000001',
'a0000000-0000-0000-0000-000000000001',
NOW() - INTERVAL '1 day'
),
(
gen_random_uuid(),
'00000000-0000-0000-0000-000000000001',
'a0000000-0000-0000-0000-000000000002',
NOW() - INTERVAL '2 days'
)
ON CONFLICT (user_id, report_id) DO NOTHING;
-- 2.7 导出任务历史
INSERT INTO analytics_export_jobs (
id, user_id, report_id, format, status, file_path, error_message, created_at, finished_at
)
VALUES
(
gen_random_uuid(),
'00000000-0000-0000-0000-000000000001',
'a0000000-0000-0000-0000-000000000001',
'xlsx',
'done',
'exports/report_001.xlsx',
'',
NOW() - INTERVAL '3 days',
NOW() - INTERVAL '3 days' + INTERVAL '5 minutes'
),
(
gen_random_uuid(),
'00000000-0000-0000-0000-000000000001',
'a0000000-0000-0000-0000-000000000002',
'pdf',
'done',
'exports/report_002.pdf',
'',
NOW() - INTERVAL '1 day',
NOW() - INTERVAL '1 day' + INTERVAL '2 minutes'
),
(
gen_random_uuid(),
'00000000-0000-0000-0000-000000000001',
'a0000000-0000-0000-0000-000000000003',
'csv',
'running',
NULL,
'',
NOW() - INTERVAL '10 minutes',
NULL
)
ON CONFLICT DO NOTHING;
-- ============================================
-- 3. 补充报表明细行(为其他报表生成数据)
-- ============================================
-- 为第二个报表用户分析报表生成过去30天的趋势数据
DO $$
DECLARE
i INTEGER;
row_date DATE;
report_id_val UUID := 'a0000000-0000-0000-0000-000000000002';
BEGIN
FOR i IN 0..29 LOOP
row_date := CURRENT_DATE - (29 - i);
INSERT INTO analytics_report_rows (
id, report_id, row_date, gmv, orders, users, conversion, avg_order_amount, extra, created_at
)
VALUES (
gen_random_uuid(),
report_id_val,
row_date,
(18000 + (i * 500) + (random() * 2000))::numeric(10,2),
(120 + (i * 3) + floor(random() * 20))::integer,
(90 + (i * 2) + floor(random() * 15))::integer,
(3.2 + (i * 0.05) + (random() * 0.3))::numeric(5,2),
(150 + (i * 1) + (random() * 30))::numeric(10,2),
'{}'::jsonb,
NOW()
)
ON CONFLICT DO NOTHING;
END LOOP;
END $$;
-- 为第三个报表商家销售报表生成过去90天的趋势数据
DO $$
DECLARE
i INTEGER;
row_date DATE;
report_id_val UUID := 'a0000000-0000-0000-0000-000000000003';
BEGIN
FOR i IN 0..89 LOOP
row_date := CURRENT_DATE - (89 - i);
INSERT INTO analytics_report_rows (
id, report_id, row_date, gmv, orders, users, conversion, avg_order_amount, extra, created_at
)
VALUES (
gen_random_uuid(),
report_id_val,
row_date,
(20000 + (i * 100) + (random() * 3000))::numeric(10,2),
(150 + (i * 1) + floor(random() * 30))::integer,
(100 + (i * 1) + floor(random() * 20))::integer,
(3.5 + (i * 0.01) + (random() * 0.4))::numeric(5,2),
(130 + (i * 0.5) + (random() * 40))::numeric(10,2),
'{}'::jsonb,
NOW()
)
ON CONFLICT DO NOTHING;
END LOOP;
END $$;
-- ============================================
-- 完成
-- ============================================
SELECT 'Analytics test data seed completed!' AS message;

View File

@@ -1,388 +0,0 @@
-- ============================================
-- 优惠券效果分析 RPC 集合Supabase / Postgres
-- 说明:
-- - 这些函数只依赖业务域表:
-- coupon_templates, user_coupons, coupon_usage_logs, orders
-- - 不创建/修改业务表结构,由业务侧 schema 负责。
-- - 仅提供 Analytics Dashboard 所需的聚合统计。
-- - 调用方:/pages/mall/analytics/coupon-analysis.uvue
-- ============================================
-- 安全注意:
-- - 函数使用 SECURITY DEFINER并将执行权限收敛到 authenticated 角色。
-- - 具体可见文件末尾的 REVOKE / GRANT 语句。
-- 1) 概览 KPI发放/使用/GMV 提升/ROI/整体到期情况
CREATE OR REPLACE FUNCTION public.rpc_coupon_effectiveness_overview(
p_start timestamptz,
p_end timestamptz,
p_merchant_id uuid DEFAULT NULL
)
RETURNS TABLE (
total_issued numeric,
total_used numeric,
usage_rate numeric,
gmv_increase numeric,
issued_growth numeric,
gmv_growth numeric,
roi numeric,
about_to_expire_cnt numeric
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_prev_start timestamptz;
v_prev_end timestamptz;
v_prev_issued numeric := 0;
v_prev_gmv numeric := 0;
v_discount_sum numeric := 0;
BEGIN
IF p_start IS NULL OR p_end IS NULL OR p_start >= p_end THEN
RAISE EXCEPTION 'invalid period';
END IF;
-- 上一周期窗口:长度与当前周期一致
v_prev_start := p_start - (p_end - p_start);
v_prev_end := p_start;
-- 当前周期发放数量user_coupons.received_at
SELECT
COALESCE(COUNT(uc.id), 0)::numeric
INTO total_issued
FROM user_coupons uc
JOIN coupon_templates ct ON ct.id = uc.template_id
WHERE uc.received_at >= p_start
AND uc.received_at < p_end
AND (p_merchant_id IS NULL OR ct.merchant_id = p_merchant_id);
-- 当前周期:使用记录 + GMV / 优惠额
SELECT
COALESCE(COUNT(cul.id), 0)::numeric,
COALESCE(SUM(COALESCE(cul.order_amount, o.total_amount)), 0)::numeric,
COALESCE(SUM(cul.discount_amount), 0)::numeric
INTO total_used, gmv_increase, v_discount_sum
FROM coupon_usage_logs cul
JOIN coupon_templates ct ON ct.id = cul.template_id
LEFT JOIN orders o ON o.id = cul.order_id
WHERE cul.used_at >= p_start
AND cul.used_at < p_end
AND (p_merchant_id IS NULL OR ct.merchant_id = p_merchant_id);
-- 使用率0-100
IF total_issued > 0 THEN
usage_rate := ROUND((total_used / total_issued) * 100.0, 2);
ELSE
usage_rate := 0;
END IF;
-- 上一周期:发放与 GMV用于增长率
SELECT
COALESCE(COUNT(uc.id), 0)::numeric
INTO v_prev_issued
FROM user_coupons uc
JOIN coupon_templates ct ON ct.id = uc.template_id
WHERE uc.received_at >= v_prev_start
AND uc.received_at < v_prev_end
AND (p_merchant_id IS NULL OR ct.merchant_id = p_merchant_id);
SELECT
COALESCE(SUM(COALESCE(cul.order_amount, o.total_amount)), 0)::numeric
INTO v_prev_gmv
FROM coupon_usage_logs cul
JOIN coupon_templates ct ON ct.id = cul.template_id
LEFT JOIN orders o ON o.id = cul.order_id
WHERE cul.used_at >= v_prev_start
AND cul.used_at < v_prev_end
AND (p_merchant_id IS NULL OR ct.merchant_id = p_merchant_id);
-- 发放增长率
IF v_prev_issued > 0 THEN
issued_growth := ROUND(((total_issued - v_prev_issued) / v_prev_issued) * 100.0, 2);
ELSE
issued_growth := CASE WHEN total_issued > 0 THEN 100.0 ELSE 0.0 END;
END IF;
-- GMV 增长率
IF v_prev_gmv > 0 THEN
gmv_growth := ROUND(((gmv_increase - v_prev_gmv) / v_prev_gmv) * 100.0, 2);
ELSE
gmv_growth := CASE WHEN gmv_increase > 0 THEN 100.0 ELSE 0.0 END;
END IF;
-- ROI = (GMV_with_coupon - discount_sum) / discount_sum
IF v_discount_sum > 0 THEN
roi := ROUND(((gmv_increase - v_discount_sum) / v_discount_sum) * 100.0, 2);
ELSE
roi := 0;
END IF;
-- 未来 7 天内即将到期且未使用的券数量(整体概览)
SELECT
COALESCE(COUNT(uc2.id), 0)::numeric
INTO about_to_expire_cnt
FROM user_coupons uc2
JOIN coupon_templates ct2 ON ct2.id = uc2.template_id
WHERE uc2.status = 1 -- 假设 1 = unused参考 mall.md
AND uc2.expire_at > now()
AND uc2.expire_at <= now() + interval '7 days'
AND (p_merchant_id IS NULL OR ct2.merchant_id = p_merchant_id);
RETURN;
END;
$$;
-- 2) 按券类型统计8 种券类型效果
CREATE OR REPLACE FUNCTION public.rpc_coupon_type_stats(
p_start timestamptz,
p_end timestamptz,
p_merchant_id uuid DEFAULT NULL
)
RETURNS TABLE (
coupon_type integer,
total_issued numeric,
total_used numeric,
usage_rate numeric,
gmv_with_coupon numeric,
amount_saved numeric
)
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
ct.coupon_type::integer AS coupon_type,
COALESCE(COUNT(DISTINCT uc.id), 0)::numeric AS total_issued,
COALESCE(COUNT(DISTINCT cul.id), 0)::numeric AS total_used,
CASE
WHEN COUNT(DISTINCT uc.id) > 0
THEN ROUND(
(COUNT(DISTINCT cul.id)::numeric / COUNT(DISTINCT uc.id)::numeric) * 100.0,
2
)
ELSE 0
END AS usage_rate,
COALESCE(SUM(COALESCE(cul.order_amount, o.total_amount)), 0)::numeric AS gmv_with_coupon,
COALESCE(SUM(cul.discount_amount), 0)::numeric AS amount_saved
FROM coupon_templates ct
LEFT JOIN user_coupons uc
ON uc.template_id = ct.id
AND uc.received_at >= p_start
AND uc.received_at < p_end
LEFT JOIN coupon_usage_logs cul
ON cul.template_id = ct.id
AND cul.used_at >= p_start
AND cul.used_at < p_end
LEFT JOIN orders o
ON o.id = cul.order_id
WHERE (p_merchant_id IS NULL OR ct.merchant_id = p_merchant_id)
GROUP BY ct.coupon_type
ORDER BY total_issued DESC;
$$;
-- 3) 按发放渠道统计:主动领取/自动发放/活动/邀请/客服/积分
CREATE OR REPLACE FUNCTION public.rpc_coupon_channel_stats(
p_start timestamptz,
p_end timestamptz,
p_merchant_id uuid DEFAULT NULL
)
RETURNS TABLE (
channel text,
total_issued numeric,
total_used numeric,
usage_rate numeric,
gmv_with_coupon numeric
)
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
uc.obtain_channel::text AS channel,
COALESCE(COUNT(DISTINCT uc.id), 0)::numeric AS total_issued,
COALESCE(COUNT(DISTINCT cul.id), 0)::numeric AS total_used,
CASE
WHEN COUNT(DISTINCT uc.id) > 0
THEN ROUND(
(COUNT(DISTINCT cul.id)::numeric / COUNT(DISTINCT uc.id)::numeric) * 100.0,
2
)
ELSE 0
END AS usage_rate,
COALESCE(SUM(COALESCE(cul.order_amount, o.total_amount)), 0)::numeric AS gmv_with_coupon
FROM user_coupons uc
JOIN coupon_templates ct
ON ct.id = uc.template_id
LEFT JOIN coupon_usage_logs cul
ON cul.user_coupon_id = uc.id
AND cul.used_at >= p_start
AND cul.used_at < p_end
LEFT JOIN orders o
ON o.id = cul.order_id
WHERE uc.received_at >= p_start
AND uc.received_at < p_end
AND (p_merchant_id IS NULL OR ct.merchant_id = p_merchant_id)
AND uc.obtain_channel IS NOT NULL
GROUP BY uc.obtain_channel
ORDER BY total_issued DESC;
$$;
-- 4) 使用趋势:按天发放 vs 使用
CREATE OR REPLACE FUNCTION public.rpc_coupon_trend_daily(
p_start timestamptz,
p_end timestamptz,
p_merchant_id uuid DEFAULT NULL
)
RETURNS TABLE (
day date,
issued numeric,
used numeric
)
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
WITH days AS (
SELECT generate_series(
date_trunc('day', p_start)::date,
date_trunc('day', p_end)::date,
interval '1 day'
)::date AS d
),
issued AS (
SELECT
uc_day::date AS d,
COUNT(*)::numeric AS cnt
FROM (
SELECT DATE(uc.received_at) AS uc_day
FROM user_coupons uc
JOIN coupon_templates ct ON ct.id = uc.template_id
WHERE uc.received_at >= p_start
AND uc.received_at < p_end
AND (p_merchant_id IS NULL OR ct.merchant_id = p_merchant_id)
) t
GROUP BY uc_day
),
used AS (
SELECT
cul_day::date AS d,
COUNT(*)::numeric AS cnt
FROM (
SELECT DATE(cul.used_at) AS cul_day
FROM coupon_usage_logs cul
JOIN coupon_templates ct ON ct.id = cul.template_id
WHERE cul.used_at >= p_start
AND cul.used_at < p_end
AND (p_merchant_id IS NULL OR ct.merchant_id = p_merchant_id)
) t
GROUP BY cul_day
)
SELECT
d.d AS day,
COALESCE(i.cnt, 0) AS issued,
COALESCE(u.cnt, 0) AS used
FROM days d
LEFT JOIN issued i ON i.d = d.d
LEFT JOIN used u ON u.d = d.d
ORDER BY d.d;
$$;
-- 5) 转化效果:有券 vs 无券GMV/订单数/客单价)
CREATE OR REPLACE FUNCTION public.rpc_coupon_conversion_effect(
p_start timestamptz,
p_end timestamptz,
p_merchant_id uuid DEFAULT NULL
)
RETURNS TABLE (
metric text,
with_coupon numeric,
without_coupon numeric
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_with_orders numeric := 0;
v_without_orders numeric := 0;
v_with_gmv numeric := 0;
v_without_gmv numeric := 0;
BEGIN
-- 有券订单集合
SELECT
COALESCE(COUNT(DISTINCT o.id), 0)::numeric,
COALESCE(SUM(o.total_amount), 0)::numeric
INTO v_with_orders, v_with_gmv
FROM orders o
WHERE o.created_at >= p_start
AND o.created_at < p_end
AND (p_merchant_id IS NULL OR o.merchant_id = p_merchant_id)
AND EXISTS (
SELECT 1
FROM coupon_usage_logs cul
WHERE cul.order_id = o.id
);
-- 无券订单集合
SELECT
COALESCE(COUNT(DISTINCT o.id), 0)::numeric,
COALESCE(SUM(o.total_amount), 0)::numeric
INTO v_without_orders, v_without_gmv
FROM orders o
WHERE o.created_at >= p_start
AND o.created_at < p_end
AND (p_merchant_id IS NULL OR o.merchant_id = p_merchant_id)
AND NOT EXISTS (
SELECT 1
FROM coupon_usage_logs cul
WHERE cul.order_id = o.id
);
-- GMV 行
metric := 'GMV';
with_coupon := v_with_gmv;
without_coupon := v_without_gmv;
RETURN NEXT;
-- 订单数 行
metric := 'orders';
with_coupon := v_with_orders;
without_coupon := v_without_orders;
RETURN NEXT;
-- 客单价 行
metric := 'avg_order_amount';
with_coupon := CASE WHEN v_with_orders > 0 THEN ROUND(v_with_gmv / v_with_orders, 2) ELSE 0 END;
without_coupon := CASE WHEN v_without_orders > 0 THEN ROUND(v_without_gmv / v_without_orders, 2) ELSE 0 END;
RETURN NEXT;
RETURN;
END;
$$;
-- ============================================
-- 权限收敛:只允许 authenticated 角色调用
-- ============================================
REVOKE ALL ON FUNCTION public.rpc_coupon_effectiveness_overview(timestamptz,timestamptz,uuid) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.rpc_coupon_type_stats(timestamptz,timestamptz,uuid) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.rpc_coupon_channel_stats(timestamptz,timestamptz,uuid) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.rpc_coupon_trend_daily(timestamptz,timestamptz,uuid) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.rpc_coupon_conversion_effect(timestamptz,timestamptz,uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_coupon_effectiveness_overview(timestamptz,timestamptz,uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.rpc_coupon_type_stats(timestamptz,timestamptz,uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.rpc_coupon_channel_stats(timestamptz,timestamptz,uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.rpc_coupon_trend_daily(timestamptz,timestamptz,uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.rpc_coupon_conversion_effect(timestamptz,timestamptz,uuid) TO authenticated;
-- 完成
SELECT 'Coupon analysis RPCs created successfully!' AS message;

View File

@@ -1,342 +0,0 @@
-- ============================================
-- DATA_DETAIL_RPCS.sql
-- 数据分析详情页专用 RPC 定义
-- ============================================
-- 目标:
-- 1) 为 `pages/mall/analytics/data-detail.uvue` 提供统一的数据服务
-- 2) 仅复用现有 analytics_* 表与业务表,不新增物理表
-- 3) 权限完全依赖各表自身的 RLS 策略,本文件只负责函数与 GRANT
--
-- 依赖前置脚本:
-- - 01_create_tables.sql
-- - ../../user/test/USER_AUTH_SCHEMA.sql
-- - ../../user/test/USER_AUTH_TRIGGER.sql
-- - ANALYTICS_DB_SCHEMA.sql
--
-- 使用说明:
-- - 前端通过 supabase-js / UTS 调用 `rpc()` 访问本文件中的函数
-- - 所有函数仅对 `authenticated` 角色开放执行权限
-- ============================================
-- --------------------------------------------
-- 1. 报表基础信息(用于初始化筛选器)
-- --------------------------------------------
-- 根据报表 ID 返回基础配置,包含标题、类型、时间范围等
CREATE OR REPLACE FUNCTION public.rpc_data_detail_report_info(
p_report_id uuid
)
RETURNS TABLE (
id uuid,
title text,
type text,
period text,
date_start date,
date_end date,
status text,
merchant_id uuid
)
LANGUAGE sql
AS $$
SELECT
r.id,
r.title,
r.type,
r.period,
r.date_start,
r.date_end,
r.status,
r.merchant_id
FROM public.analytics_reports r
WHERE r.id = p_report_id
$$;
REVOKE ALL ON FUNCTION public.rpc_data_detail_report_info(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_data_detail_report_info(uuid) TO authenticated;
-- --------------------------------------------
-- 2. 报表明细行(表格数据)
-- --------------------------------------------
-- 说明:
-- - 以 analytics_report_rows 作为数据源
-- - 可按日期 / GMV / 订单数 / 用户数排序
-- - 维度信息通过 extra(JSONB) 透出,前端可自由解析
CREATE OR REPLACE FUNCTION public.rpc_data_detail_rows(
p_report_id uuid,
p_sort_by text DEFAULT 'row_date', -- row_date | gmv | orders | users
p_sort_dir text DEFAULT 'asc', -- asc | desc
p_limit integer DEFAULT 200,
p_offset integer DEFAULT 0
)
RETURNS TABLE (
row_date date,
gmv numeric,
orders integer,
users integer,
conversion numeric,
avg_order_amount numeric,
extra jsonb
)
LANGUAGE plpgsql
AS $$
BEGIN
-- 统一的 LIMIT / OFFSET 处理
IF lower(p_sort_by) = 'gmv' THEN
IF lower(p_sort_dir) = 'desc' THEN
RETURN QUERY
SELECT
r.row_date,
r.gmv,
r.orders,
r.users,
r.conversion,
r.avg_order_amount,
r.extra
FROM public.analytics_report_rows r
WHERE r.report_id = p_report_id
ORDER BY r.gmv DESC, r.row_date DESC
LIMIT GREATEST(p_limit, 0)
OFFSET GREATEST(p_offset, 0);
ELSE
RETURN QUERY
SELECT
r.row_date,
r.gmv,
r.orders,
r.users,
r.conversion,
r.avg_order_amount,
r.extra
FROM public.analytics_report_rows r
WHERE r.report_id = p_report_id
ORDER BY r.gmv ASC, r.row_date ASC
LIMIT GREATEST(p_limit, 0)
OFFSET GREATEST(p_offset, 0);
END IF;
ELSIF lower(p_sort_by) = 'orders' THEN
IF lower(p_sort_dir) = 'desc' THEN
RETURN QUERY
SELECT
r.row_date,
r.gmv,
r.orders,
r.users,
r.conversion,
r.avg_order_amount,
r.extra
FROM public.analytics_report_rows r
WHERE r.report_id = p_report_id
ORDER BY r.orders DESC, r.row_date DESC
LIMIT GREATEST(p_limit, 0)
OFFSET GREATEST(p_offset, 0);
ELSE
RETURN QUERY
SELECT
r.row_date,
r.gmv,
r.orders,
r.users,
r.conversion,
r.avg_order_amount,
r.extra
FROM public.analytics_report_rows r
WHERE r.report_id = p_report_id
ORDER BY r.orders ASC, r.row_date ASC
LIMIT GREATEST(p_limit, 0)
OFFSET GREATEST(p_offset, 0);
END IF;
ELSIF lower(p_sort_by) = 'users' THEN
IF lower(p_sort_dir) = 'desc' THEN
RETURN QUERY
SELECT
r.row_date,
r.gmv,
r.orders,
r.users,
r.conversion,
r.avg_order_amount,
r.extra
FROM public.analytics_report_rows r
WHERE r.report_id = p_report_id
ORDER BY r.users DESC, r.row_date DESC
LIMIT GREATEST(p_limit, 0)
OFFSET GREATEST(p_offset, 0);
ELSE
RETURN QUERY
SELECT
r.row_date,
r.gmv,
r.orders,
r.users,
r.conversion,
r.avg_order_amount,
r.extra
FROM public.analytics_report_rows r
WHERE r.report_id = p_report_id
ORDER BY r.users ASC, r.row_date ASC
LIMIT GREATEST(p_limit, 0)
OFFSET GREATEST(p_offset, 0);
END IF;
ELSE
-- 默认按日期排序
IF lower(p_sort_dir) = 'desc' THEN
RETURN QUERY
SELECT
r.row_date,
r.gmv,
r.orders,
r.users,
r.conversion,
r.avg_order_amount,
r.extra
FROM public.analytics_report_rows r
WHERE r.report_id = p_report_id
ORDER BY r.row_date DESC
LIMIT GREATEST(p_limit, 0)
OFFSET GREATEST(p_offset, 0);
ELSE
RETURN QUERY
SELECT
r.row_date,
r.gmv,
r.orders,
r.users,
r.conversion,
r.avg_order_amount,
r.extra
FROM public.analytics_report_rows r
WHERE r.report_id = p_report_id
ORDER BY r.row_date ASC
LIMIT GREATEST(p_limit, 0)
OFFSET GREATEST(p_offset, 0);
END IF;
END IF;
END;
$$;
REVOKE ALL ON FUNCTION public.rpc_data_detail_rows(uuid,text,text,integer,integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_data_detail_rows(uuid,text,text,integer,integer) TO authenticated;
-- --------------------------------------------
-- 3. 钻取指标列表KPI / 汇总卡片)
-- --------------------------------------------
-- 说明:
-- - 直接从 analytics_report_metrics 读取
-- - 前端可根据 format 字段决定展示方式(数字 / 金额 / 百分比)
CREATE OR REPLACE FUNCTION public.rpc_data_detail_drill_items(
p_report_id uuid
)
RETURNS TABLE (
metric_key text,
metric_label text,
metric_value_num numeric,
metric_value_text text,
format text,
change_pct numeric,
icon text,
color text
)
LANGUAGE sql
AS $$
SELECT
m.metric_key,
m.metric_label,
m.metric_value_num,
m.metric_value_text,
m.format,
m.change_pct,
m.icon,
m.color
FROM public.analytics_report_metrics m
WHERE m.report_id = p_report_id
ORDER BY m.metric_key
$$;
REVOKE ALL ON FUNCTION public.rpc_data_detail_drill_items(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_data_detail_drill_items(uuid) TO authenticated;
-- --------------------------------------------
-- 4. GMV 对比数据(当前周期 vs 对比周期)
-- --------------------------------------------
-- 说明:
-- - 当前周期 = analytics_reports.period / date_start/date_end 所定义的范围
-- - 对比周期 = 与当前周期长度相同的上一段时间
-- - 聚合来源orders
CREATE OR REPLACE FUNCTION public.rpc_data_detail_compare_gmv(
p_report_id uuid
)
RETURNS TABLE (
day date,
gmv_current numeric,
gmv_previous numeric
)
LANGUAGE plpgsql
AS $$
DECLARE
v_date_start date;
v_date_end date;
v_period_len integer;
BEGIN
SELECT
COALESCE(r.date_start, (now() - INTERVAL '7 days')::date),
COALESCE(r.date_end, now()::date)
INTO v_date_start, v_date_end
FROM public.analytics_reports r
WHERE r.id = p_report_id;
IF v_date_start IS NULL OR v_date_end IS NULL THEN
RETURN;
END IF;
v_period_len := (v_date_end - v_date_start) + 1;
RETURN QUERY
WITH cur AS (
SELECT
o.created_at::date AS day,
SUM(o.total_amount) AS gmv
FROM public.orders o
WHERE o.created_at::date BETWEEN v_date_start AND v_date_end
AND o.status = 2
GROUP BY o.created_at::date
),
prev_range AS (
SELECT
(v_date_start - v_period_len) AS start_date,
(v_date_start - 1) AS end_date
),
prev AS (
SELECT
o.created_at::date AS day,
SUM(o.total_amount) AS gmv
FROM public.orders o, prev_range pr
WHERE o.created_at::date BETWEEN pr.start_date AND pr.end_date
AND o.status = 2
GROUP BY o.created_at::date
),
series AS (
SELECT generate_series(v_date_start, v_date_end, INTERVAL '1 day')::date AS day
)
SELECT
s.day,
COALESCE(c.gmv, 0) AS gmv_current,
COALESCE(p.gmv, 0) AS gmv_previous
FROM series s
LEFT JOIN cur c ON c.day = s.day
LEFT JOIN prev p ON p.day = (s.day - v_period_len);
END;
$$;
REVOKE ALL ON FUNCTION public.rpc_data_detail_compare_gmv(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_data_detail_compare_gmv(uuid) TO authenticated;
-- ============================================
-- 文件结束
-- ============================================

View File

@@ -1,73 +0,0 @@
-- ============================================
-- 配送效率分析(按 assigned_at 统计周期,接单 -> 送达)
-- 依赖表ml_delivery_tasks, ml_delivery_drivers
-- ============================================
-- 1) 每日趋势:平均时效/平均配送费/总配送费/完成单量
CREATE OR REPLACE FUNCTION public.rpc_delivery_efficiency_daily(
p_start TIMESTAMPTZ,
p_end TIMESTAMPTZ
)
RETURNS TABLE (
day DATE,
completed_orders INTEGER,
avg_delivery_minutes NUMERIC,
total_fee NUMERIC,
avg_fee NUMERIC
)
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
DATE(t.assigned_at) AS day,
COUNT(*)::INTEGER AS completed_orders,
AVG(EXTRACT(EPOCH FROM (t.delivered_at - t.assigned_at)) / 60.0) AS avg_delivery_minutes,
COALESCE(SUM(t.delivery_fee), 0) AS total_fee,
AVG(t.delivery_fee) AS avg_fee
FROM public.ml_delivery_tasks t
WHERE t.status = 5
AND t.assigned_at >= p_start
AND t.assigned_at < p_end
AND t.delivered_at IS NOT NULL
GROUP BY DATE(t.assigned_at)
ORDER BY day;
$$;
-- 2) Top N 配送员:按完成单量排序
CREATE OR REPLACE FUNCTION public.rpc_delivery_efficiency_top_drivers(
p_start TIMESTAMPTZ,
p_end TIMESTAMPTZ,
p_limit INTEGER DEFAULT 10
)
RETURNS TABLE (
driver_id UUID,
driver_name TEXT,
orders INTEGER,
rating_avg NUMERIC
)
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
d.id AS driver_id,
CAST(d.real_name AS TEXT) AS driver_name,
COUNT(*)::INTEGER AS orders,
COALESCE(d.rating_avg, 0) AS rating_avg
FROM public.ml_delivery_tasks t
JOIN public.ml_delivery_drivers d ON d.id = t.driver_id
WHERE t.status = 5
AND t.assigned_at >= p_start
AND t.assigned_at < p_end
AND t.delivered_at IS NOT NULL
GROUP BY d.id, d.real_name, d.rating_avg
ORDER BY orders DESC
LIMIT p_limit;
$$;
-- 建议:只允许 authenticated 调用(按你现有 analytics 的做法)
REVOKE ALL ON FUNCTION public.rpc_delivery_efficiency_daily(TIMESTAMPTZ, TIMESTAMPTZ) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.rpc_delivery_efficiency_top_drivers(TIMESTAMPTZ, TIMESTAMPTZ, INTEGER) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_delivery_efficiency_daily(TIMESTAMPTZ, TIMESTAMPTZ) TO authenticated;
GRANT EXECUTE ON FUNCTION public.rpc_delivery_efficiency_top_drivers(TIMESTAMPTZ, TIMESTAMPTZ, INTEGER) TO authenticated;

View File

@@ -1,178 +0,0 @@
# 数据分析实时大屏 - 测试数据说明
本目录包含用于测试数据分析实时大屏功能的 SQL 脚本和测试数据。
## 文件说明
### 1. `01_create_tables.sql`
创建所需的数据表结构,包括:
- `orders` - 订单表
- `user_sessions` - 用户会话表
- `users` - 用户表
- `products` - 商品表(可选)
- `order_items` - 订单商品关联表(可选)
- `page_views` - 访问日志表(可选)
**执行顺序:** 首先执行此文件创建表结构
### 2. `02_insert_test_data.sql`
插入测试数据,包括:
- 8个测试用户
- 5个在线用户会话最近5分钟内有活动
- 15个今日订单用于计算实时GMV和订单数
- 10个昨日同时段订单用于计算增长率
- 15条访问日志用于转化率计算
**执行顺序:** 在创建表后执行此文件插入测试数据
### 3. `03_test_queries.sql`
包含各种测试查询,用于验证数据计算逻辑:
- 实时GMV查询
- 在线用户查询
- 转化率查询
- 综合实时大屏数据查询
- 数据验证查询
**执行顺序:** 在插入测试数据后执行此文件验证数据
## 使用方法
### 方式 1: 通过 Supabase Dashboard推荐
1. **打开 Supabase Studio / Dashboard**
- 请使用你自己的部署地址访问(不要在仓库文档里硬编码地址/账号/密码)。
2. **打开 SQL Editor**
- 在左侧菜单找到 "SQL Editor"
- 点击 "New Query"
3. **执行脚本**
- 复制 `01_create_tables.sql` 的内容,粘贴并执行
- 复制 `02_insert_test_data.sql` 的内容,粘贴并执行
- (可选)复制 `03_test_queries.sql` 的内容,验证数据
### 方式 2: 使用命令行PostgreSQL
```bash
# 连接到 Supabase Postgres参数请按你的环境填写
psql -h <DB_HOST> -p <DB_PORT> -U postgres -d postgres
# 执行 SQL 文件(需要完整路径)
\i D:/datas/hfkj/mall/pages/mall/analytics/test/01_create_tables.sql
\i D:/datas/hfkj/mall/pages/mall/analytics/test/02_insert_test_data.sql
\i D:/datas/hfkj/mall/pages/mall/analytics/test/03_test_queries.sql
```
### 方式 3: 使用图形工具DBeaver / pgAdmin
1. **创建连接**
- 主机:`<DB_HOST>`
- 端口:`<DB_PORT>`
- 数据库:`postgres`
- 用户名:`postgres`(或你的管理员账号)
- 密码:`<DB_PASSWORD>`
2. **执行 SQL**
- 打开 SQL 编辑器
- 复制 SQL 文件内容并执行
**详细说明请查看:**
- **`ANALYTICS_DATA_QUICK_START.md`** - ⭐ **SQL 文件执行顺序指南(必读!)**
- `SQL_USAGE_GUIDE.md` - SQL 脚本执行详细指南
- `TEST_DATA_INSERT_GUIDE.md` - 测试数据插入指南(包含 RLS 处理说明)
## 测试数据说明
### 实时GMV测试数据
- **今日订单总数:** 15笔
- **今日GMV** 约 3,500 元(根据订单金额累加)
- **昨日同时段订单:** 10笔
- **昨日同时段GMV** 约 2,200 元
- **预期增长率:** 约 59%(3500-2200)/2200 * 100
### 实时订单测试数据
- **今日订单数:** 15笔
- **昨日同时段订单数:** 10笔
- **预期增长率:** 50%(15-10)/10 * 100
### 在线用户测试数据
- **最近5分钟内有活动的用户** 5个
- 这些用户会在实时大屏中显示为"在线用户"
### 转化率测试数据
- **今日访问用户数:** 约 10-15个从 user_sessions 表统计)
- **今日下单用户数:** 约 8个从 orders 表去重统计)
- **预期转化率:** 约 53-80%(根据实际数据计算)
## ⚠️ 重要RLS行级安全策略说明
**所有表已启用 RLS**,插入测试数据时需要注意:
1. **推荐方式**:使用 Supabase Dashboard 的 SQL Editor 执行脚本
- Dashboard 默认使用 `service_role` 权限,可以绕过 RLS
- 无需额外配置,直接执行即可
2. **命令行方式**:如果使用命令行或脚本执行
- 需要临时禁用 RLS`02_insert_test_data.sql` 中的注释说明)
- 或使用 `SECURITY DEFINER` 函数(见 `TEST_DATA_INSERT_GUIDE.md`
3. **详细说明**:请查看 `TEST_DATA_INSERT_GUIDE.md` 获取完整的插入指南
## 注意事项
1. **时间依赖**
- 测试数据使用了 `NOW()` 和相对时间(如 `INTERVAL '1 hour'`
- 每次执行时,数据的时间戳会基于当前时间生成
- 建议在测试前先清空相关表的数据(谨慎操作)
2. **数据冲突**
- 脚本使用了 `ON CONFLICT DO NOTHING``ON CONFLICT DO UPDATE`
- 可以多次执行而不会产生重复数据
- 如需重新生成数据,请先清空表
3. **状态值**
- 订单状态:`2` 表示已支付/已完成
- 用户会话:`is_active = true` 表示活跃会话
4. **UUID 格式**
- 所有 ID 使用 UUID 格式
- 测试数据使用了固定的 UUID 便于识别
5. **RLS 权限**
- 插入数据后,前端查询需要用户已登录
- 测试数据的 `user_id` 需要与登录用户的 `auth.uid()` 匹配才能查询到
- 或者使用公开数据(如 `products``merchants` 表)
## 清理测试数据
如需清理测试数据,请使用独立的清理脚本(例如 `04_cleanup.sql`)。
## 验证实时大屏功能
执行完测试数据后,在数据分析页面应该能看到:
1. **实时GMV** 约 ¥3,500根据实际订单金额
2. **实时订单:** 15笔
3. **在线用户:** 5人
4. **转化率:** 约 50-80%(根据实际计算)
增长率会根据昨日同时段的数据自动计算。
## 问题排查
如果实时大屏显示异常,可以:
1. 执行 `03_test_queries.sql` 中的查询验证数据
2. 检查订单状态是否为 `2`(已支付)
3. 检查时间范围是否正确(今日 vs 昨日同时段)
4. 检查用户会话的 `last_active_at` 是否在最近5分钟内
5. 查看浏览器控制台的错误信息
## 扩展测试数据
如果需要更多测试数据,可以:
1. 修改 `02_insert_test_data.sql` 中的 INSERT 语句
2. 调整订单金额、数量和时间分布
3. 添加更多用户和会话数据
4. 使用循环生成大量测试数据(注意性能)

View File

@@ -1,15 +0,0 @@
# SQL 文件执行顺序指南(已弃用)
> 本文件已停止维护,避免与新脚本冲突。
>
> ✅ **请以 `ANALYTICS_DATA_QUICK_START.md` 为唯一权威执行顺序与权限说明文档。**
## 当前推荐执行顺序(摘要)
1. `01_create_tables.sql`(基础业务表 + RLS + 中文注释Drop-first
2. `../../user/test/USER_AUTH_SCHEMA.sql``ak_users` + RLS + 资料函数Drop-first
3. `../../user/test/USER_AUTH_TRIGGER.sql`auth.users → ak_users 触发器)
4. `ANALYTICS_DB_SCHEMA.sql`analytics_* 表 + RLS + RPCDrop-first
5. `02_insert_test_data.sql`(基础表测试数据,需 service_role/postgres
6. `ANALYTICS_TEST_SEED.sql`analytics_* 测试数据,需 service_role/postgres
7. `03_test_queries.sql`(可选:验证查询)

View File

@@ -1,274 +0,0 @@
# SQL 测试脚本使用指南
本指南说明如何在内网 Supabase 环境中执行测试 SQL 脚本。
## 📋 目录结构
```
pages/mall/analytics/test/
├── 01_create_tables.sql # 创建表结构
├── 02_insert_test_data.sql # 插入测试数据
├── 03_test_queries.sql # 测试查询
├── 04_cleanup.sql # 清理数据
└── SQL_USAGE_GUIDE.md # 本指南
```
## 🚀 执行方式
### 方式 1: 通过 Supabase Dashboard推荐
如果您的内网 Supabase 有 Dashboard 界面:
1. **打开 Supabase Studio / Dashboard**
- 使用你自己的部署地址访问(不要在仓库文档里硬编码地址/账号/密码)。
2. **打开 SQL Editor**
- 在左侧菜单找到 "SQL Editor" 或 "SQL"
- 点击 "New Query"
4. **执行脚本**
- 复制 `01_create_tables.sql` 的内容
- 粘贴到 SQL Editor
- 点击 "Run" 或按 `Ctrl+Enter`
- 等待执行完成
5. **依次执行其他脚本**
- 执行 `02_insert_test_data.sql`(插入测试数据)
- 执行 `03_test_queries.sql`(验证数据,可选)
### 方式 2: 通过 PostgreSQL 客户端psql
如果 Dashboard 不可用,可以直接连接 PostgreSQL
1. **连接数据库**
```bash
# 使用 psql 连接
psql -h <DB_HOST> -p <DB_PORT> -U postgres -d postgres
# 密码请按你的环境输入/从安全渠道获取(不要写进仓库)
```
2. **执行 SQL 文件**
```sql
-- 在 psql 中执行
\i /path/to/01_create_tables.sql
\i /path/to/02_insert_test_data.sql
\i /path/to/03_test_queries.sql
```
或者直接复制粘贴 SQL 内容到 psql 中执行。
### 方式 3: 通过 DBeaver / pgAdmin 等图形工具
1. **创建新连接**
- 主机:`<DB_HOST>`
- 端口:`<DB_PORT>`
- 数据库:`postgres`
- 用户名:`postgres`
- 密码:`<DB_PASSWORD>`
2. **执行 SQL**
- 打开 SQL 编辑器
- 复制 SQL 文件内容
- 执行脚本
> 不建议通过 HTTP API “执行任意 SQL”高风险
> 如需服务端能力,请用 Supabase Edge Functions + 限定输入输出的 RPC。
## 📝 执行顺序
**重要:必须按顺序执行!**
> ✅ 以 `ANALYTICS_DATA_QUICK_START.md` 为权威执行顺序与权限说明(本文件只做执行方式补充)。
1. ✅ **第一步:创建表结构**
```sql
-- 执行 01_create_tables.sql
-- 这会创建所有需要的表和索引
```
2. ✅ **第二步:插入测试数据**
```sql
-- 执行 02_insert_test_data.sql
-- 这会插入测试用户、订单、会话等数据
```
3. ✅ **第三步:验证数据(可选)**
```sql
-- 执行 03_test_queries.sql
-- 验证数据是否正确插入,查看统计信息
```
4. ⚠️ **清理数据(需要时)**
```sql
-- 执行 04_cleanup.sql
-- 谨慎使用:会删除测试数据
```
## 🔍 验证执行结果
### 检查表是否创建成功
```sql
-- 查看所有表
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;
-- 应该看到:
-- orders
-- user_sessions
-- users
-- products (可选)
-- order_items (可选)
-- page_views (可选)
```
### 检查数据是否插入成功
```sql
-- 检查用户数量
SELECT COUNT(*) FROM users;
-- 应该返回 8
-- 检查订单数量
SELECT COUNT(*) FROM orders WHERE created_at >= DATE_TRUNC('day', NOW());
-- 应该返回 15今日订单
-- 检查在线用户
SELECT COUNT(*) FROM user_sessions
WHERE last_active_at >= NOW() - INTERVAL '5 minutes' AND is_active = true;
-- 应该返回 5
```
### 检查实时大屏数据
```sql
-- 执行 03_test_queries.sql 中的综合查询
-- 应该能看到:
-- - 实时GMV: 约 3,500 元
-- - 实时订单: 15 笔
-- - 在线用户: 5 人
-- - 转化率: 约 50-80%
```
## ⚠️ 注意事项
### 1. 权限问题
如果遇到权限错误:
```sql
-- 确保 postgres 用户有足够权限
GRANT ALL PRIVILEGES ON DATABASE postgres TO postgres;
GRANT ALL PRIVILEGES ON SCHEMA public TO postgres;
```
### 2. 表已存在
如果表已存在:
- `01_create_tables.sql` / `ANALYTICS_DB_SCHEMA.sql` 现为 **只创建Create-only** 脚本,不包含 `DROP/DELETE/TRUNCATE`,可重复执行且不会清空数据。
- 如需结构变更请用迁移脚本ALTER TABLE
> 如确实要“清理后重建”,请另外单独维护清理脚本(避免把破坏性操作放进默认文档/默认流程)。
### 3. 时间依赖
测试数据使用 `NOW()` 函数,每次执行都会基于当前时间生成。
- 今日订单:基于当前日期
- 昨日订单:当前时间往前推 24 小时
- 在线用户:最近 5 分钟内有活动
### 4. UUID 冲突
如果重复执行插入脚本,由于使用了 `ON CONFLICT DO NOTHING`,不会产生重复数据。
但如果需要重新插入,先执行清理脚本。
## 🐛 常见问题
### Q1: 连接被拒绝
```
Error: connection refused
```
**解决:**
- 检查 Supabase 服务是否运行
- 检查防火墙设置
- 确认端口 5432 是否开放
### Q2: 认证失败
```
Error: password authentication failed
```
**解决:**
- 确认密码是否正确:`yxyHINygZMLSq9jLddrZQBB-CoyGHSF5DwlwWmbrYXc`
- 检查用户名是否为 `postgres`
### Q3: 表已存在错误
```
Error: relation "orders" already exists
```
**解决:**
- 说明你执行的脚本版本与当前仓库不一致,或只拷贝了部分 SQL
- 请按 `ANALYTICS_DATA_QUICK_START.md` 的顺序完整执行最新脚本Drop-first不应出现该错误
### Q4: 权限不足
```
Error: permission denied
```
**解决:**
- 使用 postgres 超级用户执行
- 或授予相应权限
## 📊 执行后的预期结果
执行完所有脚本后,您应该能看到:
1. **数据库表**
- 6 个表已创建orders, user_sessions, users, products, order_items, page_views
- 所有索引已创建
2. **测试数据**
- 8 个测试用户
- 15 个今日订单
- 10 个昨日订单
- 5 个在线用户会话
- 15 条访问日志
3. **实时大屏显示**
- 在数据分析页面应该能看到实时数据
- GMV、订单数、在线用户、转化率都有值
## 🔄 重新执行
如果需要重新生成测试数据:
1. **清理数据**
```sql
-- 执行 04_cleanup.sql
```
2. **重新插入**
```sql
-- 执行 02_insert_test_data.sql
```
## 📞 获取帮助
如果遇到问题:
1. 检查 Supabase 日志
2. 查看数据库连接状态
3. 验证配置文件 `ak/config.uts` 是否正确
4. 使用测试页面验证连接:`/pages/mall/analytics/test/test-connection`
## 🎯 快速开始
**最简单的执行方式:**
1. 打开 Supabase Dashboard如果有
2. 进入 SQL Editor
3. 复制 `01_create_tables.sql` 内容,执行
4. 复制 `02_insert_test_data.sql` 内容,执行
5. 完成!
现在可以开始测试实时大屏功能了!🎉

View File

@@ -1,209 +0,0 @@
# 测试数据插入指南
> 本文档说明如何在启用 RLS行级安全策略的情况下插入测试数据。
## 📋 前置条件
1. **已执行表结构创建脚本**
- `01_create_tables.sql` - 创建表结构和 RLS 策略
- `ANALYTICS_DB_SCHEMA.sql` - 创建 analytics_* 表(可选)
2. **确认 Supabase 连接**
- 已配置 Supabase 项目
- 可以访问 Supabase Dashboard 的 SQL Editor
## 🚀 插入测试数据的三种方式
### 方式一:使用 Supabase Dashboard推荐
**优点**:最简单,无需处理 RLS 权限问题
**适用场景**:开发测试、快速验证
**步骤**
1. 打开 Supabase Dashboard
2. 进入 **SQL Editor**
3. 复制 `02_insert_test_data.sql` 的全部内容
4. 粘贴到 SQL Editor 中
5. 点击 **Run** 执行
**说明**Supabase Dashboard 的 SQL Editor 默认使用 `service_role` 权限,可以绕过 RLS 策略,直接插入数据。
---
### 方式二:临时禁用 RLS适用于命令行
**优点**:可以在命令行或脚本中执行
**适用场景**自动化脚本、CI/CD
**步骤**(不推荐,除非你明确理解风险):
1. 编辑 `02_insert_test_data.sql`
2. 取消文件开头关于禁用 RLS 的注释(第 12-19 行)
3. 取消文件末尾关于重新启用 RLS 的注释(第 137-144 行)
4. 执行脚本
**示例**
```sql
-- 在脚本开头添加
BEGIN;
ALTER TABLE orders DISABLE ROW LEVEL SECURITY;
ALTER TABLE user_sessions DISABLE ROW LEVEL SECURITY;
-- ... 其他表
-- 插入数据...
-- 在脚本末尾添加
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
-- ... 其他表
COMMIT;
```
**⚠️ 注意**:执行完成后务必重新启用 RLS否则数据将不受保护
---
### 方式三:使用 SECURITY DEFINER 函数(高级)
**优点**:更安全,不需要禁用 RLS
**适用场景**:生产环境、需要定期插入测试数据
**步骤**
1. 创建一个 SECURITY DEFINER 函数来插入测试数据
2. 调用该函数执行插入
**示例函数**
```sql
CREATE OR REPLACE FUNCTION insert_test_data()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- 插入测试用户
INSERT INTO users (id, phone, email, nickname, last_login_at) VALUES
('11111111-1111-1111-1111-111111111111', '13800000001', 'user1@test.com', '测试用户1', NOW() - INTERVAL '2 minutes')
ON CONFLICT (id) DO NOTHING;
-- 插入其他测试数据...
END;
$$;
-- 执行函数
SELECT insert_test_data();
```
---
## ✅ 验证数据插入
执行以下查询验证数据是否插入成功:
```sql
-- 检查用户数量
SELECT COUNT(*) FROM users;
-- 预期8
-- 检查订单数量
SELECT COUNT(*) FROM orders;
-- 预期2515个今日订单 + 10个昨日订单
-- 检查用户会话数量
SELECT COUNT(*) FROM user_sessions;
-- 预期10
-- 检查访问日志数量
SELECT COUNT(*) FROM page_views;
-- 预期15
-- 检查商家数量
SELECT COUNT(*) FROM merchants;
-- 预期2
-- 检查商品数量
SELECT COUNT(*) FROM products;
-- 预期3
```
---
## 🔍 常见问题
### Q1: 执行 INSERT 时提示 "new row violates row-level security policy"
**原因**RLS 策略阻止了插入操作。
**解决方案**
- 使用方式一Supabase Dashboard
- 或使用方式二(临时禁用 RLS
- 或使用方式三SECURITY DEFINER 函数)
### Q2: 插入数据后,前端查询不到数据
**原因**RLS 策略限制了查询权限。
**解决方案**
1. 确认前端已正确登录(`auth.uid()` 不为 NULL
2. 检查 RLS 策略是否正确配置
3. 确认测试数据的 `user_id` 与登录用户的 `auth.uid()` 匹配
### Q3: 如何清空测试数据重新插入?
为避免在默认文档里包含破坏性 SQL本项目将“清理/删除”动作放在独立清理脚本中(如 `04_cleanup.sql`)。
如你需要重新生成测试数据:
- 先执行清理脚本
- 再重新执行 seed 脚本
---
## 📝 测试数据说明
### 用户数据
- **数量**8 个测试用户
- **UUID 范围**`11111111-...``88888888-...`
- **用途**:用于订单、会话、访问日志等关联数据
### 订单数据
- **今日订单**15 笔status = 2已支付
- **昨日订单**10 笔(用于增长率对比)
- **总 GMV**:约 3,500 元(今日)
### 在线用户
- **最近 5 分钟活跃**5 个用户
- **用于**:实时大屏的"在线用户"统计
### 访问日志
- **数量**15 条
- **来源分布**direct/search/social/ad
- **用于**:转化率计算、流量来源分析
---
## 🔗 相关文件
- `01_create_tables.sql` - 表结构创建脚本
- `02_insert_test_data.sql` - 测试数据插入脚本
- `03_test_queries.sql` - 数据验证查询脚本
- `ANALYTICS_DB_SCHEMA.sql` - Analytics 表结构(可选)
---
## 📚 下一步
插入测试数据后,可以:
1. **验证前端页面**
- 访问 `/pages/mall/analytics/index` 查看实时大屏
- 检查 KPI 数据是否正确显示
2. **执行验证查询**
- 运行 `03_test_queries.sql` 验证数据计算逻辑
3. **测试 RPC 函数**
- 调用 `rpc_analytics_realtime_kpis` 验证实时 KPI 计算

View File

@@ -1,529 +0,0 @@
<!-- Supabase 连接测试页面 -->
<template>
<view class="test-container">
<view class="header">
<text class="title">Supabase 连接测试</text>
</view>
<view class="config-section">
<text class="section-title">当前配置</text>
<view class="config-item">
<text class="config-label">Supabase URL:</text>
<text class="config-value">{{ configUrl }}</text>
</view>
<view class="config-item">
<text class="config-label">API Key:</text>
<text class="config-value">{{ configKey.substring(0, 20) }}...</text>
</view>
<view class="config-item">
<text class="config-label">WebSocket URL:</text>
<text class="config-value">{{ configWs }}</text>
</view>
</view>
<view class="test-section">
<button class="test-btn" @click="testConnection" :disabled="isTesting">
{{ isTesting ? '测试中...' : '测试连接' }}
</button>
</view>
<view class="result-section" v-if="testResult">
<text class="section-title">测试结果</text>
<view class="result-item" :class="{ success: testResult.success, error: !testResult.success }">
<text class="result-icon">{{ testResult.success ? '✅' : '❌' }}</text>
<text class="result-text">{{ testResult.message }}</text>
</view>
<view v-if="testResult.details" class="result-details">
<text class="details-title">详细信息:</text>
<text class="details-text">{{ testResult.details }}</text>
</view>
<view v-if="testResult.data" class="result-data">
<text class="data-title">返回数据:</text>
<text class="data-text">{{ JSON.stringify(testResult.data, null, 2) }}</text>
</view>
</view>
<view class="test-list">
<text class="section-title">测试项目</text>
<view class="test-item" v-for="(test, index) in testList" :key="index">
<view class="test-info">
<text class="test-name">{{ test.name }}</text>
<text class="test-status" :class="test.status">{{ getStatusText(test.status) }}</text>
</view>
<button class="test-item-btn" @click="runTest(test)" :disabled="isTesting">执行</button>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import { SUPA_URL, SUPA_KEY, WS_URL } from '@/ak/config.uts'
type TestResultType = {
success: boolean
message: string
details?: string
data?: any
}
type TestItemType = {
name: string
status: string
func: () => Promise<TestResultType>
}
export default {
data() {
return {
configUrl: SUPA_URL,
configKey: SUPA_KEY,
configWs: WS_URL,
isTesting: false,
testResult: null as TestResultType | null,
testList: [
{
name: '1. 基础连接测试',
status: 'pending',
func: this.testBasicConnection
} as TestItemType,
{
name: '2. 查询测试(查询用户表)',
status: 'pending',
func: this.testQuery
} as TestItemType,
{
name: '3. 认证测试',
status: 'pending',
func: this.testAuth
} as TestItemType,
{
name: '4. 实时连接测试',
status: 'pending',
func: this.testRealtime
} as TestItemType
] as Array<TestItemType>
}
},
methods: {
// 综合连接测试
async testConnection() {
this.isTesting = true
this.testResult = null
try {
// 测试1: 基础连接
const basicResult = await this.testBasicConnection()
this.updateTestStatus(0, basicResult.success ? 'success' : 'error')
if (!basicResult.success) {
this.testResult = basicResult
this.isTesting = false
return
}
// 测试2: 查询测试
const queryResult = await this.testQuery()
this.updateTestStatus(1, queryResult.success ? 'success' : 'error')
// 测试3: 认证测试
const authResult = await this.testAuth()
this.updateTestStatus(2, authResult.success ? 'success' : 'error')
// 汇总结果
const allSuccess = basicResult.success && queryResult.success && authResult.success
this.testResult = {
success: allSuccess,
message: allSuccess
? '所有测试通过Supabase 连接正常。'
: '部分测试失败,请查看详细信息。',
details: `基础连接: ${basicResult.success ? '✓' : '✗'}, 查询: ${queryResult.success ? '✓' : '✗'}, 认证: ${authResult.success ? '✓' : '✗'}`,
data: {
basic: basicResult,
query: queryResult,
auth: authResult
}
}
} catch (err) {
this.testResult = {
success: false,
message: '测试过程中发生错误',
details: err?.toString() || '未知错误'
}
} finally {
this.isTesting = false
}
},
// 测试1: 基础连接
async testBasicConnection(): Promise<TestResultType> {
try {
// 尝试访问 Supabase REST API
const response = await uni.request({
url: `${SUPA_URL}/rest/v1/`,
method: 'GET',
header: {
'apikey': SUPA_KEY,
'Authorization': `Bearer ${SUPA_KEY}`
},
timeout: 5000
})
if (response.statusCode === 200 || response.statusCode === 404) {
// 404 也是正常的,说明服务器响应了
return {
success: true,
message: '基础连接成功',
details: `HTTP 状态码: ${response.statusCode}`,
data: response.data
}
} else {
return {
success: false,
message: '连接失败',
details: `HTTP 状态码: ${response.statusCode}`
}
}
} catch (err) {
return {
success: false,
message: '无法连接到 Supabase',
details: err?.toString() || '网络错误或服务器不可达'
}
}
},
// 测试2: 查询测试
async testQuery(): Promise<TestResultType> {
try {
// 尝试查询 users 表(如果存在)
const { data, error } = await supa
.from('users')
.select('id, phone, nickname')
.limit(5)
if (error !== null) {
// 如果表不存在,尝试查询其他表
if (error.message?.includes('relation') || error.message?.includes('does not exist')) {
// 尝试查询 orders 表
const { data: orderData, error: orderError } = await supa
.from('orders')
.select('id')
.limit(1)
if (orderError !== null) {
return {
success: false,
message: '查询失败',
details: `错误: ${orderError.message || orderError.toString()}`
}
}
return {
success: true,
message: '查询成功(使用 orders 表)',
details: 'users 表不存在,但 orders 表可访问',
data: orderData
}
}
return {
success: false,
message: '查询失败',
details: `错误: ${error.message || error.toString()}`
}
}
return {
success: true,
message: '查询成功',
details: `返回 ${data?.length || 0} 条记录`,
data: data
}
} catch (err) {
return {
success: false,
message: '查询测试失败',
details: err?.toString() || '未知错误'
}
}
},
// 测试3: 认证测试
async testAuth(): Promise<TestResultType> {
try {
// 检查是否已登录
const { data: sessionData, error: sessionError } = await supa.auth.getSession()
if (sessionError !== null) {
return {
success: false,
message: '获取会话失败',
details: sessionError.message || sessionError.toString()
}
}
if (sessionData?.session !== null) {
return {
success: true,
message: '认证成功',
details: `用户已登录: ${sessionData.session.user.email || sessionData.session.user.phone || '未知'}`,
data: {
user: sessionData.session.user,
expires_at: sessionData.session.expires_at
}
}
} else {
return {
success: false,
message: '未登录',
details: '需要先登录才能测试认证功能'
}
}
} catch (err) {
return {
success: false,
message: '认证测试失败',
details: err?.toString() || '未知错误'
}
}
},
// 测试4: 实时连接测试
async testRealtime(): Promise<TestResultType> {
try {
// WebSocket 连接测试比较复杂,这里只做 URL 验证
if (WS_URL.startsWith('ws://') || WS_URL.startsWith('wss://')) {
return {
success: true,
message: 'WebSocket URL 格式正确',
details: `URL: ${WS_URL}`
}
} else {
return {
success: false,
message: 'WebSocket URL 格式错误',
details: `URL 应以 ws:// 或 wss:// 开头`
}
}
} catch (err) {
return {
success: false,
message: '实时连接测试失败',
details: err?.toString() || '未知错误'
}
}
},
// 运行单个测试
async runTest(test: TestItemType) {
this.isTesting = true
test.status = 'testing'
try {
const result = await test.func()
test.status = result.success ? 'success' : 'error'
this.testResult = result
} catch (err) {
test.status = 'error'
this.testResult = {
success: false,
message: '测试执行失败',
details: err?.toString() || '未知错误'
}
} finally {
this.isTesting = false
}
},
// 更新测试状态
updateTestStatus(index: number, status: string) {
if (this.testList[index]) {
this.testList[index].status = status
}
},
// 获取状态文本
getStatusText(status: string): string {
const statusMap: Record<string, string> = {
'pending': '待测试',
'testing': '测试中...',
'success': '✓ 通过',
'error': '✗ 失败'
}
return statusMap[status] || '未知'
}
}
}
</script>
<style>
.test-container {
padding: 40rpx;
background-color: #f5f5f5;
min-height: 100vh;
}
.header {
margin-bottom: 40rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.config-section, .test-section, .result-section, .test-list {
background-color: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 30rpx;
}
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.config-item {
display: flex;
flex-direction: column;
margin-bottom: 20rpx;
}
.config-label {
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
}
.config-value {
font-size: 22rpx;
color: #333;
word-break: break-all;
}
.test-btn {
width: 100%;
height: 80rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-radius: 12rpx;
font-size: 28rpx;
border: none;
}
.test-btn:disabled {
background: #ccc;
}
.result-item {
display: flex;
align-items: center;
padding: 20rpx;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
.result-item.success {
background-color: #e8f5e8;
}
.result-item.error {
background-color: #ffebee;
}
.result-icon {
font-size: 32rpx;
margin-right: 15rpx;
}
.result-text {
font-size: 26rpx;
color: #333;
flex: 1;
}
.result-details, .result-data {
margin-top: 20rpx;
padding: 20rpx;
background-color: #f8f9fa;
border-radius: 8rpx;
}
.details-title, .data-title {
font-size: 24rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.details-text, .data-text {
font-size: 22rpx;
color: #666;
word-break: break-all;
white-space: pre-wrap;
}
.test-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 25rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.test-item:last-child {
border-bottom: none;
}
.test-info {
flex: 1;
}
.test-name {
font-size: 26rpx;
color: #333;
margin-bottom: 8rpx;
}
.test-status {
font-size: 22rpx;
}
.test-status.pending {
color: #999;
}
.test-status.testing {
color: #2196f3;
}
.test-status.success {
color: #4caf50;
}
.test-status.error {
color: #f44336;
}
.test-item-btn {
padding: 12rpx 24rpx;
background-color: #667eea;
color: #fff;
border-radius: 8rpx;
font-size: 24rpx;
border: none;
}
.test-item-btn:disabled {
background-color: #ccc;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { computeDateRange } from './dateRange.uts'
import { computeDateRange, toDateOnly } from './dateRange.uts'
import { rpcOrEmptyArray, rpcOrNull } from './rpc.uts'
export type CouponAnalysisData = {
@@ -11,31 +11,21 @@ export type CouponAnalysisData = {
export async function fetchCouponAnalysis(period: string): Promise<CouponAnalysisData> {
const { startIso, endIso } = computeDateRange(period)
const p_start_date = toDateOnly(startIso)
const p_end_date = toDateOnly(endIso)
const overviewRow = await rpcOrNull('rpc_coupon_effectiveness_overview', {
p_start: startIso,
p_end: endIso
} as UTSJSONObject)
const params = {
p_start_date,
p_end_date
} as any
const typeList = await rpcOrEmptyArray('rpc_coupon_type_stats', {
p_start: startIso,
p_end: endIso
} as UTSJSONObject)
const channelList = await rpcOrEmptyArray('rpc_coupon_channel_stats', {
p_start: startIso,
p_end: endIso
} as UTSJSONObject)
const trendList = await rpcOrEmptyArray('rpc_coupon_trend_daily', {
p_start: startIso,
p_end: endIso
} as UTSJSONObject)
const conversionList = await rpcOrEmptyArray('rpc_coupon_conversion_effect', {
p_start: startIso,
p_end: endIso
} as UTSJSONObject)
const [overviewRow, typeList, channelList, trendList, conversionList] = await Promise.all([
rpcOrNull('rpc_analytics_coupon_overview', params),
rpcOrEmptyArray('rpc_analytics_coupon_by_type', params),
rpcOrEmptyArray('rpc_analytics_coupon_by_channel', params),
rpcOrEmptyArray('rpc_analytics_coupon_trend', params),
rpcOrEmptyArray('rpc_analytics_coupon_conversion', params)
])
return { overviewRow, typeList, channelList, trendList, conversionList }
}

View File

@@ -1,5 +1,4 @@
import supa from '@/components/supadb/aksupainstance.uts'
import { rpcOrValue } from './rpc.uts'
import { rpcOrEmptyArray, rpcOrValue } from './rpc.uts'
export type CustomReportListItem = {
id: string
@@ -24,41 +23,41 @@ export type UpdateCustomReportParams = {
period: string | null
}
function safeString(v: any): string {
return v != null ? `${v}` : ''
}
// 改造:不再直查 analytics_reports 表,统一通过 RPC 获取当前用户的报表列表
export async function listCustomReports(ownerUserId: string): Promise<Array<CustomReportListItem>> {
const res: any = await supa
.from('analytics_reports')
.select('id, title, description, period, updated_at')
.eq('type', 'custom')
.eq('owner_user_id', ownerUserId)
.order('updated_at', { ascending: false } as any)
if (res?.error != null) {
throw res.error
}
const rows: Array<any> = Array.isArray(res.data) ? (res.data as Array<any>) : []
const rows = await rpcOrEmptyArray('rpc_get_custom_reports', {} as any)
const list: Array<CustomReportListItem> = []
for (let i = 0; i < rows.length; i++) {
const r = rows[i]
const r: any = rows[i]
list.push({
id: `${r.id}`,
title: `${r.title}`,
description: `${r.description || ''}`,
period: `${r.period || ''}`,
updated_at: `${r.updated_at || ''}`
id: safeString(r.getAny?.('id') ?? r.getString?.('id')),
title: safeString(r.getAny?.('title') ?? r.getString?.('title')),
description: safeString(r.getAny?.('description') ?? r.getString?.('description')),
// 兼容旧 UI 字段custom-report 页面里可能还在用 period 字段
period: '',
updated_at: safeString(r.getAny?.('updated_at') ?? r.getString?.('updated_at'))
})
}
return list
}
// 改造RPC 参数改为 p_definitionJSONB承载 period/metrics/chartType
export async function createCustomReport(params: CreateCustomReportParams): Promise<string> {
const definition = {
period: params.period,
metrics: params.metrics,
chartType: params.chartType || 'line'
}
const data = await rpcOrValue('rpc_create_custom_report', {
p_title: params.title,
p_description: params.description || '',
p_period: params.period,
p_metrics: params.metrics,
p_chart_type: params.chartType || 'line'
} as UTSJSONObject)
p_definition: definition
} as any)
if (data == null) {
throw new Error('保存失败未返回报表ID')
@@ -68,12 +67,17 @@ export async function createCustomReport(params: CreateCustomReportParams): Prom
}
export async function updateCustomReport(params: UpdateCustomReportParams): Promise<boolean> {
// 注意:旧 UI 只传 title/description/period这里把 period 合并进 definition
const definition = {
period: params.period
}
await rpcOrValue('rpc_update_custom_report', {
p_report_id: params.reportId,
p_title: params.title,
p_description: params.description,
p_period: params.period
} as UTSJSONObject)
p_definition: definition
} as any)
return true
}
@@ -81,7 +85,7 @@ export async function updateCustomReport(params: UpdateCustomReportParams): Prom
export async function deleteCustomReport(reportId: string): Promise<boolean> {
await rpcOrValue('rpc_delete_custom_report', {
p_report_id: reportId
} as UTSJSONObject)
} as any)
return true
}

View File

@@ -1,5 +1,5 @@
import { computeDateRange, toDateOnly } from './dateRange.uts'
import { rpcOrEmptyArray, rpcOrNull } from './rpc.uts'
import { rpcOrEmptyArray, rpcOrNull, rpcOrValue } from './rpc.uts'
export type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
export type SegmentItem = { name: string; value: number }
@@ -17,10 +17,9 @@ export async function fetchDashboardTrend(period: string): Promise<TrendData> {
const p_start_date = toDateOnly(startIso)
const p_end_date = toDateOnly(endIso)
const rows = await rpcOrEmptyArray('rpc_analytics_trend_data', {
const rows = await rpcOrEmptyArray('rpc_analytics_sales_trend', {
p_start_date,
p_end_date,
p_merchant_id: null
p_end_date
} as any)
const x: Array<string> = []
@@ -28,45 +27,30 @@ export async function fetchDashboardTrend(period: string): Promise<TrendData> {
const orders: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const row: any = rows[i]
const d = `${row.getString?.('date') ?? row.getString?.('day') ?? row.getString?.('date_key') ?? ''}`
if (d && d.length >= 10) x.push(d.slice(5))
else x.push(`${i + 1}`)
gmv.push(safeNumber(row.getAny?.('gmv') ?? row.getAny?.('total_amount') ?? 0))
orders.push(safeNumber(row.getAny?.('orders') ?? row.getAny?.('order_count') ?? 0))
const d = `${row.getAny?.('date') ?? ''}`
x.push(d.length >= 10 ? d.slice(5) : d)
gmv.push(safeNumber(row.getAny?.('gmv') ?? 0))
orders.push(safeNumber(row.getAny?.('orders') ?? 0))
}
return { x, gmv, orders }
}
export async function fetchDashboardRealtime(): Promise<any> {
const now = new Date()
const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const todayISO = today0.toISOString()
const [kpiRow, onlineUsersVal] = await Promise.all([
rpcOrNull('rpc_analytics_realtime_kpis', {} as any),
rpcOrValue('rpc_analytics_online_users', {} as any)
])
const ySame = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const y0 = new Date(ySame.getFullYear(), ySame.getMonth(), ySame.getDate())
const obj: any = kpiRow != null ? kpiRow : ({} as any)
const row = await rpcOrNull('rpc_analytics_realtime_kpis', {
p_start: todayISO,
p_end: now.toISOString(),
p_compare_start: y0.toISOString(),
p_compare_end: ySame.toISOString(),
p_merchant_id: null
} as any)
const safe = (v: any): number => {
const n = Number(v)
return isFinite(n) ? n : 0
}
const obj: any = row != null ? row : ({} as any)
return {
gmv: Math.round(safe(obj.getAny?.('gmv') ?? obj.getAny?.('total_gmv') ?? obj.getAny?.('revenue') ?? 0)),
gmv_growth: safe(obj.getAny?.('gmv_growth') ?? obj.getAny?.('gmv_growth_rate') ?? obj.getAny?.('revenue_growth') ?? 0),
orders: Math.round(safe(obj.getAny?.('orders') ?? obj.getAny?.('order_count') ?? obj.getAny?.('total_orders') ?? 0)),
order_growth: safe(obj.getAny?.('order_growth') ?? obj.getAny?.('order_growth_rate') ?? 0),
online_users: Math.round(safe(obj.getAny?.('online_users') ?? obj.getAny?.('active_users') ?? obj.getAny?.('current_users') ?? 0)),
conversion_rate: safe(obj.getAny?.('conversion_rate') ?? obj.getAny?.('conversion') ?? 0),
conversion_growth: safe(obj.getAny?.('conversion_growth') ?? obj.getAny?.('conversion_growth_rate') ?? 0)
gmv: Math.round(safeNumber(obj.getAny?.('gmv') ?? 0)),
gmv_growth: safeNumber(obj.getAny?.('gmv_growth') ?? 0),
orders: Math.round(safeNumber(obj.getAny?.('orders') ?? 0)),
order_growth: safeNumber(obj.getAny?.('order_growth') ?? 0),
online_users: Math.round(safeNumber(onlineUsersVal ?? 0)),
conversion_rate: safeNumber(obj.getAny?.('conversion_rate') ?? 0),
conversion_growth: safeNumber(obj.getAny?.('conversion_growth') ?? 0)
}
}
@@ -75,8 +59,7 @@ export async function fetchDashboardTopProducts(period: string, limit: number =
const rows = await rpcOrEmptyArray('rpc_analytics_top_products', {
p_start_date: toDateOnly(startIso),
p_end_date: toDateOnly(endIso),
p_limit: limit,
p_merchant_id: null
p_limit: limit
} as any)
const list: Array<TopProductItem> = []
@@ -86,7 +69,7 @@ export async function fetchDashboardTopProducts(period: string, limit: number =
id: `${row.getAny?.('id') ?? i}`,
rank: i + 1,
name: `${row.getAny?.('name') ?? '未知商品'}`,
sales: safeNumber(row.getAny?.('sales') ?? row.getAny?.('total_amount') ?? 0)
sales: safeNumber(row.getAny?.('sales') ?? 0)
})
}
return list
@@ -107,8 +90,8 @@ export async function fetchDashboardTopMerchants(period: string, limit: number =
id: `${row.getAny?.('id') ?? i}`,
rank: i + 1,
name: `${row.getAny?.('name') ?? row.getAny?.('shop_name') ?? '未知商家'}`,
sales: safeNumber(row.getAny?.('sales') ?? row.getAny?.('total_amount') ?? 0),
growth: safeNumber(row.getAny?.('growth') ?? row.getAny?.('growth_rate') ?? 0)
sales: safeNumber(row.getAny?.('sales') ?? 0),
growth: safeNumber(row.getAny?.('growth') ?? 0)
})
}
return list

View File

@@ -1,70 +1,58 @@
import { rpcOrEmptyArray, rpcOrNull } from './rpc.uts'
export type DataDetailReportInfo = {
period: string
}
export type DataDetailRow = {
export type ReportInfo = {
id: string
date: string
gmv: number
orders: number
users: number
title: string
description: string
definition: any
updated_at: string
}
export type DataDetailDrillItem = {
id: string
label: string
value: string
type: string
function safeString(v: any): string {
return v != null ? `${v}` : ''
}
export async function fetchDataDetailReportInfo(reportId: string): Promise<DataDetailReportInfo | null> {
const info = await rpcOrNull('rpc_data_detail_report_info', {
// 改造:调用 rpc_data_detail_report_info
export async function fetchReportInfo(reportId: string): Promise<ReportInfo | null> {
const row = await rpcOrNull('rpc_data_detail_report_info', {
p_report_id: reportId
} as UTSJSONObject)
if (info == null) return null
return { period: info.getString('period') ?? '' }
} as any)
if (row == null) return null
return {
id: safeString(row.getAny?.('id')),
title: safeString(row.getAny?.('title')),
description: safeString(row.getAny?.('description')),
definition: row.getAny?.('definition'),
updated_at: safeString(row.getAny?.('updated_at'))
}
}
export async function fetchDataDetailRows(reportId: string, sortBy: string, sortDir: string, limit: number, offset: number): Promise<Array<DataDetailRow>> {
const rows = await rpcOrEmptyArray('rpc_data_detail_rows', {
// 改造:调用 rpc_data_detail_rows
export async function fetchReportRows(reportId: string, params: any): Promise<Array<UTSJSONObject>> {
const result = await rpcOrNull('rpc_data_detail_rows', {
p_report_id: reportId,
p_sort_by: sortBy,
p_sort_dir: sortDir,
p_limit: limit,
p_offset: offset
} as UTSJSONObject)
p_params: params
} as any)
const out: Array<DataDetailRow> = []
for (let i = 0; i < rows.length; i++) {
const r = rows[i]
const dayStr = r.getString('row_date') ?? ''
out.push({
id: dayStr + '_' + i.toString(),
date: dayStr,
gmv: r.getNumber('gmv') ?? 0,
orders: r.getNumber('orders') ?? 0,
users: r.getNumber('users') ?? 0
})
}
return out
if (result == null) return []
const anyData = result as any
return Array.isArray(anyData) ? (anyData as Array<UTSJSONObject>) : ([] as Array<UTSJSONObject>)
}
export async function fetchDataDetailDrillItems(reportId: string): Promise<Array<DataDetailDrillItem>> {
const rows = await rpcOrEmptyArray('rpc_data_detail_drill_items', {
p_report_id: reportId
} as UTSJSONObject)
const out: Array<DataDetailDrillItem> = []
for (let i = 0; i < rows.length; i++) {
const r = rows[i]
out.push({
id: `${r.getAny('id') ?? i}`,
label: `${r.getString('label') ?? ''}`,
value: `${r.getAny('value') ?? ''}`,
type: `${r.getString('type') ?? ''}`
})
}
return out
// 保留调用,但 RPC 是模拟数据
export async function fetchDrilldown(reportId: string, itemId: string): Promise<Array<UTSJSONObject>> {
return await rpcOrEmptyArray('rpc_data_detail_drill_items', {
p_report_id: reportId,
p_item_id: itemId
} as any)
}
// 保留调用,但 RPC 是模拟数据
export async function fetchComparison(itemId: string, period: string): Promise<Array<UTSJSONObject>> {
return await rpcOrEmptyArray('rpc_data_detail_compare_gmv', {
p_item_id: itemId,
p_period: period
} as any)
}

View File

@@ -1,5 +1,5 @@
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import { computeDateRange } from './dateRange.uts'
import { rpcOrEmptyArray } from './rpc.uts'
export type DeliveryAnalysisData = {
trendList: Array<UTSJSONObject>
@@ -11,122 +11,16 @@ export type DeliveryAnalysisData = {
export async function fetchDeliveryAnalysis(period: string): Promise<DeliveryAnalysisData> {
const { startIso, endIso } = computeDateRange(period)
await ensureSupabaseReady()
// 优先走 RPC需要在 Supabase 执行 DELIVERY_ANALYSIS_RPCS.sql 创建函数)
let trendList: Array<UTSJSONObject> = []
let topList: Array<UTSJSONObject> = []
const trendRes: any = await supa.rpc('rpc_delivery_efficiency_daily', {
const trendList = await rpcOrEmptyArray('rpc_delivery_efficiency_daily', {
p_start: startIso,
p_end: endIso
} as UTSJSONObject)
} as any)
if (trendRes.status === 404) {
// RPC 不存在:降级到直查表聚合(测试阶段兜底)
const taskRes: any = 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 } as any)
.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>()
const driverFeeAgg = new Map<string, number>()
const driverTimeAgg = 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() !== '') {
driverAgg.set(driverId, (driverAgg.get(driverId) ?? 0) + 1)
driverFeeAgg.set(driverId, (driverFeeAgg.get(driverId) ?? 0) + fee)
driverTimeAgg.set(driverId, (driverTimeAgg.get(driverId) ?? 0) + diffMin)
}
}
// dayAgg -> trendList
const days = Array.from(dayAgg.keys()).sort()
for (let i = 0; i < days.length; i++) {
const day = days[i]
const obj = dayAgg.get(day)
if (obj != null) {
const completed = obj.getNumber('completed_orders') ?? 0
const sumMin = obj.getNumber('sum_minutes') ?? 0
const totalFee = obj.getNumber('total_fee') ?? 0
const out = new UTSJSONObject()
out.set('day', day)
out.set('avg_delivery_time', completed > 0 ? sumMin / completed : 0)
out.set('total_fee', totalFee)
out.set('completed_orders', completed)
trendList.push(out)
}
}
// driverAgg -> topList (Top10)
const drivers = Array.from(driverAgg.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10)
for (let i = 0; i < drivers.length; i++) {
const [driverId, orders] = drivers[i]
const out = new UTSJSONObject()
out.set('driver_id', driverId)
out.set('orders', orders)
out.set('total_fee', driverFeeAgg.get(driverId) ?? 0)
out.set('total_minutes', driverTimeAgg.get(driverId) ?? 0)
topList.push(out)
}
} else if (trendRes.error != null) {
throw trendRes.error
} else {
const anyData = trendRes.data as any
trendList = Array.isArray(anyData) ? (anyData as Array<UTSJSONObject>) : []
// Top drivers
const topRes = await supa.rpc('rpc_delivery_efficiency_top_drivers', {
p_start: startIso,
p_end: endIso,
p_limit: 10
})
if (topRes.status === 404) {
console.warn('rpc_delivery_efficiency_top_drivers not found, top drivers will be empty')
} else if (topRes.error != null) {
throw topRes.error
} else {
const topAny = topRes.data as any
topList = Array.isArray(topAny) ? (topAny as Array<UTSJSONObject>) : []
}
}
const topList = await rpcOrEmptyArray('rpc_delivery_efficiency_top_drivers', {
p_start: startIso,
p_end: endIso,
p_limit: 10
} as any)
return { trendList, topList, startIso, endIso }
}

View File

@@ -23,8 +23,8 @@ function safeNumber(v: any): number {
export async function fetchProductOverview(period: string): Promise<ProductOverview> {
const { startIso, endIso } = computeDateRange(period)
const row = await rpcOrNull('rpc_product_insights_overview', {
p_start: startIso,
p_end: endIso
p_start: toDateOnly(startIso),
p_end: toDateOnly(endIso)
} as any)
const obj: any = row != null ? row : ({} as any)
@@ -44,8 +44,7 @@ export async function fetchTopProducts(period: string, limit: number = 10): Prom
const rows = await rpcOrEmptyArray('rpc_analytics_top_products', {
p_start_date: toDateOnly(startIso),
p_end_date: toDateOnly(endIso),
p_limit: limit,
p_merchant_id: null
p_limit: limit
} as any)
const list: Array<ProductRank> = []
@@ -55,8 +54,8 @@ export async function fetchTopProducts(period: string, limit: number = 10): Prom
id: `${r.getAny?.('id') ?? i}`,
rank: i + 1,
name: `${r.getAny?.('name') ?? '未知商品'}`,
sales: safeNumber(r.getAny?.('sales') ?? r.getAny?.('total_amount') ?? 0),
growth: safeNumber(r.getAny?.('growth') ?? r.getAny?.('growth_rate') ?? 0)
sales: safeNumber(r.getAny?.('sales') ?? 0),
growth: safeNumber(r.getAny?.('growth') ?? 0)
})
}
return list
@@ -73,12 +72,12 @@ export async function fetchProductTrend(period: string, productId: string): Prom
const out: Array<ProductTrendRow> = []
for (let i = 0; i < rows.length; i++) {
const r: any = rows[i]
const date = `${r.getAny?.('date') ?? r.getAny?.('day') ?? r.getAny?.('date_key') ?? ''}`
const date = `${r.getAny?.('date') ?? ''}`
out.push({
date,
gmv: safeNumber(r.getAny?.('gmv') ?? r.getAny?.('total_amount') ?? 0),
qty: safeNumber(r.getAny?.('qty') ?? r.getAny?.('sales_qty') ?? 0),
orders: safeNumber(r.getAny?.('orders') ?? r.getAny?.('order_count') ?? 0)
gmv: safeNumber(r.getAny?.('gmv') ?? 0),
qty: safeNumber(r.getAny?.('qty') ?? 0),
orders: safeNumber(r.getAny?.('orders') ?? 0)
})
}
return out
@@ -93,11 +92,7 @@ export async function fetchCategorySales(period: string): Promise<Array<UTSJSONO
}
export async function fetchStockInsights(period: string): Promise<Array<UTSJSONObject>> {
const { startIso, endIso } = computeDateRange(period)
return await rpcOrEmptyArray('rpc_product_insights_stock', {
p_start: startIso,
p_end: endIso
} as any)
return await rpcOrEmptyArray('rpc_product_insights_stock', {} as any)
}
export async function fetchPriceTrend(period: string): Promise<Array<UTSJSONObject>> {

View File

@@ -24,22 +24,9 @@ function safeNumber(v: any): number {
export async function fetchSalesKpis(period: string): Promise<SalesKpis> {
const { startIso, endIso } = computeDateRange(period)
const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365
const startDateObj = new Date(startIso)
const endDateObj = new Date(endIso)
const periodStart = new Date(startDateObj.getFullYear(), startDateObj.getMonth(), startDateObj.getDate())
const periodEnd = new Date(endDateObj.getFullYear(), endDateObj.getMonth(), endDateObj.getDate() + 1)
const prevStart = new Date(periodStart.getTime() - days * 24 * 60 * 60 * 1000)
const prevEnd = new Date(periodStart.getTime())
const row = await rpcOrNull('rpc_analytics_realtime_kpis', {
p_start: periodStart.toISOString(),
p_end: periodEnd.toISOString(),
p_compare_start: prevStart.toISOString(),
p_compare_end: prevEnd.toISOString(),
p_merchant_id: null
const row = await rpcOrNull('rpc_analytics_sales_kpis', {
p_start_date: toDateOnly(startIso),
p_end_date: toDateOnly(endIso)
} as any)
const obj: any = row != null ? row : ({} as any)
@@ -61,10 +48,9 @@ export async function fetchSalesKpis(period: string): Promise<SalesKpis> {
export async function fetchSalesTrend(period: string): Promise<TrendData> {
const { startIso, endIso } = computeDateRange(period)
const rows = await rpcOrEmptyArray('rpc_analytics_trend_data', {
const rows = await rpcOrEmptyArray('rpc_analytics_sales_trend', {
p_start_date: toDateOnly(startIso),
p_end_date: toDateOnly(endIso),
p_merchant_id: null
p_end_date: toDateOnly(endIso)
} as any)
const x: Array<string> = []
@@ -87,8 +73,7 @@ export async function fetchSalesTopProducts(period: string, limit: number = 50):
const rows = await rpcOrEmptyArray('rpc_analytics_top_products', {
p_start_date: toDateOnly(startIso),
p_end_date: toDateOnly(endIso),
p_limit: limit,
p_merchant_id: null
p_limit: limit
} as any)
const list: Array<ProductRank> = []

3
types/analytics.uts Normal file
View File

@@ -0,0 +1,3 @@
// types/analytics.uts
export type AnalyticsTypesMigrated = true

View File

@@ -0,0 +1,5 @@
// types/analytics/common.uts
export type TimePeriod = { value: string; label: string }
export type ChartType = { value: string; label: string }
export type Metric = { key: string; label: string }

View File

@@ -0,0 +1,11 @@
// types/analytics/coupon.uts
export type CouponData = {
total_issued: number
issued_growth: number
total_used: number
usage_rate: number
gmv_increase: number
gmv_growth: number
roi: number
}

View File

@@ -0,0 +1,29 @@
// types/analytics/custom-report.uts
import type { Metric, TimePeriod, ChartType } from './common.uts'
export type CustomReport = {
id: string
name: string
description: string
metrics: Array<string>
charts: Array<string>
updated_at: string
period?: string
}
export type ReportForm = {
name: string
description: string
metrics: Array<string>
period: string
chartType: string
}
export type ReportFormErrors = {
name: string
description: string
metrics: string
period: string
chartType: string
}

View File

@@ -0,0 +1,7 @@
// types/analytics/dashboard.uts
export type TrendData = { x: Array<string>; gmv: Array<number>; orders: Array<number> }
export type SegmentItem = { name: string; value: number }
export type TrafficItem = { name: string; value: number }
export type TopProductItem = { id: string; rank: number; name: string; sales: number }
export type TopMerchantItem = { id: string; rank: number; name: string; sales: number; growth: number }

View File

@@ -0,0 +1,4 @@
// types/analytics/data-detail.uts
export type TableColumn = { key: string; label: string; type: string; sortable: boolean }
export type DrillDownItem = { id: string; label: string; value: string; type: string }

View File

@@ -0,0 +1,25 @@
// types/analytics/delivery.uts
/**
* Key Performance Indicators for the Delivery Analysis page.
*/
export type DeliveryData = {
avg_delivery_time: number;
time_growth: number;
total_fee: number;
avg_fee: number;
avg_orders_per_driver: number;
satisfaction_rate: number;
satisfaction_growth: number;
};
/**
* Represents a driver's ranking based on performance.
*/
export type DriverRank = {
id: string;
rank: number;
name: string;
orders: number;
rating: number
};

View File

@@ -0,0 +1,19 @@
// types/analytics/insight.uts
export type InsightDetail = {
id: string
report_id: string
type: string
impact: string
title: string
content: string
created_at: string
}
export type RelatedReport = {
id: string
title: string
type: string
period: string
generated_at: string
}

View File

@@ -0,0 +1,9 @@
// types/analytics/market.uts
export type MarketTrendsResponse = {
trendRows: any
categoryRows: any
seasonalRows: any
priceRows: any
competitionRows: any
}

View File

@@ -0,0 +1,14 @@
// types/analytics/product.uts
export type ProductData = {
total_products: number
product_growth: number
hot_products: number
turnover_rate: number
turnover_growth: number
avg_stock: number
stock_growth: number
}
export type ProductRank = { id: string; rank: number; name: string; sales: number; growth: number }
export type ProductTrendRow = { date: string; gmv: number; qty: number; orders: number }

View File

@@ -0,0 +1,38 @@
// types/analytics/profile.uts
export type ReportStatus = 'pending' | 'ready' | 'failed' | 'scheduled' | 'shared' | string
export type RecentReport = {
id: string
title: string
description: string
status: ReportStatus
created_at: string
}
export type OverviewData = {
totalSales: string
salesGrowth: number
totalUsers: string
userGrowth: number
totalOrders: string
orderGrowth: number
conversionRate: number
conversionGrowth: number
}
export type ReportCounts = {
total: number
pending: number
scheduled: number
shared: number
}
export type TodayInsights = {
hotProduct: string
peakTraffic: string
conversionAnomaly: string
mobileRatio: number
}
export type TrendDatum = { label: string; sales: number; orders: number }

View File

@@ -0,0 +1,46 @@
// types/analytics/report-detail.uts
export type ReportType = {
id: string;
title: string;
type: string;
period: string;
generated_at: string;
description: string;
};
export type MetricType = {
key: string;
label: string;
value: number;
format: string;
icon: string;
color: string;
change: number;
};
export type ChartTabType = {
key: string;
label: string;
};
export type ChartLegendType = {
key: string;
label: string;
color: string;
};
export type TableColumnType = {
key: string;
title: string;
width: string;
type: string;
};
export type InsightType = {
id: string;
type: string;
title: string;
content: string;
impact: string;
};

22
types/analytics/sales.uts Normal file
View File

@@ -0,0 +1,22 @@
// types/analytics/sales.uts
// Re-exporting shared types from dashboard for semantic clarity in the sales context.
import type { TrendData, TopProductItem, TopMerchantItem } from './dashboard.uts'
export type SalesTrendData = TrendData
export type ProductRank = TopProductItem
export type MerchantRank = TopMerchantItem
/**
* Key Performance Indicators for the Sales Report page.
*/
export type SalesData = {
gmv: number
gmv_growth: number
orders: number
order_growth: number
conversion_rate: number
conversion_growth: number
avg_order_amount: number
avg_order_growth: number
}

25
types/analytics/user.uts Normal file
View File

@@ -0,0 +1,25 @@
// types/analytics/user.uts
/**
* Key Performance Indicators for the User Analysis page.
*/
export type UserData = {
total_users: number;
user_growth: number;
new_users: number;
new_user_growth: number;
active_users: number;
active_growth: number;
ordering_users: number;
ordering_growth: number;
paid_users: number;
paid_growth: number;
new_user_conversion_rate: number;
repurchase_rate: number;
repurchase_growth: number;
}
/**
* Represents a single step in a conversion funnel.
*/
export type FunnelStep = { step: string; value: number };