sql数据流,amdin业务逻辑接入
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 分销代理商管理表
|
||||
-- 位置:docs/sql/10_schema/distribution/ak_distribution_agents_v1.sql
|
||||
-- 说明:管理事业部旗下的代理商,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.ak_distribution_agents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
division_id UUID NOT NULL REFERENCES public.ak_distribution_divisions(id) ON DELETE CASCADE,
|
||||
uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL, -- 代理商名称(或备注名)
|
||||
status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
|
||||
-- 约束:一个用户在一个商家下只能成为一个代理商
|
||||
UNIQUE(merchant_id, uid)
|
||||
);
|
||||
|
||||
-- 启用 RLS
|
||||
ALTER TABLE public.ak_distribution_agents ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 权限策略:商家仅能管理自己的代理商
|
||||
CREATE POLICY "Merchants manage their own agents"
|
||||
ON public.ak_distribution_agents FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许查看
|
||||
CREATE POLICY "Authenticated users view active agents"
|
||||
ON public.ak_distribution_agents FOR SELECT
|
||||
TO authenticated
|
||||
USING (status = true);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_merchant ON public.ak_distribution_agents(merchant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_division ON public.ak_distribution_agents(division_id);
|
||||
@@ -0,0 +1,45 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 分销事业部申请表
|
||||
-- 位置:docs/sql/10_schema/distribution/ak_distribution_division_applications_v1.sql
|
||||
-- 说明:记录用户申请加入事业部成为代理商的流水,支持审核流转,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.ak_distribution_division_applications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
division_id UUID NOT NULL REFERENCES public.ak_distribution_divisions(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL, -- 申请人填写的代理商名称
|
||||
phone TEXT NOT NULL, -- 申请人联系电话
|
||||
images JSONB DEFAULT '[]'::jsonb, -- 申请附件图片 (数组)
|
||||
|
||||
status INTEGER DEFAULT 1, -- 状态: 1待审核, 2已同意, 3已拒绝
|
||||
admin_remark TEXT, -- 审核备注
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 启用 RLS
|
||||
ALTER TABLE public.ak_distribution_division_applications ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 权限策略
|
||||
CREATE POLICY "Merchants manage their own applications"
|
||||
ON public.ak_distribution_division_applications FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许用户提交和查看自己的申请
|
||||
CREATE POLICY "Users handle own applications"
|
||||
ON public.ak_distribution_division_applications FOR ALL
|
||||
TO authenticated
|
||||
USING (uid = auth.uid())
|
||||
WITH CHECK (uid = auth.uid());
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_div_app_merchant ON public.ak_distribution_division_applications(merchant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_div_app_uid ON public.ak_distribution_division_applications(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_div_app_status ON public.ak_distribution_division_applications(status);
|
||||
@@ -0,0 +1,46 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 分销事业部管理表
|
||||
-- 位置:docs/sql/10_schema/distribution/ak_distribution_divisions_v1.sql
|
||||
-- 说明:管理分销体系中的事业部,支持独立分销比例、邀请码及有效期,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.ak_distribution_divisions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
uid UUID NOT NULL REFERENCES public.ak_users(id), -- 事业部负责人UID
|
||||
name TEXT NOT NULL, -- 事业部名称
|
||||
invite_code TEXT UNIQUE NOT NULL, -- 事业部专属邀请码
|
||||
|
||||
ratio DECIMAL(5,2) DEFAULT 0, -- 事业部额外分销比例 (%)
|
||||
agent_count INTEGER DEFAULT 0, -- 下属代理商数量 (由程序或触发器维护)
|
||||
|
||||
end_time TIMESTAMPTZ, -- 协议截止时间
|
||||
status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
|
||||
-- 约束:一个用户在一个商家下只能负责一个事业部
|
||||
UNIQUE(merchant_id, uid)
|
||||
);
|
||||
|
||||
-- 启用 RLS
|
||||
ALTER TABLE public.ak_distribution_divisions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 权限策略
|
||||
CREATE POLICY "Merchants manage their own divisions"
|
||||
ON public.ak_distribution_divisions FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许查看
|
||||
CREATE POLICY "Authenticated users view active divisions"
|
||||
ON public.ak_distribution_divisions FOR SELECT
|
||||
TO authenticated
|
||||
USING (status = true);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_divisions_merchant ON public.ak_distribution_divisions(merchant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_divisions_uid ON public.ak_distribution_divisions(uid);
|
||||
77
docs/sql/10_schema/marketing/ak_advanced_marketing_v1.sql
Normal file
77
docs/sql/10_schema/marketing/ak_advanced_marketing_v1.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 秒杀与拼团活动表
|
||||
-- 位置:docs/sql/10_schema/marketing/ak_advanced_marketing_v1.sql
|
||||
-- 说明:管理秒杀活动与拼团活动,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
-- 1. 秒杀活动表
|
||||
CREATE TABLE IF NOT EXISTS public.ak_seckill_activities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
title TEXT NOT NULL, -- 活动标题
|
||||
single_limit INTEGER DEFAULT 1, -- 单次限购
|
||||
total_limit INTEGER DEFAULT 10, -- 总购买数量限制
|
||||
product_count INTEGER DEFAULT 0, -- 包含商品数量
|
||||
time_range TEXT NOT NULL, -- 活动时段 (如 "06:00-24:00")
|
||||
|
||||
start_date TIMESTAMPTZ NOT NULL, -- 开始日期
|
||||
end_date TIMESTAMPTZ NOT NULL, -- 结束日期
|
||||
|
||||
status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 2. 拼团活动表 (开团记录)
|
||||
CREATE TABLE IF NOT EXISTS public.ak_combination_activities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
uid UUID NOT NULL REFERENCES public.ak_users(id), -- 开团团长
|
||||
product_id UUID NOT NULL REFERENCES public.ml_products(id), -- 拼团商品
|
||||
|
||||
people INTEGER DEFAULT 2, -- 几人团
|
||||
count_people INTEGER DEFAULT 1, -- 当前几人参加
|
||||
|
||||
start_time TIMESTAMPTZ DEFAULT now(), -- 开团时间
|
||||
stop_time TIMESTAMPTZ NOT NULL, -- 结束时间
|
||||
|
||||
status TEXT NOT NULL DEFAULT 'ongoing', -- ongoing进行中, pending未完成, ended已成功
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
|
||||
CONSTRAINT chk_comb_status CHECK (status IN ('ongoing', 'pending', 'ended'))
|
||||
);
|
||||
|
||||
-- 3. 启用 RLS
|
||||
ALTER TABLE public.ak_seckill_activities ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ak_combination_activities ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 4. 创建权限策略 (按 merchant_id 隔离)
|
||||
-- 秒杀策略
|
||||
CREATE POLICY "Merchants can manage their own seckill activities"
|
||||
ON public.ak_seckill_activities FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 拼团策略
|
||||
CREATE POLICY "Merchants can manage their own combination activities"
|
||||
ON public.ak_combination_activities FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许所有认证用户查看(用于移动端展示)
|
||||
CREATE POLICY "Anyone can view active marketing activities"
|
||||
ON public.ak_seckill_activities FOR SELECT
|
||||
TO authenticated
|
||||
USING (status = true);
|
||||
|
||||
CREATE POLICY "Anyone can view ongoing combinations"
|
||||
ON public.ak_combination_activities FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
78
docs/sql/10_schema/marketing/ak_bargain_groupbuy_v1.sql
Normal file
78
docs/sql/10_schema/marketing/ak_bargain_groupbuy_v1.sql
Normal file
@@ -0,0 +1,78 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 砍价与团购活动表
|
||||
-- 位置:docs/sql/10_schema/marketing/ak_bargain_groupbuy_v1.sql
|
||||
-- 说明:管理砍价与团购活动,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
-- 1. 砍价活动表
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_bargains (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
|
||||
|
||||
title TEXT NOT NULL, -- 活动标题
|
||||
min_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 砍价最低价
|
||||
stock INTEGER DEFAULT 0, -- 活动库存
|
||||
|
||||
start_time TIMESTAMPTZ NOT NULL, -- 开始时间
|
||||
stop_time TIMESTAMPTZ NOT NULL, -- 结束时间
|
||||
|
||||
status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 2. 团购活动表
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_groupbuys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
|
||||
|
||||
title TEXT NOT NULL, -- 活动标题
|
||||
price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 团购价格
|
||||
people INTEGER DEFAULT 2, -- 成团人数要求
|
||||
stock INTEGER DEFAULT 0, -- 活动库存
|
||||
|
||||
start_time TIMESTAMPTZ NOT NULL, -- 开始时间
|
||||
stop_time TIMESTAMPTZ NOT NULL, -- 结束时间
|
||||
|
||||
status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 3. 启用 RLS
|
||||
ALTER TABLE public.ak_marketing_bargains ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ak_marketing_groupbuys ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 4. 创建权限策略 (按 merchant_id 隔离)
|
||||
-- 砍价策略
|
||||
CREATE POLICY "Merchants can manage their own bargains"
|
||||
ON public.ak_marketing_bargains FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 团购策略
|
||||
CREATE POLICY "Merchants can manage their own groupbuys"
|
||||
ON public.ak_marketing_groupbuys FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许所有认证用户查看(用于移动端展示)
|
||||
CREATE POLICY "Anyone can view active marketing activities"
|
||||
ON public.ak_marketing_bargains FOR SELECT
|
||||
TO authenticated
|
||||
USING (status = true);
|
||||
|
||||
CREATE POLICY "Anyone can view active groupbuys"
|
||||
ON public.ak_marketing_groupbuys FOR SELECT
|
||||
TO authenticated
|
||||
USING (status = true);
|
||||
|
||||
-- 5. 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_bargains_merchant ON public.ak_marketing_bargains(merchant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_groupbuys_merchant ON public.ak_marketing_groupbuys(merchant_id);
|
||||
41
docs/sql/10_schema/marketing/ak_live_products_v1.sql
Normal file
41
docs/sql/10_schema/marketing/ak_live_products_v1.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 直播商品管理表
|
||||
-- 位置:docs/sql/10_schema/marketing/ak_live_products_v1.sql
|
||||
-- 说明:管理直播活动关联的商品,支持直播价设置与审核状态,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_live_products (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
product_id UUID NOT NULL REFERENCES public.ml_products(id) ON DELETE CASCADE,
|
||||
|
||||
live_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 直播专属价
|
||||
stock INTEGER DEFAULT 0, -- 直播可用库存
|
||||
|
||||
audit_status INTEGER DEFAULT 1, -- 审核状态: 1待审核, 2审核通过, 3审核驳回
|
||||
is_show BOOLEAN DEFAULT true, -- 是否在直播间显示
|
||||
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 启用 RLS
|
||||
ALTER TABLE public.ak_marketing_live_products ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 权限策略:商家仅能管理自己的直播商品
|
||||
CREATE POLICY "Merchants can manage their own live products"
|
||||
ON public.ak_marketing_live_products FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许查看审核通过的商品
|
||||
CREATE POLICY "Anyone can view approved live products"
|
||||
ON public.ak_marketing_live_products FOR SELECT
|
||||
TO authenticated
|
||||
USING (audit_status = 2 AND is_show = true);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_live_products_merchant ON public.ak_marketing_live_products(merchant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_products_product ON public.ak_marketing_live_products(product_id);
|
||||
93
docs/sql/10_schema/marketing/ak_lottery_live_v1.sql
Normal file
93
docs/sql/10_schema/marketing/ak_lottery_live_v1.sql
Normal file
@@ -0,0 +1,93 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 抽奖与直播管理表
|
||||
-- 位置:docs/sql/10_schema/marketing/ak_lottery_live_v1.sql
|
||||
-- 说明:管理抽奖活动、奖品、主播及直播间,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
-- 1. 抽奖活动表
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_lotteries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL, -- 活动名称
|
||||
type INTEGER DEFAULT 1, -- 活动类型: 1积分抽奖, 2订单评价, 3订单支付
|
||||
|
||||
start_time TIMESTAMPTZ NOT NULL, -- 开始时间
|
||||
end_time TIMESTAMPTZ NOT NULL, -- 结束时间
|
||||
|
||||
is_open BOOLEAN DEFAULT true, -- 是否开启
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 2. 抽奖奖品表
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_lottery_prizes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lottery_id UUID NOT NULL REFERENCES public.ak_marketing_lotteries(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL, -- 奖品名称
|
||||
prize_type TEXT NOT NULL, -- 奖品类型: points, balance, coupon, physical
|
||||
amount DECIMAL(12,2) DEFAULT 0, -- 奖励面值/数量
|
||||
stock INTEGER DEFAULT 0, -- 奖品库存
|
||||
probability DECIMAL(5,2) DEFAULT 0, -- 中奖概率 (0-100)
|
||||
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 3. 直播主播表
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_live_anchors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
nickname TEXT NOT NULL, -- 主播昵称
|
||||
wechat TEXT, -- 微信号
|
||||
phone TEXT, -- 联系电话
|
||||
avatar_url TEXT, -- 头像
|
||||
|
||||
status BOOLEAN DEFAULT true, -- 状态
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 4. 直播间管理表
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_live_rooms (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
anchor_id UUID REFERENCES public.ak_marketing_live_anchors(id) ON DELETE SET NULL,
|
||||
|
||||
name TEXT NOT NULL, -- 直播间名称
|
||||
background_url TEXT, -- 背景图
|
||||
share_img_url TEXT, -- 分享图
|
||||
|
||||
start_time TIMESTAMPTZ NOT NULL, -- 开始时间
|
||||
end_time TIMESTAMPTZ NOT NULL, -- 计划结束时间
|
||||
|
||||
sort INTEGER DEFAULT 0, -- 排序
|
||||
type TEXT DEFAULT 'phone', -- 类型: phone手机直播等
|
||||
|
||||
like_enabled BOOLEAN DEFAULT true, -- 开启点赞
|
||||
sale_enabled BOOLEAN DEFAULT true, -- 开启卖货
|
||||
comment_enabled BOOLEAN DEFAULT true, -- 开启评论
|
||||
|
||||
is_show BOOLEAN DEFAULT true, -- 是否显示
|
||||
live_status INTEGER DEFAULT 1, -- 1未开始, 2直播中, 3暂停, 4已结束
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 5. 启用 RLS
|
||||
ALTER TABLE public.ak_marketing_lotteries ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ak_marketing_lottery_prizes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ak_marketing_live_anchors ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ak_marketing_live_rooms ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 6. 创建权限策略 (按 merchant_id 隔离)
|
||||
CREATE POLICY "Merchants manage their own lotteries" ON public.ak_marketing_lotteries FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
|
||||
CREATE POLICY "Merchants manage their own anchors" ON public.ak_marketing_live_anchors FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
|
||||
CREATE POLICY "Merchants manage their own rooms" ON public.ak_marketing_live_rooms FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许查看
|
||||
CREATE POLICY "Users can view lotteries" ON public.ak_marketing_lotteries FOR SELECT TO authenticated USING (is_open = true);
|
||||
CREATE POLICY "Users can view active rooms" ON public.ak_marketing_live_rooms FOR SELECT TO authenticated USING (is_show = true);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 打卡/签到增强配置表
|
||||
-- 位置:docs/sql/10_schema/marketing/ak_marketing_checkin_configs_v1.sql
|
||||
-- 说明:管理打卡开关、模式、提醒及基础奖励(积分/经验),按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_checkin_configs (
|
||||
id TEXT PRIMARY KEY DEFAULT 'checkin_config',
|
||||
merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
is_open BOOLEAN DEFAULT true, -- 签到开关
|
||||
mode TEXT DEFAULT 'none', -- 签到模式: none(无限制), week(周循环), month(月循环)
|
||||
notice_enabled BOOLEAN DEFAULT false, -- 签到提醒开关
|
||||
|
||||
integral_reward INTEGER DEFAULT 10, -- 每日签到赠送积分
|
||||
exp_reward INTEGER DEFAULT 1, -- 每日签到赠送经验
|
||||
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_by UUID REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- 启用 RLS
|
||||
ALTER TABLE public.ak_marketing_checkin_configs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 权限策略:商家仅能管理自己的打卡配置
|
||||
CREATE POLICY "Merchants manage their own checkin configs"
|
||||
ON public.ak_marketing_checkin_configs FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许所有认证用户查看(用于前台展示)
|
||||
CREATE POLICY "Anyone can view checkin config"
|
||||
ON public.ak_marketing_checkin_configs FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- 插入初始化数据(为每个管理员/商家初始化一条)
|
||||
-- 实际应在商家创建时触发,此处先预留
|
||||
@@ -0,0 +1,35 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 新人礼配置表
|
||||
-- 位置:docs/sql/10_schema/marketing/ak_marketing_newcomer_config_v1.sql
|
||||
-- 说明:管理新用户注册后的奖励(余额、积分、优惠券),按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_newcomer_config (
|
||||
id TEXT PRIMARY KEY DEFAULT 'newcomer_config',
|
||||
merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
balance_reward DECIMAL(12,2) DEFAULT 0.00, -- 赠送余额
|
||||
integral_reward INTEGER DEFAULT 0, -- 赠送积分
|
||||
|
||||
-- 赠送优惠券 (JSONB 格式): [{ "id": "coupon_uuid", "name": "显示名称", "desc": "发放描述" }]
|
||||
coupons_json JSONB DEFAULT '[]'::jsonb,
|
||||
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_by UUID REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- 启用 RLS
|
||||
ALTER TABLE public.ak_marketing_newcomer_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 权限策略:商家仅能管理自己的新人礼配置
|
||||
CREATE POLICY "Merchants manage their own newcomer configs"
|
||||
ON public.ak_marketing_newcomer_config FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许查看配置(用于移动端展示)
|
||||
CREATE POLICY "Anyone can view newcomer config"
|
||||
ON public.ak_marketing_newcomer_config FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
36
docs/sql/10_schema/marketing/ak_marketing_signin_logs_v1.sql
Normal file
36
docs/sql/10_schema/marketing/ak_marketing_signin_logs_v1.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 签到记录表
|
||||
-- 位置:docs/sql/10_schema/marketing/ak_marketing_signin_logs_v1.sql
|
||||
-- 说明:记录用户每日签到的详细流水,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_signin_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
points INTEGER NOT NULL DEFAULT 0, -- 本次签到获得的积分
|
||||
is_continuous_reward BOOLEAN DEFAULT false, -- 是否包含连续签到额外奖励
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 启用 RLS
|
||||
ALTER TABLE public.ak_marketing_signin_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 权限策略:商家仅能管理/查看自己的签到记录
|
||||
CREATE POLICY "Merchants manage their own signin logs"
|
||||
ON public.ak_marketing_signin_logs FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许用户查看自己的签到记录
|
||||
CREATE POLICY "Users view own signin logs"
|
||||
ON public.ak_marketing_signin_logs FOR SELECT
|
||||
TO authenticated
|
||||
USING (uid = auth.uid());
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_signin_logs_merchant ON public.ak_marketing_signin_logs(merchant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_signin_logs_uid ON public.ak_marketing_signin_logs(uid, created_at DESC);
|
||||
71
docs/sql/10_schema/marketing/ak_member_management_v1.sql
Normal file
71
docs/sql/10_schema/marketing/ak_member_management_v1.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 付费会员管理相关表
|
||||
-- 位置:docs/sql/10_schema/marketing/ak_member_management_v1.sql
|
||||
-- 说明:管理会员卡类型、权益内容及基础配置,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
-- 1. 会员卡类型表
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_member_types (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL, -- 会员名 (如: 月卡, 年卡)
|
||||
duration_days INTEGER DEFAULT 30, -- 有效期(天),0表示永久
|
||||
price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 原价
|
||||
discount_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 优惠价/实际支付价
|
||||
|
||||
is_open BOOLEAN DEFAULT true, -- 是否开启
|
||||
sort_order INTEGER DEFAULT 0, -- 排序
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 2. 会员权益表
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_member_rights (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL, -- 权益名称
|
||||
description TEXT, -- 权益简介
|
||||
icon_url TEXT, -- 权益图标
|
||||
|
||||
is_show BOOLEAN DEFAULT true, -- 是否展示
|
||||
sort_order INTEGER DEFAULT 0, -- 排序
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 3. 会员基础配置表 (每个商家一条记录)
|
||||
CREATE TABLE IF NOT EXISTS public.ak_marketing_member_config (
|
||||
id TEXT PRIMARY KEY DEFAULT 'member_config',
|
||||
merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
is_enabled BOOLEAN DEFAULT true, -- 是否开启付费会员功能
|
||||
bg_img_url TEXT, -- 会员期内背景图
|
||||
expire_bg_img_url TEXT, -- 会员到期背景图
|
||||
rules_description TEXT, -- 会员规则说明文本
|
||||
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 4. 启用 RLS
|
||||
ALTER TABLE public.ak_marketing_member_types ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ak_marketing_member_rights ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ak_marketing_member_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 5. 创建权限策略 (按 merchant_id 隔离)
|
||||
-- 商家管理自己的数据
|
||||
CREATE POLICY "Merchants manage their own member types" ON public.ak_marketing_member_types FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
|
||||
CREATE POLICY "Merchants manage their own member rights" ON public.ak_marketing_member_rights FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
|
||||
CREATE POLICY "Merchants manage their own member config" ON public.ak_marketing_member_config FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许所有认证用户查看 (移动端展示)
|
||||
CREATE POLICY "Users can view active member types" ON public.ak_marketing_member_types FOR SELECT TO authenticated USING (is_open = true);
|
||||
CREATE POLICY "Users can view active member rights" ON public.ak_marketing_member_rights FOR SELECT TO authenticated USING (is_show = true);
|
||||
CREATE POLICY "Users can view member config" ON public.ak_marketing_member_config FOR SELECT TO authenticated USING (true);
|
||||
|
||||
-- 6. 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_member_types_merchant ON public.ak_marketing_member_types(merchant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_member_rights_merchant ON public.ak_marketing_member_rights(merchant_id);
|
||||
48
docs/sql/10_schema/marketing/ak_recharge_management_v1.sql
Normal file
48
docs/sql/10_schema/marketing/ak_recharge_management_v1.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 充值配置与额度模板表
|
||||
-- 位置:docs/sql/10_schema/marketing/ak_recharge_management_v1.sql
|
||||
-- 说明:管理用户充值开关、最低金额及预设额度,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
-- 1. 充值基础配置表 (每个商家一条记录)
|
||||
CREATE TABLE IF NOT EXISTS public.ak_recharge_configs (
|
||||
id TEXT PRIMARY KEY DEFAULT 'recharge_config',
|
||||
merchant_id UUID NOT NULL UNIQUE REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
balance_enabled BOOLEAN DEFAULT true, -- 余额功能是否启用
|
||||
recharge_notice TEXT, -- 充值注意事项说明
|
||||
mp_recharge_enabled BOOLEAN DEFAULT false, -- 小程序充值开关
|
||||
min_recharge_amount DECIMAL(12,2) DEFAULT 0.01, -- 最低充值金额
|
||||
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 2. 充值额度模板表
|
||||
CREATE TABLE IF NOT EXISTS public.ak_recharge_quotas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 售价(实际充值金额)
|
||||
bonus_price DECIMAL(12,2) NOT NULL DEFAULT 0, -- 赠送金额
|
||||
|
||||
is_open BOOLEAN DEFAULT true, -- 是否可用
|
||||
sort_order INTEGER DEFAULT 0, -- 排序
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 3. 启用 RLS
|
||||
ALTER TABLE public.ak_recharge_configs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.ak_recharge_quotas ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 4. 创建权限策略 (按 merchant_id 隔离)
|
||||
CREATE POLICY "Merchants manage their own recharge configs" ON public.ak_recharge_configs FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
|
||||
CREATE POLICY "Merchants manage their own recharge quotas" ON public.ak_recharge_quotas FOR ALL TO authenticated USING (merchant_id = auth.uid()) WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许所有认证用户查看
|
||||
CREATE POLICY "Users can view recharge configs" ON public.ak_recharge_configs FOR SELECT TO authenticated USING (true);
|
||||
CREATE POLICY "Users can view active recharge quotas" ON public.ak_recharge_quotas FOR SELECT TO authenticated USING (is_open = true);
|
||||
|
||||
-- 5. 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_recharge_quotas_merchant ON public.ak_recharge_quotas(merchant_id);
|
||||
42
docs/sql/10_schema/marketing/ak_signin_configs_v1.sql
Normal file
42
docs/sql/10_schema/marketing/ak_signin_configs_v1.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 签到规则配置表
|
||||
-- 位置:docs/sql/10_schema/marketing/ak_signin_configs_v1.sql
|
||||
-- 说明:记录每日签到积分、连续签到奖励及规则说明,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.ak_signin_configs (
|
||||
id TEXT PRIMARY KEY DEFAULT 'signin_config', -- 每个商家一个配置记录
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
is_enabled BOOLEAN DEFAULT true, -- 签到功能是否启用
|
||||
daily_points INTEGER DEFAULT 10, -- 每日签到固定奖励积分
|
||||
|
||||
-- 连续签到奖励 (JSONB 格式): [{ "day": 3, "points": 20 }, { "day": 7, "points": 50 }]
|
||||
continuous_rewards JSONB DEFAULT '[]'::jsonb,
|
||||
|
||||
rules_description TEXT DEFAULT '1.每日签到可获得积分奖励;\n2.连续签到满足天数可获得额外阶梯奖励;\n3.签到中断将重新从第一天开始计算。',
|
||||
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
|
||||
-- 约束:同一个商家只有一个签到配置记录
|
||||
UNIQUE(merchant_id)
|
||||
);
|
||||
|
||||
-- 启用 RLS
|
||||
ALTER TABLE public.ak_signin_configs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 权限策略:商家仅能管理自己的签到配置
|
||||
CREATE POLICY "Merchants can manage their own signin configs"
|
||||
ON public.ak_signin_configs
|
||||
FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许所有认证用户查看配置(用于移动端签到展示)
|
||||
CREATE POLICY "Authenticated users can view signin configs"
|
||||
ON public.ak_signin_configs
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
37
docs/sql/10_schema/product/ak_shipping_templates_v1.sql
Normal file
37
docs/sql/10_schema/product/ak_shipping_templates_v1.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- =====================================================================================
|
||||
-- Schema: 运费模板表
|
||||
-- 位置:docs/sql/10_schema/product/ak_shipping_templates_v1.sql
|
||||
-- 说明:管理商家的运费计算规则,按商家隔离。
|
||||
-- =====================================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.ak_shipping_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL, -- 模板名称
|
||||
calc_method TEXT DEFAULT 'piece', -- 计费方式: piece(件数), weight(重量), volume(体积)
|
||||
is_free_shipping BOOLEAN DEFAULT false, -- 是否包邮
|
||||
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 启用 RLS
|
||||
ALTER TABLE public.ak_shipping_templates ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 权限策略:商家管理自己的模板
|
||||
CREATE POLICY "Merchants manage own shipping templates"
|
||||
ON public.ak_shipping_templates FOR ALL
|
||||
TO authenticated
|
||||
USING (merchant_id = auth.uid())
|
||||
WITH CHECK (merchant_id = auth.uid());
|
||||
|
||||
-- 允许查看
|
||||
CREATE POLICY "Authenticated users view shipping templates"
|
||||
ON public.ak_shipping_templates FOR SELECT
|
||||
TO authenticated
|
||||
USING (true);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_shipping_templates_merchant ON public.ak_shipping_templates(merchant_id);
|
||||
39
docs/sql/10_schema/product/ml_products_ext_v1.sql
Normal file
39
docs/sql/10_schema/product/ml_products_ext_v1.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- =====================================================================================
|
||||
-- Schema Update: ml_products 扩展字段 (物流、营销、高级设置)
|
||||
-- 位置:docs/sql/10_schema/product/ml_products_ext_v1.sql
|
||||
-- 说明:补齐商品编辑页 Step 3-6 所需的持久化字段。
|
||||
-- =====================================================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- 1. 物流设置:关联运费模板
|
||||
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'shipping_template_id') THEN
|
||||
ALTER TABLE public.ml_products ADD COLUMN shipping_template_id UUID REFERENCES public.ak_shipping_templates(id) ON DELETE SET NULL;
|
||||
COMMENT ON COLUMN public.ml_products.shipping_template_id IS '关联运费模板ID';
|
||||
END IF;
|
||||
|
||||
-- 2. 营销设置:赠送积分
|
||||
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'give_integral') THEN
|
||||
ALTER TABLE public.ml_products ADD COLUMN give_integral INTEGER DEFAULT 0;
|
||||
COMMENT ON COLUMN public.ml_products.give_integral IS '购买赠送积分';
|
||||
END IF;
|
||||
|
||||
-- 3. 高级设置:警戒库存
|
||||
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'stock_warning') THEN
|
||||
ALTER TABLE public.ml_products ADD COLUMN stock_warning INTEGER DEFAULT 10;
|
||||
COMMENT ON COLUMN public.ml_products.stock_warning IS '库存报警数值';
|
||||
END IF;
|
||||
|
||||
-- 4. 高级设置:虚拟销量
|
||||
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'virtual_sales') THEN
|
||||
ALTER TABLE public.ml_products ADD COLUMN virtual_sales INTEGER DEFAULT 0;
|
||||
COMMENT ON COLUMN public.ml_products.virtual_sales IS '虚拟销量(展示用)';
|
||||
END IF;
|
||||
|
||||
-- 5. 高级设置:排序
|
||||
IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'ml_products' AND column_name = 'sort_order') THEN
|
||||
ALTER TABLE public.ml_products ADD COLUMN sort_order INTEGER DEFAULT 0;
|
||||
COMMENT ON COLUMN public.ml_products.sort_order IS '商品排序权重';
|
||||
END IF;
|
||||
|
||||
END $$;
|
||||
@@ -0,0 +1,91 @@
|
||||
-- RPC: rpc_admin_get_integral_stats
|
||||
-- 位置:docs/sql/30_rpc/marketing/rpc_admin_get_integral_stats_v1.sql
|
||||
-- 说明:聚合统计积分概况(总额、趋势、分布)
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_admin_get_integral_stats(
|
||||
p_start_time TIMESTAMP WITH TIME ZONE,
|
||||
p_end_time TIMESTAMP WITH TIME ZONE
|
||||
)
|
||||
RETURNS JSONB
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_total_stats RECORD;
|
||||
v_trend_data JSONB;
|
||||
v_source_dist JSONB;
|
||||
v_consume_dist JSONB;
|
||||
BEGIN
|
||||
-- 1. 权限检查
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM public.ak_users
|
||||
WHERE id = auth.uid() AND role = 'admin'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Permission denied';
|
||||
END IF;
|
||||
|
||||
-- 2. 计算核心指标 (所有时间)
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN pm = 1 THEN number ELSE -number END), 0) as current_total,
|
||||
COALESCE(SUM(CASE WHEN pm = 1 THEN number ELSE 0 END), 0) as cumulative_income,
|
||||
COALESCE(SUM(CASE WHEN pm = 0 THEN number ELSE 0 END), 0) as cumulative_expend
|
||||
INTO v_total_stats
|
||||
FROM public.ml_user_bill
|
||||
WHERE category = 'integral' AND status = 1;
|
||||
|
||||
-- 3. 趋势数据 (按日聚合)
|
||||
SELECT jsonb_agg(t) INTO v_trend_data
|
||||
FROM (
|
||||
SELECT
|
||||
to_char(date_trunc('day', gs.day), 'MM-DD') AS date_group,
|
||||
COALESCE((SELECT SUM(number) FROM public.ml_user_bill b
|
||||
WHERE b.category = 'integral' AND b.pm = 1 AND b.status = 1
|
||||
AND date_trunc('day', b.created_at) = gs.day), 0) as income,
|
||||
COALESCE((SELECT SUM(number) FROM public.ml_user_bill b
|
||||
WHERE b.category = 'integral' AND b.pm = 0 AND b.status = 1
|
||||
AND date_trunc('day', b.created_at) = gs.day), 0) as expend
|
||||
FROM generate_series(date_trunc('day', p_start_time), date_trunc('day', p_end_time), '1 day'::interval) gs(day)
|
||||
ORDER BY gs.day ASC
|
||||
) t;
|
||||
|
||||
-- 4. 来源分布 (按 type 分组)
|
||||
SELECT jsonb_agg(t) INTO v_source_dist
|
||||
FROM (
|
||||
SELECT
|
||||
type as label,
|
||||
SUM(number) as value,
|
||||
ROUND((SUM(number) * 100 / NULLIF(v_total_stats.cumulative_income, 0)), 2) as percent
|
||||
FROM public.ml_user_bill
|
||||
WHERE category = 'integral' AND pm = 1 AND status = 1
|
||||
GROUP BY type
|
||||
ORDER BY value DESC
|
||||
) t;
|
||||
|
||||
-- 5. 消耗分布 (按 type 分组)
|
||||
SELECT jsonb_agg(t) INTO v_consume_dist
|
||||
FROM (
|
||||
SELECT
|
||||
type as label,
|
||||
SUM(number) as value,
|
||||
ROUND((SUM(number) * 100 / NULLIF(v_total_stats.cumulative_expend, 0)), 2) as percent
|
||||
FROM public.ml_user_bill
|
||||
WHERE category = 'integral' AND pm = 0 AND status = 1
|
||||
GROUP BY type
|
||||
ORDER BY value DESC
|
||||
) t;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'totals', jsonb_build_object(
|
||||
'current', v_total_stats.current_total,
|
||||
'income', v_total_stats.cumulative_income,
|
||||
'expend', v_total_stats.cumulative_expend
|
||||
),
|
||||
'trend', COALESCE(v_trend_data, '[]'::jsonb),
|
||||
'sources', COALESCE(v_source_dist, '[]'::jsonb),
|
||||
'consumes', COALESCE(v_consume_dist, '[]'::jsonb)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.rpc_admin_get_integral_stats(timestamptz, timestamptz) TO authenticated;
|
||||
@@ -4,7 +4,7 @@
|
||||
<view class="filter-row">
|
||||
<view class="filter-item">
|
||||
<text class="label">代理商查询:</text>
|
||||
<input class="filter-input" placeholder="请输入姓名、手机号或UID" />
|
||||
<input class="filter-input" placeholder="请输入姓名、手机号或UID" v-model="searchQuery" @confirm="onSearch" />
|
||||
</view>
|
||||
<view class="filter-btns">
|
||||
<button class="btn primary" @click="onSearch">查询</button>
|
||||
@@ -16,51 +16,119 @@
|
||||
<button class="btn primary small" @click="onAdd">添加代理商</button>
|
||||
</view>
|
||||
<view class="table-container">
|
||||
<!-- Loading 遮罩 -->
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="table-header">
|
||||
<view class="col col-uid"><text>用户UID</text></view>
|
||||
<view class="col col-avatar"><text>头像</text></view>
|
||||
<view class="col col-name"><text>名称</text></view>
|
||||
<view class="col col-ratio"><text>分佣比例</text></view>
|
||||
<view class="col col-count"><text>员工数量</text></view>
|
||||
<view class="col col-time"><text>过期时间</text></view>
|
||||
<view class="col col-ratio"><text>所属事业部</text></view>
|
||||
<view class="col col-time"><text>加入时间</text></view>
|
||||
<view class="col col-status"><text>状态</text></view>
|
||||
<view class="col col-ops"><text>操作</text></view>
|
||||
</view>
|
||||
<view class="table-body">
|
||||
<view v-for="item in agentList" :key="item.uid" class="table-row">
|
||||
<view class="col col-uid"><text>{{ item.uid }}</text></view>
|
||||
<view class="col col-avatar">
|
||||
<image class="avatar-img" src="/static/logo.png" mode="aspectFill" />
|
||||
<view v-if="agentList.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无代理商数据</text>
|
||||
</view>
|
||||
<view class="col col-name"><text>{{ item.name }}</text></view>
|
||||
<view class="col col-ratio"><text>{{ item.ratio }}%</text></view>
|
||||
<view class="col col-count"><text>{{ item.staffCount }}</text></view>
|
||||
<view class="col col-time"><text>{{ item.endTime }}</text></view>
|
||||
<view v-for="item in agentList" :key="item.uid" class="table-row">
|
||||
<view class="col col-uid"><text class="td-txt-small">{{ item.uid }}</text></view>
|
||||
<view class="col col-avatar">
|
||||
<image class="avatar-img" :src="item.avatar_url || '/static/logo.png'" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="col col-name"><text>{{ item.name || item.nickname }}</text></view>
|
||||
<view class="col col-ratio"><text>{{ item.division_name || '-' }}</text></view>
|
||||
<view class="col col-time"><text>{{ formatDateTime(item.created_at) }}</text></view>
|
||||
<view class="col col-status">
|
||||
<switch :checked="item.status" color="#1890ff" scale="0.8" />
|
||||
<switch :checked="item.status" color="#1890ff" scale="0.8" @change="toggleStatus(item)" />
|
||||
</view>
|
||||
<view class="col col-ops">
|
||||
<text class="op-link">编辑</text>
|
||||
<text class="op-link">详情</text>
|
||||
<text class="op-divider">|</text>
|
||||
<text class="op-link">查看</text>
|
||||
<text class="op-divider">|</text>
|
||||
<text class="op-link">员工</text>
|
||||
<text class="op-divider">|</text>
|
||||
<text class="op-link">删除</text>
|
||||
<text class="op-link danger" @click="onDelete(item.id)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pagination">
|
||||
<view class="pager-btns">
|
||||
<button class="btn small" :disabled="page <= 1" @click="onPrevPage">上一页</button>
|
||||
<text class="page-num">第 {{ page }} 页</text>
|
||||
<button class="btn small" :disabled="agentList.length < pageSize" @click="onNextPage">下一页</button>
|
||||
</view>
|
||||
<text class="page-info">共 {{ total }} 条记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
const agentList = ref([
|
||||
{ uid: '60569', name: 'cs2020', ratio: 50, staffCount: 1, endTime: '2026-01-01', status: true },
|
||||
])
|
||||
function onSearch() { uni.showToast({ title: '查询中...', icon: 'none' }) }
|
||||
function onAdd() { uni.showToast({ title: '添加中...', icon: 'none' }) }
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchAgents, saveAgent, DistributionAgent } from '@/services/admin/distributionService.uts'
|
||||
|
||||
const agentList = ref<DistributionAgent[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchAgents({
|
||||
search: searchQuery.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize
|
||||
})
|
||||
agentList.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
uni.showToast({ title: '添加代理商功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
async function toggleStatus(item : DistributionAgent) {
|
||||
if (item.id == null) return
|
||||
const nextStatus = !item.status
|
||||
const success = await saveAgent({ ...item, status: nextStatus } as DistributionAgent)
|
||||
if (success) {
|
||||
item.status = nextStatus
|
||||
uni.showToast({ title: '状态修改成功', icon: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (agentList.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.admin-page { padding: 0; }
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="table-container">
|
||||
<!-- Loading 遮罩 -->
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="table-header">
|
||||
<view class="col col-uid"><text>用户UID</text></view>
|
||||
<view class="col col-name"><text>代理商名称</text></view>
|
||||
@@ -30,40 +35,152 @@
|
||||
<view class="col col-ops"><text>操作</text></view>
|
||||
</view>
|
||||
<view class="table-body">
|
||||
<view v-for="item in applyList" :key="item.uid" class="table-row">
|
||||
<view class="col col-uid"><text>{{ item.uid }}</text></view>
|
||||
<view v-if="applyList.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无申请记录</text>
|
||||
</view>
|
||||
<view v-for="item in applyList" :key="item.id" class="table-row">
|
||||
<view class="col col-uid"><text class="td-txt-small">{{ item.uid }}</text></view>
|
||||
<view class="col col-name"><text>{{ item.name }}</text></view>
|
||||
<view class="col col-phone"><text>{{ item.phone }}</text></view>
|
||||
<view class="col col-dept"><text>{{ item.deptName }}</text></view>
|
||||
<view class="col col-dept"><text>{{ item.division_name || '-' }}</text></view>
|
||||
<view class="col col-img">
|
||||
<image class="table-img" src="/static/logo.png" mode="aspectFill" />
|
||||
<image class="table-img" :src="item.images[0] || '/static/logo.png'" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="col col-time"><text>{{ item.time }}</text></view>
|
||||
<view class="col col-time"><text class="td-txt-small">{{ formatTime(item.created_at) }}</text></view>
|
||||
<view class="col col-status">
|
||||
<view class="status-tag"><text>{{ item.statusText }}</text></view>
|
||||
<view class="status-tag"><text>{{ getStatusLabel(item.status) }}</text></view>
|
||||
</view>
|
||||
<view class="col col-code"><view class="code-box"><text>{{ item.code }}</text></view></view>
|
||||
<view class="col col-code"><view class="code-box"><text>{{ item.invite_code || '-' }}</text></view></view>
|
||||
<view class="col col-ops">
|
||||
<text class="op-link">同意</text>
|
||||
<template v-if="item.status === 1">
|
||||
<text class="op-link" @click="handleAudit(item, 2)">同意</text>
|
||||
<text class="op-divider">|</text>
|
||||
<text class="op-link">拒绝</text>
|
||||
<text class="op-link" @click="handleAudit(item, 3)">拒绝</text>
|
||||
<text class="op-divider">|</text>
|
||||
<text class="op-link">删除</text>
|
||||
</template>
|
||||
<text class="op-link danger" @click="onDelete(item.id)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pagination" style="padding: 16px 24px;">
|
||||
<view class="pager-btns">
|
||||
<button class="btn small" :disabled="page <= 1" @click="onPrevPage">上一页</button>
|
||||
<text class="page-num">第 {{ page }} 页</text>
|
||||
<button class="btn small" :disabled="applyList.length < pageSize" @click="onNextPage">下一页</button>
|
||||
</view>
|
||||
<text class="page-info">共 {{ total }} 条记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { fetchApplications, auditApplication, DistributionApplication } from '@/services/admin/distributionService.uts'
|
||||
|
||||
const activeTab = ref(0)
|
||||
const tabs = ['全部', '申请中', '已同意', '已拒绝']
|
||||
const applyList = ref([
|
||||
{ uid: '81806', name: '测试测试', phone: '19910205954', deptName: '26991', time: '2026-01-08 15:30:39', statusText: '申请中', code: '70623142' },
|
||||
])
|
||||
function onSearch() { uni.showToast({ title: '查询中...', icon: 'none' }) }
|
||||
const statusMap = [0, 1, 2, 3] // 映射到接口状态: 0:全部, 1:待审核, 2:已同意, 3:已拒绝
|
||||
|
||||
const applyList = ref<DistributionApplication[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
watch(activeTab, () => {
|
||||
page.value = 1
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchApplications({
|
||||
search: searchQuery.value,
|
||||
status: statusMap[activeTab.value],
|
||||
page: page.value,
|
||||
pageSize: pageSize
|
||||
})
|
||||
applyList.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleAudit(item : DistributionApplication, status : number) {
|
||||
if (item.id == null) return
|
||||
const actionText = status === 2 ? '同意' : '拒绝'
|
||||
uni.showModal({
|
||||
title: '确认操作',
|
||||
content: `确定要${actionText}该申请吗?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
const success = await auditApplication(item.id!, status)
|
||||
if (success) {
|
||||
uni.showToast({ title: '操作成功', icon: 'success' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function onDelete(id : string | undefined) {
|
||||
if (id == null) return
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该申请记录吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
// 暂用审核接口或补充删除接口,此处示例为提示
|
||||
uni.showToast({ title: '删除功能开发中', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (applyList.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso : string | null | undefined) : string {
|
||||
if (iso == null) return '-'
|
||||
return iso.substring(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
function getStatusLabel(status : number) : string {
|
||||
switch (status) {
|
||||
case 1: return '申请中'
|
||||
case 2: return '已同意'
|
||||
case 3: return '已拒绝'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.admin-page { padding: 0; }
|
||||
@@ -87,3 +204,4 @@ function onSearch() { uni.showToast({ title: '查询中...', icon: 'none' }) }
|
||||
.op-link { color: #1890ff; cursor: pointer; }
|
||||
.op-divider { color: #e8e8e8; margin: 0 8px; }
|
||||
</style>
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
<view class="filter-row">
|
||||
<view class="filter-item">
|
||||
<text class="label">搜索:</text>
|
||||
<input class="filter-input" placeholder="请输入姓名、UID" />
|
||||
<input class="filter-input" placeholder="请输入姓名、UID" v-model="searchQuery" @confirm="onSearch" />
|
||||
</view>
|
||||
<view class="filter-btns">
|
||||
<button class="btn primary" @click="onSearch">查询</button>
|
||||
@@ -16,6 +16,11 @@
|
||||
<button class="btn primary small" @click="onAdd">添加事业部</button>
|
||||
</view>
|
||||
<view class="table-container">
|
||||
<!-- Loading 遮罩 -->
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="table-header">
|
||||
<view class="col col-uid"><text>用户UID</text></view>
|
||||
<view class="col col-avatar"><text>头像</text></view>
|
||||
@@ -28,39 +33,128 @@
|
||||
<view class="col col-ops"><text>操作</text></view>
|
||||
</view>
|
||||
<view class="table-body">
|
||||
<view v-for="item in divisionList" :key="item.uid" class="table-row">
|
||||
<view class="col col-uid"><text>{{ item.uid }}</text></view>
|
||||
<view class="col col-avatar">
|
||||
<image class="avatar-img" src="/static/logo.png" mode="aspectFill" />
|
||||
<view v-if="divisionList.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无事业部数据</text>
|
||||
</view>
|
||||
<view class="col col-name"><text>{{ item.name }}</text></view>
|
||||
<view class="col col-code"><text>{{ item.code }}</text></view>
|
||||
<view v-for="item in divisionList" :key="item.uid" class="table-row">
|
||||
<view class="col col-uid"><text class="td-txt-small">{{ item.uid }}</text></view>
|
||||
<view class="col col-avatar">
|
||||
<image class="avatar-img" :src="item.avatar_url || '/static/logo.png'" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="col col-name"><text>{{ item.name || item.nickname }}</text></view>
|
||||
<view class="col col-code"><text>{{ item.invite_code }}</text></view>
|
||||
<view class="col col-ratio"><text>{{ item.ratio }}%</text></view>
|
||||
<view class="col col-count"><text>{{ item.agentCount }}</text></view>
|
||||
<view class="col col-time"><text>{{ item.endTime }}</text></view>
|
||||
<view class="col col-count"><text>{{ item.agent_count }}</text></view>
|
||||
<view class="col col-time"><text>{{ formatTime(item.end_time) }}</text></view>
|
||||
<view class="col col-status">
|
||||
<switch :checked="item.status" color="#1890ff" scale="0.8" />
|
||||
<switch :checked="item.status" color="#1890ff" scale="0.8" @change="toggleStatus(item)" />
|
||||
</view>
|
||||
<view class="col col-ops">
|
||||
<text class="op-link">查看代理商</text>
|
||||
<text class="op-divider">|</text>
|
||||
<text class="op-link">编辑</text>
|
||||
<text class="op-divider">|</text>
|
||||
<text class="op-link">删除</text>
|
||||
<text class="op-link" @click="onDelete(item.id)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pagination">
|
||||
<view class="pager-btns">
|
||||
<button class="btn small" :disabled="page <= 1" @click="onPrevPage">上一页</button>
|
||||
<text class="page-num">第 {{ page }} 页</text>
|
||||
<button class="btn small" :disabled="divisionList.length < pageSize" @click="onNextPage">下一页</button>
|
||||
</view>
|
||||
<text class="page-info">共 {{ total }} 条记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
const divisionList = ref([
|
||||
{ uid: '26991', name: '26991', code: '70623142', ratio: 40, agentCount: 1, endTime: '2026-12-31', status: true },
|
||||
])
|
||||
function onSearch() { uni.showToast({ title: '查询中...', icon: 'none' }) }
|
||||
function onAdd() { uni.showToast({ title: '添加中...', icon: 'none' }) }
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchDivisions, saveDivision, deleteDivision, DistributionDivision } from '@/services/admin/distributionService.uts'
|
||||
|
||||
const divisionList = ref<DistributionDivision[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchDivisions({
|
||||
search: searchQuery.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize
|
||||
})
|
||||
divisionList.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
uni.showToast({ title: '添加事业部功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
async function toggleStatus(item : DistributionDivision) {
|
||||
if (item.id == null) return
|
||||
const nextStatus = !item.status
|
||||
const success = await saveDivision({ ...item, status: nextStatus } as DistributionDivision)
|
||||
if (success) {
|
||||
item.status = nextStatus
|
||||
uni.showToast({ title: '状态修改成功', icon: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(id : string | undefined) {
|
||||
if (id == null) return
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该事业部吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
const success = await deleteDivision(id!)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (divisionList.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso : string | null) : string {
|
||||
if (!iso) return '-'
|
||||
return iso.substring(0, 10)
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.admin-page { padding: 0; }
|
||||
|
||||
@@ -1,81 +1,285 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<view class="page-header">
|
||||
<text class="page-title">砍价活动</text>
|
||||
<text class="page-subtitle">Component: MarketingBargain</text>
|
||||
<view class="marketing-bargain-list">
|
||||
<view class="filter-card border-shadow">
|
||||
<view class="filter-row">
|
||||
<view class="filter-item">
|
||||
<text class="label">活动搜索:</text>
|
||||
<input class="input-mock" placeholder="请输入活动名称" v-model="searchQuery" @confirm="handleSearch" />
|
||||
</view>
|
||||
<view class="filter-btns">
|
||||
<button class="btn-query" @click="handleSearch">查询</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<view class="placeholder-card">
|
||||
<text class="placeholder-title">页面占位</text>
|
||||
<text class="placeholder-desc">该功能模块正在开发中</text>
|
||||
<text class="placeholder-info">当前采用 CRMEB 路由体系 1:1 映射</text>
|
||||
<view class="action-bar">
|
||||
<button class="btn-add" @click="handleAdd">添加砍价活动</button>
|
||||
</view>
|
||||
|
||||
<view class="table-card border-shadow">
|
||||
<view class="table-container">
|
||||
<!-- Loading 遮罩 -->
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="table-head">
|
||||
<view class="th cell-id">ID</view>
|
||||
<view class="th cell-product">关联商品</view>
|
||||
<view class="th cell-title">活动标题</view>
|
||||
<view class="th cell-price">最低价</view>
|
||||
<view class="th cell-stock">库存</view>
|
||||
<view class="th cell-time">活动时间</view>
|
||||
<view class="th cell-status">状态</view>
|
||||
<view class="th cell-op">操作</view>
|
||||
</view>
|
||||
|
||||
<view class="table-body">
|
||||
<view v-if="bargainList.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无砍价活动</text>
|
||||
</view>
|
||||
<view v-for="item in bargainList" :key="item.id" class="table-row">
|
||||
<view class="td cell-id">
|
||||
<text class="td-txt-small">{{ item.id }}</text>
|
||||
</view>
|
||||
<view class="td cell-product">
|
||||
<view class="p-info">
|
||||
<image class="p-img" :src="item.product_image || '/static/logo.png'" mode="aspectFill" />
|
||||
<text class="p-name line-clamp-1">{{ item.product_name || '未知商品' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="td cell-title">
|
||||
<text class="td-txt">{{ item.title }}</text>
|
||||
</view>
|
||||
<view class="td cell-price">
|
||||
<text class="td-txt danger">¥{{ item.min_price }}</text>
|
||||
</view>
|
||||
<view class="td cell-stock">
|
||||
<text class="td-txt">{{ item.stock }}</text>
|
||||
</view>
|
||||
<view class="td cell-time">
|
||||
<text class="td-txt-small">始: {{ formatTime(item.start_time) }}</text>
|
||||
<text class="td-txt-small">终: {{ formatTime(item.stop_time) }}</text>
|
||||
</view>
|
||||
<view class="td cell-status">
|
||||
<view class="switch-mock" :class="{ active: item.status }" @click="toggleStatus(item)">
|
||||
<view class="switch-dot"></view>
|
||||
<text class="switch-txt">{{ item.status ? '开启' : '关闭' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="td cell-op">
|
||||
<view class="op-links">
|
||||
<text class="op-link" @click="handleEdit(item)">编辑</text>
|
||||
<text class="op-split">|</text>
|
||||
<text class="op-link danger" @click="handleDelete(item)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pagination-footer">
|
||||
<view class="page-total">
|
||||
<text class="total-txt">共 {{ total }} 条</text>
|
||||
</view>
|
||||
<view class="page-btns">
|
||||
<text class="p-btn" :class="{ disabled: page <= 1 }" @click="onPrevPage">‹</text>
|
||||
<text class="p-btn active">{{ page }}</text>
|
||||
<text class="p-btn" :class="{ disabled: bargainList.length < pageSize }" @click="onNextPage">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchBargainActivities, saveBargainActivity, deleteBargainActivity, BargainActivity } from '@/services/admin/marketingService.uts'
|
||||
|
||||
// TODO: 实现 砍价活动 的具体功能
|
||||
const loading = ref<boolean>(false)
|
||||
const bargainList = ref<BargainActivity[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchBargainActivities({
|
||||
search: searchQuery.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize
|
||||
})
|
||||
bargainList.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function toggleStatus(item : BargainActivity) {
|
||||
if (item.id == null) return
|
||||
const nextStatus = !item.status
|
||||
const success = await saveBargainActivity({ ...item, status: nextStatus } as BargainActivity)
|
||||
if (success) {
|
||||
item.status = nextStatus
|
||||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
uni.showToast({ title: '添加功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function handleEdit(item : BargainActivity) {
|
||||
uni.showToast({ title: '编辑功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
async function handleDelete(item : BargainActivity) {
|
||||
if (item.id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除该砍价活动吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
const success = await deleteBargainActivity(item.id!)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (bargainList.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '-'
|
||||
return iso.substring(0, 10)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
.marketing-bargain-list {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
background: #f0f2f5;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
.border-shadow {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
.filter-card { padding: 24px; margin-bottom: 16px; }
|
||||
.filter-row { display: flex; flex-direction: row; align-items: center; gap: 24px; }
|
||||
.filter-item { display: flex; flex-direction: row; align-items: center; }
|
||||
.label { font-size: 14px; color: #606266; }
|
||||
.input-mock {
|
||||
width: 240px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px;
|
||||
padding: 0 12px; font-size: 13px;
|
||||
}
|
||||
.btn-query {
|
||||
width: 64px; height: 32px; background-color: #1890ff; color: #fff;
|
||||
font-size: 14px; border: none; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
.action-bar { margin-bottom: 16px; }
|
||||
.btn-add {
|
||||
padding: 0 16px; height: 32px; background-color: #1890ff; color: #fff;
|
||||
font-size: 14px; border: none; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
|
||||
.placeholder-desc {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
.table-card { padding: 24px; position: relative; }
|
||||
.table-container { min-height: 400px; position: relative; }
|
||||
.loading-mask {
|
||||
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(255,255,255,0.7); display: flex; align-items: center; justify-content: center; z-index: 10;
|
||||
}
|
||||
.loading-text { color: #1890ff; font-size: 14px; }
|
||||
|
||||
.placeholder-info {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
.table-head {
|
||||
display: flex; flex-direction: row; background-color: #f8f8f9;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
}
|
||||
.th { padding: 12px 8px; font-size: 13px; color: #515a6e; font-weight: bold; text-align: center; }
|
||||
|
||||
.table-row {
|
||||
display: flex; flex-direction: row; border-bottom: 1px solid #e8eaec; align-items: center;
|
||||
&:hover { background-color: #fafafa; }
|
||||
}
|
||||
.td { padding: 12px 8px; text-align: center; display: flex; align-items: center; justify-content: center; }
|
||||
.td-txt { font-size: 13px; color: #515a6e; }
|
||||
.td-txt-small { font-size: 12px; color: #999; display: block; }
|
||||
.danger { color: #f5222d; }
|
||||
|
||||
.cell-id { width: 60px; }
|
||||
.cell-product { flex: 1.5; justify-content: flex-start; }
|
||||
.cell-title { flex: 1; }
|
||||
.cell-price { width: 100px; }
|
||||
.cell-stock { width: 80px; }
|
||||
.cell-time { width: 160px; }
|
||||
.cell-status { width: 100px; }
|
||||
.cell-op { width: 120px; }
|
||||
|
||||
.p-info { display: flex; flex-direction: row; align-items: center; gap: 8px; }
|
||||
.p-img { width: 32px; height: 32px; border-radius: 4px; background: #f5f5f5; }
|
||||
.p-name { font-size: 12px; color: #1890ff; text-align: left; }
|
||||
.line-clamp-1 { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
.switch-mock {
|
||||
width: 44px; height: 20px; background-color: #bfbfbf; border-radius: 10px;
|
||||
position: relative; transition: all 0.3s;
|
||||
&.active { background-color: #1890ff; }
|
||||
}
|
||||
.switch-dot {
|
||||
width: 16px; height: 16px; background-color: #fff; border-radius: 50%;
|
||||
position: absolute; top: 2px; left: 2px; transition: left 0.3s;
|
||||
}
|
||||
.active .switch-dot { left: 26px; }
|
||||
.switch-txt { font-size: 10px; color: #fff; position: absolute; right: 6px; top: 2px; }
|
||||
.active .switch-txt { left: 6px; right: auto; }
|
||||
|
||||
.op-links { display: flex; flex-direction: row; gap: 8px; }
|
||||
.op-link { font-size: 13px; color: #1890ff; cursor: pointer; &.danger { color: #f5222d; } }
|
||||
.op-split { color: #eee; }
|
||||
|
||||
.pagination-footer {
|
||||
margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px;
|
||||
}
|
||||
.total-txt { font-size: 13px; color: #999; }
|
||||
.page-btns { display: flex; flex-direction: row; gap: 8px; }
|
||||
.p-btn {
|
||||
width: 28px; height: 28px; border: 1px solid #dcdfe6; border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 14px;
|
||||
&.active { background: #1890ff; color: #fff; border-color: #1890ff; }
|
||||
&.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
.empty-row { padding: 60px 0; text-align: center; color: #999; }
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
</view>
|
||||
|
||||
<view class="config-body">
|
||||
<!-- 加载遮罩 -->
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="config-item">
|
||||
<view class="item-label">
|
||||
<text class="label-txt">签到开关:</text>
|
||||
@@ -88,7 +93,7 @@
|
||||
</view>
|
||||
|
||||
<view class="config-footer">
|
||||
<button class="btn-submit" @click="handleSave">提交</button>
|
||||
<button class="btn-submit" @click="handleSave" :loading="isSaving">提交</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -96,7 +101,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { reactive } from 'vue'
|
||||
import { reactive, onMounted, ref } from 'vue'
|
||||
import { fetchCheckinConfig, saveCheckinConfig, CheckinConfig } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const config = reactive({
|
||||
is_open: true,
|
||||
@@ -106,8 +115,52 @@ const config = reactive({
|
||||
exp: 1
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
|
||||
async function loadConfig() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchCheckinConfig()
|
||||
if (res != null) {
|
||||
config.is_open = res.is_open
|
||||
config.mode = res.mode
|
||||
config.notice_enabled = res.notice_enabled
|
||||
config.integral = res.integral_reward
|
||||
config.exp = res.exp_reward
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载配置失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (isSaving.value) return
|
||||
|
||||
isSaving.value = true
|
||||
const payload : CheckinConfig = {
|
||||
is_open: config.is_open,
|
||||
mode: config.mode,
|
||||
notice_enabled: config.notice_enabled,
|
||||
integral_reward: config.integral,
|
||||
exp_reward: config.exp
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await saveCheckinConfig(payload)
|
||||
if (success) {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '系统异常', icon: 'none' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -124,7 +177,7 @@ const handleSave = () => {
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.config-card { padding: 24px; }
|
||||
.config-card { padding: 24px; position: relative; }
|
||||
|
||||
.config-header {
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
@@ -172,7 +225,7 @@ const handleSave = () => {
|
||||
|
||||
.config-input {
|
||||
width: 400px;
|
||||
height: 36px;
|
||||
height: 32px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
@@ -186,8 +239,8 @@ const handleSave = () => {
|
||||
|
||||
.btn-submit {
|
||||
width: 80px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
@@ -195,4 +248,15 @@ const handleSave = () => {
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading-mask {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.loading-text { color: #1890ff; font-size: 14px; }
|
||||
</style>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<text class="stat-ic">👥</text>
|
||||
</view>
|
||||
<view class="stat-info">
|
||||
<text class="stat-value">349</text>
|
||||
<text class="stat-value">{{ statParticipants }}</text>
|
||||
<text class="stat-label">参与人数(人)</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -34,7 +34,7 @@
|
||||
<text class="stat-ic">📦</text>
|
||||
</view>
|
||||
<view class="stat-info">
|
||||
<text class="stat-value">44</text>
|
||||
<text class="stat-value">{{ statSuccessGroups }}</text>
|
||||
<text class="stat-label">成团数量(个)</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -42,6 +42,10 @@
|
||||
|
||||
<view class="table-card border-shadow">
|
||||
<view class="table-container">
|
||||
<!-- Loading 遮罩 -->
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">数据加载中...</text>
|
||||
</view>
|
||||
<view class="table-head">
|
||||
<view class="th cell-avatar">头像</view>
|
||||
<view class="th cell-leader">开团团长</view>
|
||||
@@ -55,15 +59,18 @@
|
||||
</view>
|
||||
|
||||
<view class="table-body">
|
||||
<view v-if="combos.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无拼团记录</text>
|
||||
</view>
|
||||
<view v-for="item in combos" :key="item.id" class="table-row">
|
||||
<view class="td cell-avatar">
|
||||
<image class="thumb" :src="item.avatar" mode="aspectFill"></image>
|
||||
<image class="thumb" :src="item.avatar || '/static/logo.png'" mode="aspectFill"></image>
|
||||
</view>
|
||||
<view class="td cell-leader">
|
||||
<text class="td-txt">{{ item.nickname }} / {{ item.uid }}</text>
|
||||
<text class="td-txt">{{ item.nickname || '未知用户' }} / {{ item.uid }}</text>
|
||||
</view>
|
||||
<view class="td cell-time">
|
||||
<text class="td-txt-small">{{ item.start_time }}</text>
|
||||
<text class="td-txt-small">{{ item.start_time.substring(0, 16).replace('T', ' ') }}</text>
|
||||
</view>
|
||||
<view class="td cell-product">
|
||||
<text class="product-title line-clamp-2">{{ item.title }} / {{ item.cid }}</text>
|
||||
@@ -75,7 +82,7 @@
|
||||
<text class="td-txt-bold">{{ item.count_people }}</text>
|
||||
</view>
|
||||
<view class="td cell-time">
|
||||
<text class="td-txt-small">{{ item.stop_time }}</text>
|
||||
<text class="td-txt-small">{{ item.stop_time.substring(0, 16).replace('T', ' ') }}</text>
|
||||
</view>
|
||||
<view class="td cell-status">
|
||||
<view :class="['status-tag', item.status]">
|
||||
@@ -86,7 +93,7 @@
|
||||
<view class="op-links">
|
||||
<text class="op-link" @click="viewDetails(item)">查看详情</text>
|
||||
<text class="op-split" v-if="item.status === 'ongoing'">|</text>
|
||||
<text class="op-link" v-if="item.status === 'ongoing'" @click="completeGroup(item)">立即成团</text>
|
||||
<text class="op-link" v-if="item.status === 'ongoing'" @click="handleCompleteGroup(item)">立即成团</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -95,23 +102,12 @@
|
||||
|
||||
<view class="pagination-footer">
|
||||
<view class="page-total">
|
||||
<text class="total-txt">共 {{ combos.length }} 条</text>
|
||||
</view>
|
||||
<view class="page-select">
|
||||
<view class="select-mock mini">
|
||||
<text class="select-val">15条/页</text>
|
||||
<text class="arrow">▼</text>
|
||||
</view>
|
||||
<text class="total-txt">共 {{ total }} 条</text>
|
||||
</view>
|
||||
<view class="page-btns">
|
||||
<text class="p-btn">‹</text>
|
||||
<text class="p-btn active">1</text>
|
||||
<text class="p-btn">›</text>
|
||||
</view>
|
||||
<view class="page-jump">
|
||||
<text class="jump-txt">前往</text>
|
||||
<input class="jump-input" placeholder="1" />
|
||||
<text class="jump-txt">页</text>
|
||||
<text class="p-btn" :class="{ disabled: page <= 1 }" @click="onPrevPage">‹</text>
|
||||
<text class="p-btn active">{{ page }}</text>
|
||||
<text class="p-btn" :class="{ disabled: combos.length < pageSize }" @click="onNextPage">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -119,114 +115,99 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchCombinationActivities, getCombinationStats, completeCombinationGroup, CombinationActivity } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const statusLabels = {
|
||||
const statusLabels : Record<string, string> = {
|
||||
ongoing: '进行中',
|
||||
pending: '未完成',
|
||||
ended: '已成功',
|
||||
}
|
||||
|
||||
const combos = ref([
|
||||
{
|
||||
id: 101,
|
||||
avatar: 'https://img0.baidu.com/it/u=3033502919,1657850259&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
nickname: '1岁上班22岁退休',
|
||||
uid: 82713,
|
||||
start_time: '2026-02-03 10:09',
|
||||
stop_time: '2026-02-04 10:09',
|
||||
title: 'FOMIX 蛋壳椅 进口头层牛皮自然色单人沙发椅 Egg chair设计师蛋椅',
|
||||
cid: 191,
|
||||
people: 2,
|
||||
count_people: 1,
|
||||
status: 'ongoing',
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
avatar: 'https://img1.baidu.com/it/u=2295552459,2083538461&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
nickname: '132****8769',
|
||||
uid: 82683,
|
||||
start_time: '2026-02-01 13:29',
|
||||
stop_time: '2026-02-02 13:29',
|
||||
title: '阿迪达斯官网 adidas BBALL CAP COT 男女训练运动帽子FQ5270',
|
||||
cid: 192,
|
||||
people: 2,
|
||||
count_people: 1,
|
||||
status: 'ongoing',
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
avatar: 'https://img0.baidu.com/it/u=1550993072,4086699313&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
nickname: 'Jk',
|
||||
uid: 82598,
|
||||
start_time: '2026-01-28 16:10',
|
||||
stop_time: '2026-01-29 16:10',
|
||||
title: 'FOMIX 蛋壳椅 进口头层牛皮自然色单人沙发椅 Egg chair设计师蛋椅',
|
||||
cid: 191,
|
||||
people: 2,
|
||||
count_people: 1,
|
||||
status: 'ongoing',
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
avatar: 'https://img1.baidu.com/it/u=3175865615,2002599723&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
nickname: '177****1523',
|
||||
uid: 82565,
|
||||
start_time: '2026-01-27 07:19',
|
||||
stop_time: '2026-01-28 07:19',
|
||||
title: 'FOMIX 蛋壳椅 进口头层牛皮自然色单人沙发椅 Egg chair设计师蛋椅',
|
||||
cid: 191,
|
||||
people: 2,
|
||||
count_people: 1,
|
||||
status: 'ongoing',
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
avatar: 'https://img2.baidu.com/it/u=2719717192,3826027113&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
nickname: '0-1',
|
||||
uid: 79417,
|
||||
start_time: '2026-01-25 23:53',
|
||||
stop_time: '2026-01-26 23:53',
|
||||
title: 'FOMIX 蛋壳椅 进口头层牛皮自然色单人沙发椅 Egg chair设计师蛋椅',
|
||||
cid: 191,
|
||||
people: 2,
|
||||
count_people: 1,
|
||||
status: 'ongoing',
|
||||
},
|
||||
{
|
||||
id: 106,
|
||||
avatar: 'https://img0.baidu.com/it/u=1893322197,2940863863&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
nickname: 'abc',
|
||||
uid: 75343,
|
||||
start_time: '2026-01-22 21:29',
|
||||
stop_time: '2026-01-23 21:29',
|
||||
title: 'FOMIX 蛋壳椅 进口头层牛皮自然色单人沙发椅 Egg chair设计师蛋椅',
|
||||
cid: 191,
|
||||
people: 2,
|
||||
count_people: 1,
|
||||
status: 'ongoing',
|
||||
},
|
||||
{
|
||||
id: 107,
|
||||
avatar: 'https://img2.baidu.com/it/u=176219800,2487920112&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
nickname: '181****6910',
|
||||
uid: 81141,
|
||||
start_time: '2026-01-19 16:16',
|
||||
stop_time: '2026-01-19 16:45',
|
||||
title: 'UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤UWG440060',
|
||||
cid: 190,
|
||||
people: 2,
|
||||
count_people: 1,
|
||||
status: 'pending',
|
||||
},
|
||||
])
|
||||
const combos = ref<CombinationActivity[]>([])
|
||||
const isLoading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
|
||||
const viewDetails = (item: any) => {
|
||||
console.log('查看详情', item.id)
|
||||
const statParticipants = ref(0)
|
||||
const statSuccessGroups = ref(0)
|
||||
|
||||
const activeStatus = ref<string | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadStats()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchCombinationActivities({
|
||||
status: activeStatus.value ?? undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize
|
||||
})
|
||||
combos.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载拼团列表失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const completeGroup = (item: any) => {
|
||||
console.log('立即成团', item.id)
|
||||
async function loadStats() {
|
||||
try {
|
||||
const stats = await getCombinationStats()
|
||||
statParticipants.value = stats.participantCount
|
||||
statSuccessGroups.value = stats.successCount
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const handleStatusChange = (e : any) => {
|
||||
const index = parseInt(String(e.detail.value))
|
||||
const options = [null, 'ongoing', 'pending', 'ended']
|
||||
activeStatus.value = options[index]
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const viewDetails = (item : CombinationActivity) => {
|
||||
console.log('查看详情', item.id)
|
||||
uni.showToast({ title: '详情功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
const handleCompleteGroup = (item : CombinationActivity) => {
|
||||
if (item.id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要手动将其设为成团状态吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
const success = await completeCombinationGroup(item.id!)
|
||||
if (success) {
|
||||
uni.showToast({ title: '操作成功', icon: 'success' })
|
||||
loadData()
|
||||
loadStats()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (combos.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,65 +1,246 @@
|
||||
<template>
|
||||
<AdminLayout currentPage="coupon-receive">
|
||||
<view class="page">
|
||||
<view class="Header">
|
||||
<text class="Title">领取情况</text>
|
||||
<text class="SubTitle">marketing/coupon/receive</text>
|
||||
<view class="marketing-coupon-receive">
|
||||
<!-- 1. 搜索过滤栏 -->
|
||||
<view class="filter-card border-shadow">
|
||||
<view class="filter-row">
|
||||
<view class="filter-item">
|
||||
<text class="label">搜索记录:</text>
|
||||
<input class="input-mock" placeholder="用户名/券码" v-model="searchQuery" @confirm="handleSearch" />
|
||||
</view>
|
||||
<view class="filter-btns">
|
||||
<button class="btn-query" @click="handleSearch">查询</button>
|
||||
<button class="btn-reset" @click="handleReset">重置</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="Card">
|
||||
<text class="Label">页面参数(query)</text>
|
||||
<text class="Mono">{{ params }}</text>
|
||||
<!-- 2. 数据表格 -->
|
||||
<view class="table-card border-shadow">
|
||||
<view class="table-container">
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="table-header">
|
||||
<view class="th cell-id">ID</view>
|
||||
<view class="th cell-user">用户信息</view>
|
||||
<view class="th cell-name">优惠券名称</view>
|
||||
<view class="th cell-code">优惠券码</view>
|
||||
<view class="th cell-status">状态</view>
|
||||
<view class="th cell-time">领取时间</view>
|
||||
<view class="th cell-time">使用时间</view>
|
||||
</view>
|
||||
|
||||
<view class="table-body">
|
||||
<view v-if="recordList.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无领取记录</text>
|
||||
</view>
|
||||
<view v-for="item in recordList" :key="item.id" class="table-row">
|
||||
<view class="td cell-id">
|
||||
<text class="td-txt-small">{{ item.id }}</text>
|
||||
</view>
|
||||
<view class="td cell-user">
|
||||
<view class="u-info">
|
||||
<image class="u-avatar" :src="item.avatar || '/static/logo.png'" mode="aspectFill" />
|
||||
<text class="u-nick">{{ item.nickname || '未知用户' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
<view class="td cell-name">
|
||||
<text class="td-txt">{{ item.coupon_name || '已失效优惠券' }}</text>
|
||||
</view>
|
||||
<view class="td cell-code">
|
||||
<text class="td-txt code-mono">{{ item.coupon_code }}</text>
|
||||
</view>
|
||||
<view class="td cell-status">
|
||||
<text class="status-tag" :class="getStatusClass(item.status)">
|
||||
{{ getStatusLabel(item.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="td cell-time">
|
||||
<text class="td-txt-small">{{ formatDateTime(item.received_at) }}</text>
|
||||
</view>
|
||||
<view class="td cell-time">
|
||||
<text class="td-txt-small">{{ formatDateTime(item.used_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 3. 分页控制 -->
|
||||
<view class="pagination-footer">
|
||||
<view class="page-total">
|
||||
<text class="total-txt">共 {{ total }} 条</text>
|
||||
</view>
|
||||
<view class="page-btns">
|
||||
<text class="p-btn" :class="{ disabled: page <= 1 }" @click="onPrevPage">‹</text>
|
||||
<text class="p-btn active">{{ page }}</text>
|
||||
<text class="p-btn" :class="{ disabled: recordList.length < pageSize }" @click="onNextPage">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
|
||||
import { fetchCouponReceiveRecords, UserCouponRecord } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const recordList = ref<UserCouponRecord[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const total = ref(0)
|
||||
|
||||
const params = ref('')
|
||||
|
||||
onLoad((options) => {
|
||||
// options: Record<string, any>
|
||||
params.value = JSON.stringify(options ?? {})
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchCouponReceiveRecords({
|
||||
search: searchQuery.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize
|
||||
})
|
||||
recordList.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchQuery.value = ''
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (recordList.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status : number) : string {
|
||||
switch (status) {
|
||||
case 1: return '未使用'
|
||||
case 2: return '已使用'
|
||||
case 3: return '已过期'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusClass(status : number) : string {
|
||||
switch (status) {
|
||||
case 1: return 'status-unused'
|
||||
case 2: return 'status-used'
|
||||
case 3: return 'status-expired'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso : string | null) : string {
|
||||
if (!iso) return '-'
|
||||
return iso.substring(0, 16).replace('T', ' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.Page {
|
||||
padding: 24rpx;
|
||||
<style scoped lang="scss">
|
||||
.marketing-coupon-receive {
|
||||
min-height: 100vh;
|
||||
background: #f0f2f5;
|
||||
padding: 16px;
|
||||
}
|
||||
.Header {
|
||||
padding: 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
|
||||
.border-shadow {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.Title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
|
||||
.filter-card { padding: 24px; margin-bottom: 16px; }
|
||||
.filter-row { display: flex; flex-direction: row; align-items: center; gap: 24px; }
|
||||
.filter-item { display: flex; flex-direction: row; align-items: center; }
|
||||
.label { font-size: 14px; color: #606266; }
|
||||
.input-mock {
|
||||
width: 240px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px;
|
||||
padding: 0 12px; font-size: 13px;
|
||||
}
|
||||
.SubTitle {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
opacity: 0.7;
|
||||
.filter-btns { display: flex; flex-direction: row; gap: 12px; }
|
||||
.btn-query { background: #1890ff; color: #fff; height: 32px; padding: 0 16px; border-radius: 4px; font-size: 14px; border: none; cursor: pointer; }
|
||||
.btn-reset { background: #fff; color: #666; height: 32px; padding: 0 16px; border-radius: 4px; font-size: 14px; border: 1px solid #dcdfe6; cursor: pointer; }
|
||||
|
||||
.table-card { padding: 24px; position: relative; }
|
||||
.table-container { min-height: 400px; position: relative; }
|
||||
.loading-mask {
|
||||
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(255,255,255,0.7); display: flex; align-items: center; justify-content: center; z-index: 10;
|
||||
}
|
||||
.Card {
|
||||
margin-top: 24rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
.loading-text { color: #1890ff; font-size: 14px; }
|
||||
|
||||
.table-header {
|
||||
display: flex; flex-direction: row; background-color: #f8faff;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
}
|
||||
.Label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
.th { padding: 12px 8px; font-size: 13px; color: #515a6e; font-weight: bold; text-align: center; }
|
||||
|
||||
.table-row {
|
||||
display: flex; flex-direction: row; border-bottom: 1px solid #e8eaec; align-items: center;
|
||||
&:hover { background-color: #fafafa; }
|
||||
}
|
||||
.Mono {
|
||||
font-size: 24rpx;
|
||||
font-family: monospace;
|
||||
line-height: 36rpx;
|
||||
word-break: break-all;
|
||||
.td { padding: 12px 8px; text-align: center; display: flex; align-items: center; justify-content: center; }
|
||||
.td-txt { font-size: 13px; color: #515a6e; }
|
||||
.td-txt-small { font-size: 12px; color: #999; }
|
||||
.code-mono { font-family: monospace; color: #1890ff; }
|
||||
|
||||
.cell-id { width: 60px; }
|
||||
.cell-user { width: 180px; justify-content: flex-start; }
|
||||
.cell-name { flex: 1; min-width: 150px; }
|
||||
.cell-code { width: 120px; }
|
||||
.cell-status { width: 100px; }
|
||||
.cell-time { width: 150px; }
|
||||
|
||||
.u-info { display: flex; flex-direction: row; align-items: center; gap: 8px; }
|
||||
.u-avatar { width: 32px; height: 32px; border-radius: 16px; background: #f5f5f5; }
|
||||
.u-nick { font-size: 13px; color: #333; }
|
||||
|
||||
.status-tag {
|
||||
padding: 2px 8px; border-radius: 4px; font-size: 12px;
|
||||
&.status-unused { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
|
||||
&.status-used { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
|
||||
&.status-expired { background: #f5f5f5; color: #999; border: 1px solid #d9d9d9; }
|
||||
}
|
||||
|
||||
.pagination-footer {
|
||||
margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px;
|
||||
}
|
||||
.total-txt { font-size: 13px; color: #999; }
|
||||
.page-btns { display: flex; flex-direction: row; gap: 8px; }
|
||||
.p-btn {
|
||||
width: 28px; height: 28px; border: 1px solid #dcdfe6; border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 14px;
|
||||
&.active { background: #1890ff; color: #fff; border-color: #1890ff; }
|
||||
&.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
.empty-row { padding: 60px 0; text-align: center; color: #999; }
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
<view class="filter-card border-shadow">
|
||||
<view class="filter-item">
|
||||
<text class="label-txt">时间选择:</text>
|
||||
<view class="date-picker-mock">
|
||||
<text class="calendar-ic">📅</text>
|
||||
<text class="date-range">2026/01/05 - 2026/02/03</text>
|
||||
</view>
|
||||
<AnalyticsDateRangePicker
|
||||
:initialStartDate="startDate"
|
||||
:initialEndDate="endDate"
|
||||
@apply="onApplyRange"
|
||||
@clear="onClearRange"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -19,7 +21,7 @@
|
||||
<text class="sc-icon">💠</text>
|
||||
</view>
|
||||
<view class="sc-right">
|
||||
<text class="sc-val">744904340.25</text>
|
||||
<text class="sc-val">{{ statsTotal.current.toFixed(2) }}</text>
|
||||
<text class="sc-label">当前积分</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -28,7 +30,7 @@
|
||||
<text class="sc-icon">🪙</text>
|
||||
</view>
|
||||
<view class="sc-right">
|
||||
<text class="sc-val">59026484</text>
|
||||
<text class="sc-val">{{ statsTotal.income.toFixed(2) }}</text>
|
||||
<text class="sc-label">累计总积分</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -37,7 +39,7 @@
|
||||
<text class="sc-icon">💎</text>
|
||||
</view>
|
||||
<view class="sc-right">
|
||||
<text class="sc-val">3189</text>
|
||||
<text class="sc-val">{{ statsTotal.expend.toFixed(2) }}</text>
|
||||
<text class="sc-label">累计消耗积分</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -141,43 +143,102 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import AnalyticsPieChart from '@/components/analytics/AnalyticsPieChart.uvue'
|
||||
import AnalyticsMultiLineChart from '@/components/analytics/AnalyticsMultiLineChart.uvue'
|
||||
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||
import { fetchIntegralStats, IntegralStats } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const dates = ['01-05', '01-06', '01-07', '01-08', '01-09', '01-10', '01-11', '01-12', '01-13', '01-14', '01-15', '01-16', '01-17', '01-18', '01-19', '01-20', '01-21', '01-22', '01-23', '01-24', '01-25', '01-26', '01-27', '01-28', '01-29', '01-30', '01-31', '02-01', '02-02', '02-03']
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const trendSeries = [
|
||||
{
|
||||
name: '积分积累',
|
||||
data: [120, 132, 101, 134, 90, 230, 210, 182, 191, 234, 290, 330, 310, 220, 182, 191, 234, 290, 330, 310, 220, 182, 191, 234, 290, 330, 310, 220, 182, 191],
|
||||
color: '#409eff'
|
||||
},
|
||||
{
|
||||
name: '积分消耗',
|
||||
data: [220, 182, 191, 234, 290, 330, 310, 120, 132, 101, 134, 90, 230, 210, 120, 132, 101, 134, 90, 230, 210, 120, 132, 101, 134, 90, 230, 210, 120, 132],
|
||||
color: '#19be6b'
|
||||
}
|
||||
]
|
||||
const statsTotal = reactive({
|
||||
current: 0,
|
||||
income: 0,
|
||||
expend: 0
|
||||
})
|
||||
|
||||
const dates = ref<string[]>([])
|
||||
const trendSeries = ref<any[]>([])
|
||||
|
||||
const sourceStyle = ref('pie')
|
||||
const consumeStyle = ref('pie')
|
||||
|
||||
const sourceData = [
|
||||
{ label: '后台赠送', value: 59021632, percent: 100, color: '#778899' },
|
||||
{ label: '签到获得', value: 3620, percent: 0, color: '#FFB980' },
|
||||
{ label: '九宫格抽奖', value: 0, percent: 0, color: '#FF7F50' },
|
||||
{ label: '商品赠送', value: 0, percent: 0, color: '#5AB1EF' },
|
||||
{ label: '订单赠送', value: 0, percent: 0, color: '#2EC7C9' }
|
||||
]
|
||||
const sourceData = ref<any[]>([])
|
||||
const consumeData = ref<any[]>([])
|
||||
|
||||
const consumeData = [
|
||||
{ label: '订单抵扣', value: 3051, percent: 95.7, color: '#5AB1EF' },
|
||||
{ label: '九宫格抽奖', value: 138, percent: 4.3, color: '#2EC7C9' },
|
||||
{ label: '兑换商品', value: 0, percent: 0, color: '#FF7F50' },
|
||||
{ label: '后台减少', value: 0, percent: 0, color: '#FFB980' },
|
||||
{ label: '退款退回', value: 0, percent: 0, color: '#D87A80' }
|
||||
]
|
||||
onMounted(() => {
|
||||
// 默认最近 30 天
|
||||
const end = new Date()
|
||||
const start = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
startDate.value = start.toISOString().substring(0, 10)
|
||||
endDate.value = end.toISOString().substring(0, 10)
|
||||
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const st = startDate.value ? (startDate.value + ' 00:00:00') : null
|
||||
const et = endDate.value ? (endDate.value + ' 23:59:59') : null
|
||||
|
||||
const res = await fetchIntegralStats(st!, et!)
|
||||
if (res != null) {
|
||||
// 1. 核心指标
|
||||
statsTotal.current = res.totals.current
|
||||
statsTotal.income = res.totals.income
|
||||
statsTotal.expend = res.totals.expend
|
||||
|
||||
// 2. 趋势图
|
||||
dates.value = res.trend.map(t => t.date_group)
|
||||
trendSeries.value = [
|
||||
{
|
||||
name: '积分积累',
|
||||
data: res.trend.map(t => t.income),
|
||||
color: '#409eff'
|
||||
},
|
||||
{
|
||||
name: '积分消耗',
|
||||
data: res.trend.map(t => t.expend),
|
||||
color: '#19be6b'
|
||||
}
|
||||
]
|
||||
|
||||
// 3. 来源与消耗分布
|
||||
sourceData.value = res.sources.map(s => ({
|
||||
label: s.label,
|
||||
value: s.value,
|
||||
percent: s.percent,
|
||||
color: '#409eff' // 这里可以根据类型映射不同颜色
|
||||
}))
|
||||
|
||||
consumeData.value = res.consumes.map(c => ({
|
||||
label: c.label,
|
||||
value: c.value,
|
||||
percent: c.percent,
|
||||
color: '#19be6b'
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载统计失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onApplyRange(payload : any) {
|
||||
startDate.value = payload?.start ?? ''
|
||||
endDate.value = payload?.end ?? ''
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onClearRange() {
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
loadData()
|
||||
}
|
||||
|
||||
const toggleSourceStyle = () => {
|
||||
sourceStyle.value = sourceStyle.value === 'pie' ? 'list' : 'pie'
|
||||
|
||||
@@ -59,11 +59,11 @@
|
||||
<view class="modal-content">
|
||||
<view class="form-item">
|
||||
<text class="form-label required">主播名称:</text>
|
||||
<input class="form-input" placeholder="请输入主播名称" />
|
||||
<input class="form-input" v-model="formData.nickname" placeholder="请输入主播名称" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label required">主播微信号:</text>
|
||||
<input class="form-input" placeholder="请输入主播微信号" />
|
||||
<input class="form-input" v-model="formData.wechat" placeholder="请输入主播微信号" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label required">主播手机号:</text>
|
||||
@@ -72,7 +72,7 @@
|
||||
<view class="form-item">
|
||||
<text class="form-label">主播图像:</text>
|
||||
<view class="upload-mock" @click="handleUpload">
|
||||
<image v-if="formData.avatar" :src="formData.avatar" mode="aspectFill" class="avatar-preview" />
|
||||
<image v-if="formData.avatar_url" :src="formData.avatar_url" mode="aspectFill" class="avatar-preview" />
|
||||
<text v-else class="upload-ic">🖼️</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -86,45 +86,65 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchLiveAnchors, saveLiveAnchor, deleteLiveAnchor, LiveAnchor } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const showModal = ref(false)
|
||||
const formData = ref({
|
||||
id: 0,
|
||||
name: '',
|
||||
const isLoading = ref(false)
|
||||
const anchorList = ref<LiveAnchor[]>([])
|
||||
|
||||
const formData = ref<LiveAnchor>({
|
||||
nickname: '',
|
||||
wechat: '',
|
||||
phone: '',
|
||||
avatar: ''
|
||||
avatar_url: '',
|
||||
status: true
|
||||
})
|
||||
|
||||
const anchorList = ref([
|
||||
{
|
||||
id: 11,
|
||||
name: '万万',
|
||||
phone: '15012341234',
|
||||
wechat: 'xiao112032014'
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: '打羽毛球',
|
||||
phone: '13333333333',
|
||||
wechat: 'evoxwht'
|
||||
}
|
||||
])
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
formData.value = { ...item, avatar: '' }
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchLiveAnchors()
|
||||
anchorList.value = res
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载主播失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
formData.value = {
|
||||
nickname: '',
|
||||
wechat: '',
|
||||
phone: '',
|
||||
avatar_url: '',
|
||||
status: true
|
||||
} as LiveAnchor
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (item: any) => {
|
||||
function handleEdit(item : LiveAnchor) {
|
||||
formData.value = { ...item } as LiveAnchor
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(item : LiveAnchor) {
|
||||
if (item.id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除该主播吗?',
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
anchorList.value = anchorList.value.filter(i => i.id !== item.id)
|
||||
const success = await deleteLiveAnchor(item.id!)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -134,14 +154,27 @@ const handleUpload = () => {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
formData.value.avatar = res.tempFilePaths[0]
|
||||
formData.value.avatar_url = res.tempFilePaths[0]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.nickname || !formData.value.phone) {
|
||||
uni.showToast({ title: '请填写必填项', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await saveLiveAnchor(formData.value)
|
||||
if (success) {
|
||||
uni.showToast({ title: '操作成功', icon: 'success' })
|
||||
showModal.value = false
|
||||
loadData()
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '系统异常', icon: 'none' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -112,73 +112,75 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchLiveProducts, saveLiveProduct, deleteLiveProduct, LiveProduct } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const isAdding = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const productList = ref<LiveProduct[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const searchQuery = ref('')
|
||||
const auditStatus = ref(0) // 0:全部
|
||||
|
||||
const selectedList = ref([
|
||||
{ image: 'https://img0.baidu.com/it/u=3033502919,1657850259&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500' }
|
||||
])
|
||||
|
||||
const productList = ref([
|
||||
{
|
||||
id: 92,
|
||||
image: 'https://img0.baidu.com/it/u=3023224345,1529124233&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
title: '绣球永生花网红干花花束大',
|
||||
live_price: 149.00,
|
||||
price: 149.00,
|
||||
stock: 10617,
|
||||
audit_status: '审核通过',
|
||||
is_show: false
|
||||
},
|
||||
{
|
||||
id: 89,
|
||||
image: 'https://img1.baidu.com/it/u=3175865615,2002599723&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
title: '家居梵高系列款软版盒袋',
|
||||
live_price: 350.00,
|
||||
price: 350.00,
|
||||
stock: 8625,
|
||||
audit_status: '审核通过',
|
||||
is_show: false
|
||||
},
|
||||
{
|
||||
id: 93,
|
||||
image: 'https://img2.baidu.com/it/u=2719717192,3826027113&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
title: '【LESHUCANGHU',
|
||||
live_price: 300.00,
|
||||
price: 300.00,
|
||||
stock: 164,
|
||||
audit_status: '审核通过',
|
||||
is_show: false
|
||||
},
|
||||
{
|
||||
id: 116,
|
||||
image: 'https://img0.baidu.com/it/u=2257917711,1359654032&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
title: '爱奇艺智能 奇遇LT01',
|
||||
live_price: 1199.00,
|
||||
price: 1199.00,
|
||||
stock: 6287,
|
||||
audit_status: '审核通过',
|
||||
is_show: false
|
||||
}
|
||||
])
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
const toggleStatus = (item: any) => {
|
||||
item.is_show = !item.is_show
|
||||
uni.showToast({ title: '状态修改成功', icon: 'success' })
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchLiveProducts({
|
||||
search: searchQuery.value,
|
||||
audit_status: auditStatus,
|
||||
page: page.value,
|
||||
pageSize: pageSize
|
||||
})
|
||||
productList.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function toggleStatus(item : LiveProduct) {
|
||||
if (item.id == null) return
|
||||
const nextShow = !item.is_show
|
||||
const success = await saveLiveProduct({ ...item, is_show: nextShow } as LiveProduct)
|
||||
if (success) {
|
||||
item.is_show = nextShow
|
||||
uni.showToast({ title: '状态修改成功', icon: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(item : LiveProduct) {
|
||||
uni.showToast({ title: '详情查看中', icon: 'none' })
|
||||
}
|
||||
|
||||
const handleDelete = (item: any) => {
|
||||
async function handleDelete(item : LiveProduct) {
|
||||
if (item.id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除该商品吗?',
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
productList.value = productList.value.filter(i => i.id !== item.id)
|
||||
const success = await deleteLiveProduct(item.id!)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -188,7 +190,7 @@ const handleAddProduct = () => {
|
||||
uni.showToast({ title: '选择商品功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
const removeSelected = (index: number) => {
|
||||
const removeSelected = (index : number) => {
|
||||
selectedList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -196,6 +198,29 @@ const handleGenerate = () => {
|
||||
uni.showToast({ title: '生成成功', icon: 'success' })
|
||||
isAdding.value = false
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (productList.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function getAuditStatusLabel(status : number) : string {
|
||||
switch (status) {
|
||||
case 1: return '待审核'
|
||||
case 2: return '审核通过'
|
||||
case 3: return '审核驳回'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -200,124 +200,182 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
fetchLiveRooms,
|
||||
saveLiveRoom,
|
||||
deleteLiveRoom,
|
||||
fetchLiveAnchors,
|
||||
LiveRoom,
|
||||
LiveAnchor
|
||||
} from '@/services/admin/marketingService.uts'
|
||||
|
||||
const showDrawer = ref(false)
|
||||
const isAnimating = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const formData = ref({
|
||||
anchor_nick: '',
|
||||
const roomList = ref<LiveRoom[]>([])
|
||||
const anchorList = ref<LiveAnchor[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const searchQuery = ref('')
|
||||
|
||||
const formData = ref<LiveRoom>({
|
||||
name: '',
|
||||
background: '',
|
||||
share_img: '',
|
||||
phone: '',
|
||||
anchor_id: null,
|
||||
anchor_nick: '',
|
||||
background_url: '',
|
||||
share_img_url: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
sort: 0,
|
||||
type: 'phone',
|
||||
like_enabled: true,
|
||||
sale_enabled: true,
|
||||
comment_enabled: true
|
||||
comment_enabled: true,
|
||||
is_show: true,
|
||||
live_status: 1
|
||||
})
|
||||
|
||||
const roomList = ref([
|
||||
{
|
||||
id: 88,
|
||||
name: 'CRMEB 年中618活动开始',
|
||||
anchor_nick: '打羽毛球',
|
||||
anchor_wechat: 'evoxwht',
|
||||
start_time: '2025-06-17 00:00:00',
|
||||
end_time: '2025-06-18 00:00:00',
|
||||
create_time: '2025-06-16 14:56:53',
|
||||
is_show: true,
|
||||
live_status: '已结束',
|
||||
sort: 1
|
||||
},
|
||||
{
|
||||
id: 90,
|
||||
name: '123456789',
|
||||
anchor_nick: '万万',
|
||||
anchor_wechat: 'xiao112032014',
|
||||
start_time: '2025-07-07 10:20:00',
|
||||
end_time: '2025-07-07 12:00:00',
|
||||
create_time: '2025-07-07 10:05:43',
|
||||
is_show: true,
|
||||
live_status: '已结束',
|
||||
sort: 0
|
||||
},
|
||||
{
|
||||
id: 89,
|
||||
name: '测试1111111',
|
||||
anchor_nick: '打羽毛球',
|
||||
anchor_wechat: '',
|
||||
start_time: '2025-05-20 14:50:00',
|
||||
end_time: '2025-05-20 15:22:00',
|
||||
create_time: '2025-06-17 10:03:08',
|
||||
is_show: true,
|
||||
live_status: '已结束',
|
||||
sort: 0
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: '开学季,最后一天',
|
||||
anchor_nick: '等风来',
|
||||
anchor_wechat: 'welalnidaobel',
|
||||
start_time: '2021-09-01 19:00:00',
|
||||
end_time: '2021-09-01 20:00:00',
|
||||
create_time: '2021-08-30 11:53:01',
|
||||
is_show: false,
|
||||
live_status: '已结束',
|
||||
sort: 0
|
||||
}
|
||||
])
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadAnchors()
|
||||
})
|
||||
|
||||
const toggleStatus = (item: any) => {
|
||||
item.is_show = !item.is_show
|
||||
uni.showToast({ title: '状态修改成功', icon: 'success' })
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchLiveRooms({
|
||||
search: searchQuery.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize
|
||||
})
|
||||
roomList.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载直播间失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
formData.value = { ...item, like_enabled: true, sale_enabled: true, comment_enabled: true }
|
||||
async function loadAnchors() {
|
||||
anchorList.value = await fetchLiveAnchors()
|
||||
}
|
||||
|
||||
async function toggleStatus(item : LiveRoom) {
|
||||
if (item.id == null) return
|
||||
const nextStatus = !item.is_show
|
||||
const success = await saveLiveRoom({ ...item, is_show: nextStatus } as LiveRoom)
|
||||
if (success) {
|
||||
item.is_show = nextStatus
|
||||
uni.showToast({ title: '状态修改成功', icon: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
formData.value = {
|
||||
name: '',
|
||||
anchor_id: null,
|
||||
anchor_nick: '',
|
||||
background_url: '',
|
||||
share_img_url: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
sort: 0,
|
||||
type: 'phone',
|
||||
like_enabled: true,
|
||||
sale_enabled: true,
|
||||
comment_enabled: true,
|
||||
is_show: true,
|
||||
live_status: 1
|
||||
} as LiveRoom
|
||||
showDrawer.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (item: any) => {
|
||||
function handleEdit(item : LiveRoom) {
|
||||
formData.value = { ...item } as LiveRoom
|
||||
showDrawer.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(item : LiveRoom) {
|
||||
if (item.id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除该直播间吗?',
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
roomList.value = roomList.value.filter(i => i.id !== item.id)
|
||||
const success = await deleteLiveRoom(item.id!)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAnchor = () => {
|
||||
uni.showToast({ title: '功能开发中', icon: 'none' })
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleUpload = (type: string) => {
|
||||
async function handleSubmit() {
|
||||
if (!formData.value.name || !formData.value.start_time) {
|
||||
uni.showToast({ title: '请填写必填项', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const success = await saveLiveRoom(formData.value)
|
||||
if (success) {
|
||||
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||
closeDrawer()
|
||||
loadData()
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectAnchor() {
|
||||
const nicks = anchorList.value.map(a => a.nickname)
|
||||
if (nicks.length === 0) {
|
||||
uni.showToast({ title: '请先添加主播', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showActionSheet({
|
||||
itemList: nicks,
|
||||
success: (res) => {
|
||||
const anchor = anchorList.value[res.tapIndex]
|
||||
formData.value.anchor_id = anchor.id ?? null
|
||||
formData.value.anchor_nick = anchor.nickname
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleUpload(field: string) {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
if (type === 'background') {
|
||||
formData.value.background = res.tempFilePaths[0]
|
||||
if (field === 'background') {
|
||||
formData.value.background_url = res.tempFilePaths[0]
|
||||
} else {
|
||||
formData.value.share_img = res.tempFilePaths[0]
|
||||
formData.value.share_img_url = res.tempFilePaths[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenDatePicker = () => {
|
||||
uni.showToast({ title: '日期选择功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||
closeDrawer()
|
||||
function handleOpenDatePicker() {
|
||||
// 简化处理,实际推荐使用日期选择组件
|
||||
uni.showToast({ title: '请在输入框直接修改时间', icon: 'none' })
|
||||
}
|
||||
|
||||
const closeDrawer = () => {
|
||||
@@ -327,6 +385,20 @@ const closeDrawer = () => {
|
||||
isAnimating.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (roomList.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -4,29 +4,31 @@
|
||||
<view class="filter-card box-shadow">
|
||||
<view class="filter-row">
|
||||
<view class="filter-item">
|
||||
<text class="label">活动时间:</text>
|
||||
<view class="date-range-mock">
|
||||
<text class="date-text">开始日期 - 结束日期</text>
|
||||
<text class="iconfont icon-calendar"></text>
|
||||
</view>
|
||||
<text class="label">活动时段:</text>
|
||||
<AnalyticsDateRangePicker
|
||||
:initialStartDate="startDate"
|
||||
:initialEndDate="endDate"
|
||||
@apply="onApplyRange"
|
||||
@clear="onClearRange"
|
||||
/>
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<text class="label">活动状态:</text>
|
||||
<picker :range="statusOptions" range-key="label" @change="statusChange">
|
||||
<view class="picker-value">{{ getStatusLabel(currentStatus) }} <text class="iconfont icon-arrow-down"></text></view>
|
||||
<view class="picker-value">{{ statusOptions[statusIndex].label }} <text class="iconfont icon-arrow-down">▼</text></view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<text class="label">活动类型:</text>
|
||||
<picker :range="typeOptions" range-key="label" @change="typeChange">
|
||||
<view class="picker-value">{{ getTypeLabel(currentType) }} <text class="iconfont icon-arrow-down"></text></view>
|
||||
<view class="picker-value">{{ typeOptions[typeIndex].label }} <text class="iconfont icon-arrow-down">▼</text></view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
<view class="filter-row second-row">
|
||||
<view class="filter-item">
|
||||
<text class="label">搜索抽奖:</text>
|
||||
<input class="admin-input" placeholder="请输入活动名称" v-model="searchQuery" style="width: 200px;" />
|
||||
<input class="admin-input" placeholder="请输入活动名称" v-model="searchQuery" style="width: 200px;" @confirm="handleSearch" />
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<button class="admin-btn admin-btn-primary search-btn" @click="handleSearch">搜索</button>
|
||||
@@ -36,11 +38,17 @@
|
||||
|
||||
<!-- 操作工具栏 -->
|
||||
<view class="table-toolbar">
|
||||
<button class="admin-btn admin-btn-primary">创建抽奖活动</button>
|
||||
<button class="admin-btn admin-btn-primary" @click="handleAdd">创建抽奖活动</button>
|
||||
</view>
|
||||
|
||||
<!-- 表格区域 -->
|
||||
<view class="table-card box-shadow">
|
||||
<view class="table-container">
|
||||
<!-- Loading 遮罩 -->
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">数据加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="table-header">
|
||||
<text class="col-60 center">ID</text>
|
||||
<text class="col-120">活动名称</text>
|
||||
@@ -56,32 +64,36 @@
|
||||
</view>
|
||||
|
||||
<view class="table-body">
|
||||
<view v-if="tableData.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无抽奖活动数据</text>
|
||||
</view>
|
||||
<view v-for="(item, index) in tableData" :key="index" class="table-row">
|
||||
<view class="col-60 center"><text class="id-text">{{ item.id }}</text></view>
|
||||
<view class="col-120"><text class="cell-text">{{ item.name }}</text></view>
|
||||
<view class="col-100"><text class="cell-text">{{ item.typeName }}</text></view>
|
||||
<view class="col-80 center"><text class="cell-text">{{ item.memberCount }}</text></view>
|
||||
<view class="col-80 center"><text class="cell-text">{{ item.winningMemberCount }}</text></view>
|
||||
<view class="col-80 center"><text class="cell-text">{{ item.lotteryTimes }}</text></view>
|
||||
<view class="col-80 center"><text class="cell-text">{{ item.winningTimes }}</text></view>
|
||||
<view class="col-100"><text class="cell-text">{{ getTypeName(item.type) }}</text></view>
|
||||
<view class="col-80 center"><text class="cell-text">{{ item.memberCount ?? 0 }}</text></view>
|
||||
<view class="col-80 center"><text class="cell-text">{{ item.winningMemberCount ?? 0 }}</text></view>
|
||||
<view class="col-80 center"><text class="cell-text">{{ item.lotteryTimes ?? 0 }}</text></view>
|
||||
<view class="col-80 center"><text class="cell-text">{{ item.winningTimes ?? 0 }}</text></view>
|
||||
<view class="col-80 center">
|
||||
<text class="status-text">{{ item.statusText }}</text>
|
||||
<text class="status-text">{{ getStatusLabel(item) }}</text>
|
||||
</view>
|
||||
<view class="col-80 center">
|
||||
<view class="switch-box" :class="item.isOpen ? 'active' : ''" @click="toggleStatus(item)">
|
||||
<view class="switch-box" :class="item.is_open ? 'active' : ''" @click="toggleStatus(item)">
|
||||
<view class="switch-dot"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="col-180">
|
||||
<view class="time-range">
|
||||
<text class="time-label">开始: {{ item.startTime }}</text>
|
||||
<text class="time-label">结束: {{ item.endTime }}</text>
|
||||
<text class="time-label">始: {{ formatDateTime(item.start_time) }}</text>
|
||||
<text class="time-label">终: {{ formatDateTime(item.end_time) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="col-120 center ops-cell">
|
||||
<text class="op-link">编辑</text>
|
||||
<text class="op-link">抽奖记录</text>
|
||||
<text class="op-link">更多 <text class="iconfont icon-arrow-down" style="font-size: 10px;"></text></text>
|
||||
<text class="op-link" @click="handleEdit(item)">编辑</text>
|
||||
<text class="op-link" @click="handleRecords(item)">记录</text>
|
||||
<text class="op-link danger" @click="handleDelete(item.id)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -90,126 +102,170 @@
|
||||
<view class="table-pagination">
|
||||
<text class="total-text">共 {{ total }} 条</text>
|
||||
<view class="page-ops">
|
||||
<picker :range="pageSizes" @change="pageSizeChange">
|
||||
<view class="size-picker">{{ currentSize }}条/页 <text class="iconfont icon-arrow-down"></text></view>
|
||||
</picker>
|
||||
<button class="page-btn" disabled>上一页</button>
|
||||
<text class="current-page">1</text>
|
||||
<button class="page-btn" disabled>下一页</button>
|
||||
<text class="jump-text">前往</text>
|
||||
<input class="jump-input" value="1" />
|
||||
<text class="jump-text">页</text>
|
||||
<button class="page-btn" :disabled="page <= 1" @click="onPrevPage">上一页</button>
|
||||
<text class="current-page">{{ page }}</text>
|
||||
<button class="page-btn" :disabled="tableData.length < pageSize" @click="onNextPage">下一页</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script uts>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
currentStatus: 0,
|
||||
currentType: 0,
|
||||
total: 4,
|
||||
currentSize: 15,
|
||||
pageSizes: ['15条/页', '30条/页', '50条/页'],
|
||||
statusOptions: [
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||
import { fetchLotteryList, saveLotteryActivity, deleteLotteryActivity, LotteryActivity } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const isLoading = ref(false)
|
||||
const total = ref(0)
|
||||
const tableData = ref<LotteryActivity[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: 0 },
|
||||
{ label: '未开始', value: 1 },
|
||||
{ label: '进行中', value: 2 },
|
||||
{ label: '已结束', value: 3 }
|
||||
] as any[],
|
||||
typeOptions: [
|
||||
{ label: '开启', value: 1 },
|
||||
{ label: '关闭', value: 2 }
|
||||
]
|
||||
const statusIndex = ref(0)
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '全部', value: 0 },
|
||||
{ label: '积分抽奖', value: 1 },
|
||||
{ label: '订单评价', value: 2 },
|
||||
{ label: '订单支付', value: 3 }
|
||||
] as any[],
|
||||
tableData: [
|
||||
{
|
||||
id: 87,
|
||||
name: '积分抽奖',
|
||||
typeName: '积分抽取',
|
||||
memberCount: 166,
|
||||
winningMemberCount: 0,
|
||||
lotteryTimes: 329,
|
||||
winningTimes: 0,
|
||||
statusText: '已结束',
|
||||
isOpen: true,
|
||||
startTime: '2025-12-04 00:00:00',
|
||||
endTime: '2026-01-31 23:59:59'
|
||||
},
|
||||
{
|
||||
id: 86,
|
||||
name: '评价抽奖',
|
||||
typeName: '订单评价',
|
||||
memberCount: 3,
|
||||
winningMemberCount: 3,
|
||||
lotteryTimes: 4,
|
||||
winningTimes: 3,
|
||||
statusText: '已结束',
|
||||
isOpen: true,
|
||||
startTime: '2023-12-12 00:00:00',
|
||||
endTime: '2024-01-16 23:59:59'
|
||||
},
|
||||
{
|
||||
id: 82,
|
||||
name: '订单抽奖',
|
||||
typeName: '订单支付',
|
||||
memberCount: 100,
|
||||
winningMemberCount: 5,
|
||||
lotteryTimes: 124,
|
||||
winningTimes: 6,
|
||||
statusText: '已结束',
|
||||
isOpen: true,
|
||||
startTime: '2023-08-16 00:00:00',
|
||||
endTime: '2024-01-31 23:59:59'
|
||||
},
|
||||
{
|
||||
id: 75,
|
||||
name: '积分',
|
||||
typeName: '积分抽取',
|
||||
memberCount: 1288,
|
||||
winningMemberCount: 1130,
|
||||
lotteryTimes: 3413,
|
||||
winningTimes: 2628,
|
||||
statusText: '已结束',
|
||||
isOpen: true,
|
||||
startTime: '2025-10-01 00:00:00',
|
||||
endTime: '2025-11-30 23:59:59'
|
||||
]
|
||||
const typeIndex = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const st = startDate.value ? (startDate.value + ' 00:00:00') : null
|
||||
const et = endDate.value ? (endDate.value + ' 23:59:59') : null
|
||||
|
||||
const res = await fetchLotteryList({
|
||||
search: searchQuery.value,
|
||||
status: statusOptions[statusIndex.value].value,
|
||||
type: typeOptions[typeIndex.value].value,
|
||||
page: page.value,
|
||||
pageSize: pageSize,
|
||||
startTime: st,
|
||||
endTime: et
|
||||
} as any)
|
||||
tableData.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
] as any[]
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onStatusChange(e : any) {
|
||||
statusIndex.value = parseInt(String(e.detail.value))
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function onTypeChange(e : any) {
|
||||
typeIndex.value = parseInt(String(e.detail.value))
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
async function toggleStatus(item : LotteryActivity) {
|
||||
if (item.id == null) return
|
||||
const nextOpen = !item.is_open
|
||||
const success = await saveLotteryActivity({ ...item, is_open: nextOpen } as LotteryActivity)
|
||||
if (success) {
|
||||
item.is_open = nextOpen
|
||||
uni.showToast({ title: '状态已变更', icon: 'success' })
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStatusLabel(val : number) : string {
|
||||
const found = this.statusOptions.find((item : any) : boolean => item.value == val)
|
||||
return found != null ? (found['label'] as string) : '全部'
|
||||
},
|
||||
getTypeLabel(val : number) : string {
|
||||
const found = this.typeOptions.find((item : any) : boolean => item.value == val)
|
||||
return found != null ? (found['label'] as string) : '全部'
|
||||
},
|
||||
statusChange(e : any) {
|
||||
this.currentStatus = this.statusOptions[e.detail.value].value
|
||||
},
|
||||
typeChange(e : any) {
|
||||
this.currentType = this.typeOptions[e.detail.value].value
|
||||
},
|
||||
pageSizeChange(e : any) {
|
||||
const val = this.pageSizes[e.detail.value]
|
||||
this.currentSize = parseInt(val.replace('条/页', ''))
|
||||
},
|
||||
handleSearch() {
|
||||
uni.showToast({ title: '搜索', icon: 'none' })
|
||||
},
|
||||
toggleStatus(item : any) {
|
||||
item.isOpen = !item.isOpen
|
||||
uni.showToast({ title: '状态已变更', icon: 'none' })
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
uni.showToast({ title: '添加功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function handleEdit(item : LotteryActivity) {
|
||||
uni.showToast({ title: '编辑功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function handleRecords(item : LotteryActivity) {
|
||||
uni.showToast({ title: '记录功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
async function handleDelete(id : string | undefined) {
|
||||
if (id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除该抽奖活动吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
const success = await deleteLotteryActivity(id!)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onApplyRange(payload : any) {
|
||||
startDate.value = payload?.start ?? ''
|
||||
endDate.value = payload?.end ?? ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function onClearRange() {
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (tableData.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeName(type : number) : string {
|
||||
const found = typeOptions.find(o => o.value === type)
|
||||
return found != null ? found.label : '未知'
|
||||
}
|
||||
|
||||
function getStatusLabel(item : LotteryActivity) : string {
|
||||
const now = Date.now()
|
||||
const start = new Date(item.start_time).getTime()
|
||||
const end = new Date(item.end_time).getTime()
|
||||
|
||||
if (now < start) return '未开始'
|
||||
if (now > end) return '已结束'
|
||||
return '进行中'
|
||||
}
|
||||
|
||||
function formatDateTime(iso : string) : string {
|
||||
return iso.substring(0, 16).replace('T', ' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -253,21 +309,8 @@ export default {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-range-mock {
|
||||
width: 280px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.date-text { font-size: 13px; color: #c0c4cc; }
|
||||
|
||||
.picker-value {
|
||||
width: 160px;
|
||||
min-width: 120px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dcdfe6;
|
||||
@@ -277,6 +320,7 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.admin-input {
|
||||
@@ -293,6 +337,20 @@ export default {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.admin-btn-primary {
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 表格工具栏 */
|
||||
.table-toolbar {
|
||||
margin-bottom: 16px;
|
||||
@@ -301,7 +359,18 @@ export default {
|
||||
/* 表格主体 */
|
||||
.table-card {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.table-container {
|
||||
min-height: 400px;
|
||||
position: relative;
|
||||
}
|
||||
.loading-mask {
|
||||
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(255,255,255,0.7); display: flex; align-items: center; justify-content: center; z-index: 10;
|
||||
}
|
||||
.loading-text { color: #1890ff; font-size: 14px; }
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -321,6 +390,7 @@ export default {
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.table-row:hover { background-color: #fafafa; }
|
||||
|
||||
/* 列定义 */
|
||||
.col-60 { width: 60px; }
|
||||
@@ -328,11 +398,11 @@ export default {
|
||||
.col-100 { width: 100px; }
|
||||
.col-120 { width: 120px; }
|
||||
.col-180 { width: 180px; }
|
||||
.center { text-align: center; justify-content: center; }
|
||||
.center { text-align: center; justify-content: center; display: flex; align-items: center; }
|
||||
|
||||
.cell-text { font-size: 13px; color: #515a6e; }
|
||||
.id-text { font-size: 13px; color: #808695; }
|
||||
.status-text { font-size: 13px; color: #515a6e; }
|
||||
.status-text { font-size: 13px; color: #1890ff; }
|
||||
|
||||
/* 开关组件 */
|
||||
.switch-box {
|
||||
@@ -355,10 +425,10 @@ export default {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.3s;
|
||||
transition: left 0.3s;
|
||||
}
|
||||
.switch-box.active .switch-dot {
|
||||
transform: translateX(20px);
|
||||
left: 22px;
|
||||
}
|
||||
|
||||
.time-range {
|
||||
@@ -374,13 +444,14 @@ export default {
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
.op-link {
|
||||
font-size: 13px;
|
||||
color: #2d8cf0;
|
||||
margin: 0 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.op-link.danger { color: #f5222d; }
|
||||
|
||||
/* 分页 */
|
||||
.table-pagination {
|
||||
@@ -399,47 +470,22 @@ export default {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.size-picker {
|
||||
font-size: 14px;
|
||||
color: #515a6e;
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 15px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-btn {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 8px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
background-color: #fff;
|
||||
}
|
||||
.current-page {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
background-color: #2d8cf0;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
border-radius: 2px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
.jump-text { font-size: 14px; color: #515a6e; margin: 0 5px; }
|
||||
.jump-input {
|
||||
width: 40px;
|
||||
height: 28px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.page-btn[disabled] { opacity: 0.5; }
|
||||
.current-page {
|
||||
font-size: 14px;
|
||||
color: #2d8cf0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.empty-row { padding: 60px 0; text-align: center; color: #999; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -69,21 +69,72 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { fetchMemberConfig, saveMemberConfig, MemberConfig } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const isSaving = ref(false)
|
||||
|
||||
const config = reactive({
|
||||
is_open: true,
|
||||
bg_img: 'https://demo26.crmeb.net/uploads/attach/2021/11/20211115/a6f3b06e9d6d5a1b3c9d6d5a1b3c9d6d.png',
|
||||
bg_img: '',
|
||||
expire_bg_img: '',
|
||||
rules: '1. 会员有效期自购买之日起计算\n2. 会员权益仅限本人使用\n3. 会员卡一经售出,概不退换'
|
||||
rules: ''
|
||||
})
|
||||
|
||||
const handleUpload = (type: string) => {
|
||||
uni.showToast({ title: '文件管理器暂未开启', icon: 'none' })
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await fetchMemberConfig()
|
||||
if (res != null) {
|
||||
config.is_open = res.is_enabled
|
||||
config.bg_img = res.bg_img_url ?? ''
|
||||
config.expire_bg_img = res.expire_bg_img_url ?? ''
|
||||
config.rules = res.rules_description ?? ''
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载配置失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const handleUpload = (type: string) => {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
if (type === 'bg') {
|
||||
config.bg_img = res.tempFilePaths[0]
|
||||
} else {
|
||||
config.expire_bg_img = res.tempFilePaths[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (isSaving.value) return
|
||||
|
||||
isSaving.value = true
|
||||
const payload : MemberConfig = {
|
||||
is_enabled: config.is_open,
|
||||
bg_img_url: config.bg_img,
|
||||
expire_bg_img_url: config.expire_bg_img,
|
||||
rules_description: config.rules
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await saveMemberConfig(payload)
|
||||
if (success) {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '系统异常', icon: 'none' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
<view class="marketing-member-right">
|
||||
<view class="table-card border-shadow">
|
||||
<view class="table-container">
|
||||
<!-- Loading 遮罩 -->
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="table-head">
|
||||
<view class="th cell-id">ID</view>
|
||||
<view class="th cell-icon">权益图标</view>
|
||||
@@ -13,20 +18,23 @@
|
||||
</view>
|
||||
|
||||
<view class="table-body">
|
||||
<view v-if="memberRights.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无权益配置</text>
|
||||
</view>
|
||||
<view v-for="item in memberRights" :key="item.id" class="table-row">
|
||||
<view class="td cell-id"><text class="td-txt">{{ item.id }}</text></view>
|
||||
<view class="td cell-icon">
|
||||
<image class="right-icon" :src="item.icon" mode="aspectFit"></image>
|
||||
<image class="right-icon" :src="item.icon_url || '/static/logo.png'" mode="aspectFit"></image>
|
||||
</view>
|
||||
<view class="td cell-name"><text class="td-txt">{{ item.name }}</text></view>
|
||||
<view class="td cell-desc"><text class="td-txt">{{ item.desc }}</text></view>
|
||||
<view class="td cell-desc"><text class="td-txt">{{ item.description || '-' }}</text></view>
|
||||
<view class="td cell-status">
|
||||
<view class="switch-mock" :class="{ active: item.is_show }" @click="toggleStatus(item)">
|
||||
<view class="switch-dot"></view>
|
||||
<text class="switch-txt">{{ item.is_show ? '显示' : '隐藏' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="td cell-sort"><text class="td-txt">{{ item.sort }}</text></view>
|
||||
<view class="td cell-sort"><text class="td-txt">{{ item.sort_order }}</text></view>
|
||||
<view class="td cell-op">
|
||||
<text class="op-link" @click="handleEdit(item)">编辑</text>
|
||||
</view>
|
||||
@@ -38,20 +46,39 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchMemberRights, saveMemberRight, MemberRight } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const memberRights = ref([
|
||||
{ id: 9, icon: 'https://demo26.crmeb.net/uploads/attach/2021/11/20211115/a6f3b06e9d6d5a1b3c9d6d5a1b3c9d6d.png', name: '运费券', desc: '每月领取运费券', is_show: true, sort: 10 },
|
||||
{ id: 8, icon: 'https://demo26.crmeb.net/uploads/attach/2021/11/20211115/a6f3b06e9d6d5a1b3c9d6d5a1b3c9d6d.png', name: '充值优惠', desc: '充值立减优惠', is_show: true, sort: 8 },
|
||||
{ id: 7, icon: 'https://demo26.crmeb.net/uploads/attach/2021/11/20211115/a6f3b06e9d6d5a1b3c9d6d5a1b3c9d6d.png', name: '积分翻倍', desc: '购物获取双倍积分', is_show: true, sort: 7 }
|
||||
])
|
||||
const memberRights = ref<MemberRight[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
const toggleStatus = (item: any) => {
|
||||
item.is_show = !item.is_show
|
||||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchMemberRights()
|
||||
memberRights.value = res
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
async function toggleStatus(item : MemberRight) {
|
||||
if (item.id == null) return
|
||||
const nextStatus = !item.is_show
|
||||
const success = await saveMemberRight({ ...item, is_show: nextStatus } as MemberRight)
|
||||
if (success) {
|
||||
item.is_show = nextStatus
|
||||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (item: MemberRight) => {
|
||||
uni.showToast({ title: '编辑功能开发中', icon: 'none' })
|
||||
}
|
||||
</script>
|
||||
@@ -136,6 +163,31 @@ const handleEdit = (item: any) => {
|
||||
.switch-mock.active .switch-txt { margin-left: 4px; }
|
||||
|
||||
.op-link { color: #1890ff; font-size: 13px; cursor: pointer; }
|
||||
|
||||
/* Loading & Empty Styles */
|
||||
.table-container {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.loading-mask {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-text { color: #1890ff; font-size: 14px; }
|
||||
|
||||
.empty-row {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -13,20 +13,41 @@
|
||||
<view class="th cell-op">操作</view>
|
||||
</view>
|
||||
|
||||
<!-- 表格主体 -->
|
||||
<view class="table-container">
|
||||
<!-- Loading 遮罩 -->
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">数据加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="table-header">
|
||||
<view class="th cell-id">ID</view>
|
||||
<view class="th cell-name">会员名</view>
|
||||
<view class="th cell-days">有效期(天)</view>
|
||||
<view class="th cell-price">原价</view>
|
||||
<view class="th cell-discount">优惠价</view>
|
||||
<view class="th cell-status">是否开启</view>
|
||||
<view class="th cell-sort">排序</view>
|
||||
<view class="th cell-op">操作</view>
|
||||
</view>
|
||||
|
||||
<view class="table-body">
|
||||
<view v-if="memberTypes.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无会员类型配置</text>
|
||||
</view>
|
||||
<view v-for="item in memberTypes" :key="item.id" class="table-row">
|
||||
<view class="td cell-id"><text class="td-txt">{{ item.id }}</text></view>
|
||||
<view class="td cell-name"><text class="td-txt">{{ item.name }}</text></view>
|
||||
<view class="td cell-days"><text class="td-txt">{{ item.days }}</text></view>
|
||||
<view class="td cell-days"><text class="td-txt">{{ item.duration_days == 0 ? '永久' : item.duration_days }}</text></view>
|
||||
<view class="td cell-price"><text class="td-txt">¥{{ item.price.toFixed(2) }}</text></view>
|
||||
<view class="td cell-discount"><text class="td-txt">¥{{ item.discount.toFixed(2) }}</text></view>
|
||||
<view class="td cell-discount"><text class="td-txt">¥{{ item.discount_price.toFixed(2) }}</text></view>
|
||||
<view class="td cell-status">
|
||||
<view class="switch-mock" :class="{ active: item.is_open }" @click="toggleStatus(item)">
|
||||
<view class="switch-dot"></view>
|
||||
<text class="switch-txt">{{ item.is_open ? '开启' : '关闭' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="td cell-sort"><text class="td-txt">{{ item.sort }}</text></view>
|
||||
<view class="td cell-sort"><text class="td-txt">{{ item.sort_order }}</text></view>
|
||||
<view class="td cell-op">
|
||||
<text class="op-link" @click="handleEdit(item)">编辑</text>
|
||||
</view>
|
||||
@@ -35,25 +56,43 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchMemberTypes, saveMemberType, MemberType } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const memberTypes = ref([
|
||||
{ id: 5, name: '44555', days: '12', price: 69.00, discount: 0.00, is_open: true, sort: 55 },
|
||||
{ id: 4, name: '5566', days: '永久', price: 1080.00, discount: 1080.00, is_open: true, sort: 5 },
|
||||
{ id: 3, name: '年卡', days: '365', price: 99.00, discount: 0.01, is_open: true, sort: 5 },
|
||||
{ id: 2, name: '55', days: '90', price: 699.00, discount: 499.00, is_open: true, sort: 5 },
|
||||
{ id: 1, name: '55', days: '30', price: 699.00, discount: 499.00, is_open: true, sort: 5 }
|
||||
])
|
||||
const memberTypes = ref<MemberType[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
const toggleStatus = (item: any) => {
|
||||
item.is_open = !item.is_open
|
||||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchMemberTypes()
|
||||
memberTypes.value = res
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
async function toggleStatus(item : MemberType) {
|
||||
if (item.id == null) return
|
||||
const nextStatus = !item.is_open
|
||||
const success = await saveMemberType({ ...item, is_open: nextStatus } as MemberType)
|
||||
if (success) {
|
||||
item.is_open = nextStatus
|
||||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (item: MemberType) => {
|
||||
uni.showToast({ title: '编辑功能开发中', icon: 'none' })
|
||||
}
|
||||
</script>
|
||||
@@ -141,6 +180,30 @@ const handleEdit = (item: any) => {
|
||||
|
||||
.op-link { color: #1890ff; font-size: 13px; cursor: pointer; }
|
||||
|
||||
/* Loading & Empty Styles */
|
||||
.table-container {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.loading-mask {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-text { color: #1890ff; font-size: 14px; }
|
||||
|
||||
.empty-row {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -45,13 +45,13 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<button class="btn-select-action" @click="showCouponModal = true">选择优惠券</button>
|
||||
<button class="btn-select-action" @click="openCouponModal">选择优惠券</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 确认按钮 -->
|
||||
<view class="form-submit-bar">
|
||||
<button class="btn-primary-confirm" @click="handleSubmit">确认</button>
|
||||
<button class="btn-primary-confirm" @click="handleSubmit" :disabled="isLoading">确认</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -65,8 +65,13 @@
|
||||
</view>
|
||||
|
||||
<view class="modal-body">
|
||||
<!-- 编辑/设置模式:当用户由于点击“修改设置”时触发 -->
|
||||
<view v-if="isEditing" class="setting-form">
|
||||
<!-- 加载中 -->
|
||||
<view v-if="isLoading" class="modal-loading">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 编辑/设置模式 -->
|
||||
<view v-else-if="isEditing" class="setting-form">
|
||||
<view class="setting-row">
|
||||
<text class="setting-label">显示名称:</text>
|
||||
<input class="setting-input" v-model="editingCoupon.name" placeholder="请输入页面显示的名称" />
|
||||
@@ -75,13 +80,13 @@
|
||||
<text class="setting-label">发放描述:</text>
|
||||
<input class="setting-input" v-model="editingCoupon.desc" placeholder="请输入发放时的描述" />
|
||||
</view>
|
||||
<view class="setting-tip">
|
||||
<text class="tip-text">* 此处的修改仅影响“新人礼”活动中的展示,不影响优惠券自身配置</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 选择模式 -->
|
||||
<view v-else class="selection-list">
|
||||
<view v-if="couponOptions.length === 0" class="empty-tip">
|
||||
<text>暂无可用优惠券</text>
|
||||
</view>
|
||||
<view v-for="(item, index) in couponOptions" :key="index"
|
||||
class="selection-card" :class="{'selected-card': isSelected(item)}"
|
||||
@click="toggleCoupon(item)">
|
||||
@@ -108,36 +113,73 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { fetchNewcomerConfig, saveNewcomerConfig, fetchAdminCoupons, NewcomerConfig, CouponTemplate } from '@/services/admin/marketingService.uts'
|
||||
|
||||
interface Coupon {
|
||||
id: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
interface NewcomerCoupon {
|
||||
id : string;
|
||||
name : string;
|
||||
desc : string;
|
||||
}
|
||||
|
||||
const formData = reactive({
|
||||
balance: '88888',
|
||||
integral: '88888',
|
||||
coupons: [] as Coupon[]
|
||||
balance: '0',
|
||||
integral: '0',
|
||||
coupons: [] as NewcomerCoupon[]
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const showCouponModal = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingIndex = ref(-1)
|
||||
const editingCoupon = reactive<Coupon>({ id: 0, name: '', desc: '' })
|
||||
const editingCoupon = reactive<NewcomerCoupon>({ id: '', name: '', desc: '' })
|
||||
|
||||
const couponOptions = reactive<Coupon[]>([
|
||||
{ id: 1, name: '满100减10元券', desc: '全场通用' },
|
||||
{ id: 2, name: '新人5元无门槛', desc: '仅限新人使用' },
|
||||
{ id: 3, name: '满200减50元券', desc: '限特定商品' }
|
||||
])
|
||||
const couponOptions = ref<NewcomerCoupon[]>([])
|
||||
|
||||
function isSelected(item: Coupon): boolean {
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
|
||||
async function loadConfig() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchNewcomerConfig()
|
||||
if (res != null) {
|
||||
formData.balance = String(res.balance_reward)
|
||||
formData.integral = String(res.integral_reward)
|
||||
formData.coupons = res.coupons_json.map((c : any) : NewcomerCoupon => ({
|
||||
id: String(c.id),
|
||||
name: String(c.name),
|
||||
desc: String(c.desc)
|
||||
} as NewcomerCoupon))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载配置失败:', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openCouponModal() {
|
||||
showCouponModal.value = true
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchAdminCoupons({ page: 1, pageSize: 100, status: 1 })
|
||||
couponOptions.value = res.items.map((item : CouponTemplate) : NewcomerCoupon => ({
|
||||
id: item.id!,
|
||||
name: item.name,
|
||||
desc: item.description ?? ''
|
||||
} as NewcomerCoupon))
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(item : NewcomerCoupon) : boolean {
|
||||
return formData.coupons.some(c => c.id === item.id)
|
||||
}
|
||||
|
||||
function toggleCoupon(item: Coupon) {
|
||||
function toggleCoupon(item : NewcomerCoupon) {
|
||||
const index = formData.coupons.findIndex(c => c.id === item.id)
|
||||
if (index > -1) {
|
||||
formData.coupons.splice(index, 1)
|
||||
@@ -146,11 +188,11 @@ function toggleCoupon(item: Coupon) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeCoupon(index: number) {
|
||||
function removeCoupon(index : number) {
|
||||
formData.coupons.splice(index, 1)
|
||||
}
|
||||
|
||||
function editCoupon(index: number) {
|
||||
function editCoupon(index : number) {
|
||||
editingIndex.value = index
|
||||
const coupon = formData.coupons[index]
|
||||
editingCoupon.id = coupon.id
|
||||
@@ -173,15 +215,25 @@ function confirmModal() {
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
async function handleSubmit() {
|
||||
uni.showLoading({ title: '保存中...' })
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const payload : NewcomerConfig = {
|
||||
balance_reward: parseFloat(formData.balance),
|
||||
integral_reward: parseInt(formData.integral),
|
||||
coupons_json: formData.coupons as any[]
|
||||
}
|
||||
const success = await saveNewcomerConfig(payload)
|
||||
if (success) {
|
||||
uni.showToast({ title: '设置已生效', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '系统异常', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '设置已生效',
|
||||
icon: 'success'
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -516,6 +568,22 @@ function handleSubmit() {
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-loading {
|
||||
padding: 40px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1890ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -1,27 +1,219 @@
|
||||
<template>
|
||||
<AdminLayout :currentPage="currentPage">
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="title">{{ title }}</text>
|
||||
<text class="sub-title">页面占位 (自动生成)</text>
|
||||
<AdminLayout currentPage="points-record">
|
||||
<view class="marketing-points-record">
|
||||
<!-- 1. 搜索过滤栏 -->
|
||||
<view class="filter-card border-shadow">
|
||||
<view class="filter-row">
|
||||
<view class="filter-item">
|
||||
<text class="label">搜索记录:</text>
|
||||
<input class="input-mock" placeholder="用户名/业务标题" v-model="searchQuery" @confirm="handleSearch" />
|
||||
</view>
|
||||
<view class="filter-btns">
|
||||
<button class="btn-query" @click="handleSearch">查询</button>
|
||||
<button class="btn-reset" @click="handleReset">重置</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 2. 数据表格 -->
|
||||
<view class="table-card border-shadow">
|
||||
<view class="table-container">
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="table-header">
|
||||
<view class="th cell-id">ID</view>
|
||||
<view class="th cell-user">用户信息</view>
|
||||
<view class="th cell-title">业务标题</view>
|
||||
<view class="th cell-points">积分变动</view>
|
||||
<view class="th cell-balance">变动后余额</view>
|
||||
<view class="th cell-time">记录时间</view>
|
||||
</view>
|
||||
|
||||
<view class="table-body">
|
||||
<view v-if="recordList.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无积分变动记录</text>
|
||||
</view>
|
||||
<view v-for="item in recordList" :key="item.id" class="table-row">
|
||||
<view class="td cell-id">
|
||||
<text class="td-txt-small">{{ item.id }}</text>
|
||||
</view>
|
||||
<view class="td cell-user">
|
||||
<view class="u-info">
|
||||
<image class="u-avatar" :src="item.avatar || '/static/logo.png'" mode="aspectFill" />
|
||||
<text class="u-nick">{{ item.nickname || '未知用户' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="td cell-title">
|
||||
<text class="td-txt">{{ item.title }}</text>
|
||||
</view>
|
||||
<view class="td cell-points">
|
||||
<text class="td-txt-bold" :class="item.pm === 1 ? 'color-blue' : 'color-red'">
|
||||
{{ item.pm === 1 ? '+' : '-' }}{{ item.number }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="td cell-balance">
|
||||
<text class="td-txt">{{ item.balance }}</text>
|
||||
</view>
|
||||
<view class="td cell-time">
|
||||
<text class="td-txt-small">{{ formatDateTime(item.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 3. 分页控制 -->
|
||||
<view class="pagination-footer">
|
||||
<view class="page-total">
|
||||
<text class="total-txt">共 {{ total }} 条</text>
|
||||
</view>
|
||||
<view class="page-btns">
|
||||
<text class="p-btn" :class="{ disabled: page <= 1 }" @click="onPrevPage">‹</text>
|
||||
<text class="p-btn active">{{ page }}</text>
|
||||
<text class="p-btn" :class="{ disabled: recordList.length < pageSize }" @click="onNextPage">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
|
||||
const currentPage = ref<string>('points-record')
|
||||
const title = ref<string>('record')
|
||||
import { fetchPointsRecords } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const currentPage = ref('points-record')
|
||||
const recordList = ref<any[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchPointsRecords({
|
||||
search: searchQuery.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize
|
||||
})
|
||||
recordList.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchQuery.value = ''
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (recordList.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso : string) : string {
|
||||
return iso.substring(0, 16).replace('T', ' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/uni.scss';
|
||||
.page { padding: $space-lg; }
|
||||
.header { padding: $space-lg; border-radius: $radius; background: $background-primary; box-shadow: $shadow-xs; }
|
||||
.title { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $text-primary; }
|
||||
.sub-title { margin-top: $space-xs; font-size: $font-size-md; color: $text-secondary; }
|
||||
.marketing-points-record {
|
||||
min-height: 100vh;
|
||||
background: #f0f2f5;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.border-shadow {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.filter-card { padding: 24px; margin-bottom: 16px; }
|
||||
.filter-row { display: flex; flex-direction: row; align-items: center; gap: 24px; }
|
||||
.filter-item { display: flex; flex-direction: row; align-items: center; }
|
||||
.label { font-size: 14px; color: #606266; }
|
||||
.input-mock {
|
||||
width: 240px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px;
|
||||
padding: 0 12px; font-size: 13px;
|
||||
}
|
||||
.filter-btns { display: flex; flex-direction: row; gap: 12px; }
|
||||
.btn-query { background: #1890ff; color: #fff; height: 32px; padding: 0 16px; border-radius: 4px; font-size: 14px; border: none; cursor: pointer; }
|
||||
.btn-reset { background: #fff; color: #666; height: 32px; padding: 0 16px; border-radius: 4px; font-size: 14px; border: 1px solid #dcdfe6; cursor: pointer; }
|
||||
|
||||
.table-card { padding: 24px; position: relative; }
|
||||
.table-container { min-height: 400px; position: relative; }
|
||||
.loading-mask {
|
||||
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(255,255,255,0.7); display: flex; align-items: center; justify-content: center; z-index: 10;
|
||||
}
|
||||
.loading-text { color: #1890ff; font-size: 14px; }
|
||||
|
||||
.table-header {
|
||||
display: flex; flex-direction: row; background-color: #f8faff;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
}
|
||||
.th { padding: 12px 8px; font-size: 13px; color: #515a6e; font-weight: bold; text-align: center; }
|
||||
|
||||
.table-row {
|
||||
display: flex; flex-direction: row; border-bottom: 1px solid #e8eaec; align-items: center;
|
||||
&:hover { background-color: #fafafa; }
|
||||
}
|
||||
.td { padding: 12px 8px; text-align: center; display: flex; align-items: center; justify-content: center; }
|
||||
.td-txt { font-size: 13px; color: #515a6e; }
|
||||
.td-txt-bold { font-size: 14px; color: #333; font-weight: bold; }
|
||||
.td-txt-small { font-size: 12px; color: #999; }
|
||||
.color-blue { color: #1890ff; }
|
||||
.color-red { color: #f5222d; }
|
||||
|
||||
.cell-id { width: 60px; }
|
||||
.cell-user { width: 180px; justify-content: flex-start; }
|
||||
.cell-title { flex: 1; min-width: 150px; }
|
||||
.cell-points { width: 100px; }
|
||||
.cell-balance { width: 120px; }
|
||||
.cell-time { width: 150px; }
|
||||
|
||||
.u-info { display: flex; flex-direction: row; align-items: center; gap: 8px; }
|
||||
.u-avatar { width: 32px; height: 32px; border-radius: 16px; background: #f5f5f5; }
|
||||
.u-nick { font-size: 13px; color: #333; }
|
||||
|
||||
.pagination-footer {
|
||||
margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px;
|
||||
}
|
||||
.total-txt { font-size: 13px; color: #999; }
|
||||
.page-btns { display: flex; flex-direction: row; gap: 8px; }
|
||||
.p-btn {
|
||||
width: 28px; height: 28px; border: 1px solid #dcdfe6; border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 14px;
|
||||
&.active { background: #1890ff; color: #fff; border-color: #1890ff; }
|
||||
&.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
.empty-row { padding: 60px 0; text-align: center; color: #999; }
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -73,17 +73,63 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { reactive } from 'vue'
|
||||
import { reactive, onMounted, ref } from 'vue'
|
||||
import { fetchRechargeConfig, saveRechargeConfig, RechargeConfig } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const config = reactive({
|
||||
balance_enabled: true,
|
||||
notice: '充值后账户的金额不能提现,可用于商城消费使用\n佣金导入账户之后不能再次导出、不可提现\n账户充值出现问题可联系商城客服',
|
||||
notice: '',
|
||||
mp_recharge: false,
|
||||
min_amount: 0.01
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
|
||||
async function loadConfig() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchRechargeConfig()
|
||||
if (res != null) {
|
||||
config.balance_enabled = res.balance_enabled
|
||||
config.notice = res.recharge_notice ?? ''
|
||||
config.mp_recharge = res.mp_recharge_enabled
|
||||
config.min_amount = res.min_recharge_amount
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载配置失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (isSaving.value) return
|
||||
|
||||
isSaving.value = true
|
||||
const payload : RechargeConfig = {
|
||||
balance_enabled: config.balance_enabled,
|
||||
recharge_notice: config.notice,
|
||||
mp_recharge_enabled: config.mp_recharge,
|
||||
min_recharge_amount: config.min_amount
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await saveRechargeConfig(payload)
|
||||
if (success) {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '保存异常', icon: 'none' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -120,74 +120,120 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { fetchRechargeQuotas, saveRechargeQuota, deleteRechargeQuota, RechargeQuota } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const showAddModal = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const list = ref<RechargeQuota[]>([])
|
||||
|
||||
const formData = reactive({
|
||||
id: null as string | null,
|
||||
price: '',
|
||||
bonus: '',
|
||||
sort: 1,
|
||||
is_open: true
|
||||
})
|
||||
|
||||
const list = ref([
|
||||
{ id: 640, price: 10, bonus: 2, is_open: true, sort: 6 },
|
||||
{ id: 641, price: 20, bonus: 8, is_open: true, sort: 5 },
|
||||
{ id: 642, price: 50, bonus: 20, is_open: true, sort: 4 },
|
||||
{ id: 643, price: 100, bonus: 50, is_open: true, sort: 3 },
|
||||
{ id: 644, price: 200, bonus: 110, is_open: true, sort: 2 },
|
||||
{ id: 645, price: 300, bonus: 200, is_open: true, sort: 1 }
|
||||
])
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
const toggleStatus = (item: any) => {
|
||||
item.is_open = !item.is_open
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchRechargeQuotas()
|
||||
list.value = res
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item : RechargeQuota) {
|
||||
if (item.id == null) return
|
||||
const nextStatus = !item.is_open
|
||||
isLoading.value = true
|
||||
try {
|
||||
const success = await saveRechargeQuota({ ...item, is_open: nextStatus } as RechargeQuota)
|
||||
if (success) {
|
||||
item.is_open = nextStatus
|
||||
uni.showToast({ title: '操作成功', icon: 'success' })
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
uni.showToast({ title: '编辑功能', icon: 'none' })
|
||||
function handleEdit(item : RechargeQuota) {
|
||||
formData.id = item.id ?? null
|
||||
formData.price = String(item.price)
|
||||
formData.bonus = String(item.bonus_price)
|
||||
formData.sort = item.sort_order
|
||||
formData.is_open = item.is_open
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (item: any) => {
|
||||
async function handleDelete(item : RechargeQuota) {
|
||||
if (item.id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确认删除该项吗?',
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
const index = list.value.findIndex(i => i.id === item.id)
|
||||
if (index > -1) {
|
||||
list.value.splice(index, 1)
|
||||
isLoading.value = true
|
||||
try {
|
||||
const success = await deleteRechargeQuota(item.id!)
|
||||
if (success) {
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
loadData()
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
async function handleSubmit() {
|
||||
if (!formData.price) {
|
||||
uni.showToast({ title: '请输入售价', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const newItem = {
|
||||
id: Math.floor(Math.random() * 1000) + 700,
|
||||
isSaving.value = true
|
||||
const payload : RechargeQuota = {
|
||||
id: formData.id ?? undefined,
|
||||
price: parseFloat(formData.price),
|
||||
bonus: parseFloat(formData.bonus || '0'),
|
||||
sort: parseInt(formData.sort.toString()),
|
||||
bonus_price: parseFloat(formData.bonus || '0'),
|
||||
sort_order: parseInt(formData.sort.toString()),
|
||||
is_open: formData.is_open
|
||||
}
|
||||
|
||||
list.value.push(newItem)
|
||||
try {
|
||||
const success = await saveRechargeQuota(payload)
|
||||
if (success) {
|
||||
uni.showToast({ title: formData.id ? '修改成功' : '添加成功', icon: 'success' })
|
||||
showAddModal.value = false
|
||||
resetForm()
|
||||
loadData()
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作异常', icon: 'none' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
formData.id = null
|
||||
formData.price = ''
|
||||
formData.bonus = ''
|
||||
formData.sort = 1
|
||||
formData.is_open = true
|
||||
|
||||
showAddModal.value = false
|
||||
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,19 +4,12 @@
|
||||
<view class="filter-row">
|
||||
<view class="filter-item">
|
||||
<text class="label">活动搜索:</text>
|
||||
<input class="input-mock" placeholder="请输入活动名称, ID" />
|
||||
<input class="input-mock" placeholder="请输入活动名称, ID" v-model="searchQuery" @confirm="handleSearch" />
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<text class="label">活动状态:</text>
|
||||
<view class="select-mock">
|
||||
<text class="select-val">请选择</text>
|
||||
<text class="arrow">▼</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="filter-item">
|
||||
<text class="label">活动时段:</text>
|
||||
<view class="select-mock">
|
||||
<text class="select-val">请选择</text>
|
||||
<text class="select-val">全部</text>
|
||||
<text class="arrow">▼</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -26,10 +19,10 @@
|
||||
<text class="label">活动时间:</text>
|
||||
<view class="date-picker-mock">
|
||||
<text class="calendar-ic">📅</text>
|
||||
<text class="date-placeholder">开始日期 - 结束日期</text>
|
||||
<text class="date-placeholder">暂时使用默认范围</text>
|
||||
</view>
|
||||
</view>
|
||||
<button class="btn-query">查询</button>
|
||||
<button class="btn-query" @click="handleSearch">查询</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -39,19 +32,26 @@
|
||||
|
||||
<view class="table-card border-shadow">
|
||||
<view class="table-container">
|
||||
<!-- Loading 遮罩 -->
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
<view class="table-head">
|
||||
<view class="th cell-id">ID</view>
|
||||
<view class="th cell-title">活动标题</view>
|
||||
<view class="th cell-limit">单次限购</view>
|
||||
<view class="th cell-total">总购买数量限制</view>
|
||||
<view class="th cell-count">商品数量</view>
|
||||
<view class="th cell-period">活动时段</view>
|
||||
<view class="th cell-time">活动时间</view>
|
||||
<view class="th cell-total">总限购</view>
|
||||
<view class="th cell-count">商品数</view>
|
||||
<view class="th cell-period">时段</view>
|
||||
<view class="th cell-time">活动日期</view>
|
||||
<view class="th cell-status">状态</view>
|
||||
<view class="th cell-op">操作</view>
|
||||
</view>
|
||||
|
||||
<view class="table-body">
|
||||
<view v-if="seckillList.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无数据</text>
|
||||
</view>
|
||||
<view v-for="item in seckillList" :key="item.id" class="table-row">
|
||||
<view class="td cell-id">
|
||||
<text class="td-txt">{{ item.id }}</text>
|
||||
@@ -74,8 +74,8 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="td cell-time">
|
||||
<text class="td-txt-small">开始: {{ item.start_date }}</text>
|
||||
<text class="td-txt-small">结束: {{ item.end_date }}</text>
|
||||
<text class="td-txt-small">{{ item.start_date.substring(0,10) }}</text>
|
||||
<text class="td-txt-small">{{ item.end_date.substring(0,10) }}</text>
|
||||
</view>
|
||||
<view class="td cell-status">
|
||||
<view class="switch-mock" :class="{ active: item.status }" @click="toggleStatus(item)">
|
||||
@@ -96,23 +96,12 @@
|
||||
|
||||
<view class="pagination-footer">
|
||||
<view class="page-total">
|
||||
<text class="total-txt">共 {{ seckillList.length }} 条</text>
|
||||
</view>
|
||||
<view class="page-select">
|
||||
<view class="select-mock mini">
|
||||
<text class="select-val">15条/页</text>
|
||||
<text class="arrow">▼</text>
|
||||
</view>
|
||||
<text class="total-txt">共 {{ total }} 条</text>
|
||||
</view>
|
||||
<view class="page-btns">
|
||||
<text class="p-btn disabled">‹</text>
|
||||
<text class="p-btn active">1</text>
|
||||
<text class="p-btn disabled">›</text>
|
||||
</view>
|
||||
<view class="page-jump">
|
||||
<text class="jump-txt">前往</text>
|
||||
<input class="jump-input" placeholder="1" />
|
||||
<text class="jump-txt">页</text>
|
||||
<text class="p-btn" :class="{ disabled: page <= 1 }" @click="onPrevPage">‹</text>
|
||||
<text class="p-btn active">{{ page }}</text>
|
||||
<text class="p-btn" :class="{ disabled: seckillList.length < pageSize }" @click="onNextPage">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -120,47 +109,90 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchSeckillActivities, deleteSeckillActivity, saveSeckillActivity, SeckillActivity } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const seckillList = ref([
|
||||
{
|
||||
id: 91,
|
||||
title: '秒杀活动',
|
||||
single_limit: 1,
|
||||
total_limit: 10,
|
||||
product_count: 5,
|
||||
time_range: '06:00-24:00',
|
||||
start_date: '2025-07-01 00:00:00',
|
||||
end_date: '2028-08-22 23:59:59',
|
||||
status: true
|
||||
const seckillList = ref<SeckillActivity[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchSeckillActivities({
|
||||
search: searchQuery.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize
|
||||
})
|
||||
seckillList.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
const toggleStatus = (item: any) => {
|
||||
item.status = !item.status
|
||||
async function toggleStatus(item : SeckillActivity) {
|
||||
if (item.id == null) return
|
||||
const nextStatus = !item.status
|
||||
const success = await saveSeckillActivity({ ...item, status: nextStatus } as SeckillActivity)
|
||||
if (success) {
|
||||
item.status = nextStatus
|
||||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
uni.showToast({ title: '添加活动功能开发中', icon: 'none' })
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
uni.showToast({ title: '编辑活动功能开发中', icon: 'none' })
|
||||
function handleAdd() {
|
||||
uni.showToast({ title: '添加功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
const handleDelete = (item: any) => {
|
||||
function handleEdit(item : SeckillActivity) {
|
||||
uni.showToast({ title: '编辑功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
async function handleDelete(item : SeckillActivity) {
|
||||
if (item.id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除该活动吗?',
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
seckillList.value = seckillList.value.filter(i => i.id !== item.id)
|
||||
const success = await deleteSeckillActivity(item.id!)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (seckillList.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,65 +1,218 @@
|
||||
<template>
|
||||
<AdminLayout currentPage="signin-record">
|
||||
<view class="page">
|
||||
<view class="Header">
|
||||
<text class="Title">签到奖励</text>
|
||||
<text class="SubTitle">marketing/signin/record</text>
|
||||
<view class="marketing-signin-record">
|
||||
<!-- 1. 搜索过滤栏 -->
|
||||
<view class="filter-card border-shadow">
|
||||
<view class="filter-row">
|
||||
<view class="filter-item">
|
||||
<text class="label">搜索用户:</text>
|
||||
<input class="input-mock" placeholder="请输入用户名" v-model="searchQuery" @confirm="handleSearch" />
|
||||
</view>
|
||||
<view class="filter-btns">
|
||||
<button class="btn-query" @click="handleSearch">查询</button>
|
||||
<button class="btn-reset" @click="handleReset">重置</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="Card">
|
||||
<text class="Label">页面参数(query)</text>
|
||||
<text class="Mono">{{ params }}</text>
|
||||
<!-- 2. 数据表格 -->
|
||||
<view class="table-card border-shadow">
|
||||
<view class="table-container">
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="table-header">
|
||||
<view class="th cell-id">ID</view>
|
||||
<view class="th cell-user">用户信息</view>
|
||||
<view class="th cell-points">获得积分</view>
|
||||
<view class="th cell-extra">连续签到奖励</view>
|
||||
<view class="th cell-time">签到时间</view>
|
||||
</view>
|
||||
|
||||
<view class="table-body">
|
||||
<view v-if="recordList.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无签到记录</text>
|
||||
</view>
|
||||
<view v-for="item in recordList" :key="item.id" class="table-row">
|
||||
<view class="td cell-id">
|
||||
<text class="td-txt-small">{{ item.id }}</text>
|
||||
</view>
|
||||
<view class="td cell-user">
|
||||
<view class="u-info">
|
||||
<image class="u-avatar" :src="item.avatar || '/static/logo.png'" mode="aspectFill" />
|
||||
<text class="u-nick">{{ item.nickname || '未知用户' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
<view class="td cell-points">
|
||||
<text class="td-txt-bold color-blue">+{{ item.points }}</text>
|
||||
</view>
|
||||
<view class="td cell-extra">
|
||||
<text class="status-tag" :class="item.is_continuous_reward ? 'yes' : 'no'">
|
||||
{{ item.is_continuous_reward ? '是' : '否' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="td cell-time">
|
||||
<text class="td-txt-small">{{ formatDateTime(item.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 3. 分页控制 -->
|
||||
<view class="pagination-footer">
|
||||
<view class="page-total">
|
||||
<text class="total-txt">共 {{ total }} 条</text>
|
||||
</view>
|
||||
<view class="page-btns">
|
||||
<text class="p-btn" :class="{ disabled: page <= 1 }" @click="onPrevPage">‹</text>
|
||||
<text class="p-btn active">{{ page }}</text>
|
||||
<text class="p-btn" :class="{ disabled: recordList.length < pageSize }" @click="onNextPage">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
|
||||
import { fetchSignInRecords, SignInLog } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const recordList = ref<SignInLog[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const total = ref(0)
|
||||
|
||||
const params = ref('')
|
||||
|
||||
onLoad((options) => {
|
||||
// options: Record<string, any>
|
||||
params.value = JSON.stringify(options ?? {})
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchSignInRecords({
|
||||
search: searchQuery.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize
|
||||
})
|
||||
recordList.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchQuery.value = ''
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (recordList.value.length >= pageSize) {
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso : string) : string {
|
||||
return iso.substring(0, 16).replace('T', ' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.Page {
|
||||
padding: 24rpx;
|
||||
<style scoped lang="scss">
|
||||
.marketing-signin-record {
|
||||
min-height: 100vh;
|
||||
background: #f0f2f5;
|
||||
padding: 16px;
|
||||
}
|
||||
.Header {
|
||||
padding: 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
|
||||
.border-shadow {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.Title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
|
||||
.filter-card { padding: 24px; margin-bottom: 16px; }
|
||||
.filter-row { display: flex; flex-direction: row; align-items: center; gap: 24px; }
|
||||
.filter-item { display: flex; flex-direction: row; align-items: center; }
|
||||
.label { font-size: 14px; color: #606266; }
|
||||
.input-mock {
|
||||
width: 240px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px;
|
||||
padding: 0 12px; font-size: 13px;
|
||||
}
|
||||
.SubTitle {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
opacity: 0.7;
|
||||
.filter-btns { display: flex; flex-direction: row; gap: 12px; }
|
||||
.btn-query { background: #1890ff; color: #fff; height: 32px; padding: 0 16px; border-radius: 4px; font-size: 14px; border: none; cursor: pointer; }
|
||||
.btn-reset { background: #fff; color: #666; height: 32px; padding: 0 16px; border-radius: 4px; font-size: 14px; border: 1px solid #dcdfe6; cursor: pointer; }
|
||||
|
||||
.table-card { padding: 24px; position: relative; }
|
||||
.table-container { min-height: 400px; position: relative; }
|
||||
.loading-mask {
|
||||
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(255,255,255,0.7); display: flex; align-items: center; justify-content: center; z-index: 10;
|
||||
}
|
||||
.Card {
|
||||
margin-top: 24rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
.loading-text { color: #1890ff; font-size: 14px; }
|
||||
|
||||
.table-header {
|
||||
display: flex; flex-direction: row; background-color: #f8faff;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
}
|
||||
.Label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
.th { padding: 12px 8px; font-size: 13px; color: #515a6e; font-weight: bold; text-align: center; }
|
||||
|
||||
.table-row {
|
||||
display: flex; flex-direction: row; border-bottom: 1px solid #e8eaec; align-items: center;
|
||||
&:hover { background-color: #fafafa; }
|
||||
}
|
||||
.Mono {
|
||||
font-size: 24rpx;
|
||||
font-family: monospace;
|
||||
line-height: 36rpx;
|
||||
word-break: break-all;
|
||||
.td { padding: 12px 8px; text-align: center; display: flex; align-items: center; justify-content: center; }
|
||||
.td-txt { font-size: 13px; color: #515a6e; }
|
||||
.td-txt-bold { font-size: 14px; color: #333; font-weight: bold; }
|
||||
.td-txt-small { font-size: 12px; color: #999; }
|
||||
.color-blue { color: #1890ff; }
|
||||
|
||||
.cell-id { width: 60px; }
|
||||
.cell-user { flex: 1; justify-content: flex-start; }
|
||||
.cell-points { width: 120px; }
|
||||
.cell-extra { width: 120px; }
|
||||
.cell-time { width: 180px; }
|
||||
|
||||
.u-info { display: flex; flex-direction: row; align-items: center; gap: 8px; }
|
||||
.u-avatar { width: 32px; height: 32px; border-radius: 16px; background: #f5f5f5; }
|
||||
.u-nick { font-size: 13px; color: #333; }
|
||||
|
||||
.status-tag {
|
||||
padding: 2px 10px; border-radius: 4px; font-size: 12px;
|
||||
&.yes { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
|
||||
&.no { background: #f5f5f5; color: #999; border: 1px solid #d9d9d9; }
|
||||
}
|
||||
|
||||
.pagination-footer {
|
||||
margin-top: 24px; display: flex; flex-direction: row; align-items: center; justify-content: flex-end; gap: 12px;
|
||||
}
|
||||
.total-txt { font-size: 13px; color: #999; }
|
||||
.page-btns { display: flex; flex-direction: row; gap: 8px; }
|
||||
.p-btn {
|
||||
width: 28px; height: 28px; border: 1px solid #dcdfe6; border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 14px;
|
||||
&.active { background: #1890ff; color: #fff; border-color: #1890ff; }
|
||||
&.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
.empty-row { padding: 60px 0; text-align: center; color: #999; }
|
||||
</style>
|
||||
|
||||
@@ -1,65 +1,276 @@
|
||||
<template>
|
||||
<AdminLayout currentPage="signin-rule">
|
||||
<view class="page">
|
||||
<view class="Header">
|
||||
<text class="Title">签到配置</text>
|
||||
<text class="SubTitle">marketing/signin/rule</text>
|
||||
<view class="signin-rule-page">
|
||||
<view class="header-card">
|
||||
<text class="title">签到规则设置</text>
|
||||
<text class="sub-title">配置每日签到奖励及连续签到阶梯奖励</text>
|
||||
</view>
|
||||
|
||||
<view class="Card">
|
||||
<text class="Label">页面参数(query)</text>
|
||||
<text class="Mono">{{ params }}</text>
|
||||
<view class="form-card border-shadow">
|
||||
<!-- 1. 功能开关 -->
|
||||
<view class="form-item">
|
||||
<view class="label-box"><text class="label-txt">是否开启:</text></view>
|
||||
<view class="input-box">
|
||||
<switch :checked="form.is_enabled" color="#2d8cf0" @change="e => form.is_enabled = e.detail.value" />
|
||||
<text class="hint-txt">开启后,用户可在移动端进行每日签到获取积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
|
||||
<!-- 2. 每日奖励 -->
|
||||
<view class="form-item">
|
||||
<view class="label-box"><text class="label-txt">每日签到积分:</text></view>
|
||||
<view class="input-box">
|
||||
<input class="input-base" type="number" v-model="form.daily_points" placeholder="请输入积分数" />
|
||||
<text class="hint-txt">用户每天签到可获得的固定积分奖励</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 3. 连续签到奖励 -->
|
||||
<view class="form-item align-start">
|
||||
<view class="label-box"><text class="label-txt">连续签到奖励:</text></view>
|
||||
<view class="input-box">
|
||||
<view class="reward-steps">
|
||||
<view class="step-header">
|
||||
<text class="th flex-1">连续天数</text>
|
||||
<text class="th flex-1">额外奖励积分</text>
|
||||
<text class="th" style="width: 60px;">操作</text>
|
||||
</view>
|
||||
<view v-for="(item, index) in form.continuous_rewards" :key="index" class="step-row">
|
||||
<view class="td flex-1">
|
||||
<input class="inner-input" type="number" v-model="item.day" placeholder="天数" />
|
||||
</view>
|
||||
<view class="td flex-1">
|
||||
<input class="inner-input" type="number" v-model="item.points" placeholder="积分" />
|
||||
</view>
|
||||
<view class="td" style="width: 60px;">
|
||||
<text class="btn-del" @click="removeRewardStep(index)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="btn-add-step" @click="addRewardStep">
|
||||
<text class="add-txt">+ 添加阶梯奖励</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="hint-txt">配置连续签到奖励,例如:连续 3 天额外奖励 20 积分</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 4. 规则说明 -->
|
||||
<view class="form-item align-start">
|
||||
<view class="label-box"><text class="label-txt">规则说明:</text></view>
|
||||
<view class="input-box">
|
||||
<textarea class="textarea-base" v-model="form.rules_description" placeholder="请输入签到规则说明" />
|
||||
<text class="hint-txt">显示在移动端签到页面的说明文字</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<view class="footer-btns">
|
||||
<button class="btn-submit" @click="handleSave" :loading="isSaving">保存设置</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import AdminLayout from '@/layouts/admin/AdminLayout.uvue'
|
||||
import { fetchSignInConfig, saveSignInConfig, SignInConfig, SignInReward } from '@/services/admin/marketingService.uts'
|
||||
|
||||
const isSaving = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const params = ref('')
|
||||
|
||||
onLoad((options) => {
|
||||
// options: Record<string, any>
|
||||
params.value = JSON.stringify(options ?? {})
|
||||
const form = reactive<SignInConfig>({
|
||||
is_enabled: true,
|
||||
daily_points: 10,
|
||||
continuous_rewards: [] as SignInReward[],
|
||||
rules_description: '1.每日签到可获得积分奖励;\n2.连续签到满足天数可获得额外阶梯奖励;\n3.签到中断将重新从第一天开始计算。'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
|
||||
async function loadConfig() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchSignInConfig()
|
||||
if (res != null) {
|
||||
form.is_enabled = res.is_enabled
|
||||
form.daily_points = res.daily_points
|
||||
form.continuous_rewards = res.continuous_rewards
|
||||
form.rules_description = res.rules_description
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载签到配置失败:', e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addRewardStep() {
|
||||
form.continuous_rewards.push({
|
||||
day: 0,
|
||||
points: 0
|
||||
} as SignInReward)
|
||||
}
|
||||
|
||||
function removeRewardStep(index: number) {
|
||||
form.continuous_rewards.splice(index, 1)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (isSaving.value) return
|
||||
|
||||
// 基础校验
|
||||
if (form.daily_points < 0) {
|
||||
uni.showToast({ title: '每日积分不能为负数', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const success = await saveSignInConfig(form as SignInConfig)
|
||||
if (success) {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作异常', icon: 'none' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.Page {
|
||||
padding: 24rpx;
|
||||
<style scoped lang="scss">
|
||||
.signin-rule-page {
|
||||
padding: 24px;
|
||||
background-color: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.Header {
|
||||
padding: 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
|
||||
.header-card {
|
||||
margin-bottom: 20px;
|
||||
.title { font-size: 20px; font-weight: bold; color: #333; }
|
||||
.sub-title { font-size: 14px; color: #999; margin-top: 4px; }
|
||||
}
|
||||
.Title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
|
||||
.form-card {
|
||||
background-color: #fff;
|
||||
padding: 40px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.SubTitle {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
opacity: 0.7;
|
||||
|
||||
.border-shadow {
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.Card {
|
||||
margin-top: 24rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #ffffff;
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
&.align-start { align-items: flex-start; }
|
||||
}
|
||||
.Label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
.label-box {
|
||||
width: 140px;
|
||||
text-align: right;
|
||||
margin-right: 24px;
|
||||
.label-txt { font-size: 14px; color: #333; font-weight: 500; }
|
||||
}
|
||||
.Mono {
|
||||
font-size: 24rpx;
|
||||
font-family: monospace;
|
||||
line-height: 36rpx;
|
||||
word-break: break-all;
|
||||
|
||||
.input-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.input-base {
|
||||
height: 36px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.textarea-base {
|
||||
height: 120px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hint-txt { font-size: 12px; color: #999; margin-top: 8px; }
|
||||
|
||||
/* 奖励阶梯样式 */
|
||||
.reward-steps {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: #f8f9fa;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
.th { font-size: 13px; font-weight: bold; color: #666; text-align: center; }
|
||||
}
|
||||
|
||||
.step-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 48px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
.td { display: flex; justify-content: center; }
|
||||
}
|
||||
|
||||
.inner-input {
|
||||
width: 80px;
|
||||
height: 28px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-del { font-size: 13px; color: #f5222d; cursor: pointer; }
|
||||
|
||||
.btn-add-step {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
.add-txt { font-size: 13px; color: #2d8cf0; }
|
||||
}
|
||||
|
||||
.footer-btns {
|
||||
margin-top: 40px;
|
||||
padding-left: 164px;
|
||||
.btn-submit {
|
||||
background-color: #2d8cf0;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0 32px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-1 { flex: 1; }
|
||||
</style>
|
||||
|
||||
@@ -180,11 +180,75 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 其他步骤占位 -->
|
||||
<view v-if="activeStep > 2" class="step-content">
|
||||
<view class="placeholder-step">
|
||||
<text>步骤 {{ steps[activeStep] }} 逻辑接入中...</text>
|
||||
<text class="sub-tip">该模块涉及物流模板、会员等级定价等,将在后续阶段补齐</text>
|
||||
<!-- Step 3: 物流设置 -->
|
||||
<view v-if="activeStep === 3" class="step-content">
|
||||
<view class="form-item">
|
||||
<view class="label"><text class="required">*</text><text>运费模板:</text></view>
|
||||
<view class="input-wrap">
|
||||
<picker :value="shippingIndex" :range="shippingLabels" @change="onShippingChange">
|
||||
<view class="tag-selector">
|
||||
<view v-if="form.shipping_template_id" class="tag-item">
|
||||
<text>{{ shippingLabels[shippingIndex] }}</text>
|
||||
</view>
|
||||
<text class="add-link">{{ form.shipping_template_id ? '点击切换模板' : '选择运费模板' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Step 4: 会员价/佣金 -->
|
||||
<view v-if="activeStep === 4" class="step-content">
|
||||
<view class="form-item">
|
||||
<view class="label"><text>会员价设置:</text></view>
|
||||
<view class="input-wrap">
|
||||
<button class="btn-white small-btn" @click="goMemberPrice">前往配置会员价</button>
|
||||
<text class="tip" style="margin-top: 8px; display: block;">点击上方按钮,可以为不同规格的商品设置各级会员的专属价格。</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Step 5: 营销设置 -->
|
||||
<view v-if="activeStep === 5" class="step-content">
|
||||
<view class="form-item">
|
||||
<view class="label"><text>赠送积分:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box small">
|
||||
<input class="real-input" type="number" v-model="form.give_integral" placeholder="0" />
|
||||
</view>
|
||||
<text class="tip">用户购买该商品后赠送的积分数量</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Step 6: 其他设置 -->
|
||||
<view v-if="activeStep === 6" class="step-content">
|
||||
<view class="form-item">
|
||||
<view class="label"><text>排序权重:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box small">
|
||||
<input class="real-input" type="number" v-model="form.sort_order" placeholder="0" />
|
||||
</view>
|
||||
<text class="tip">数字越大,商品在列表中越靠前</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<view class="label"><text>虚拟销量:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box small">
|
||||
<input class="real-input" type="number" v-model="form.virtual_sales" placeholder="0" />
|
||||
</view>
|
||||
<text class="tip">显示的销量 = 真实销量 + 虚拟销量</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<view class="label"><text>库存预警:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box small">
|
||||
<input class="real-input" type="number" v-model="form.stock_warning" placeholder="10" />
|
||||
</view>
|
||||
<text class="tip">当商品总库存低于此数值时,管理后台将进行提醒</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -229,8 +293,10 @@ import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
|
||||
import {
|
||||
fetchAdminProductDetail,
|
||||
saveAdminProduct,
|
||||
fetchShippingTemplates,
|
||||
AdminProduct,
|
||||
AdminProductSku
|
||||
AdminProductSku,
|
||||
ShippingTemplate
|
||||
} from '@/services/admin/productService.uts'
|
||||
import { fetchAdminCategoryList, AdminCategory } from '@/services/admin/productCategoryService.uts'
|
||||
import { fetchLabels, ProductLabel } from '@/services/admin/productLabelService.uts'
|
||||
@@ -240,6 +306,7 @@ import { fetchSpecTemplates, fetchParamTemplates, ProductSpecTemplate, ProductPa
|
||||
const activeStep = ref(0)
|
||||
const steps = ['基础信息', '规格库存', '商品详情', '物流设置', '会员价/佣金', '营销设置', '其他设置']
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const productId = ref<string | null>(null)
|
||||
|
||||
// 响应式表单数据
|
||||
@@ -256,7 +323,13 @@ const form = reactive({
|
||||
price: 0,
|
||||
stock: 0,
|
||||
product_code: '',
|
||||
attributes: {} as any
|
||||
attributes: {} as any,
|
||||
// 扩展字段
|
||||
shipping_template_id: null as string | null,
|
||||
give_integral: 0,
|
||||
stock_warning: 10,
|
||||
virtual_sales: 0,
|
||||
sort_order: 0
|
||||
})
|
||||
|
||||
const skus = ref<AdminProductSku[]>([])
|
||||
@@ -266,10 +339,12 @@ const categories = ref<AdminCategory[]>([])
|
||||
const labelList = ref<ProductLabel[]>([])
|
||||
const specTemplates = ref<ProductSpecTemplate[]>([])
|
||||
const paramTemplates = ref<ProductParamTemplate[]>([])
|
||||
const shippingTemplates = ref<ShippingTemplate[]>([])
|
||||
|
||||
// --- UI 控制状态 ---
|
||||
const showLabelModal = ref(false)
|
||||
const categoryIndex = ref(0)
|
||||
const shippingIndex = ref(-1)
|
||||
|
||||
// 计算属性:分类名称列表供 picker 使用
|
||||
const categoryLabels = computed((): string[] => {
|
||||
@@ -287,6 +362,11 @@ const specTemplateLabels = computed((): string[] => {
|
||||
return specTemplates.value.map(t => t.name)
|
||||
})
|
||||
|
||||
// 计算属性:运费模板列表
|
||||
const shippingLabels = computed((): string[] => {
|
||||
return shippingTemplates.value.map(t => t.name)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 1. 获取商品 ID
|
||||
const pages = getCurrentPages()
|
||||
@@ -305,16 +385,18 @@ onMounted(() => {
|
||||
|
||||
async function initBaseData() {
|
||||
try {
|
||||
const [cateRes, labelRes, specRes, paramRes] = await Promise.all([
|
||||
const [cateRes, labelRes, specRes, paramRes, shipRes] = await Promise.all([
|
||||
fetchAdminCategoryList({ isActive: true }),
|
||||
fetchLabels(),
|
||||
fetchSpecTemplates(),
|
||||
fetchParamTemplates()
|
||||
fetchParamTemplates(),
|
||||
fetchShippingTemplates()
|
||||
])
|
||||
categories.value = cateRes
|
||||
labelList.value = labelRes
|
||||
specTemplates.value = specRes
|
||||
paramTemplates.value = paramRes
|
||||
shippingTemplates.value = shipRes
|
||||
} catch (e) {
|
||||
console.error('加载基础资料失败:', e)
|
||||
}
|
||||
@@ -340,6 +422,18 @@ async function loadProductDetail(id: string) {
|
||||
form.product_code = p.product_code ?? ''
|
||||
form.attributes = p.attributes ?? {}
|
||||
|
||||
// 扩展字段
|
||||
form.shipping_template_id = p.shipping_template_id
|
||||
form.give_integral = p.give_integral
|
||||
form.stock_warning = p.stock_warning
|
||||
form.virtual_sales = p.virtual_sales
|
||||
form.sort_order = p.sort_order
|
||||
|
||||
// 匹配运费模板索引
|
||||
if (p.shipping_template_id != null) {
|
||||
shippingIndex.value = shippingTemplates.value.findIndex(t => t.id === p.shipping_template_id)
|
||||
}
|
||||
|
||||
skus.value = res.skus
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -367,10 +461,25 @@ function prevStep() {
|
||||
}
|
||||
}
|
||||
|
||||
function onShippingChange(e : any) {
|
||||
const index = parseInt(String(e.detail.value))
|
||||
shippingIndex.value = index
|
||||
form.shipping_template_id = shippingTemplates.value[index].id
|
||||
}
|
||||
|
||||
function goMemberPrice() {
|
||||
if (productId.value == null) {
|
||||
uni.showToast({ title: '请先保存商品基础信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
openRoute('product_member_price', { id: productId.value })
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isLoading.value = true
|
||||
if (isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const productData: Partial<AdminProduct> = {
|
||||
const productData : Partial<AdminProduct> = {
|
||||
id: productId.value ?? undefined,
|
||||
...form
|
||||
}
|
||||
@@ -384,7 +493,7 @@ async function handleSave() {
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '系统异常', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<view class="search-wrap">
|
||||
<view class="search-item">
|
||||
<text class="label">搜索:</text>
|
||||
<input class="input" placeholder="请输入模板名称" v-model="searchKey" />
|
||||
<input class="input" placeholder="请输入模板名称" v-model="searchKey" @confirm="onSearch" />
|
||||
</view>
|
||||
<button class="btn btn-primary" @click="onSearch">查询</button>
|
||||
</view>
|
||||
@@ -16,69 +16,189 @@
|
||||
|
||||
<!-- 表格区域 -->
|
||||
<view class="table-wrap">
|
||||
<!-- Loading 遮罩 -->
|
||||
<view v-if="isLoading" class="loading-mask">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="table-header">
|
||||
<view class="th" style="flex: 1;">ID</view>
|
||||
<view class="th" style="flex: 2;">模板名称</view>
|
||||
<view class="th" style="flex: 2;">计费方式</view>
|
||||
<view class="th" style="flex: 2;">指定包邮</view>
|
||||
<view class="th" style="flex: 2;">是否包邮</view>
|
||||
<view class="th" style="flex: 1;">排序</view>
|
||||
<view class="th" style="flex: 3;">添加时间</view>
|
||||
<view class="th" style="flex: 2;">操作</view>
|
||||
</view>
|
||||
<view class="table-body">
|
||||
<view v-if="freightList.length === 0 && !isLoading" class="empty-row">
|
||||
<text>暂无运费模板数据</text>
|
||||
</view>
|
||||
<view v-for="item in freightList" :key="item.id" class="tr">
|
||||
<view class="td" style="flex: 1;">{{ item.id }}</view>
|
||||
<view class="td" style="flex: 1;"><text class="td-txt-small">{{ item.id }}</text></view>
|
||||
<view class="td" style="flex: 2;">{{ item.name }}</view>
|
||||
<view class="td" style="flex: 2;">{{ item.method }}</view>
|
||||
<view class="td" style="flex: 2;">{{ item.freeShipping ? '开启' : '关闭' }}</view>
|
||||
<view class="td" style="flex: 1;">{{ item.sort }}</view>
|
||||
<view class="td" style="flex: 3;">{{ item.addTime }}</view>
|
||||
<view class="td" style="flex: 2;">{{ getCalcMethodName(item.calc_method) }}</view>
|
||||
<view class="td" style="flex: 2;">{{ item.is_free_shipping ? '是' : '否' }}</view>
|
||||
<view class="td" style="flex: 1;">{{ item.sort_order }}</view>
|
||||
<view class="td" style="flex: 2;">
|
||||
<text class="action-btn" @click="onEdit(item)">修改</text>
|
||||
<text class="action-btn" @click="onEdit(item)">编辑</text>
|
||||
<text class="action-btn-del" @click="onDel(item)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 添加/编辑弹窗 -->
|
||||
<view v-if="showModal" class="modal-mask" @click="showModal = false">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{ isEdit ? '编辑运费模板' : '添加运费模板' }}</text>
|
||||
<text class="modal-close" @click="showModal = false">×</text>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<text class="form-label">模板名称:</text>
|
||||
<input class="form-input" v-model="editForm.name" placeholder="请输入模板名称" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">计费方式:</text>
|
||||
<picker :range="['按件数', '按重量', '按体积']" @change="(e) => {
|
||||
const methods = ['piece', 'weight', 'volume']
|
||||
editForm.calc_method = methods[e.detail.value as number]
|
||||
}">
|
||||
<view class="picker-box">{{ getCalcMethodName(editForm.calc_method ?? 'piece') }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">是否包邮:</text>
|
||||
<switch :checked="editForm.is_free_shipping" color="#1890ff" @change="(e) => editForm.is_free_shipping = e.detail.value" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">排序:</text>
|
||||
<input class="form-input" type="number" v-model="editForm.sort_order" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<button class="btn-cancel" @click="showModal = false">取消</button>
|
||||
<button class="btn-primary" @click="handleSave">保存</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { fetchShippingTemplates, saveShippingTemplate, deleteShippingTemplate, ShippingTemplate } from '@/services/admin/productService.uts'
|
||||
|
||||
const searchKey = ref('')
|
||||
const isLoading = ref(false)
|
||||
const freightList = ref<ShippingTemplate[]>([])
|
||||
|
||||
type FreightItem = {
|
||||
id: number
|
||||
name: string
|
||||
method: string
|
||||
freeShipping: boolean
|
||||
sort: number
|
||||
addTime: string
|
||||
const showModal = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editForm = reactive<Partial<ShippingTemplate>>({
|
||||
id: '',
|
||||
name: '',
|
||||
calc_method: 'piece',
|
||||
is_free_shipping: false,
|
||||
sort_order: 0
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchShippingTemplates()
|
||||
// 简单前端搜索过滤,实际推荐后端过滤
|
||||
if (searchKey.value.trim() !== '') {
|
||||
freightList.value = res.filter(item => item.name.includes(searchKey.value.trim()))
|
||||
} else {
|
||||
freightList.value = res
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const freightList = reactive<FreightItem[]>([
|
||||
{ id: 5, name: '测试', method: '按件数', freeShipping: false, sort: 0, addTime: '2025-05-18 09:44:14' },
|
||||
{ id: 4, name: '模板3', method: '按体积', freeShipping: false, sort: 0, addTime: '2023-12-20 09:53:23' },
|
||||
{ id: 3, name: '运费2', method: '按重量', freeShipping: false, sort: 0, addTime: '2023-12-20 09:53:02' },
|
||||
{ id: 2, name: '模板1', method: '按件数', freeShipping: true, sort: 0, addTime: '2023-12-20 09:29:05' }
|
||||
])
|
||||
|
||||
function onSearch() {
|
||||
console.log('Search:', searchKey.value)
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
console.log('Add freight template')
|
||||
isEdit.value = false
|
||||
editForm.id = ''
|
||||
editForm.name = ''
|
||||
editForm.calc_method = 'piece'
|
||||
editForm.is_free_shipping = false
|
||||
editForm.sort_order = 0
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function onEdit(item: FreightItem) {
|
||||
console.log('Edit:', item.name)
|
||||
function onEdit(item: ShippingTemplate) {
|
||||
isEdit.value = true
|
||||
editForm.id = item.id
|
||||
editForm.name = item.name
|
||||
editForm.calc_method = item.calc_method
|
||||
editForm.is_free_shipping = item.is_free_shipping
|
||||
editForm.sort_order = item.sort_order
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function onDel(item: FreightItem) {
|
||||
console.log('Delete:', item.id)
|
||||
async function handleSave() {
|
||||
if (!editForm.name) {
|
||||
uni.showToast({ title: '请输入模板名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const success = await saveShippingTemplate(editForm)
|
||||
if (success) {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
showModal.value = false
|
||||
loadData()
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDel(item: ShippingTemplate) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定删除该运费模板吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const success = await deleteShippingTemplate(item.id)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功' })
|
||||
loadData()
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getCalcMethodName(method: string): string {
|
||||
switch (method) {
|
||||
case 'piece': return '按件数'
|
||||
case 'weight': return '按重量'
|
||||
case 'volume': return '按体积'
|
||||
default: return method
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -184,4 +304,100 @@ function onDel(item: FreightItem) {
|
||||
color: #ed4014;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Loading & Empty Styles */
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
}
|
||||
.loading-mask {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.loading-text { color: #1890ff; font-size: 14px; }
|
||||
.empty-row {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
.td-txt-small { font-size: 12px; color: #999; }
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-content {
|
||||
width: 500px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-title { font-size: 16px; font-weight: bold; }
|
||||
.modal-close { font-size: 24px; color: #ccc; cursor: pointer; }
|
||||
.modal-body { padding: 24px; }
|
||||
.modal-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-label { width: 100px; font-size: 14px; color: #666; }
|
||||
.form-input {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.picker-box {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
line-height: 34px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background-color: #fff;
|
||||
color: #666;
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 0 20px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -197,3 +197,165 @@ export async function getPromoterList(params?: PromoterListParams): Promise<Prom
|
||||
}
|
||||
return (data ?? []) as Promoter[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 事业部模型
|
||||
*/
|
||||
export type DistributionDivision = {
|
||||
id?: string
|
||||
merchant_id?: string
|
||||
uid: string
|
||||
name: string
|
||||
invite_code: string
|
||||
ratio: number
|
||||
agent_count: number
|
||||
end_time: string | null
|
||||
status: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
// 关联字段
|
||||
nickname?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理商模型
|
||||
*/
|
||||
export type DistributionAgent = {
|
||||
id?: string
|
||||
merchant_id?: string
|
||||
uid: string
|
||||
division_id: string
|
||||
name: string
|
||||
status: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
// 关联字段
|
||||
nickname?: string
|
||||
avatar_url?: string
|
||||
division_name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事业部列表
|
||||
*/
|
||||
export async function fetchDivisions(query?: { search?: string, page?: number, pageSize?: number }): Promise<{ total: number, items: DistributionDivision[] }> {
|
||||
let q = supa.from('ak_distribution_divisions').select('*, ak_users!uid(username, avatar_url)', { count: 'exact' })
|
||||
|
||||
if (query?.search != null && query.search !== '') {
|
||||
q = q.or(`name.ilike.%${query.search}%,ak_users.username.ilike.%${query.search}%`)
|
||||
}
|
||||
|
||||
const p = query?.page ?? 1
|
||||
const ps = query?.pageSize ?? 20
|
||||
const from = (p - 1) * ps
|
||||
const to = from + ps - 1
|
||||
|
||||
const { data, error, count } = await q
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to)
|
||||
.execute()
|
||||
|
||||
if (error != null) {
|
||||
console.error('获取事业部列表失败:', error)
|
||||
return { total: 0, items: [] as DistributionDivision[] }
|
||||
}
|
||||
|
||||
const items = (data ?? []).map((item: any): DistributionDivision => {
|
||||
return {
|
||||
...item,
|
||||
nickname: item.ak_users?.username,
|
||||
avatar_url: item.ak_users?.avatar_url
|
||||
} as DistributionDivision
|
||||
})
|
||||
|
||||
return { total: count ?? 0, items }
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存事业部(新增/更新)
|
||||
*/
|
||||
export async function saveDivision(division: DistributionDivision): Promise<boolean> {
|
||||
const session = supa.getSession()
|
||||
const mid = session?.user?.getString('id')
|
||||
if (mid == null) return false
|
||||
|
||||
const { error } = await supa
|
||||
.from('ak_distribution_divisions')
|
||||
.upsert({
|
||||
...division,
|
||||
merchant_id: mid,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.execute()
|
||||
|
||||
return error == null
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除事业部
|
||||
*/
|
||||
export async function deleteDivision(id: string): Promise<boolean> {
|
||||
const { error } = await supa.from('ak_distribution_divisions').delete().eq('id', id).execute()
|
||||
return error == null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理商列表
|
||||
*/
|
||||
export async function fetchAgents(query?: { search?: string, divisionId?: string, page?: number, pageSize?: number }): Promise<{ total: number, items: DistributionAgent[] }> {
|
||||
let q = supa.from('ak_distribution_agents').select('*, ak_users!uid(username, avatar_url), ak_distribution_divisions!division_id(name)', { count: 'exact' })
|
||||
|
||||
if (query?.search != null && query.search !== '') {
|
||||
q = q.or(`name.ilike.%${query.search}%,ak_users.username.ilike.%${query.search}%`)
|
||||
}
|
||||
if (query?.divisionId != null) {
|
||||
q = q.eq('division_id', query.divisionId)
|
||||
}
|
||||
|
||||
const p = query?.page ?? 1
|
||||
const ps = query?.pageSize ?? 20
|
||||
const from = (p - 1) * ps
|
||||
const to = from + ps - 1
|
||||
|
||||
const { data, error, count } = await q
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to)
|
||||
.execute()
|
||||
|
||||
if (error != null) {
|
||||
console.error('获取代理商列表失败:', error)
|
||||
return { total: 0, items: [] as DistributionAgent[] }
|
||||
}
|
||||
|
||||
const items = (data ?? []).map((item: any): DistributionAgent => {
|
||||
return {
|
||||
...item,
|
||||
nickname: item.ak_users?.username,
|
||||
avatar_url: item.ak_users?.avatar_url,
|
||||
division_name: item.ak_distribution_divisions?.name
|
||||
} as DistributionAgent
|
||||
})
|
||||
|
||||
return { total: count ?? 0, items }
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存代理商
|
||||
*/
|
||||
export async function saveAgent(agent: DistributionAgent): Promise<boolean> {
|
||||
const session = supa.getSession()
|
||||
const mid = session?.user?.getString('id')
|
||||
if (mid == null) return false
|
||||
|
||||
const { error } = await supa
|
||||
.from('ak_distribution_agents')
|
||||
.upsert({
|
||||
...agent,
|
||||
merchant_id: mid,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.execute()
|
||||
|
||||
return error == null
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { rpcOrNull, rpcOrValue } from '@/services/analytics/rpc.uts'
|
||||
|
||||
/**
|
||||
* 商品主模型 (SPU)
|
||||
*/
|
||||
export type AdminProduct = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -20,22 +23,95 @@ export type AdminProduct = {
|
||||
video_urls: string[] | null
|
||||
tags: string[] | null
|
||||
attributes: any | null
|
||||
// 扩展字段 (Step 3-6)
|
||||
shipping_template_id: string | null
|
||||
give_integral: number
|
||||
stock_warning: number
|
||||
virtual_sales: number
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品 SKU 模型
|
||||
*/
|
||||
export type AdminProductSku = {
|
||||
id?: string
|
||||
product_id?: string
|
||||
sku_code: string
|
||||
specifications: any
|
||||
price: number
|
||||
stock: number
|
||||
status: number
|
||||
image_url: string | null
|
||||
id ?: string
|
||||
product_id ?: string
|
||||
sku_code : string
|
||||
specifications : any
|
||||
price : number
|
||||
stock : number
|
||||
status : number
|
||||
image_url : string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 运费模板模型
|
||||
*/
|
||||
export type ShippingTemplate = {
|
||||
id : string
|
||||
merchant_id ?: string
|
||||
name : string
|
||||
calc_method : string // piece, weight, volume
|
||||
is_free_shipping : boolean
|
||||
sort_order : number
|
||||
created_at ?: string
|
||||
updated_at ?: string
|
||||
}
|
||||
|
||||
export type ProductPageResult = {
|
||||
total: number
|
||||
items: Array<AdminProduct>
|
||||
total : number
|
||||
items : Array<AdminProduct>
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运费模板列表
|
||||
*/
|
||||
export async function fetchShippingTemplates() : Promise<ShippingTemplate[]> {
|
||||
const { data, error } = await supa
|
||||
.from('ak_shipping_templates')
|
||||
.select('*')
|
||||
.order('sort_order', { ascending: true })
|
||||
.execute()
|
||||
|
||||
if (error != null) {
|
||||
console.error('获取运费模板失败:', error)
|
||||
return [] as ShippingTemplate[]
|
||||
}
|
||||
return (data ?? []) as ShippingTemplate[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存运费模板(新增/更新)
|
||||
*/
|
||||
export async function saveShippingTemplate(tpl : Partial<ShippingTemplate>) : Promise<boolean> {
|
||||
const session = supa.getSession()
|
||||
const uid = session?.user?.getString('id')
|
||||
if (uid == null) return false
|
||||
|
||||
const { error } = await supa
|
||||
.from('ak_shipping_templates')
|
||||
.upsert({
|
||||
...tpl,
|
||||
merchant_id: uid,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.execute()
|
||||
|
||||
return error == null
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除运费模板
|
||||
*/
|
||||
export async function deleteShippingTemplate(id : string) : Promise<boolean> {
|
||||
const { error } = await supa
|
||||
.from('ak_shipping_templates')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.execute()
|
||||
|
||||
return error == null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,8 +207,7 @@ export async function saveAdminProduct(product: Partial<AdminProduct>, skus: Adm
|
||||
|
||||
const savedProductId = (savedProduct as any).id as string
|
||||
|
||||
// 2. 保存 SKU(简单策略:先删除旧的再插入新的,或使用 upsert)
|
||||
// 注意:如果涉及订单关联,不能简单的物理删除旧 SKU
|
||||
// 2. 保存 SKU
|
||||
const skuPayload = skus.map(s => ({
|
||||
...s,
|
||||
product_id: savedProductId,
|
||||
|
||||
Reference in New Issue
Block a user