数据分析ui补充完善,接入数据库
This commit is contained in:
274
mall_sql/migrations/analytics_upgrade.sql
Normal file
274
mall_sql/migrations/analytics_upgrade.sql
Normal 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 $$;
|
||||
220
mall_sql/migrations/ml_analytics_rpcs.sql
Normal file
220
mall_sql/migrations/ml_analytics_rpcs.sql
Normal 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 $$;
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">销量 > 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%;
|
||||
}
|
||||
|
||||
@@ -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)}%`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 唯一(测试阶段允许 NULL,NULL 不冲突)
|
||||
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;
|
||||
@@ -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 $$;
|
||||
206
pages/mall/analytics/test/01_ml_analytics_rpcs_user.sql
Normal file
206
pages/mall/analytics/test/01_ml_analytics_rpcs_user.sql
Normal 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;
|
||||
$$;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -1,160 +0,0 @@
|
||||
-- =============================================
|
||||
-- 市场趋势页面测试数据 Seed(Supabase / 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;
|
||||
|
||||
@@ -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; -- 应该返回 25(15个今日 + 10个昨日)
|
||||
-- SELECT COUNT(*) FROM user_sessions; -- 应该返回 10
|
||||
-- SELECT COUNT(*) FROM page_views; -- 应该返回 15
|
||||
176
pages/mall/analytics/test/02_ml_analytics_rpcs_sales.sql
Normal file
176
pages/mall/analytics/test/02_ml_analytics_rpcs_sales.sql
Normal 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;
|
||||
$$;
|
||||
@@ -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;
|
||||
76
pages/mall/analytics/test/03_ml_analytics_rpcs_dashboard.sql
Normal file
76
pages/mall/analytics/test/03_ml_analytics_rpcs_dashboard.sql
Normal 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';
|
||||
$$;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
175
pages/mall/analytics/test/04_ml_analytics_rpcs_market_trends.sql
Normal file
175
pages/mall/analytics/test/04_ml_analytics_rpcs_market_trends.sql
Normal file
@@ -0,0 +1,175 @@
|
||||
-- =====================================================================================
|
||||
-- 数据分析模块(正式RPC,主库 ml_* 口径)
|
||||
-- 文件: 04_ml_analytics_rpcs_market_trends.sql
|
||||
-- 主题: 市场趋势(整体趋势/类目对比/季节性/价格趋势/竞争格局)
|
||||
--
|
||||
-- 说明:该模块历史上依赖测试库中的 RPC(rpc_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;
|
||||
$$;
|
||||
194
pages/mall/analytics/test/05_ml_analytics_rpcs_product.sql
Normal file
194
pages/mall/analytics/test/05_ml_analytics_rpcs_product.sql
Normal 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;
|
||||
$$;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
183
pages/mall/analytics/test/06_ml_analytics_rpcs_coupon.sql
Normal file
183
pages/mall/analytics/test/06_ml_analytics_rpcs_coupon.sql
Normal 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;
|
||||
$$;
|
||||
@@ -1,405 +0,0 @@
|
||||
-- ============================================
|
||||
-- 07_custom_report_rpcs.sql
|
||||
-- 自定义报表创建与管理 RPC 定义
|
||||
-- ============================================
|
||||
-- 目标:
|
||||
-- 1) 为 `pages/mall/analytics/custom-report.uvue` 提供安全的数据服务
|
||||
-- 2) 确保用户记录存在,解决外键约束问题
|
||||
-- 3) 创建自定义报表并生成初始数据(metrics + rows)
|
||||
--
|
||||
-- 依赖前置脚本:
|
||||
-- - 01_create_tables.sql(users 表)
|
||||
-- - ANALYTICS_DB_SCHEMA.sql(analytics_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 $$;
|
||||
85
pages/mall/analytics/test/07_ml_analytics_rpcs_delivery.sql
Normal file
85
pages/mall/analytics/test/07_ml_analytics_rpcs_delivery.sql
Normal 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_fee:delivery_fee 平均
|
||||
-- =====================================================================================
|
||||
|
||||
-- 1) 配送效率日趋势
|
||||
-- 返回字段需匹配前端 delivery-analysis.uvue:day / 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.uvue:driver_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;
|
||||
$$;
|
||||
145
pages/mall/analytics/test/08_ml_analytics_rpcs_custom_report.sql
Normal file
145
pages/mall/analytics/test/08_ml_analytics_rpcs_custom_report.sql
Normal 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;
|
||||
$$;
|
||||
@@ -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);
|
||||
$$;
|
||||
239
pages/mall/analytics/test/09_ml_analytics_rpcs_data_detail.sql
Normal file
239
pages/mall/analytics/test/09_ml_analytics_rpcs_data_detail.sql
Normal file
@@ -0,0 +1,239 @@
|
||||
-- =====================================================================================
|
||||
-- 数据分析模块(正式RPC,主库 ml_* 口径)
|
||||
-- 文件: 09_ml_analytics_rpcs_data_detail.sql
|
||||
-- 主题: data-detail 动态报表执行引擎(MVP:sales + 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;
|
||||
$$;
|
||||
218
pages/mall/analytics/test/99_ml_analytics_verify.sql
Normal file
218
pages/mall/analytics/test/99_ml_analytics_verify.sql
Normal 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');
|
||||
@@ -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 运行 *步骤1–4*。
|
||||
2. 登出 / 使用普通账号登录,再运行 *步骤5* 查询验证。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 常见问题
|
||||
|
||||
1. **RLS 阻挡插入**
|
||||
请确认 seed 在 Dashboard 执行,或先 `SET ROLE service_role;`。
|
||||
不建议在 seed 中禁用 RLS。
|
||||
|
||||
2. **重复执行报错**
|
||||
脚本为“只创建,不删除”模式:表/索引使用 `IF NOT EXISTS`,触发器/策略使用系统表判断后再创建,可重复执行。若仍报错,请先 `ROLLBACK;` 再重试。
|
||||
|
||||
3. **前端查不到 seed 数据**
|
||||
登陆用户的 `auth.uid()`必须与 seed 中 `orders.user_id` 等字段匹配;否则受 RLS 影响会看不到。测试时可在 seed 中把某条 `user_id` 改成你自己的 UID。
|
||||
|
||||
---
|
||||
|
||||
## 🔐 权限矩阵(简版)
|
||||
|
||||
| 表 / 功能 | anon | authenticated | service_role |
|
||||
| -------------------------------------- | ----------------- | ------------------- | ------------ |
|
||||
| `orders / order_items / user_sessions` | Insert❌ / Select❌ | ✅(仅本人) | ✅(全部) |
|
||||
| `products / merchants` | Select✅ | CRUD⚠️ (受策略) | ✅ |
|
||||
| `page_views` | Insert✅ / Select❌ | Select✅(本人) | ✅ |
|
||||
| `analytics_*` 表 | ❌ | ✅ (按 owner/shared) | ✅ |
|
||||
| RPC (analytics) | ❌ | ✅ | ✅ |
|
||||
|
||||
> 详细策略请见各 SQL 文件内注释。
|
||||
|
||||
---
|
||||
|
||||
## 🧹 清理
|
||||
|
||||
执行 `04_cleanup.sql` 可按时间 / 用户删除测试数据,脚本已更新为幂等。
|
||||
|
||||
---
|
||||
|
||||
最后更新:2026-01-26
|
||||
@@ -1,853 +0,0 @@
|
||||
-- ============================================
|
||||
-- 数据分析模块数据库 Schema(Supabase/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. RLS(Row 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;
|
||||
@@ -1,463 +0,0 @@
|
||||
-- ============================================
|
||||
-- 数据分析模块测试数据 Seed(Supabase/Postgres)
|
||||
-- ============================================
|
||||
-- 用途:为 `pages/mall/analytics/*` 页面提供可联调的模拟数据
|
||||
-- 参考:`docs/ANALYTICS_DB_DESIGN.md`
|
||||
--
|
||||
-- 执行顺序:
|
||||
-- 1. 先执行基础业务表的 seed(users/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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 文件结束
|
||||
-- ============================================
|
||||
|
||||
@@ -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;
|
||||
@@ -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. 使用循环生成大量测试数据(注意性能)
|
||||
@@ -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 + RPC,Drop-first)
|
||||
5. `02_insert_test_data.sql`(基础表测试数据,需 service_role/postgres)
|
||||
6. `ANALYTICS_TEST_SEED.sql`(analytics_* 测试数据,需 service_role/postgres)
|
||||
7. `03_test_queries.sql`(可选:验证查询)
|
||||
@@ -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. 完成!
|
||||
|
||||
现在可以开始测试实时大屏功能了!🎉
|
||||
@@ -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;
|
||||
-- 预期:25(15个今日订单 + 10个昨日订单)
|
||||
|
||||
-- 检查用户会话数量
|
||||
SELECT COUNT(*) FROM user_sessions;
|
||||
-- 预期:10
|
||||
|
||||
-- 检查访问日志数量
|
||||
SELECT COUNT(*) FROM page_views;
|
||||
-- 预期:15
|
||||
|
||||
-- 检查商家数量
|
||||
SELECT COUNT(*) FROM merchants;
|
||||
-- 预期:2
|
||||
|
||||
-- 检查商品数量
|
||||
SELECT COUNT(*) FROM products;
|
||||
-- 预期:3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 常见问题
|
||||
|
||||
### Q1: 执行 INSERT 时提示 "new row violates row-level security policy"
|
||||
|
||||
**原因**:RLS 策略阻止了插入操作。
|
||||
|
||||
**解决方案**:
|
||||
- 使用方式一(Supabase Dashboard)
|
||||
- 或使用方式二(临时禁用 RLS)
|
||||
- 或使用方式三(SECURITY DEFINER 函数)
|
||||
|
||||
### Q2: 插入数据后,前端查询不到数据
|
||||
|
||||
**原因**:RLS 策略限制了查询权限。
|
||||
|
||||
**解决方案**:
|
||||
1. 确认前端已正确登录(`auth.uid()` 不为 NULL)
|
||||
2. 检查 RLS 策略是否正确配置
|
||||
3. 确认测试数据的 `user_id` 与登录用户的 `auth.uid()` 匹配
|
||||
|
||||
### Q3: 如何清空测试数据重新插入?
|
||||
|
||||
为避免在默认文档里包含破坏性 SQL,本项目将“清理/删除”动作放在独立清理脚本中(如 `04_cleanup.sql`)。
|
||||
|
||||
如你需要重新生成测试数据:
|
||||
- 先执行清理脚本
|
||||
- 再重新执行 seed 脚本
|
||||
|
||||
---
|
||||
|
||||
## 📝 测试数据说明
|
||||
|
||||
### 用户数据
|
||||
- **数量**:8 个测试用户
|
||||
- **UUID 范围**:`11111111-...` 到 `88888888-...`
|
||||
- **用途**:用于订单、会话、访问日志等关联数据
|
||||
|
||||
### 订单数据
|
||||
- **今日订单**:15 笔(status = 2,已支付)
|
||||
- **昨日订单**:10 笔(用于增长率对比)
|
||||
- **总 GMV**:约 3,500 元(今日)
|
||||
|
||||
### 在线用户
|
||||
- **最近 5 分钟活跃**:5 个用户
|
||||
- **用于**:实时大屏的"在线用户"统计
|
||||
|
||||
### 访问日志
|
||||
- **数量**:15 条
|
||||
- **来源分布**:direct/search/social/ad
|
||||
- **用于**:转化率计算、流量来源分析
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文件
|
||||
|
||||
- `01_create_tables.sql` - 表结构创建脚本
|
||||
- `02_insert_test_data.sql` - 测试数据插入脚本
|
||||
- `03_test_queries.sql` - 数据验证查询脚本
|
||||
- `ANALYTICS_DB_SCHEMA.sql` - Analytics 表结构(可选)
|
||||
|
||||
---
|
||||
|
||||
## 📚 下一步
|
||||
|
||||
插入测试数据后,可以:
|
||||
|
||||
1. **验证前端页面**
|
||||
- 访问 `/pages/mall/analytics/index` 查看实时大屏
|
||||
- 检查 KPI 数据是否正确显示
|
||||
|
||||
2. **执行验证查询**
|
||||
- 运行 `03_test_queries.sql` 验证数据计算逻辑
|
||||
|
||||
3. **测试 RPC 函数**
|
||||
- 调用 `rpc_analytics_realtime_kpis` 验证实时 KPI 计算
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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_definition(JSONB),承载 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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
3
types/analytics.uts
Normal file
@@ -0,0 +1,3 @@
|
||||
// types/analytics.uts
|
||||
|
||||
export type AnalyticsTypesMigrated = true
|
||||
5
types/analytics/common.uts
Normal file
5
types/analytics/common.uts
Normal 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 }
|
||||
11
types/analytics/coupon.uts
Normal file
11
types/analytics/coupon.uts
Normal 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
|
||||
}
|
||||
29
types/analytics/custom-report.uts
Normal file
29
types/analytics/custom-report.uts
Normal 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
|
||||
}
|
||||
7
types/analytics/dashboard.uts
Normal file
7
types/analytics/dashboard.uts
Normal 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 }
|
||||
4
types/analytics/data-detail.uts
Normal file
4
types/analytics/data-detail.uts
Normal 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 }
|
||||
25
types/analytics/delivery.uts
Normal file
25
types/analytics/delivery.uts
Normal 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
|
||||
};
|
||||
19
types/analytics/insight.uts
Normal file
19
types/analytics/insight.uts
Normal 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
|
||||
}
|
||||
9
types/analytics/market.uts
Normal file
9
types/analytics/market.uts
Normal file
@@ -0,0 +1,9 @@
|
||||
// types/analytics/market.uts
|
||||
|
||||
export type MarketTrendsResponse = {
|
||||
trendRows: any
|
||||
categoryRows: any
|
||||
seasonalRows: any
|
||||
priceRows: any
|
||||
competitionRows: any
|
||||
}
|
||||
14
types/analytics/product.uts
Normal file
14
types/analytics/product.uts
Normal 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 }
|
||||
38
types/analytics/profile.uts
Normal file
38
types/analytics/profile.uts
Normal 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 }
|
||||
46
types/analytics/report-detail.uts
Normal file
46
types/analytics/report-detail.uts
Normal 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
22
types/analytics/sales.uts
Normal 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
25
types/analytics/user.uts
Normal 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 };
|
||||
Reference in New Issue
Block a user