增加推销模式
This commit is contained in:
260
doc_mall/consumer/sql/points_and_reviews_tables.sql
Normal file
260
doc_mall/consumer/sql/points_and_reviews_tables.sql
Normal file
@@ -0,0 +1,260 @@
|
||||
-- =====================================================
|
||||
-- 积分与评价功能完善 - 数据库表创建脚本
|
||||
-- 创建日期: 2026-03-05
|
||||
-- =====================================================
|
||||
|
||||
-- =====================================================
|
||||
-- 一、积分相关表
|
||||
-- =====================================================
|
||||
|
||||
-- 1. 签到记录表
|
||||
CREATE TABLE IF NOT EXISTS ml_signin_records (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
signin_date DATE NOT NULL,
|
||||
points_earned INT DEFAULT 0,
|
||||
bonus_points INT DEFAULT 0,
|
||||
continuous_days INT DEFAULT 1,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id, signin_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_signin_records_user_id ON ml_signin_records(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_signin_records_date ON ml_signin_records(signin_date);
|
||||
|
||||
COMMENT ON TABLE ml_signin_records IS '用户签到记录表';
|
||||
|
||||
-- 2. 积分兑换商品表
|
||||
CREATE TABLE IF NOT EXISTS ml_point_products (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
image_url VARCHAR(500),
|
||||
product_type VARCHAR(50) NOT NULL DEFAULT 'coupon',
|
||||
points_required INT NOT NULL,
|
||||
original_price DECIMAL(10,2),
|
||||
stock INT DEFAULT 0,
|
||||
status INT DEFAULT 1,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ml_point_products IS '积分兑换商品表';
|
||||
COMMENT ON COLUMN ml_point_products.product_type IS '类型: coupon=优惠券, physical=实物, virtual=虚拟商品';
|
||||
|
||||
-- 3. 积分兑换记录表
|
||||
CREATE TABLE IF NOT EXISTS ml_point_exchanges (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
product_id UUID NOT NULL REFERENCES ml_point_products(id),
|
||||
quantity INT DEFAULT 1,
|
||||
points_used INT NOT NULL,
|
||||
status INT DEFAULT 0,
|
||||
tracking_no VARCHAR(100),
|
||||
address_snapshot JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_point_exchanges_user_id ON ml_point_exchanges(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_point_exchanges_product_id ON ml_point_exchanges(product_id);
|
||||
|
||||
COMMENT ON TABLE ml_point_exchanges IS '积分兑换记录表';
|
||||
COMMENT ON COLUMN ml_point_exchanges.status IS '状态: 0=待处理, 1=已发货, 2=已完成, 3=已取消';
|
||||
|
||||
-- 4. 积分规则配置表
|
||||
CREATE TABLE IF NOT EXISTS ml_point_rules (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
rule_type VARCHAR(50) NOT NULL,
|
||||
rule_name VARCHAR(100) NOT NULL,
|
||||
points INT NOT NULL,
|
||||
description TEXT,
|
||||
config JSONB,
|
||||
status INT DEFAULT 1,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(rule_type)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ml_point_rules IS '积分规则配置表';
|
||||
|
||||
-- 初始化积分规则数据
|
||||
INSERT INTO ml_point_rules (rule_type, rule_name, points, description) VALUES
|
||||
('register', '注册赠送', 100, '新用户注册赠送积分'),
|
||||
('signin_daily', '每日签到', 5, '每日签到获得积分'),
|
||||
('signin_continuous_7', '连续签到7天奖励', 20, '连续签到7天额外奖励'),
|
||||
('signin_continuous_30', '连续签到30天奖励', 100, '连续签到30天额外奖励'),
|
||||
('shopping', '购物奖励', 1, '每消费1元获得1积分'),
|
||||
('review', '评价奖励', 10, '完成商品评价获得积分'),
|
||||
('review_with_image', '带图评价奖励', 20, '带图评价额外奖励')
|
||||
ON CONFLICT (rule_type) DO NOTHING;
|
||||
|
||||
-- =====================================================
|
||||
-- 二、评价相关表
|
||||
-- =====================================================
|
||||
|
||||
-- 1. 为商品评价表添加新字段
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS videos JSONB DEFAULT '[]';
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS tags JSONB DEFAULT '[]';
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS like_count INT DEFAULT 0;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS is_edited BOOLEAN DEFAULT false;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_content TEXT;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_at TIMESTAMPTZ;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_images JSONB DEFAULT '[]';
|
||||
|
||||
-- 2. 评价点赞表
|
||||
CREATE TABLE IF NOT EXISTS ml_review_likes (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
review_id UUID NOT NULL REFERENCES ml_product_reviews(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(review_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_review_likes_review_id ON ml_review_likes(review_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_likes_user_id ON ml_review_likes(user_id);
|
||||
|
||||
COMMENT ON TABLE ml_review_likes IS '评价点赞表';
|
||||
|
||||
-- 3. 评价举报表
|
||||
CREATE TABLE IF NOT EXISTS ml_review_reports (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
review_id UUID NOT NULL REFERENCES ml_product_reviews(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
reason VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
status INT DEFAULT 0,
|
||||
handle_result TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_review_reports_review_id ON ml_review_reports(review_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_reports_user_id ON ml_review_reports(user_id);
|
||||
|
||||
COMMENT ON TABLE ml_review_reports IS '评价举报表';
|
||||
COMMENT ON COLUMN ml_review_reports.status IS '状态: 0=待处理, 1=已处理, 2=已驳回';
|
||||
|
||||
-- 4. 配送员评价表
|
||||
CREATE TABLE IF NOT EXISTS ml_delivery_ratings (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
order_id UUID NOT NULL REFERENCES ml_orders(id),
|
||||
delivery_user_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
rating INT CHECK (rating >= 1 AND rating <= 5),
|
||||
content TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(order_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_ratings_delivery_user_id ON ml_delivery_ratings(delivery_user_id);
|
||||
|
||||
COMMENT ON TABLE ml_delivery_ratings IS '配送员评价表';
|
||||
|
||||
-- =====================================================
|
||||
-- 三、RLS 策略
|
||||
-- =====================================================
|
||||
|
||||
-- 签到记录表 RLS
|
||||
ALTER TABLE ml_signin_records ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own signin records"
|
||||
ON ml_signin_records FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can insert own signin records"
|
||||
ON ml_signin_records FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- 积分兑换商品表 RLS (所有人可查看上架商品)
|
||||
ALTER TABLE ml_point_products ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Anyone can view active point products"
|
||||
ON ml_point_products FOR SELECT
|
||||
TO authenticated, anon
|
||||
USING (status = 1);
|
||||
|
||||
-- 允许认证用户更新商品库存(用于兑换扣减)
|
||||
CREATE POLICY "Authenticated users can update point products"
|
||||
ON ml_point_products FOR UPDATE
|
||||
TO authenticated
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
|
||||
-- 积分兑换记录表 RLS
|
||||
ALTER TABLE ml_point_exchanges ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own exchange records"
|
||||
ON ml_point_exchanges FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can insert own exchange records"
|
||||
ON ml_point_exchanges FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- 评价点赞表 RLS
|
||||
ALTER TABLE ml_review_likes ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Anyone can view review likes"
|
||||
ON ml_review_likes FOR SELECT
|
||||
TO authenticated, anon
|
||||
USING (true);
|
||||
|
||||
CREATE POLICY "Users can insert own likes"
|
||||
ON ml_review_likes FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can delete own likes"
|
||||
ON ml_review_likes FOR DELETE
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- 评价举报表 RLS
|
||||
ALTER TABLE ml_review_reports ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own reports"
|
||||
ON ml_review_reports FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can insert reports"
|
||||
ON ml_review_reports FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- 配送员评价表 RLS
|
||||
ALTER TABLE ml_delivery_ratings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view delivery ratings"
|
||||
ON ml_delivery_ratings FOR SELECT
|
||||
TO authenticated, anon
|
||||
USING (true);
|
||||
|
||||
CREATE POLICY "Users can insert own delivery ratings"
|
||||
ON ml_delivery_ratings FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- =====================================================
|
||||
-- 四、插入测试数据
|
||||
-- =====================================================
|
||||
|
||||
-- 插入测试积分兑换商品
|
||||
INSERT INTO ml_point_products (name, description, image_url, product_type, points_required, original_price, stock, status, sort_order) VALUES
|
||||
('满10减5优惠券', '全场通用,满10元可用', '', 'coupon', 100, 5.00, 1000, 1, 1),
|
||||
('满50减10优惠券', '全场通用,满50元可用', '', 'coupon', 200, 10.00, 500, 1, 2),
|
||||
('精美手机支架', '通用手机支架,多色可选', '', 'physical', 500, 15.00, 100, 1, 3),
|
||||
('会员月卡', '享受会员专属权益30天', '', 'virtual', 1000, 29.90, 999, 1, 4),
|
||||
('满100减30优惠券', '全场通用,满100元可用', '', 'coupon', 500, 30.00, 200, 1, 5),
|
||||
('品牌保温杯', '304不锈钢保温杯500ml', '', 'physical', 2000, 59.00, 50, 1, 6)
|
||||
ON CONFLICT DO NOTHING;
|
||||
356
doc_mall/consumer/sql/points_expiry_functions.sql
Normal file
356
doc_mall/consumer/sql/points_expiry_functions.sql
Normal file
@@ -0,0 +1,356 @@
|
||||
-- =====================================================
|
||||
-- 积分过期提醒功能 - 数据库脚本
|
||||
-- 创建日期: 2026-03-06
|
||||
-- =====================================================
|
||||
|
||||
-- =====================================================
|
||||
-- 一、更新现有表结构
|
||||
-- =====================================================
|
||||
|
||||
-- 为积分记录表添加过期时间字段(如果不存在)
|
||||
ALTER TABLE ml_point_records ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
|
||||
ALTER TABLE ml_point_records ADD COLUMN IF NOT EXISTS is_expired BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE ml_point_records ADD COLUMN IF NOT EXISTS expired_at TIMESTAMPTZ;
|
||||
|
||||
-- 为积分记录表添加余额字段(记录变动后余额)
|
||||
ALTER TABLE ml_point_records ADD COLUMN IF NOT EXISTS balance_after INT;
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX IF NOT EXISTS idx_point_records_expires_at ON ml_point_records(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_point_records_is_expired ON ml_point_records(is_expired);
|
||||
|
||||
-- 更新用户积分表,添加即将过期积分字段
|
||||
ALTER TABLE ml_user_points ADD COLUMN IF NOT EXISTS expiring_points INT DEFAULT 0;
|
||||
ALTER TABLE ml_user_points ADD COLUMN IF NOT EXISTS expiring_date DATE;
|
||||
|
||||
COMMENT ON COLUMN ml_point_records.expires_at IS '积分过期时间,NULL表示永不过期';
|
||||
COMMENT ON COLUMN ml_point_records.is_expired IS '是否已过期';
|
||||
COMMENT ON COLUMN ml_point_records.expired_at IS '实际过期时间';
|
||||
COMMENT ON COLUMN ml_user_points.expiring_points IS '即将过期积分(30天内)';
|
||||
COMMENT ON COLUMN ml_user_points.expiring_date IS '最近过期日期';
|
||||
|
||||
-- =====================================================
|
||||
-- 二、创建积分过期通知表
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ml_point_expiry_notifications (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
points_expiring INT NOT NULL,
|
||||
expiry_date DATE NOT NULL,
|
||||
notification_type VARCHAR(20) NOT NULL, -- '7days', '3days', '1day'
|
||||
is_sent BOOLEAN DEFAULT FALSE,
|
||||
sent_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expiry_notifications_user_id ON ml_point_expiry_notifications(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_expiry_notifications_is_sent ON ml_point_expiry_notifications(is_sent);
|
||||
|
||||
COMMENT ON TABLE ml_point_expiry_notifications IS '积分过期通知记录表';
|
||||
|
||||
-- =====================================================
|
||||
-- 三、创建积分过期配置表
|
||||
-- =====================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ml_point_expiry_config (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
config_key VARCHAR(50) NOT NULL UNIQUE,
|
||||
config_value JSONB NOT NULL,
|
||||
description TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 初始化配置
|
||||
INSERT INTO ml_point_expiry_config (config_key, config_value, description) VALUES
|
||||
('expiry_period', '{"days": 365}', '积分有效期(天),NULL或0表示永不过期'),
|
||||
('notification_days', '{"days": [7, 3, 1]}', '过期前多少天发送提醒'),
|
||||
('enabled', '{"value": true}', '是否启用积分过期功能')
|
||||
ON CONFLICT (config_key) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE ml_point_expiry_config IS '积分过期配置表';
|
||||
|
||||
-- =====================================================
|
||||
-- 四、创建定时任务函数
|
||||
-- =====================================================
|
||||
|
||||
-- 1. 为新获取的积分设置过期时间
|
||||
CREATE OR REPLACE FUNCTION set_points_expiry()
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
expiry_days INT;
|
||||
enabled BOOLEAN;
|
||||
BEGIN
|
||||
-- 检查是否启用过期功能
|
||||
SELECT (config_value->>'value')::boolean INTO enabled
|
||||
FROM ml_point_expiry_config
|
||||
WHERE config_key = 'enabled';
|
||||
|
||||
IF NOT enabled THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- 获取过期天数
|
||||
SELECT (config_value->>'days')::int INTO expiry_days
|
||||
FROM ml_point_expiry_config
|
||||
WHERE config_key = 'expiry_period';
|
||||
|
||||
-- 为未设置过期时间且未过期的积分记录设置过期时间
|
||||
-- 只处理正积分(获取的积分)
|
||||
UPDATE ml_point_records
|
||||
SET expires_at = created_at + (expiry_days || ' days')::interval
|
||||
WHERE expires_at IS NULL
|
||||
AND is_expired = FALSE
|
||||
AND points > 0;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- 2. 处理过期积分
|
||||
CREATE OR REPLACE FUNCTION process_expired_points()
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
expired_record RECORD;
|
||||
user_points_rec RECORD;
|
||||
BEGIN
|
||||
-- 查找所有已过期但未标记的积分记录
|
||||
FOR expired_record IN
|
||||
SELECT id, user_id, points, expires_at
|
||||
FROM ml_point_records
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at < NOW()
|
||||
AND is_expired = FALSE
|
||||
AND points > 0
|
||||
LOOP
|
||||
-- 标记为已过期
|
||||
UPDATE ml_point_records
|
||||
SET is_expired = TRUE, expired_at = NOW()
|
||||
WHERE id = expired_record.id;
|
||||
|
||||
-- 扣减用户积分
|
||||
UPDATE ml_user_points
|
||||
SET points = GREATEST(0, points - expired_record.points),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = expired_record.user_id;
|
||||
|
||||
-- 记录过期扣减
|
||||
INSERT INTO ml_point_records (user_id, points, type, description, created_at)
|
||||
VALUES (
|
||||
expired_record.user_id,
|
||||
-expired_record.points,
|
||||
'expire',
|
||||
'积分过期自动扣除',
|
||||
NOW()
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- 更新用户即将过期积分统计
|
||||
FOR user_points_rec IN SELECT user_id FROM ml_user_points LOOP
|
||||
UPDATE ml_user_points up
|
||||
SET expiring_points = (
|
||||
SELECT COALESCE(SUM(points), 0)
|
||||
FROM ml_point_records
|
||||
WHERE user_id = up.user_id
|
||||
AND points > 0
|
||||
AND is_expired = FALSE
|
||||
AND expires_at IS NOT NULL
|
||||
AND expires_at < NOW() + INTERVAL '30 days'
|
||||
),
|
||||
expiring_date = (
|
||||
SELECT MIN(expires_at)::date
|
||||
FROM ml_point_records
|
||||
WHERE user_id = up.user_id
|
||||
AND points > 0
|
||||
AND is_expired = FALSE
|
||||
AND expires_at IS NOT NULL
|
||||
AND expires_at < NOW() + INTERVAL '30 days'
|
||||
)
|
||||
WHERE up.user_id = user_points_rec.user_id;
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- 3. 生成过期提醒通知
|
||||
CREATE OR REPLACE FUNCTION generate_expiry_notifications()
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
notification_days INT[];
|
||||
days_before INT;
|
||||
expiring_record RECORD;
|
||||
BEGIN
|
||||
-- 获取提醒天数配置
|
||||
SELECT ARRAY(SELECT jsonb_array_elements_text(config_value->'days')::int)
|
||||
INTO notification_days
|
||||
FROM ml_point_expiry_config
|
||||
WHERE config_key = 'notification_days';
|
||||
|
||||
-- 遍历每个提醒天数
|
||||
FOREACH days_before IN ARRAY notification_days LOOP
|
||||
-- 查找即将过期的积分
|
||||
FOR expiring_record IN
|
||||
SELECT
|
||||
user_id,
|
||||
SUM(points) as total_points,
|
||||
MIN(expires_at)::date as expiry_date
|
||||
FROM ml_point_records
|
||||
WHERE points > 0
|
||||
AND is_expired = FALSE
|
||||
AND expires_at IS NOT NULL
|
||||
AND expires_at::date = (CURRENT_DATE + days_before)
|
||||
GROUP BY user_id
|
||||
LOOP
|
||||
-- 检查是否已发送过该类型通知
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM ml_point_expiry_notifications
|
||||
WHERE user_id = expiring_record.user_id
|
||||
AND expiry_date = expiring_record.expiry_date
|
||||
AND notification_type = days_before || 'days'
|
||||
) THEN
|
||||
-- 插入通知记录
|
||||
INSERT INTO ml_point_expiry_notifications
|
||||
(user_id, points_expiring, expiry_date, notification_type)
|
||||
VALUES (
|
||||
expiring_record.user_id,
|
||||
expiring_record.total_points,
|
||||
expiring_record.expiry_date,
|
||||
days_before || 'days'
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- 4. 获取用户即将过期积分(供前端调用)
|
||||
CREATE OR REPLACE FUNCTION get_user_expiring_points(p_user_id UUID)
|
||||
RETURNS TABLE(
|
||||
expiring_points INT,
|
||||
expiring_date DATE,
|
||||
expiring_details JSONB
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
COALESCE(SUM(pr.points), 0)::INT as expiring_points,
|
||||
MIN(pr.expires_at)::date as expiring_date,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'points', pr.points,
|
||||
'expires_at', pr.expires_at,
|
||||
'description', pr.description,
|
||||
'created_at', pr.created_at
|
||||
)
|
||||
) as expiring_details
|
||||
FROM ml_point_records pr
|
||||
WHERE pr.user_id = p_user_id
|
||||
AND pr.points > 0
|
||||
AND pr.is_expired = FALSE
|
||||
AND pr.expires_at IS NOT NULL
|
||||
AND pr.expires_at < NOW() + INTERVAL '30 days';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- 5. 综合定时任务(每天执行一次)
|
||||
CREATE OR REPLACE FUNCTION daily_points_maintenance()
|
||||
RETURNS void AS $$
|
||||
BEGIN
|
||||
-- 设置新积分的过期时间
|
||||
PERFORM set_points_expiry();
|
||||
|
||||
-- 处理已过期积分
|
||||
PERFORM process_expired_points();
|
||||
|
||||
-- 生成过期提醒
|
||||
PERFORM generate_expiry_notifications();
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- =====================================================
|
||||
-- 五、配置 pg_cron 定时任务(如果可用)
|
||||
-- =====================================================
|
||||
|
||||
-- 注意:需要先在 Supabase 中启用 pg_cron 扩展
|
||||
-- 可以在 Supabase Dashboard -> Database -> Extensions 中启用
|
||||
|
||||
-- 每天凌晨2点执行积分维护任务
|
||||
-- SELECT cron.schedule(
|
||||
-- 'daily-points-maintenance',
|
||||
-- '0 2 * * *',
|
||||
-- 'SELECT daily_points_maintenance()'
|
||||
-- );
|
||||
|
||||
-- 每小时检查并发送过期提醒(可选)
|
||||
-- SELECT cron.schedule(
|
||||
-- 'hourly-expiry-notification-check',
|
||||
-- '0 * * * *',
|
||||
-- 'SELECT generate_expiry_notifications()'
|
||||
-- );
|
||||
|
||||
-- =====================================================
|
||||
-- 六、RLS 策略
|
||||
-- =====================================================
|
||||
|
||||
ALTER TABLE ml_point_expiry_notifications ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own expiry notifications"
|
||||
ON ml_point_expiry_notifications FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- =====================================================
|
||||
-- 七、创建视图方便查询
|
||||
-- =====================================================
|
||||
|
||||
-- 用户积分概览视图
|
||||
CREATE OR REPLACE VIEW ml_user_points_overview AS
|
||||
SELECT
|
||||
up.user_id,
|
||||
up.points as current_points,
|
||||
up.total_earned,
|
||||
COALESCE(up.expiring_points, 0) as expiring_points,
|
||||
up.expiring_date,
|
||||
(SELECT COUNT(*) FROM ml_signin_records WHERE user_id = up.user_id) as total_signin_days,
|
||||
(SELECT MAX(continuous_days) FROM ml_signin_records WHERE user_id = up.user_id) as max_continuous_days
|
||||
FROM ml_user_points up;
|
||||
|
||||
-- =====================================================
|
||||
-- 八、测试数据(可选)
|
||||
-- =====================================================
|
||||
|
||||
-- 插入一些即将过期的测试积分记录
|
||||
-- INSERT INTO ml_point_records (user_id, points, type, description, expires_at, created_at)
|
||||
-- VALUES
|
||||
-- ('YOUR_USER_ID', 50, 'signin', '测试即将过期积分', NOW() + INTERVAL '5 days', NOW() - INTERVAL '360 days'),
|
||||
-- ('YOUR_USER_ID', 30, 'shopping', '测试购物积分', NOW() + INTERVAL '10 days', NOW() - INTERVAL '355 days');
|
||||
|
||||
-- =====================================================
|
||||
-- 九、使用说明
|
||||
-- =====================================================
|
||||
|
||||
/*
|
||||
使用方法:
|
||||
|
||||
1. 手动执行定时任务:
|
||||
SELECT daily_points_maintenance();
|
||||
|
||||
2. 查询用户即将过期积分:
|
||||
SELECT * FROM get_user_expiring_points('用户ID');
|
||||
|
||||
3. 修改积分有效期配置:
|
||||
UPDATE ml_point_expiry_config
|
||||
SET config_value = '{"days": 180}', updated_at = NOW()
|
||||
WHERE config_key = 'expiry_period';
|
||||
|
||||
4. 禁用积分过期功能:
|
||||
UPDATE ml_point_expiry_config
|
||||
SET config_value = '{"value": false}', updated_at = NOW()
|
||||
WHERE config_key = 'enabled';
|
||||
|
||||
5. 修改提醒天数:
|
||||
UPDATE ml_point_expiry_config
|
||||
SET config_value = '{"days": [14, 7, 3, 1]}', updated_at = NOW()
|
||||
WHERE config_key = 'notification_days';
|
||||
|
||||
注意:
|
||||
- 如果 Supabase 项目启用了 pg_cron,可以取消注释定时任务配置
|
||||
- 如果没有 pg_cron,需要通过外部定时任务(如 Supabase Edge Functions)调用 daily_points_maintenance()
|
||||
*/
|
||||
570
doc_mall/consumer/sql/promotion_system_tables.sql
Normal file
570
doc_mall/consumer/sql/promotion_system_tables.sql
Normal file
@@ -0,0 +1,570 @@
|
||||
-- =====================================================
|
||||
-- 推销模式功能 - 数据库脚本(第一阶段)
|
||||
-- 包含:用户余额系统、分享免单系统、会员等级系统
|
||||
-- 创建日期: 2026-03-06
|
||||
-- =====================================================
|
||||
|
||||
-- =====================================================
|
||||
-- 一、用户余额系统
|
||||
-- =====================================================
|
||||
|
||||
-- 1. 用户余额表
|
||||
CREATE TABLE IF NOT EXISTS ml_user_balance (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
balance DECIMAL(10,2) DEFAULT 0,
|
||||
frozen_balance DECIMAL(10,2) DEFAULT 0,
|
||||
total_earned DECIMAL(10,2) DEFAULT 0,
|
||||
total_withdrawn DECIMAL(10,2) DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_balance_user_id ON ml_user_balance(user_id);
|
||||
|
||||
COMMENT ON TABLE ml_user_balance IS '用户余额表';
|
||||
COMMENT ON COLUMN ml_user_balance.balance IS '可用余额';
|
||||
COMMENT ON COLUMN ml_user_balance.frozen_balance IS '冻结余额';
|
||||
COMMENT ON COLUMN ml_user_balance.total_earned IS '累计获得';
|
||||
COMMENT ON COLUMN ml_user_balance.total_withdrawn IS '累计提现';
|
||||
|
||||
-- 2. 余额变动记录表
|
||||
CREATE TABLE IF NOT EXISTS ml_balance_records (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
type VARCHAR(50) NOT NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
balance_before DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
balance_after DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
related_id UUID,
|
||||
description VARCHAR(200),
|
||||
operator_id UUID REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_records_user_id ON ml_balance_records(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_records_type ON ml_balance_records(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_records_created_at ON ml_balance_records(created_at);
|
||||
|
||||
COMMENT ON TABLE ml_balance_records IS '余额变动记录表';
|
||||
COMMENT ON COLUMN ml_balance_records.type IS '类型:free_order-免单奖励,rebate-返利,withdraw-提现,clear-清零,manual-手动调整';
|
||||
|
||||
-- =====================================================
|
||||
-- 二、分享免单系统
|
||||
-- =====================================================
|
||||
|
||||
-- 1. 分享记录表
|
||||
CREATE TABLE IF NOT EXISTS ml_share_records (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
product_id UUID NOT NULL REFERENCES ml_products(id),
|
||||
order_id UUID NOT NULL REFERENCES ml_orders(id),
|
||||
order_item_id UUID,
|
||||
share_code VARCHAR(20) NOT NULL UNIQUE,
|
||||
product_name VARCHAR(200),
|
||||
product_image VARCHAR(500),
|
||||
product_price DECIMAL(10,2),
|
||||
required_count INT DEFAULT 4,
|
||||
current_count INT DEFAULT 0,
|
||||
status INT DEFAULT 0,
|
||||
reward_amount DECIMAL(10,2),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
expired_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_share_records_user_id ON ml_share_records(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_records_share_code ON ml_share_records(share_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_records_status ON ml_share_records(status);
|
||||
|
||||
COMMENT ON TABLE ml_share_records IS '分享免单记录表';
|
||||
COMMENT ON COLUMN ml_share_records.status IS '状态:0-进行中,1-已完成,2-已失效,3-已过期';
|
||||
|
||||
-- 2. 二级购买记录表
|
||||
CREATE TABLE IF NOT EXISTS ml_secondary_purchases (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
share_record_id UUID NOT NULL REFERENCES ml_share_records(id) ON DELETE CASCADE,
|
||||
buyer_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
order_id UUID NOT NULL REFERENCES ml_orders(id),
|
||||
quantity INT DEFAULT 1,
|
||||
unit_price DECIMAL(10,2),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(order_id, share_record_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_secondary_purchases_share_record_id ON ml_secondary_purchases(share_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_secondary_purchases_buyer_id ON ml_secondary_purchases(buyer_id);
|
||||
|
||||
COMMENT ON TABLE ml_secondary_purchases IS '二级用户购买记录表';
|
||||
|
||||
-- 3. 免单奖励记录表
|
||||
CREATE TABLE IF NOT EXISTS ml_free_order_rewards (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
share_record_id UUID NOT NULL REFERENCES ml_share_records(id),
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
status INT DEFAULT 0,
|
||||
balance_record_id UUID REFERENCES ml_balance_records(id),
|
||||
cleared_at TIMESTAMPTZ,
|
||||
cleared_by UUID REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_free_order_rewards_user_id ON ml_free_order_rewards(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_free_order_rewards_status ON ml_free_order_rewards(status);
|
||||
|
||||
COMMENT ON TABLE ml_free_order_rewards IS '免单奖励记录表';
|
||||
COMMENT ON COLUMN ml_free_order_rewards.status IS '状态:0-待发放,1-已发放,2-已清零';
|
||||
|
||||
-- =====================================================
|
||||
-- 三、会员等级系统
|
||||
-- =====================================================
|
||||
|
||||
-- 1. 会员等级配置表
|
||||
CREATE TABLE IF NOT EXISTS ml_member_levels (
|
||||
id INT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
min_amount DECIMAL(10,2) DEFAULT 0,
|
||||
discount DECIMAL(5,4) DEFAULT 1.0000,
|
||||
icon VARCHAR(200),
|
||||
description TEXT,
|
||||
sort_order INT DEFAULT 0,
|
||||
status INT DEFAULT 1,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ml_member_levels IS '会员等级配置表';
|
||||
COMMENT ON COLUMN ml_member_levels.discount IS '折扣率,0.85表示85折';
|
||||
|
||||
-- 初始化会员等级数据
|
||||
INSERT INTO ml_member_levels (id, name, min_amount, discount, description, sort_order) VALUES
|
||||
(0, '普通会员', 0, 1.0000, '注册即可成为普通会员', 0),
|
||||
(1, '铜牌会员', 500, 0.9800, '累计消费500元升级', 1),
|
||||
(2, '银牌会员', 2000, 0.9500, '累计消费2000元升级', 2),
|
||||
(3, '金牌会员', 5000, 0.9200, '累计消费5000元升级', 3),
|
||||
(4, '钻石会员', 10000, 0.8800, '累计消费10000元升级', 4),
|
||||
(5, 'VIP会员', 0, 0.8500, '商家特邀会员', 5)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 2. 用户会员信息扩展字段(添加到 ml_user_profiles)
|
||||
ALTER TABLE ml_user_profiles ADD COLUMN IF NOT EXISTS member_level INT DEFAULT 0;
|
||||
ALTER TABLE ml_user_profiles ADD COLUMN IF NOT EXISTS total_spent DECIMAL(10,2) DEFAULT 0;
|
||||
ALTER TABLE ml_user_profiles ADD COLUMN IF NOT EXISTS level_updated_at TIMESTAMPTZ;
|
||||
ALTER TABLE ml_user_profiles ADD COLUMN IF NOT EXISTS manual_level BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE ml_user_profiles ADD COLUMN IF NOT EXISTS manual_level_by UUID;
|
||||
ALTER TABLE ml_user_profiles ADD COLUMN IF NOT EXISTS manual_level_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN ml_user_profiles.member_level IS '当前会员等级';
|
||||
COMMENT ON COLUMN ml_user_profiles.total_spent IS '累计消费金额';
|
||||
COMMENT ON COLUMN ml_user_profiles.manual_level IS '是否手动设置等级';
|
||||
|
||||
-- 3. 会员等级变更记录表
|
||||
CREATE TABLE IF NOT EXISTS ml_member_level_logs (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
old_level INT DEFAULT 0,
|
||||
new_level INT NOT NULL,
|
||||
reason VARCHAR(200),
|
||||
operator_id UUID REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_member_level_logs_user_id ON ml_member_level_logs(user_id);
|
||||
|
||||
COMMENT ON TABLE ml_member_level_logs IS '会员等级变更记录表';
|
||||
|
||||
-- =====================================================
|
||||
-- 四、RLS 策略
|
||||
-- =====================================================
|
||||
|
||||
-- 用户余额表 RLS
|
||||
ALTER TABLE ml_user_balance ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own balance"
|
||||
ON ml_user_balance FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can insert own balance"
|
||||
ON ml_user_balance FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can update own balance"
|
||||
ON ml_user_balance FOR UPDATE
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- 余额记录表 RLS
|
||||
ALTER TABLE ml_balance_records ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own balance records"
|
||||
ON ml_balance_records FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can insert own balance records"
|
||||
ON ml_balance_records FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- 分享记录表 RLS
|
||||
ALTER TABLE ml_share_records ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own share records"
|
||||
ON ml_share_records FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can insert own share records"
|
||||
ON ml_share_records FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can update own share records"
|
||||
ON ml_share_records FOR UPDATE
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- 允许通过分享码查询(用于验证分享码)
|
||||
CREATE POLICY "Anyone can view by share code"
|
||||
ON ml_share_records FOR SELECT
|
||||
TO authenticated, anon
|
||||
USING (share_code IS NOT NULL);
|
||||
|
||||
-- 二级购买记录表 RLS
|
||||
ALTER TABLE ml_secondary_purchases ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own secondary purchases"
|
||||
ON ml_secondary_purchases FOR SELECT
|
||||
TO authenticated
|
||||
USING (buyer_id = auth.uid() OR EXISTS (
|
||||
SELECT 1 FROM ml_share_records WHERE id = share_record_id AND user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Users can insert secondary purchases"
|
||||
ON ml_secondary_purchases FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (true);
|
||||
|
||||
-- 免单奖励记录表 RLS
|
||||
ALTER TABLE ml_free_order_rewards ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own rewards"
|
||||
ON ml_free_order_rewards FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- 会员等级配置表 RLS(所有人可查看)
|
||||
ALTER TABLE ml_member_levels ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Anyone can view member levels"
|
||||
ON ml_member_levels FOR SELECT
|
||||
TO authenticated, anon
|
||||
USING (status = 1);
|
||||
|
||||
-- 会员等级变更记录表 RLS
|
||||
ALTER TABLE ml_member_level_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own level logs"
|
||||
ON ml_member_level_logs FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- =====================================================
|
||||
-- 五、数据库函数
|
||||
-- =====================================================
|
||||
|
||||
-- 1. 生成分享码函数
|
||||
CREATE OR REPLACE FUNCTION generate_share_code()
|
||||
RETURNS VARCHAR(20) AS $$
|
||||
DECLARE
|
||||
chars TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
result VARCHAR(20) := '';
|
||||
i INT;
|
||||
BEGIN
|
||||
FOR i IN 1..8 LOOP
|
||||
result := result || substr(chars, floor(random() * length(chars) + 1)::int, 1);
|
||||
END LOOP;
|
||||
RETURN result;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 2. 增加余额函数
|
||||
CREATE OR REPLACE FUNCTION add_user_balance(
|
||||
p_user_id UUID,
|
||||
p_amount DECIMAL(10,2),
|
||||
p_type VARCHAR(50),
|
||||
p_related_id UUID DEFAULT NULL,
|
||||
p_description VARCHAR(200) DEFAULT NULL,
|
||||
p_operator_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
v_balance_before DECIMAL(10,2);
|
||||
v_balance_after DECIMAL(10,2);
|
||||
BEGIN
|
||||
-- 获取当前余额
|
||||
SELECT COALESCE(balance, 0) INTO v_balance_before
|
||||
FROM ml_user_balance
|
||||
WHERE user_id = p_user_id;
|
||||
|
||||
IF v_balance_before IS NULL THEN
|
||||
-- 创建余额记录
|
||||
INSERT INTO ml_user_balance (user_id, balance, total_earned)
|
||||
VALUES (p_user_id, p_amount, p_amount);
|
||||
v_balance_before := 0;
|
||||
v_balance_after := p_amount;
|
||||
ELSE
|
||||
-- 更新余额
|
||||
UPDATE ml_user_balance
|
||||
SET balance = balance + p_amount,
|
||||
total_earned = total_earned + CASE WHEN p_amount > 0 THEN p_amount ELSE 0 END,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = p_user_id;
|
||||
v_balance_after := v_balance_before + p_amount;
|
||||
END IF;
|
||||
|
||||
-- 记录变动
|
||||
INSERT INTO ml_balance_records (user_id, type, amount, balance_before, balance_after, related_id, description, operator_id)
|
||||
VALUES (p_user_id, p_type, p_amount, v_balance_before, v_balance_after, p_related_id, p_description, p_operator_id);
|
||||
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- 3. 清零用户余额函数
|
||||
CREATE OR REPLACE FUNCTION clear_user_balance(
|
||||
p_user_id UUID,
|
||||
p_operator_id UUID,
|
||||
p_description VARCHAR(200) DEFAULT '商家清零余额'
|
||||
)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
v_balance_before DECIMAL(10,2);
|
||||
BEGIN
|
||||
-- 获取当前余额
|
||||
SELECT balance INTO v_balance_before
|
||||
FROM ml_user_balance
|
||||
WHERE user_id = p_user_id;
|
||||
|
||||
IF v_balance_before IS NULL OR v_balance_before = 0 THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
-- 清零余额
|
||||
UPDATE ml_user_balance
|
||||
SET balance = 0,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = p_user_id;
|
||||
|
||||
-- 记录变动
|
||||
INSERT INTO ml_balance_records (user_id, type, amount, balance_before, balance_after, description, operator_id)
|
||||
VALUES (p_user_id, 'clear', -v_balance_before, v_balance_before, 0, p_description, p_operator_id);
|
||||
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- 4. 检查并升级会员等级函数
|
||||
CREATE OR REPLACE FUNCTION check_and_upgrade_member_level(p_user_id UUID)
|
||||
RETURNS INT AS $$
|
||||
DECLARE
|
||||
v_total_spent DECIMAL(10,2);
|
||||
v_current_level INT;
|
||||
v_new_level INT;
|
||||
v_manual_level BOOLEAN;
|
||||
BEGIN
|
||||
-- 获取用户当前信息
|
||||
SELECT member_level, total_spent, manual_level
|
||||
INTO v_current_level, v_total_spent, v_manual_level
|
||||
FROM ml_user_profiles
|
||||
WHERE user_id = p_user_id;
|
||||
|
||||
-- 如果是手动设置的等级,不自动升级
|
||||
IF v_manual_level = TRUE THEN
|
||||
RETURN v_current_level;
|
||||
END IF;
|
||||
|
||||
-- 根据消费金额计算新等级
|
||||
SELECT id INTO v_new_level
|
||||
FROM ml_member_levels
|
||||
WHERE status = 1 AND min_amount <= COALESCE(v_total_spent, 0)
|
||||
ORDER BY min_amount DESC
|
||||
LIMIT 1;
|
||||
|
||||
IF v_new_level IS NULL THEN
|
||||
v_new_level := 0;
|
||||
END IF;
|
||||
|
||||
-- 如果等级有变化,更新并记录
|
||||
IF v_new_level > COALESCE(v_current_level, 0) THEN
|
||||
UPDATE ml_user_profiles
|
||||
SET member_level = v_new_level,
|
||||
level_updated_at = NOW()
|
||||
WHERE user_id = p_user_id;
|
||||
|
||||
INSERT INTO ml_member_level_logs (user_id, old_level, new_level, reason)
|
||||
VALUES (p_user_id, COALESCE(v_current_level, 0), v_new_level, '累计消费自动升级');
|
||||
|
||||
RETURN v_new_level;
|
||||
END IF;
|
||||
|
||||
RETURN COALESCE(v_current_level, 0);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- 5. 处理分享购买函数
|
||||
CREATE OR REPLACE FUNCTION process_share_purchase(
|
||||
p_share_code VARCHAR(20),
|
||||
p_buyer_id UUID,
|
||||
p_order_id UUID,
|
||||
p_quantity INT,
|
||||
p_unit_price DECIMAL(10,2)
|
||||
)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
v_share_record ml_share_records%ROWTYPE;
|
||||
v_required_count INT;
|
||||
v_reward_amount DECIMAL(10,2);
|
||||
BEGIN
|
||||
-- 查找分享记录
|
||||
SELECT * INTO v_share_record
|
||||
FROM ml_share_records
|
||||
WHERE share_code = p_share_code AND status = 0;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
-- 不能购买自己分享的商品
|
||||
IF v_share_record.user_id = p_buyer_id THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
-- 检查是否已购买过(同一用户对同一分享记录只算一次)
|
||||
IF EXISTS (SELECT 1 FROM ml_secondary_purchases WHERE share_record_id = v_share_record.id AND buyer_id = p_buyer_id) THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
-- 记录购买
|
||||
INSERT INTO ml_secondary_purchases (share_record_id, buyer_id, order_id, quantity, unit_price)
|
||||
VALUES (v_share_record.id, p_buyer_id, p_order_id, p_quantity, p_unit_price);
|
||||
|
||||
-- 更新计数
|
||||
UPDATE ml_share_records
|
||||
SET current_count = current_count + 1
|
||||
WHERE id = v_share_record.id;
|
||||
|
||||
-- 检查是否达标
|
||||
SELECT current_count, required_count INTO v_required_count, v_required_count
|
||||
FROM ml_share_records WHERE id = v_share_record.id;
|
||||
|
||||
IF v_share_record.current_count + 1 >= v_share_record.required_count THEN
|
||||
-- 计算奖励金额
|
||||
v_reward_amount := v_share_record.product_price;
|
||||
|
||||
-- 更新分享记录状态
|
||||
UPDATE ml_share_records
|
||||
SET status = 1, completed_at = NOW(), reward_amount = v_reward_amount
|
||||
WHERE id = v_share_record.id;
|
||||
|
||||
-- 创建奖励记录
|
||||
INSERT INTO ml_free_order_rewards (user_id, share_record_id, amount)
|
||||
VALUES (v_share_record.user_id, v_share_record.id, v_reward_amount);
|
||||
|
||||
-- 增加用户余额
|
||||
PERFORM add_user_balance(
|
||||
v_share_record.user_id,
|
||||
v_reward_amount,
|
||||
'free_order',
|
||||
v_share_record.id,
|
||||
'分享免单奖励:' || v_share_record.product_name
|
||||
);
|
||||
|
||||
-- 更新奖励记录状态
|
||||
UPDATE ml_free_order_rewards
|
||||
SET status = 1
|
||||
WHERE share_record_id = v_share_record.id;
|
||||
END IF;
|
||||
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- =====================================================
|
||||
-- 六、触发器
|
||||
-- =====================================================
|
||||
|
||||
-- 订单完成后更新用户累计消费并检查会员等级
|
||||
CREATE OR REPLACE FUNCTION update_user_total_spent()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 4 AND (OLD.status != 4 OR OLD.status IS NULL) THEN
|
||||
-- 更新累计消费
|
||||
UPDATE ml_user_profiles
|
||||
SET total_spent = COALESCE(total_spent, 0) + NEW.total_amount
|
||||
WHERE user_id = NEW.user_id;
|
||||
|
||||
-- 检查会员等级
|
||||
PERFORM check_and_upgrade_member_level(NEW.user_id);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 创建触发器(如果不存在)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'trigger_update_user_total_spent'
|
||||
) THEN
|
||||
CREATE TRIGGER trigger_update_user_total_spent
|
||||
AFTER UPDATE ON ml_orders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_user_total_spent();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =====================================================
|
||||
-- 七、视图
|
||||
-- =====================================================
|
||||
|
||||
-- 用户余额概览视图
|
||||
CREATE OR REPLACE VIEW ml_user_balance_overview AS
|
||||
SELECT
|
||||
ub.user_id,
|
||||
ub.balance,
|
||||
ub.frozen_balance,
|
||||
ub.total_earned,
|
||||
ub.total_withdrawn,
|
||||
up.member_level,
|
||||
ml.name as member_level_name,
|
||||
ml.discount as member_discount,
|
||||
up.total_spent
|
||||
FROM ml_user_balance ub
|
||||
LEFT JOIN ml_user_profiles up ON ub.user_id = up.user_id
|
||||
LEFT JOIN ml_member_levels ml ON up.member_level = ml.id;
|
||||
|
||||
-- 分享进度视图
|
||||
CREATE OR REPLACE VIEW ml_share_progress_view AS
|
||||
SELECT
|
||||
sr.id,
|
||||
sr.user_id,
|
||||
sr.product_id,
|
||||
sr.product_name,
|
||||
sr.product_image,
|
||||
sr.product_price,
|
||||
sr.share_code,
|
||||
sr.required_count,
|
||||
sr.current_count,
|
||||
sr.status,
|
||||
sr.reward_amount,
|
||||
sr.created_at,
|
||||
sr.completed_at,
|
||||
sr.required_count - sr.current_count as remaining_count
|
||||
FROM ml_share_records sr;
|
||||
54
pages.json
54
pages.json
@@ -258,6 +258,60 @@
|
||||
"navigationBarTitleText": "积分管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "points/signin",
|
||||
"style": {
|
||||
"navigationBarTitleText": "每日签到"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "points/exchange",
|
||||
"style": {
|
||||
"navigationBarTitleText": "积分兑换"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "points/exchange-records",
|
||||
"style": {
|
||||
"navigationBarTitleText": "兑换记录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "product-reviews",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品评价"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "my-reviews",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的评价"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "balance/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的余额"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "share/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的分享"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "share/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "分享详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "member/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会员中心"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "red-packets/index",
|
||||
"style": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 消费者端 - 个人中心 -->
|
||||
<!-- 消费者端 - 个人中心 -->
|
||||
<template>
|
||||
<view class="consumer-profile">
|
||||
<!-- 智能顶部导航栏 - 与消息页保持一致 -->
|
||||
@@ -79,9 +79,21 @@
|
||||
<text class="service-icon">⭐</text>
|
||||
<text class="service-text">关注店铺</text>
|
||||
</view>
|
||||
<view class="service-item" @click="goToSubscriptions">
|
||||
<text class="service-icon">📱</text>
|
||||
<text class="service-text">软件订阅</text>
|
||||
<view class="service-item" @click="goToPoints">
|
||||
<text class="service-icon">💰</text>
|
||||
<text class="service-text">我的积分</text>
|
||||
</view>
|
||||
<view class="service-item" @click="goToBalance">
|
||||
<text class="service-icon">💵</text>
|
||||
<text class="service-text">我的余额</text>
|
||||
</view>
|
||||
<view class="service-item" @click="goToShare">
|
||||
<text class="service-icon">🔗</text>
|
||||
<text class="service-text">我的分享</text>
|
||||
</view>
|
||||
<view class="service-item" @click="goToMember">
|
||||
<text class="service-icon">👑</text>
|
||||
<text class="service-text">会员中心</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -572,14 +584,16 @@ export default {
|
||||
}
|
||||
|
||||
// 获取积分和余额(并行获取)
|
||||
const [balance, points] = await Promise.all([
|
||||
const [balanceResult, points] = await Promise.all([
|
||||
supabaseService.getUserBalance(),
|
||||
supabaseService.getUserPoints()
|
||||
])
|
||||
|
||||
const balanceValue = balanceResult.getNumber('balance') ?? 0
|
||||
|
||||
this.userStats = {
|
||||
points: points,
|
||||
balance: balance,
|
||||
balance: balanceValue,
|
||||
level: this.calculateLevel(points) // 根据积分计算等级
|
||||
} as UserStatsType
|
||||
|
||||
@@ -1169,9 +1183,27 @@ export default {
|
||||
url: '/pages/mall/consumer/subscription/followed-shops'
|
||||
})
|
||||
},
|
||||
goToSubscriptions() {
|
||||
goToPoints() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/subscription/plan-list'
|
||||
url: '/pages/mall/consumer/points/index'
|
||||
})
|
||||
},
|
||||
|
||||
goToBalance() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/balance/index'
|
||||
})
|
||||
},
|
||||
|
||||
goToShare() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/share/index'
|
||||
})
|
||||
},
|
||||
|
||||
goToMember() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/member/index'
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
383
pages/mall/consumer/balance/index.uvue
Normal file
383
pages/mall/consumer/balance/index.uvue
Normal file
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<scroll-view class="balance-page" scroll-y>
|
||||
<view class="balance-header">
|
||||
<view class="balance-info">
|
||||
<text class="balance-label">账户余额(元)</text>
|
||||
<text class="balance-value">{{ balance }}</text>
|
||||
</view>
|
||||
<view class="balance-tips">
|
||||
<text class="tips-text">余额来源于免单奖励,请联系商家微信提现</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="stats-section">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ totalEarned }}</text>
|
||||
<text class="stat-label">累计获得</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ totalWithdrawn }}</text>
|
||||
<text class="stat-label">已提现</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="records-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">余额明细</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="records.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无余额记录</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="record-list">
|
||||
<view class="record-item" v-for="record in records" :key="record.id">
|
||||
<view class="record-left">
|
||||
<text class="record-type">{{ getTypeText(record.type) }}</text>
|
||||
<text class="record-time">{{ formatTime(record.created_at) }}</text>
|
||||
</view>
|
||||
<view class="record-right">
|
||||
<text class="record-amount" :class="record.amount > 0 ? 'positive' : 'negative'">
|
||||
{{ record.amount > 0 ? '+' : '' }}{{ record.amount }}
|
||||
</text>
|
||||
<text class="record-balance">余额: {{ record.balance_after }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="withdraw-section">
|
||||
<button class="withdraw-btn" @click="showWithdrawTips">
|
||||
<text class="btn-text">申请提现</text>
|
||||
</button>
|
||||
<text class="withdraw-tip">提现请联系商家微信处理</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type BalanceRecord = {
|
||||
id: string
|
||||
type: string
|
||||
amount: number
|
||||
balance_before: number
|
||||
balance_after: number
|
||||
description: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const balance = ref<number>(0)
|
||||
const totalEarned = ref<number>(0)
|
||||
const totalWithdrawn = ref<number>(0)
|
||||
const records = ref<BalanceRecord[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
|
||||
const loadBalance = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.getUserBalance()
|
||||
balance.value = result.getNumber('balance') ?? 0
|
||||
totalEarned.value = result.getNumber('total_earned') ?? 0
|
||||
totalWithdrawn.value = result.getNumber('total_withdrawn') ?? 0
|
||||
} catch (e) {
|
||||
console.error('加载余额失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecords = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await supabaseService.getBalanceRecords(1, 50)
|
||||
const parsed: BalanceRecord[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
const itemAny = item as any
|
||||
|
||||
let id = ''
|
||||
let type = ''
|
||||
let amount = 0
|
||||
let balanceBefore = 0
|
||||
let balanceAfter = 0
|
||||
let description: string | null = null
|
||||
let createdAt = ''
|
||||
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
id = (itemAny._getValue('id') as string) ?? ''
|
||||
type = (itemAny._getValue('type') as string) ?? ''
|
||||
amount = (itemAny._getValue('amount') as number) ?? 0
|
||||
balanceBefore = (itemAny._getValue('balance_before') as number) ?? 0
|
||||
balanceAfter = (itemAny._getValue('balance_after') as number) ?? 0
|
||||
description = itemAny._getValue('description') as string | null
|
||||
createdAt = (itemAny._getValue('created_at') as string) ?? ''
|
||||
}
|
||||
|
||||
parsed.push({
|
||||
id,
|
||||
type,
|
||||
amount,
|
||||
balance_before: balanceBefore,
|
||||
balance_after: balanceAfter,
|
||||
description,
|
||||
created_at: createdAt
|
||||
})
|
||||
}
|
||||
|
||||
records.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载余额记录失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async (): Promise<void> => {
|
||||
await Promise.all([
|
||||
loadBalance(),
|
||||
loadRecords()
|
||||
])
|
||||
}
|
||||
|
||||
const getTypeText = (type: string): string => {
|
||||
if (type === 'free_order') return '免单奖励'
|
||||
if (type === 'rebate') return '返利'
|
||||
if (type === 'withdraw') return '提现'
|
||||
if (type === 'clear') return '余额清零'
|
||||
if (type === 'manual') return '手动调整'
|
||||
return '余额变动'
|
||||
}
|
||||
|
||||
const formatTime = (timeStr: string): string => {
|
||||
if (timeStr === '') return ''
|
||||
const date = new Date(timeStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
const hh = date.getHours().toString().padStart(2, '0')
|
||||
const mm = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
const showWithdrawTips = (): void => {
|
||||
uni.showModal({
|
||||
title: '提现说明',
|
||||
content: '请添加商家微信进行提现处理,商家确认后将通过微信转账给您。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.balance-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.balance-header {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
padding: 30px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.balance-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.balance-label {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.balance-value {
|
||||
font-size: 42px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.balance-tips {
|
||||
margin-top: 16px;
|
||||
padding: 8px 16px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.tips-text {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.records-section {
|
||||
background-color: white;
|
||||
padding: 0 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 40px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
}
|
||||
|
||||
.record-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.record-type {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.record-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.record-amount {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.record-amount.positive {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.record-amount.negative {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.record-balance {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.withdraw-section {
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.withdraw-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
border-radius: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.withdraw-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
373
pages/mall/consumer/doc/推销模式功能需求文档.md
Normal file
373
pages/mall/consumer/doc/推销模式功能需求文档.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# 商城推销模式功能需求文档
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
本文档描述商城消费者端的推销模式功能,包含以下三大模块:
|
||||
1. **分享免单系统** - 用户分享商品,达成条件后免单
|
||||
2. **会员等级系统** - 用户等级享受不同优惠价格
|
||||
3. **经销点返利系统** - 经销点销售返利
|
||||
|
||||
---
|
||||
|
||||
## 二、分享免单系统
|
||||
|
||||
### 2.1 功能描述
|
||||
|
||||
用户购买商品后,可分享商品链接给其他用户(二级用户)。当二级用户通过分享链接购买该商品累计达到指定数量(默认4件)时,原用户可获得免单奖励,免单金额存入账户余额。
|
||||
|
||||
### 2.2 业务流程
|
||||
|
||||
```
|
||||
用户A购买商品 → 生成分享链接 → 分享给用户B/C/D
|
||||
↓
|
||||
二级用户通过链接购买
|
||||
↓
|
||||
累计购买数量达标(4件)
|
||||
↓
|
||||
用户A获得免单奖励
|
||||
↓
|
||||
奖励金额存入余额
|
||||
↓
|
||||
商家微信返现后清零余额
|
||||
```
|
||||
|
||||
### 2.3 数据库设计
|
||||
|
||||
#### 2.3.1 分享记录表 ml_share_records
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 分享用户ID |
|
||||
| product_id | UUID | 商品ID |
|
||||
| order_id | UUID | 关联订单ID |
|
||||
| share_code | VARCHAR(20) | 分享码(唯一) |
|
||||
| required_count | INT | 需要的购买数量(默认4) |
|
||||
| current_count | INT | 当前已购买数量 |
|
||||
| status | INT | 状态:0-进行中,1-已完成,2-已失效 |
|
||||
| reward_amount | DECIMAL(10,2) | 奖励金额 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
| completed_at | TIMESTAMPTZ | 完成时间 |
|
||||
|
||||
#### 2.3.2 二级购买记录表 ml_secondary_purchases
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| share_record_id | UUID | 关联分享记录ID |
|
||||
| buyer_id | UUID | 购买用户ID |
|
||||
| order_id | UUID | 订单ID |
|
||||
| quantity | INT | 购买数量 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
#### 2.3.3 免单奖励记录表 ml_free_order_rewards
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 获得奖励的用户ID |
|
||||
| share_record_id | UUID | 关联分享记录ID |
|
||||
| amount | DECIMAL(10,2) | 奖励金额 |
|
||||
| status | INT | 状态:0-待处理,1-已发放,2-已清零 |
|
||||
| balance_before | DECIMAL(10,2) | 发放前余额 |
|
||||
| balance_after | DECIMAL(10,2) | 发放后余额 |
|
||||
| cleared_at | TIMESTAMPTZ | 清零时间 |
|
||||
| cleared_by | UUID | 清零操作人 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
### 2.4 API 接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| /api/share/create | POST | 创建分享记录,生成分享码 |
|
||||
| /api/share/info | GET | 获取分享详情和进度 |
|
||||
| /api/share/validate | GET | 验证分享码有效性 |
|
||||
| /api/share/my-records | GET | 获取我的分享记录列表 |
|
||||
| /api/share/rewards | GET | 获取我的免单奖励记录 |
|
||||
| /api/admin/clear-balance | POST | 商家清零用户余额(后台) |
|
||||
|
||||
### 2.5 前端页面
|
||||
|
||||
#### 2.5.1 分享弹窗(订单详情页)
|
||||
- 显示分享链接/二维码
|
||||
- 显示当前进度(X/4)
|
||||
- 分享按钮(微信好友、朋友圈)
|
||||
|
||||
#### 2.5.2 我的分享页
|
||||
- 分享记录列表
|
||||
- 每条记录显示:商品信息、进度、状态
|
||||
- 邀请好友按钮
|
||||
|
||||
#### 2.5.3 我的余额页
|
||||
- 余额显示
|
||||
- 免单奖励记录
|
||||
- 提现说明(联系商家微信)
|
||||
|
||||
---
|
||||
|
||||
## 三、会员等级系统
|
||||
|
||||
### 3.1 功能描述
|
||||
|
||||
商家可设置多个会员等级,每个等级对应不同的优惠折扣。用户可通过以下方式升级:
|
||||
1. 商家手动设置等级
|
||||
2. 累计消费金额自动升级
|
||||
|
||||
### 3.2 等级设置
|
||||
|
||||
| 等级 | 名称 | 升级条件 | 折扣 |
|
||||
|------|------|---------|------|
|
||||
| 0 | 普通会员 | 注册即可 | 无折扣 |
|
||||
| 1 | 铜牌会员 | 累计消费500元 | 98折 |
|
||||
| 2 | 银牌会员 | 累计消费2000元 | 95折 |
|
||||
| 3 | 金牌会员 | 累计消费5000元 | 92折 |
|
||||
| 4 | 钻石会员 | 累计消费10000元 | 88折 |
|
||||
| 5 | VIP会员 | 商家手动设置 | 85折 |
|
||||
|
||||
### 3.3 数据库设计
|
||||
|
||||
#### 3.3.1 会员等级配置表 ml_member_levels
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | INT | 等级ID |
|
||||
| name | VARCHAR(50) | 等级名称 |
|
||||
| min_amount | DECIMAL(10,2) | 升级最低消费金额 |
|
||||
| discount | DECIMAL(5,4) | 折扣率(0.85表示85折) |
|
||||
| icon | VARCHAR(200) | 等级图标 |
|
||||
| description | TEXT | 等级说明 |
|
||||
| sort_order | INT | 排序 |
|
||||
| status | INT | 状态:0-禁用,1-启用 |
|
||||
|
||||
#### 3.3.2 用户会员信息扩展(ml_user_profiles 扩展字段)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| member_level | INT | 当前会员等级 |
|
||||
| total_spent | DECIMAL(10,2) | 累计消费金额 |
|
||||
| level_updated_at | TIMESTAMPTZ | 等级更新时间 |
|
||||
| manual_level | BOOLEAN | 是否手动设置等级 |
|
||||
| manual_level_by | UUID | 手动设置操作人 |
|
||||
|
||||
#### 3.3.3 会员等级变更记录表 ml_member_level_logs
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 用户ID |
|
||||
| old_level | INT | 原等级 |
|
||||
| new_level | INT | 新等级 |
|
||||
| reason | VARCHAR(200) | 变更原因 |
|
||||
| operator_id | UUID | 操作人(自动则为空) |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
### 3.4 API 接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| /api/member/levels | GET | 获取会员等级列表 |
|
||||
| /api/member/my-info | GET | 获取我的会员信息 |
|
||||
| /api/member/upgrade-check | POST | 检查并升级会员等级 |
|
||||
| /api/admin/set-member-level | POST | 商家手动设置用户等级 |
|
||||
|
||||
### 3.5 前端页面
|
||||
|
||||
#### 3.5.1 会员中心页
|
||||
- 当前等级显示
|
||||
- 等级进度条
|
||||
- 等级权益说明
|
||||
- 升级攻略
|
||||
|
||||
#### 3.5.2 商品详情页
|
||||
- 显示会员价
|
||||
- 显示折扣信息
|
||||
|
||||
---
|
||||
|
||||
## 四、经销点返利系统
|
||||
|
||||
### 4.1 功能描述
|
||||
|
||||
商家可创建多个经销点,每个经销点有独立的推广码。经销点通过推广码产生的订单可获得返利,返利按单数计算。
|
||||
|
||||
### 4.2 返利规则
|
||||
|
||||
| 单数范围 | 返利金额/单 |
|
||||
|---------|------------|
|
||||
| 1-10单 | 2元/单 |
|
||||
| 11-50单 | 3元/单 |
|
||||
| 51-100单 | 4元/单 |
|
||||
| 100单以上 | 5元/单 |
|
||||
|
||||
### 4.3 数据库设计
|
||||
|
||||
#### 4.3.1 经销点表 ml_distribution_points
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| name | VARCHAR(100) | 经销点名称 |
|
||||
| contact_name | VARCHAR(50) | 联系人 |
|
||||
| contact_phone | VARCHAR(20) | 联系电话 |
|
||||
| address | VARCHAR(200) | 地址 |
|
||||
| invite_code | VARCHAR(20) | 邀请码(唯一) |
|
||||
| owner_id | UUID | 负责人用户ID |
|
||||
| status | INT | 状态:0-禁用,1-启用 |
|
||||
| total_orders | INT | 累计订单数 |
|
||||
| total_rebate | DECIMAL(10,2) | 累计返利 |
|
||||
| balance | DECIMAL(10,2) | 可提现余额 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
#### 4.3.2 经销点订单关联表 ml_distribution_orders
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| distribution_id | UUID | 经销点ID |
|
||||
| order_id | UUID | 订单ID |
|
||||
| user_id | UUID | 下单用户ID |
|
||||
| order_amount | DECIMAL(10,2) | 订单金额 |
|
||||
| rebate_amount | DECIMAL(10,2) | 返利金额 |
|
||||
| status | INT | 状态:0-待结算,1-已结算 |
|
||||
| settled_at | TIMESTAMPTZ | 结算时间 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
#### 4.3.3 返利配置表 ml_rebate_config
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| min_orders | INT | 最小单数 |
|
||||
| max_orders | INT | 最大单数 |
|
||||
| rebate_per_order | DECIMAL(10,2) | 每单返利金额 |
|
||||
| status | INT | 状态 |
|
||||
|
||||
#### 4.3.4 返利提现记录表 ml_rebate_withdrawals
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| distribution_id | UUID | 经销点ID |
|
||||
| amount | DECIMAL(10,2) | 提现金额 |
|
||||
| status | INT | 状态:0-待处理,1-已完成,2-已拒绝 |
|
||||
| handled_by | UUID | 处理人 |
|
||||
| handled_at | TIMESTAMPTZ | 处理时间 |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
### 4.4 API 接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| /api/distribution/info | GET | 获取经销点信息 |
|
||||
| /api/distribution/orders | GET | 获取经销点订单列表 |
|
||||
| /api/distribution/rebate-summary | GET | 获取返利统计 |
|
||||
| /api/distribution/withdraw | POST | 申请提现 |
|
||||
| /api/admin/distributions | GET | 获取经销点列表(后台) |
|
||||
| /api/admin/create-distribution | POST | 创建经销点(后台) |
|
||||
| /api/admin/settle-rebate | POST | 结算返利(后台) |
|
||||
|
||||
### 4.5 前端页面
|
||||
|
||||
#### 4.5.1 经销点中心页(经销点负责人)
|
||||
- 经销点信息
|
||||
- 今日/本月订单数
|
||||
- 累计返利
|
||||
- 可提现余额
|
||||
- 申请提现按钮
|
||||
|
||||
#### 4.5.2 经销点订单页
|
||||
- 订单列表
|
||||
- 筛选(日期、状态)
|
||||
- 每单返利金额显示
|
||||
|
||||
---
|
||||
|
||||
## 五、用户余额系统
|
||||
|
||||
### 5.1 数据库设计
|
||||
|
||||
#### 5.1.1 用户余额表 ml_user_balance
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 用户ID |
|
||||
| balance | DECIMAL(10,2) | 当前余额 |
|
||||
| frozen_balance | DECIMAL(10,2) | 冻结余额 |
|
||||
| total_earned | DECIMAL(10,2) | 累计获得 |
|
||||
| total_withdrawn | DECIMAL(10,2) | 累计提现 |
|
||||
| updated_at | TIMESTAMPTZ | 更新时间 |
|
||||
|
||||
#### 5.1.2 余额变动记录表 ml_balance_records
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| user_id | UUID | 用户ID |
|
||||
| type | VARCHAR(50) | 类型:free_order-免单,rebate-返利,withdraw-提现,clear-清零 |
|
||||
| amount | DECIMAL(10,2) | 变动金额(正数增加,负数减少) |
|
||||
| balance_before | DECIMAL(10,2) | 变动前余额 |
|
||||
| balance_after | DECIMAL(10,2) | 变动后余额 |
|
||||
| related_id | UUID | 关联ID |
|
||||
| description | VARCHAR(200) | 描述 |
|
||||
| operator_id | UUID | 操作人(系统操作为空) |
|
||||
| created_at | TIMESTAMPTZ | 创建时间 |
|
||||
|
||||
---
|
||||
|
||||
## 六、前端页面汇总
|
||||
|
||||
### 6.1 消费者端新增页面
|
||||
|
||||
| 页面 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 我的分享 | /pages/mall/consumer/share/my-shares | 分享记录列表 |
|
||||
| 分享详情 | /pages/mall/consumer/share/detail | 分享进度详情 |
|
||||
| 我的余额 | /pages/mall/consumer/balance/index | 余额和奖励记录 |
|
||||
| 会员中心 | /pages/mall/consumer/member/index | 会员等级信息 |
|
||||
| 会员权益 | /pages/mall/consumer/member/benefits | 等级权益说明 |
|
||||
|
||||
### 6.2 经销点端页面(可选独立入口)
|
||||
|
||||
| 页面 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 经销点中心 | /pages/distribution/index | 经销点首页 |
|
||||
| 经销点订单 | /pages/distribution/orders | 订单列表 |
|
||||
| 返利记录 | /pages/distribution/rebates | 返利记录 |
|
||||
| 提现申请 | /pages/distribution/withdraw | 提现页面 |
|
||||
|
||||
---
|
||||
|
||||
## 七、开发优先级
|
||||
|
||||
### 第一阶段(核心功能)
|
||||
1. 用户余额系统
|
||||
2. 分享免单系统
|
||||
3. 会员等级系统
|
||||
|
||||
### 第二阶段(扩展功能)
|
||||
1. 经销点返利系统
|
||||
2. 后台管理功能
|
||||
3. 数据统计报表
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. **安全性**
|
||||
- 分享码唯一且不可预测
|
||||
- 防止刷单作弊(同一用户多次购买不计入)
|
||||
- 余额变动需有完整记录
|
||||
|
||||
2. **性能**
|
||||
- 高频查询使用缓存
|
||||
- 大数据量分页处理
|
||||
|
||||
3. **合规性**
|
||||
- 返利模式需符合当地法规
|
||||
- 用户协议需明确说明规则
|
||||
|
||||
4. **扩展性**
|
||||
- 返利规则可配置
|
||||
- 会员等级可扩展
|
||||
- 支持多种分享渠道
|
||||
629
pages/mall/consumer/doc/积分与评价功能完善需求文档.md
Normal file
629
pages/mall/consumer/doc/积分与评价功能完善需求文档.md
Normal file
@@ -0,0 +1,629 @@
|
||||
# 商城消费者端 - 积分与评价功能完善需求文档
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
### 1.1 项目背景
|
||||
本项目为商城消费者端应用,当前积分和评价功能已有基础实现,但存在部分功能缺失和体验优化空间。本文档旨在明确积分和评价功能的完善需求。
|
||||
|
||||
### 1.2 当前实现状态
|
||||
|
||||
| 功能模块 | 当前状态 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 消费者端积分页面 | ✅ 已完成 | 显示积分余额、积分明细列表 |
|
||||
| 积分兑换商城 | ❌ 未实现 | 点击"积分兑换"提示"开发中" |
|
||||
| 消费者端商品评价 | ✅ 已完成 | 支持评分、文字、图片、匿名评价 |
|
||||
| 消费者端店铺评价 | ✅ 已完成 | 支持描述/物流/服务三维评分 |
|
||||
| 商家端评价管理 | ✅ 已完成 | 支持查看、筛选、回复评价 |
|
||||
|
||||
---
|
||||
|
||||
## 二、积分功能完善需求
|
||||
|
||||
### 2.1 功能架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 积分系统架构 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 积分获取 │ │ 积分使用 │ │ 积分管理 │ │
|
||||
│ ├─────────────┤ ├─────────────┤ ├─────────────┤ │
|
||||
│ │ • 注册赠送 │ │ • 积分兑换 │ │ • 积分查询 │ │
|
||||
│ │ • 每日签到 │ │ • 订单抵扣 │ │ • 明细记录 │ │
|
||||
│ │ • 购物奖励 │ │ • 积分抽奖 │ │ • 过期处理 │ │
|
||||
│ │ • 评价奖励 │ │ │ │ • 积分规则 │ │
|
||||
│ │ • 邀请好友 │ │ │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 前端功能需求
|
||||
|
||||
#### 2.2.1 积分首页优化
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/index.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **积分概览卡片**
|
||||
- 显示当前可用积分
|
||||
- 显示即将过期积分(30天内)
|
||||
- 显示历史累计积分
|
||||
- 积分趋势图表(近7天/30天)
|
||||
|
||||
2. **快捷入口**
|
||||
- 签到入口(带签到状态提示)
|
||||
- 积分兑换入口
|
||||
- 积分规则说明
|
||||
|
||||
3. **积分明细列表**
|
||||
- 支持按类型筛选(获取/消费/过期)
|
||||
- 支持按时间范围筛选
|
||||
- 下拉刷新、上拉加载更多
|
||||
|
||||
#### 2.2.2 每日签到功能(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/signin.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **签到日历**
|
||||
- 显示当月签到记录
|
||||
- 连续签到天数统计
|
||||
- 签到奖励预览
|
||||
|
||||
2. **签到奖励规则**
|
||||
- 每日签到:+5积分
|
||||
- 连续签到7天:额外+20积分
|
||||
- 连续签到30天:额外+100积分
|
||||
|
||||
3. **签到弹窗**
|
||||
- 签到成功动画
|
||||
- 显示获得积分
|
||||
- 连续签到进度提示
|
||||
|
||||
#### 2.2.3 积分兑换商城(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/exchange.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **兑换商品列表**
|
||||
- 优惠券兑换
|
||||
- 实物商品兑换
|
||||
- 虚拟商品兑换(会员权益等)
|
||||
|
||||
2. **商品详情**
|
||||
- 商品图片、名称、描述
|
||||
- 兑换所需积分
|
||||
- 库存状态
|
||||
- 兑换记录
|
||||
|
||||
3. **兑换流程**
|
||||
- 积分不足提示
|
||||
- 确认兑换弹窗
|
||||
- 兑换成功/失败反馈
|
||||
- 物流跟踪(实物商品)
|
||||
|
||||
#### 2.2.4 积分规则页面(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/points/rules.uvue`
|
||||
|
||||
**需求描述**:
|
||||
- 积分获取规则说明
|
||||
- 积分使用规则说明
|
||||
- 积分有效期说明
|
||||
- 常见问题FAQ
|
||||
|
||||
### 2.3 后端API需求
|
||||
|
||||
#### 2.3.1 新增API接口
|
||||
|
||||
| 接口名称 | 请求方法 | 接口路径 | 说明 |
|
||||
|---------|---------|---------|------|
|
||||
| 签到 | POST | `/api/points/signin` | 用户每日签到 |
|
||||
| 获取签到记录 | GET | `/api/points/signin-records` | 获取月度签到记录 |
|
||||
| 获取兑换商品列表 | GET | `/api/points/exchange-products` | 获取可兑换商品 |
|
||||
| 积分兑换 | POST | `/api/points/exchange` | 兑换商品 |
|
||||
| 获取兑换记录 | GET | `/api/points/exchange-records` | 获取兑换历史 |
|
||||
| 积分过期提醒 | GET | `/api/points/expiring` | 获取即将过期积分 |
|
||||
|
||||
#### 2.3.2 接口详细设计
|
||||
|
||||
**签到接口**
|
||||
```typescript
|
||||
// 请求
|
||||
POST /api/points/signin
|
||||
Response: {
|
||||
success: boolean
|
||||
points: number // 本次获得积分
|
||||
continuous_days: number // 连续签到天数
|
||||
bonus_points: number // 额外奖励积分
|
||||
total_points: number // 当前总积分
|
||||
}
|
||||
```
|
||||
|
||||
**积分兑换接口**
|
||||
```typescript
|
||||
// 请求
|
||||
POST /api/points/exchange
|
||||
Body: {
|
||||
product_id: string // 兑换商品ID
|
||||
quantity: number // 兑换数量
|
||||
address_id?: string // 收货地址(实物商品)
|
||||
}
|
||||
Response: {
|
||||
success: boolean
|
||||
exchange_id: string // 兑换记录ID
|
||||
points_used: number // 消耗积分
|
||||
remaining_points: number // 剩余积分
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 数据库设计
|
||||
|
||||
#### 2.4.1 现有表结构
|
||||
|
||||
**用户积分表 (ml_user_points)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_user_points (
|
||||
user_id UUID NOT NULL PRIMARY KEY REFERENCES auth.users(id),
|
||||
points INT DEFAULT 0 NOT NULL, -- 当前可用积分
|
||||
total_earned INT DEFAULT 0, -- 历史累计获得积分
|
||||
total_used INT DEFAULT 0, -- 历史累计使用积分
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_user_points_user_id ON ml_user_points(user_id);
|
||||
```
|
||||
|
||||
**积分记录表 (ml_point_records)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_records (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
points INT NOT NULL, -- 变动积分 (正/负)
|
||||
balance INT NOT NULL, -- 变动后余额
|
||||
type VARCHAR(50) NOT NULL, -- 类型
|
||||
reference_id UUID, -- 关联ID(订单ID/兑换ID等)
|
||||
description TEXT,
|
||||
expires_at TIMESTAMPTZ, -- 过期时间
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_point_records_user_id ON ml_point_records(user_id);
|
||||
CREATE INDEX idx_point_records_type ON ml_point_records(type);
|
||||
CREATE INDEX idx_point_records_created_at ON ml_point_records(created_at);
|
||||
```
|
||||
|
||||
#### 2.4.2 新增表结构
|
||||
|
||||
**签到记录表 (ml_signin_records)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_signin_records (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
signin_date DATE NOT NULL, -- 签到日期
|
||||
points_earned INT DEFAULT 0, -- 获得积分
|
||||
bonus_points INT DEFAULT 0, -- 奖励积分
|
||||
continuous_days INT DEFAULT 1, -- 当次连续签到天数
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id, signin_date) -- 每用户每天只能签到一次
|
||||
);
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_signin_records_user_id ON ml_signin_records(user_id);
|
||||
CREATE INDEX idx_signin_records_date ON ml_signin_records(signin_date);
|
||||
```
|
||||
|
||||
**积分兑换商品表 (ml_point_products)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_products (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL, -- 商品名称
|
||||
description TEXT, -- 商品描述
|
||||
image_url VARCHAR(500), -- 商品图片
|
||||
product_type VARCHAR(50) NOT NULL, -- 类型: coupon/physical/virtual
|
||||
points_required INT NOT NULL, -- 所需积分
|
||||
original_price DECIMAL(10,2), -- 原价(展示用)
|
||||
stock INT DEFAULT 0, -- 库存
|
||||
status INT DEFAULT 1, -- 状态: 0=下架, 1=上架
|
||||
sort_order INT DEFAULT 0, -- 排序
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**积分兑换记录表 (ml_point_exchanges)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_exchanges (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
product_id UUID NOT NULL REFERENCES ml_point_products(id),
|
||||
quantity INT DEFAULT 1, -- 兑换数量
|
||||
points_used INT NOT NULL, -- 消耗积分
|
||||
status INT DEFAULT 0, -- 状态: 0=待处理, 1=已发货, 2=已完成, 3=已取消
|
||||
tracking_no VARCHAR(100), -- 物流单号
|
||||
address_id UUID, -- 收货地址
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**积分规则配置表 (ml_point_rules)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_point_rules (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
rule_type VARCHAR(50) NOT NULL, -- 规则类型
|
||||
rule_name VARCHAR(100) NOT NULL, -- 规则名称
|
||||
points INT NOT NULL, -- 积分值
|
||||
description TEXT, -- 规则说明
|
||||
config JSONB, -- 扩展配置
|
||||
status INT DEFAULT 1, -- 状态
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 初始化规则数据
|
||||
INSERT INTO ml_point_rules (rule_type, rule_name, points, description) VALUES
|
||||
('register', '注册赠送', 100, '新用户注册赠送积分'),
|
||||
('signin_daily', '每日签到', 5, '每日签到获得积分'),
|
||||
('signin_continuous_7', '连续签到7天奖励', 20, '连续签到7天额外奖励'),
|
||||
('signin_continuous_30', '连续签到30天奖励', 100, '连续签到30天额外奖励'),
|
||||
('shopping', '购物奖励', 1, '每消费1元获得1积分'),
|
||||
('review', '评价奖励', 10, '完成商品评价获得积分'),
|
||||
('review_with_image', '带图评价奖励', 20, '带图评价额外奖励');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、评价功能完善需求
|
||||
|
||||
### 3.1 功能架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 评价系统架构 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ 评价提交 │ │ 评价展示 │ │ 评价管理 │ │
|
||||
│ ├─────────────┤ ├─────────────┤ ├─────────────┤ │
|
||||
│ │ • 商品评价 │ │ • 商品详情 │ │ • 商家回复 │ │
|
||||
│ │ • 店铺评价 │ │ • 评价列表 │ │ • 评价统计 │ │
|
||||
│ │ • 配送评价 │ │ • 评分统计 │ │ • 违规处理 │ │
|
||||
│ │ • 追加评价 │ │ • 筛选排序 │ │ • 评价审核 │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 前端功能需求
|
||||
|
||||
#### 3.2.1 评价页面优化
|
||||
|
||||
**页面路径**: `pages/mall/consumer/review.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **评价表单优化**
|
||||
- 支持视频上传(限1个,30秒内)
|
||||
- 支持图片拖拽排序
|
||||
- 添加评价标签选择(质量好/物流快/服务好等)
|
||||
- 添加商品满意度维度(质量/包装/性价比)
|
||||
|
||||
2. **追加评价功能**
|
||||
- 订单完成后7天内可追加评价
|
||||
- 追加评价入口(订单详情页)
|
||||
- 追加评价表单
|
||||
|
||||
3. **评价预览**
|
||||
- 提交前预览评价效果
|
||||
- 匿名评价预览效果
|
||||
|
||||
#### 3.2.2 商品评价列表(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/product-reviews.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **评价统计概览**
|
||||
- 好评率百分比
|
||||
- 评分分布图(1-5星)
|
||||
- 标签云(高频评价关键词)
|
||||
|
||||
2. **评价列表**
|
||||
- 支持按评分筛选
|
||||
- 支持按时间/点赞数排序
|
||||
- 支持只看有图/有视频评价
|
||||
- 评价点赞功能
|
||||
- 评价举报功能
|
||||
|
||||
3. **评价详情**
|
||||
- 点击评价查看详情
|
||||
- 查看商家回复
|
||||
- 查看追加评价
|
||||
|
||||
#### 3.2.3 我的评价页面(新增)
|
||||
|
||||
**页面路径**: `pages/mall/consumer/my-reviews.uvue`
|
||||
|
||||
**需求描述**:
|
||||
1. **评价列表**
|
||||
- 显示已评价商品
|
||||
- 显示待评价商品
|
||||
- 显示可追加评价商品
|
||||
|
||||
2. **评价操作**
|
||||
- 编辑评价(24小时内)
|
||||
- 删除评价
|
||||
- 追加评价
|
||||
|
||||
#### 3.2.4 配送员评价(完善)
|
||||
|
||||
**页面路径**: `pages/mall/delivery/ratings.uvue`
|
||||
|
||||
**需求描述**:
|
||||
- 对接真实API(当前使用Mock数据)
|
||||
- 配送员评分统计
|
||||
- 配送评价列表
|
||||
|
||||
### 3.3 后端API需求
|
||||
|
||||
#### 3.3.1 新增API接口
|
||||
|
||||
| 接口名称 | 请求方法 | 接口路径 | 说明 |
|
||||
|---------|---------|---------|------|
|
||||
| 获取商品评价列表 | GET | `/api/reviews/product/:productId` | 分页获取商品评价 |
|
||||
| 获取评价统计 | GET | `/api/reviews/stats/:productId` | 获取商品评分统计 |
|
||||
| 追加评价 | POST | `/api/reviews/append` | 追加评价内容 |
|
||||
| 评价点赞 | POST | `/api/reviews/like/:id` | 点赞评价 |
|
||||
| 获取我的评价 | GET | `/api/reviews/my` | 获取用户评价列表 |
|
||||
| 编辑评价 | PUT | `/api/reviews/:id` | 编辑评价(24小时内) |
|
||||
| 删除评价 | DELETE | `/api/reviews/:id` | 删除评价 |
|
||||
| 评价举报 | POST | `/api/reviews/report/:id` | 举报违规评价 |
|
||||
|
||||
#### 3.3.2 接口详细设计
|
||||
|
||||
**获取商品评价列表**
|
||||
```typescript
|
||||
GET /api/reviews/product/:productId?page=1&limit=10&rating=5&has_image=true
|
||||
Response: {
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
data: [{
|
||||
id: string
|
||||
user_name: string
|
||||
user_avatar: string
|
||||
rating: number
|
||||
content: string
|
||||
images: string[]
|
||||
videos: string[]
|
||||
tags: string[]
|
||||
like_count: number
|
||||
is_liked: boolean
|
||||
reply: {
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
append_content?: {
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
created_at: string
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**获取评价统计**
|
||||
```typescript
|
||||
GET /api/reviews/stats/:productId
|
||||
Response: {
|
||||
total_count: number
|
||||
avg_rating: number
|
||||
good_rate: number // 好评率
|
||||
rating_distribution: { // 评分分布
|
||||
1: number
|
||||
2: number
|
||||
3: number
|
||||
4: number
|
||||
5: number
|
||||
}
|
||||
tags: [{ // 高频标签
|
||||
name: string
|
||||
count: number
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 数据库设计
|
||||
|
||||
#### 3.4.1 现有表结构优化
|
||||
|
||||
**商品评价表 (ml_product_reviews)**
|
||||
```sql
|
||||
-- 添加新字段
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS videos JSONB DEFAULT '[]';
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS tags JSONB DEFAULT '[]';
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS like_count INT DEFAULT 0;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS is_edited BOOLEAN DEFAULT false;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_content TEXT;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_at TIMESTAMPTZ;
|
||||
ALTER TABLE ml_product_reviews ADD COLUMN IF NOT EXISTS append_images JSONB DEFAULT '[]';
|
||||
```
|
||||
|
||||
#### 3.4.2 新增表结构
|
||||
|
||||
**评价点赞表 (ml_review_likes)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_review_likes (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
review_id UUID NOT NULL REFERENCES ml_product_reviews(id),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(review_id, user_id) -- 每用户对每条评价只能点赞一次
|
||||
);
|
||||
|
||||
CREATE INDEX idx_review_likes_review_id ON ml_review_likes(review_id);
|
||||
```
|
||||
|
||||
**评价举报表 (ml_review_reports)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_review_reports (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
review_id UUID NOT NULL REFERENCES ml_product_reviews(id),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
reason VARCHAR(200) NOT NULL, -- 举报原因
|
||||
description TEXT, -- 详细说明
|
||||
status INT DEFAULT 0, -- 状态: 0=待处理, 1=已处理, 2=已驳回
|
||||
handle_result TEXT, -- 处理结果
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**配送员评价表 (ml_delivery_ratings)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ml_delivery_ratings (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
order_id UUID NOT NULL REFERENCES ml_orders(id),
|
||||
delivery_user_id UUID NOT NULL, -- 配送员ID
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
rating INT CHECK (rating >= 1 AND rating <= 5),
|
||||
content TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(order_id) -- 每订单只能评价一次
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、开发优先级
|
||||
|
||||
### 4.1 第一阶段(高优先级)
|
||||
|
||||
| 序号 | 功能 | 模块 | 预估工时 |
|
||||
|-----|------|------|---------|
|
||||
| 1 | 每日签到功能 | 积分 | 2天 |
|
||||
| 2 | 积分兑换商城 | 积分 | 3天 |
|
||||
| 3 | 商品评价列表展示 | 评价 | 2天 |
|
||||
| 4 | 我的评价页面 | 评价 | 1天 |
|
||||
|
||||
### 4.2 第二阶段(中优先级)
|
||||
|
||||
| 序号 | 功能 | 模块 | 预估工时 |
|
||||
|-----|------|------|---------|
|
||||
| 1 | 积分规则配置 | 积分 | 1天 |
|
||||
| 2 | 追加评价功能 | 评价 | 1天 |
|
||||
| 3 | 评价点赞功能 | 评价 | 0.5天 |
|
||||
| 4 | 配送员评价对接 | 评价 | 1天 |
|
||||
|
||||
### 4.3 第三阶段(低优先级)
|
||||
|
||||
| 序号 | 功能 | 模块 | 预估工时 |
|
||||
|-----|------|------|---------|
|
||||
| 1 | 积分过期提醒 | 积分 | 0.5天 |
|
||||
| 2 | 评价视频上传 | 评价 | 1天 |
|
||||
| 3 | 评价举报功能 | 评价 | 0.5天 |
|
||||
| 4 | 管理端完善 | 综合 | 2天 |
|
||||
|
||||
---
|
||||
|
||||
## 五、技术要点
|
||||
|
||||
### 5.1 积分系统技术要点
|
||||
|
||||
1. **积分并发安全**
|
||||
- 使用数据库事务保证积分变动原子性
|
||||
- 添加乐观锁防止超扣
|
||||
|
||||
2. **积分过期处理**
|
||||
- 定时任务每日检查过期积分
|
||||
- 过期前7天推送提醒通知
|
||||
|
||||
3. **签到防刷**
|
||||
- 限制每日只能签到一次
|
||||
- 记录签到IP,检测异常行为
|
||||
|
||||
### 5.2 评价系统技术要点
|
||||
|
||||
1. **评价数据统计**
|
||||
- 使用触发器自动更新商品评分统计
|
||||
- 缓存热门商品评价数据
|
||||
|
||||
2. **图片/视频处理**
|
||||
- 图片压缩后上传
|
||||
- 视频转码处理
|
||||
- CDN加速访问
|
||||
|
||||
3. **敏感词过滤**
|
||||
- 评价内容敏感词检测
|
||||
- 自动替换或人工审核
|
||||
|
||||
---
|
||||
|
||||
## 六、测试要点
|
||||
|
||||
### 6.1 积分功能测试
|
||||
|
||||
- [ ] 签到功能:每日签到、连续签到奖励
|
||||
- [ ] 积分兑换:积分不足、库存不足、兑换成功
|
||||
- [ ] 积分过期:自动过期、过期提醒
|
||||
- [ ] 并发测试:同时兑换、同时签到
|
||||
|
||||
### 6.2 评价功能测试
|
||||
|
||||
- [ ] 评价提交:文字、图片、视频、匿名
|
||||
- [ ] 评价展示:列表、筛选、排序
|
||||
- [ ] 评价操作:编辑、删除、追加、点赞
|
||||
- [ ] 边界测试:空内容、超长内容、敏感词
|
||||
|
||||
---
|
||||
|
||||
## 七、附录
|
||||
|
||||
### 7.1 相关文件路径
|
||||
|
||||
```
|
||||
pages/
|
||||
├── mall/
|
||||
│ └── consumer/
|
||||
│ ├── points/
|
||||
│ │ ├── index.uvue # 积分首页(已有)
|
||||
│ │ ├── signin.uvue # 签到页面(新增)
|
||||
│ │ ├── exchange.uvue # 兑换商城(新增)
|
||||
│ │ └── rules.uvue # 积分规则(新增)
|
||||
│ ├── review.uvue # 评价页面(已有)
|
||||
│ ├── product-reviews.uvue # 商品评价列表(新增)
|
||||
│ └── my-reviews.uvue # 我的评价(新增)
|
||||
│
|
||||
└── user/
|
||||
└── login.uvue # 登录页面
|
||||
|
||||
utils/
|
||||
└── supabaseService.uts # API服务
|
||||
|
||||
types/
|
||||
└── mall-types.uts # 类型定义
|
||||
|
||||
doc_mall/
|
||||
└── consumer/
|
||||
└── sql/
|
||||
├── 01_wallet_and_points.sql
|
||||
└── add_reviews_tables.sql
|
||||
```
|
||||
|
||||
### 7.2 参考文档
|
||||
|
||||
- [Supabase 官方文档](https://supabase.com/docs)
|
||||
- [uni-app x 开发文档](https://doc.dcloud.net.cn/uni-app-x/)
|
||||
- [UTS 语法指南](https://doc.dcloud.net.cn/uni-app-x/uts/)
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**创建日期**: 2026-03-05
|
||||
**最后更新**: 2026-03-05
|
||||
640
pages/mall/consumer/member/index.uvue
Normal file
640
pages/mall/consumer/member/index.uvue
Normal file
@@ -0,0 +1,640 @@
|
||||
<template>
|
||||
<scroll-view class="member-page" scroll-y>
|
||||
<view class="member-header">
|
||||
<view class="member-info">
|
||||
<view class="level-badge" :class="'level-' + memberInfo.member_level">
|
||||
<text class="level-name">{{ memberInfo.level_name }}</text>
|
||||
</view>
|
||||
<view class="discount-info">
|
||||
<text class="discount-value">{{ getDiscountText(memberInfo.discount) }}</text>
|
||||
<text class="discount-label">会员折扣</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="progress-section" v-if="memberInfo.next_level != null">
|
||||
<view class="progress-header">
|
||||
<text class="progress-title">距离{{ getNextLevelName() }}还需</text>
|
||||
<text class="progress-amount">{{ getRemainingAmount() }}元</text>
|
||||
</view>
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: memberInfo.progress_percent + '%' }"></view>
|
||||
</view>
|
||||
<view class="progress-footer">
|
||||
<text class="current-amount">已消费 {{ memberInfo.total_spent }}元</text>
|
||||
<text class="target-amount">目标 {{ getNextLevelMinAmount() }}元</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="levels-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">会员等级</text>
|
||||
</view>
|
||||
<view class="level-list">
|
||||
<view
|
||||
class="level-item"
|
||||
v-for="level in levels"
|
||||
:key="level.id"
|
||||
:class="{ current: level.id === memberInfo.member_level }"
|
||||
>
|
||||
<view class="level-left">
|
||||
<view class="level-icon" :class="'level-bg-' + level.id">
|
||||
<text class="icon-text">{{ level.name.charAt(0) }}</text>
|
||||
</view>
|
||||
<view class="level-detail">
|
||||
<text class="level-title">{{ level.name }}</text>
|
||||
<text class="level-condition">{{ level.description || ('累计消费' + level.min_amount + '元') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="level-right">
|
||||
<text class="level-discount">{{ getDiscountText(level.discount) }}</text>
|
||||
<view class="current-tag" v-if="level.id === memberInfo.member_level">
|
||||
<text class="tag-text">当前</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="benefits-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">会员权益</text>
|
||||
</view>
|
||||
<view class="benefit-list">
|
||||
<view class="benefit-item">
|
||||
<text class="benefit-icon">💰</text>
|
||||
<text class="benefit-text">专属折扣价格</text>
|
||||
</view>
|
||||
<view class="benefit-item">
|
||||
<text class="benefit-icon">🎁</text>
|
||||
<text class="benefit-text">生日专属优惠</text>
|
||||
</view>
|
||||
<view class="benefit-item">
|
||||
<text class="benefit-icon">🚀</text>
|
||||
<text class="benefit-text">优先发货权益</text>
|
||||
</view>
|
||||
<view class="benefit-item">
|
||||
<text class="benefit-icon">📞</text>
|
||||
<text class="benefit-text">专属客服通道</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="logs-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">等级变更记录</text>
|
||||
</view>
|
||||
|
||||
<view v-if="logsLoading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="logs.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无变更记录</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="log-list">
|
||||
<view class="log-item" v-for="log in logs" :key="log.id">
|
||||
<view class="log-left">
|
||||
<text class="log-change">{{ getLevelName(log.old_level) }} → {{ getLevelName(log.new_level) }}</text>
|
||||
<text class="log-reason">{{ log.reason || '系统升级' }}</text>
|
||||
</view>
|
||||
<text class="log-time">{{ formatDate(log.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type MemberLevel = {
|
||||
id: number
|
||||
name: string
|
||||
min_amount: number
|
||||
discount: number
|
||||
description: string | null
|
||||
}
|
||||
|
||||
type MemberInfo = {
|
||||
member_level: number
|
||||
level_name: string
|
||||
discount: number
|
||||
total_spent: number
|
||||
next_level: MemberLevel | null
|
||||
progress_percent: number
|
||||
manual_level: boolean
|
||||
}
|
||||
|
||||
type LevelLog = {
|
||||
id: string
|
||||
old_level: number
|
||||
new_level: number
|
||||
reason: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const memberInfo = ref<MemberInfo>({
|
||||
member_level: 0,
|
||||
level_name: '普通会员',
|
||||
discount: 1.0,
|
||||
total_spent: 0,
|
||||
next_level: null,
|
||||
progress_percent: 0,
|
||||
manual_level: false
|
||||
})
|
||||
|
||||
const levels = ref<MemberLevel[]>([])
|
||||
const logs = ref<LevelLog[]>([])
|
||||
const logsLoading = ref<boolean>(false)
|
||||
|
||||
const loadMemberInfo = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.getUserMemberInfo()
|
||||
|
||||
memberInfo.value = {
|
||||
member_level: result.getNumber('member_level') ?? 0,
|
||||
level_name: result.getString('level_name') ?? '普通会员',
|
||||
discount: result.getNumber('discount') ?? 1.0,
|
||||
total_spent: result.getNumber('total_spent') ?? 0,
|
||||
next_level: null,
|
||||
progress_percent: result.getNumber('progress_percent') ?? 0,
|
||||
manual_level: result.getBoolean('manual_level') ?? false
|
||||
}
|
||||
|
||||
const nextLevelRaw = result.get('next_level')
|
||||
if (nextLevelRaw != null) {
|
||||
const nextLevelAny = nextLevelRaw as any
|
||||
if (typeof nextLevelAny._getValue === 'function') {
|
||||
memberInfo.value.next_level = {
|
||||
id: (nextLevelAny._getValue('id') as number) ?? 0,
|
||||
name: (nextLevelAny._getValue('name') as string) ?? '',
|
||||
min_amount: (nextLevelAny._getValue('min_amount') as number) ?? 0,
|
||||
discount: 1.0,
|
||||
description: null
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载会员信息失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadLevels = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.getMemberLevels()
|
||||
const parsed: MemberLevel[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
const itemAny = item as any
|
||||
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
parsed.push({
|
||||
id: (itemAny._getValue('id') as number) ?? 0,
|
||||
name: (itemAny._getValue('name') as string) ?? '',
|
||||
min_amount: (itemAny._getValue('min_amount') as number) ?? 0,
|
||||
discount: (itemAny._getValue('discount') as number) ?? 1.0,
|
||||
description: itemAny._getValue('description') as string | null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
levels.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载会员等级失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadLogs = async (): Promise<void> => {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const result = await supabaseService.getMemberLevelLogs()
|
||||
const parsed: LevelLog[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
const itemAny = item as any
|
||||
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
parsed.push({
|
||||
id: (itemAny._getValue('id') as string) ?? '',
|
||||
old_level: (itemAny._getValue('old_level') as number) ?? 0,
|
||||
new_level: (itemAny._getValue('new_level') as number) ?? 0,
|
||||
reason: itemAny._getValue('reason') as string | null,
|
||||
created_at: (itemAny._getValue('created_at') as string) ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logs.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载变更记录失败:', e)
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getDiscountText = (discount: number): string => {
|
||||
if (discount >= 1) return '无折扣'
|
||||
return Math.round(discount * 100) / 10 + '折'
|
||||
}
|
||||
|
||||
const getNextLevelName = (): string => {
|
||||
if (memberInfo.value.next_level != null) {
|
||||
return memberInfo.value.next_level.name
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getNextLevelMinAmount = (): number => {
|
||||
if (memberInfo.value.next_level != null) {
|
||||
return memberInfo.value.next_level.min_amount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const getRemainingAmount = (): number => {
|
||||
if (memberInfo.value.next_level != null) {
|
||||
return memberInfo.value.next_level.min_amount - memberInfo.value.total_spent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const getLevelName = (level: number): string => {
|
||||
for (let i = 0; i < levels.value.length; i++) {
|
||||
if (levels.value[i].id === level) {
|
||||
return levels.value[i].name
|
||||
}
|
||||
}
|
||||
return '普通会员'
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (dateStr === '') return ''
|
||||
const date = new Date(dateStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMemberInfo()
|
||||
loadLevels()
|
||||
loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.member-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.member-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 30px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
padding: 8px 24px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.level-badge.level-0 {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.level-badge.level-1 {
|
||||
background: linear-gradient(135deg, #cd7f32 0%, #daa520 100%);
|
||||
}
|
||||
|
||||
.level-badge.level-2 {
|
||||
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
|
||||
}
|
||||
|
||||
.level-badge.level-3 {
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffec8b 100%);
|
||||
}
|
||||
|
||||
.level-badge.level-4 {
|
||||
background: linear-gradient(135deg, #b9f2ff 0%, #89cff0 100%);
|
||||
}
|
||||
|
||||
.level-badge.level-5 {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ff8e8e 100%);
|
||||
}
|
||||
|
||||
.level-name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.discount-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.discount-value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.discount-label {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.progress-amount {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.progress-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.current-amount {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.target-amount {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.levels-section {
|
||||
background-color: white;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.level-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.level-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
}
|
||||
|
||||
.level-item.current {
|
||||
background-color: #f8f5ff;
|
||||
}
|
||||
|
||||
.level-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.level-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.level-bg-0 {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.level-bg-1 {
|
||||
background: linear-gradient(135deg, #cd7f32 0%, #daa520 100%);
|
||||
}
|
||||
|
||||
.level-bg-2 {
|
||||
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
|
||||
}
|
||||
|
||||
.level-bg-3 {
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffec8b 100%);
|
||||
}
|
||||
|
||||
.level-bg-4 {
|
||||
background: linear-gradient(135deg, #b9f2ff 0%, #89cff0 100%);
|
||||
}
|
||||
|
||||
.level-bg-5 {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ff8e8e 100%);
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.level-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.level-title {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.level-condition {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.level-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.level-discount {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.current-tag {
|
||||
background-color: #667eea;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.benefits-section {
|
||||
background-color: white;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.benefit-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.benefit-item {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.benefit-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.benefit-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.logs-section {
|
||||
background-color: white;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
}
|
||||
|
||||
.log-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-change {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.log-reason {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
697
pages/mall/consumer/my-reviews.uvue
Normal file
697
pages/mall/consumer/my-reviews.uvue
Normal file
@@ -0,0 +1,697 @@
|
||||
<template>
|
||||
<view class="my-reviews-page">
|
||||
<view class="tabs">
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'published' }"
|
||||
@click="switchTab('published')"
|
||||
>
|
||||
<text class="tab-text">已评价</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'pending' }"
|
||||
@click="switchTab('pending')"
|
||||
>
|
||||
<text class="tab-text">待评价</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="review-list" v-if="activeTab === 'published'">
|
||||
<view class="review-item" v-for="review in reviews" :key="review.id">
|
||||
<view class="product-info" @click="goToProduct(review.product_id)">
|
||||
<image
|
||||
class="product-image"
|
||||
:src="review.product_image || defaultImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="product-detail">
|
||||
<text class="product-name">{{ review.product_name }}</text>
|
||||
<view class="rating-row">
|
||||
<view class="rating-stars">
|
||||
<text
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
class="star"
|
||||
:class="{ filled: star <= review.rating }"
|
||||
>★</text>
|
||||
</view>
|
||||
<text class="review-time">{{ formatTime(review.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="review-content">
|
||||
<text class="review-text">{{ review.content }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-images" v-if="review.images.length > 0">
|
||||
<image
|
||||
v-for="(img, idx) in review.images.slice(0, 4)"
|
||||
:key="idx"
|
||||
class="review-image"
|
||||
:src="img"
|
||||
mode="aspectFill"
|
||||
@click="previewImage(review.images, idx)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="review-append" v-if="review.append_content">
|
||||
<text class="append-label">追评:</text>
|
||||
<text class="append-text">{{ review.append_content }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-actions">
|
||||
<view
|
||||
class="action-btn append"
|
||||
v-if="review.can_append"
|
||||
@click="showAppendPopup(review)"
|
||||
>
|
||||
<text class="action-text">追加评价</text>
|
||||
</view>
|
||||
<view
|
||||
class="action-btn delete"
|
||||
@click="confirmDelete(review)"
|
||||
>
|
||||
<text class="action-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pending-list" v-if="activeTab === 'pending'">
|
||||
<view class="pending-item" v-for="item in pendingItems" :key="item.order_id">
|
||||
<view class="product-info">
|
||||
<image
|
||||
class="product-image"
|
||||
:src="item.product_image || defaultImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="product-detail">
|
||||
<text class="product-name">{{ item.product_name }}</text>
|
||||
<text class="order-time">下单时间:{{ formatTime(item.order_time) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pending-actions">
|
||||
<button class="review-btn" @click="goToReview(item)">去评价</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="!loading && ((activeTab === 'published' && reviews.length === 0) || (activeTab === 'pending' && pendingItems.length === 0))">
|
||||
<text class="empty-text">{{ activeTab === 'published' ? '暂无评价记录' : '暂无待评价商品' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="loading-state" v-if="loading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="append-popup" v-if="showAppendModal" @click="closeAppendPopup">
|
||||
<view class="popup-content" @click.stop>
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">追加评价</text>
|
||||
<text class="popup-close" @click="closeAppendPopup">×</text>
|
||||
</view>
|
||||
|
||||
<textarea
|
||||
class="append-input"
|
||||
v-model="appendContent"
|
||||
placeholder="请输入追加评价内容"
|
||||
:maxlength="500"
|
||||
/>
|
||||
|
||||
<view class="popup-footer">
|
||||
<button class="cancel-btn" @click="closeAppendPopup">取消</button>
|
||||
<button class="submit-btn" @click="submitAppend">提交</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type MyReviewItem = {
|
||||
id: string
|
||||
product_id: string
|
||||
product_name: string
|
||||
product_image: string
|
||||
rating: number
|
||||
content: string
|
||||
images: string[]
|
||||
append_content: string | null
|
||||
can_append: boolean
|
||||
can_edit: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type PendingItem = {
|
||||
order_id: string
|
||||
product_id: string
|
||||
product_name: string
|
||||
product_image: string
|
||||
order_time: string
|
||||
}
|
||||
|
||||
const activeTab = ref<string>('published')
|
||||
const reviews = ref<MyReviewItem[]>([])
|
||||
const pendingItems = ref<PendingItem[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const showAppendModal = ref<boolean>(false)
|
||||
const appendContent = ref<string>('')
|
||||
const selectedReview = ref<MyReviewItem | null>(null)
|
||||
|
||||
const defaultImage: string = '/static/images/default-product.png'
|
||||
|
||||
const loadReviews = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await supabaseService.getMyReviews()
|
||||
const parsed: MyReviewItem[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
let reviewObj: UTSJSONObject
|
||||
if (item instanceof UTSJSONObject) {
|
||||
reviewObj = item
|
||||
} else {
|
||||
reviewObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
|
||||
}
|
||||
|
||||
let images: string[] = []
|
||||
const imagesRaw = reviewObj.get('images')
|
||||
if (imagesRaw != null && typeof imagesRaw === 'string') {
|
||||
try {
|
||||
const parsedImages = JSON.parse(imagesRaw as string)
|
||||
if (Array.isArray(parsedImages)) {
|
||||
images = parsedImages as string[]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析图片失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const review: MyReviewItem = {
|
||||
id: reviewObj.getString('id') ?? '',
|
||||
product_id: reviewObj.getString('product_id') ?? '',
|
||||
product_name: reviewObj.getString('product_name') ?? '',
|
||||
product_image: reviewObj.getString('product_image') ?? '',
|
||||
rating: reviewObj.getNumber('rating') ?? 5,
|
||||
content: reviewObj.getString('content') ?? '',
|
||||
images: images,
|
||||
append_content: reviewObj.getString('append_content'),
|
||||
can_append: reviewObj.getBoolean('can_append') ?? false,
|
||||
can_edit: reviewObj.getBoolean('can_edit') ?? false,
|
||||
created_at: reviewObj.getString('created_at') ?? ''
|
||||
}
|
||||
parsed.push(review)
|
||||
}
|
||||
|
||||
reviews.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载评价失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadPendingItems = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const orders = await supabaseService.getOrders('completed')
|
||||
const pending: PendingItem[] = []
|
||||
|
||||
for (let i = 0; i < orders.length; i++) {
|
||||
const order = orders[i]
|
||||
let orderObj: UTSJSONObject
|
||||
if (order instanceof UTSJSONObject) {
|
||||
orderObj = order
|
||||
} else {
|
||||
orderObj = JSON.parse(JSON.stringify(order)) as UTSJSONObject
|
||||
}
|
||||
|
||||
const orderId = orderObj.getString('id') ?? ''
|
||||
const itemsRaw = orderObj.get('items')
|
||||
|
||||
if (itemsRaw != null && Array.isArray(itemsRaw)) {
|
||||
const items = itemsRaw as any[]
|
||||
for (let j = 0; j < items.length; j++) {
|
||||
const orderItem = items[j]
|
||||
let itemObj: UTSJSONObject
|
||||
if (orderItem instanceof UTSJSONObject) {
|
||||
itemObj = orderItem
|
||||
} else {
|
||||
itemObj = JSON.parse(JSON.stringify(orderItem)) as UTSJSONObject
|
||||
}
|
||||
|
||||
pending.push({
|
||||
order_id: orderId,
|
||||
product_id: itemObj.getString('product_id') ?? '',
|
||||
product_name: itemObj.getString('product_name') ?? '',
|
||||
product_image: itemObj.getString('product_image') ?? '',
|
||||
order_time: orderObj.getString('created_at') ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingItems.value = pending
|
||||
} catch (e) {
|
||||
console.error('加载待评价商品失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchTab = (tab: string): void => {
|
||||
activeTab.value = tab
|
||||
if (tab === 'published' && reviews.value.length === 0) {
|
||||
loadReviews()
|
||||
} else if (tab === 'pending' && pendingItems.value.length === 0) {
|
||||
loadPendingItems()
|
||||
}
|
||||
}
|
||||
|
||||
const goToProduct = (productId: string): void => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/product-detail?id=${productId}`
|
||||
})
|
||||
}
|
||||
|
||||
const goToReview = (item: PendingItem): void => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/review?order_id=${item.order_id}`
|
||||
})
|
||||
}
|
||||
|
||||
const showAppendPopup = (review: MyReviewItem): void => {
|
||||
selectedReview.value = review
|
||||
appendContent.value = ''
|
||||
showAppendModal.value = true
|
||||
}
|
||||
|
||||
const closeAppendPopup = (): void => {
|
||||
showAppendModal.value = false
|
||||
selectedReview.value = null
|
||||
appendContent.value = ''
|
||||
}
|
||||
|
||||
const submitAppend = async (): Promise<void> => {
|
||||
if (selectedReview.value == null || appendContent.value.trim() === '') {
|
||||
uni.showToast({ title: '请输入评价内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
uni.showLoading({ title: '提交中...' })
|
||||
|
||||
try {
|
||||
const success = await supabaseService.appendReview(
|
||||
selectedReview.value.id,
|
||||
appendContent.value.trim(),
|
||||
[]
|
||||
)
|
||||
|
||||
if (success) {
|
||||
selectedReview.value.append_content = appendContent.value.trim()
|
||||
selectedReview.value.can_append = false
|
||||
closeAppendPopup()
|
||||
uni.showToast({ title: '追加成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '追加失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('追加评价失败:', e)
|
||||
uni.showToast({ title: '追加失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (review: MyReviewItem): void => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除这条评价吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
doDelete(review)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const doDelete = async (review: MyReviewItem): Promise<void> => {
|
||||
uni.showLoading({ title: '删除中...' })
|
||||
|
||||
try {
|
||||
const success = await supabaseService.deleteReview(review.id)
|
||||
if (success) {
|
||||
const index = reviews.value.indexOf(review)
|
||||
if (index > -1) {
|
||||
reviews.value.splice(index, 1)
|
||||
}
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('删除评价失败:', e)
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const previewImage = (images: string[], index: number): void => {
|
||||
uni.previewImage({
|
||||
urls: images,
|
||||
current: index
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (timeStr: string | null): string => {
|
||||
if (timeStr == null || timeStr === '') return ''
|
||||
const date = new Date(timeStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadReviews()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.my-reviews-page {
|
||||
flex: 1;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 14px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
border-bottom-color: #ff6b35;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-text {
|
||||
color: #ff6b35;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.review-list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.review-item {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.product-detail {
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
lines: 2;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rating-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.rating-stars {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.star {
|
||||
font-size: 12px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.star.filled {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.review-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.review-content {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.review-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.review-images {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.review-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.review-append {
|
||||
background-color: #f9f9f9;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.append-label {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.append-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.review-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 16px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.action-btn.append {
|
||||
background-color: #fff5f0;
|
||||
}
|
||||
|
||||
.action-btn.append .action-text {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.action-btn.delete .action-text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pending-list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.pending-item {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pending-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.review-btn {
|
||||
background-color: #ff6b35;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
border-radius: 16px;
|
||||
padding: 0 20px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.order-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.append-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
background-color: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.append-input {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
flex: 1;
|
||||
background-color: #f5f5f5;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
border-radius: 24px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
flex: 1;
|
||||
background-color: #ff6b35;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
border-radius: 24px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 消费者端 - 订单详情页 -->
|
||||
<!-- 消费者端 - 订单详情页 -->
|
||||
<template>
|
||||
<view class="order-detail-page">
|
||||
<scroll-view scroll-y="true" class="scroll-content">
|
||||
@@ -12,6 +12,15 @@
|
||||
</view>
|
||||
<text class="status-desc">{{ getStatusDesc() }}</text>
|
||||
</view>
|
||||
<!-- 分享免单入口 -->
|
||||
<view v-if="order?.order_status === 4" class="share-free-entry" @click="shareForFree">
|
||||
<text class="share-free-icon">🎁</text>
|
||||
<view class="share-free-info">
|
||||
<text class="share-free-title">分享免单</text>
|
||||
<text class="share-free-desc">分享给好友,4人购买即可免单</text>
|
||||
</view>
|
||||
<text class="share-free-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -143,6 +152,7 @@
|
||||
|
||||
<view v-if="order?.order_status === 4" class="btn-group">
|
||||
<button class="btn" @click="applyAfterSales">申请售后</button>
|
||||
<button class="btn share-free" @click="shareForFree">分享免单</button>
|
||||
<button class="btn" @click="rePurchase">再次购买</button>
|
||||
<button class="btn primary" @click="goToReview">评价订单</button>
|
||||
</view>
|
||||
@@ -686,6 +696,53 @@ const goToProduct = (pid: string) => {
|
||||
uni.navigateTo({ url: `/pages/mall/consumer/product-detail?id=${pid}` })
|
||||
}
|
||||
|
||||
const shareForFree = async () => {
|
||||
if (orderItems.value.length === 0) {
|
||||
uni.showToast({ title: '没有可分享的商品', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const firstItem = orderItems.value[0]
|
||||
|
||||
try {
|
||||
uni.showLoading({ title: '创建分享...' })
|
||||
const result = await supabaseService.createShareRecord(
|
||||
firstItem.product_id,
|
||||
orderId.value,
|
||||
firstItem.id,
|
||||
firstItem.product_name,
|
||||
firstItem.image_url,
|
||||
firstItem.price
|
||||
)
|
||||
uni.hideLoading()
|
||||
|
||||
const shareIdRaw = result.get('id')
|
||||
const shareCodeRaw = result.get('share_code')
|
||||
|
||||
if (shareIdRaw != null && shareCodeRaw != null) {
|
||||
const shareId = shareIdRaw as string
|
||||
const shareCode = shareCodeRaw as string
|
||||
|
||||
uni.showModal({
|
||||
title: '分享成功',
|
||||
content: `您的分享码: ${shareCode}\n分享给好友,当有4人购买后即可免单!`,
|
||||
confirmText: '查看详情',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: `/pages/mall/consumer/share/detail?id=${shareId}` })
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
uni.showToast({ title: '分享创建失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
console.error('[shareForFree] 创建分享失败:', e)
|
||||
uni.showToast({ title: '分享失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 onBackPress 拦截物理返回键和系统导航栏返回
|
||||
onBackPress((_): boolean => {
|
||||
const pages = getCurrentPages()
|
||||
@@ -792,6 +849,48 @@ onLoad((options) => {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 分享免单入口 */
|
||||
.share-free-entry {
|
||||
margin-top: 20px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.share-free-icon {
|
||||
font-size: 28px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.share-free-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.share-free-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.share-free-desc {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.share-free-arrow {
|
||||
font-size: 20px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* 配送信息 */
|
||||
.section-title {
|
||||
font-weight: bold;
|
||||
@@ -1113,6 +1212,13 @@ onLoad((options) => {
|
||||
box-shadow: 0 4px 8px rgba(255, 80, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn.share-free {
|
||||
background: linear-gradient(to right, #52c41a, #73d13d);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media screen and (min-width: 768px) {
|
||||
.card {
|
||||
|
||||
285
pages/mall/consumer/points/exchange-records.uvue
Normal file
285
pages/mall/consumer/points/exchange-records.uvue
Normal file
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<scroll-view class="records-page" scroll-y>
|
||||
<view class="empty-state" v-if="!loading && records.length === 0">
|
||||
<text class="empty-text">暂无兑换记录</text>
|
||||
</view>
|
||||
|
||||
<view class="loading-state" v-if="loading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="record-list" v-if="!loading && records.length > 0">
|
||||
<view class="record-item" v-for="record in records" :key="record.id">
|
||||
<view class="record-header">
|
||||
<text class="record-product-name">{{ record.product_name }}</text>
|
||||
<text class="record-status" :class="getStatusClass(record.status)">{{ getStatusText(record.status) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="record-info">
|
||||
<view class="info-row">
|
||||
<text class="info-label">消耗积分</text>
|
||||
<text class="info-value">{{ record.points_used }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">兑换数量</text>
|
||||
<text class="info-value">{{ record.quantity }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">兑换时间</text>
|
||||
<text class="info-value">{{ formatTime(record.created_at) }}</text>
|
||||
</view>
|
||||
<view class="info-row" v-if="record.tracking_no">
|
||||
<text class="info-label">物流单号</text>
|
||||
<text class="info-value">{{ record.tracking_no }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type ExchangeRecord = {
|
||||
id: string
|
||||
product_name: string
|
||||
product_image: string | null
|
||||
product_type: string
|
||||
quantity: number
|
||||
points_used: number
|
||||
status: number
|
||||
tracking_no: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const records = ref<ExchangeRecord[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
|
||||
const loadRecords = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await supabaseService.getExchangeRecords()
|
||||
const parsed: ExchangeRecord[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
const itemAny = item as any
|
||||
|
||||
// 处理数组返回
|
||||
let recordData: any
|
||||
if (Array.isArray(itemAny)) {
|
||||
recordData = itemAny[0]
|
||||
} else {
|
||||
recordData = itemAny
|
||||
}
|
||||
|
||||
let id = ''
|
||||
let quantity = 1
|
||||
let points_used = 0
|
||||
let status = 0
|
||||
let tracking_no: string | null = null
|
||||
let created_at = ''
|
||||
let product_name = ''
|
||||
let product_image: string | null = null
|
||||
let product_type = 'coupon'
|
||||
|
||||
// 使用 _getValue 方法
|
||||
if (typeof recordData._getValue === 'function') {
|
||||
id = (recordData._getValue('id') as string) ?? ''
|
||||
quantity = (recordData._getValue('quantity') as number) ?? 1
|
||||
points_used = (recordData._getValue('points_used') as number) ?? 0
|
||||
status = (recordData._getValue('status') as number) ?? 0
|
||||
tracking_no = recordData._getValue('tracking_no') as string | null
|
||||
created_at = (recordData._getValue('created_at') as string) ?? ''
|
||||
|
||||
// 获取关联的商品信息
|
||||
const product = recordData._getValue('product')
|
||||
if (product != null) {
|
||||
const productAny = product as any
|
||||
if (typeof productAny._getValue === 'function') {
|
||||
product_name = (productAny._getValue('name') as string) ?? ''
|
||||
product_image = productAny._getValue('image_url') as string | null
|
||||
product_type = (productAny._getValue('product_type') as string) ?? 'coupon'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
id = recordData['id'] ?? ''
|
||||
quantity = recordData['quantity'] ?? 1
|
||||
points_used = recordData['points_used'] ?? 0
|
||||
status = recordData['status'] ?? 0
|
||||
tracking_no = recordData['tracking_no'] ?? null
|
||||
created_at = recordData['created_at'] ?? ''
|
||||
|
||||
const product = recordData['product']
|
||||
if (product != null) {
|
||||
product_name = product['name'] ?? ''
|
||||
product_image = product['image_url'] ?? null
|
||||
product_type = product['product_type'] ?? 'coupon'
|
||||
}
|
||||
}
|
||||
|
||||
parsed.push({
|
||||
id,
|
||||
product_name,
|
||||
product_image,
|
||||
product_type,
|
||||
quantity,
|
||||
points_used,
|
||||
status,
|
||||
tracking_no,
|
||||
created_at
|
||||
})
|
||||
}
|
||||
|
||||
records.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载兑换记录失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: number): string => {
|
||||
if (status === 0) return '待处理'
|
||||
if (status === 1) return '已发货'
|
||||
if (status === 2) return '已完成'
|
||||
if (status === 3) return '已取消'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const getStatusClass = (status: number): string => {
|
||||
if (status === 0) return 'status-pending'
|
||||
if (status === 1) return 'status-shipped'
|
||||
if (status === 2) return 'status-completed'
|
||||
if (status === 3) return 'status-cancelled'
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatTime = (timeStr: string): string => {
|
||||
if (timeStr == '') return ''
|
||||
const date = new Date(timeStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
const hh = date.getHours().toString().padStart(2, '0')
|
||||
const mm = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.records-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.record-product-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.record-status {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background-color: #fff7e6;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.status-shipped {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
background-color: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.record-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
735
pages/mall/consumer/points/exchange.uvue
Normal file
735
pages/mall/consumer/points/exchange.uvue
Normal file
@@ -0,0 +1,735 @@
|
||||
<template>
|
||||
<scroll-view class="exchange-page" scroll-y>
|
||||
<view class="header">
|
||||
<view class="points-info">
|
||||
<text class="points-label">可用积分</text>
|
||||
<text class="points-value">{{ totalPoints }}</text>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<text class="records-link" @click="goToRecords">兑换记录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tabs">
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="activeTab === 'all' ? 'active' : ''"
|
||||
@click="switchTab('all')"
|
||||
>
|
||||
<text class="tab-text">全部</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="activeTab === 'coupon' ? 'active' : ''"
|
||||
@click="switchTab('coupon')"
|
||||
>
|
||||
<text class="tab-text">优惠券</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="activeTab === 'physical' ? 'active' : ''"
|
||||
@click="switchTab('physical')"
|
||||
>
|
||||
<text class="tab-text">实物</text>
|
||||
</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="activeTab === 'virtual' ? 'active' : ''"
|
||||
@click="switchTab('virtual')"
|
||||
>
|
||||
<text class="tab-text">虚拟</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="product-list" v-if="!loading">
|
||||
<view
|
||||
class="product-card"
|
||||
v-for="product in filteredProducts"
|
||||
:key="product.id"
|
||||
@click="showExchangePopup(product)"
|
||||
>
|
||||
<image
|
||||
class="product-image"
|
||||
:src="product.image_url || defaultImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<text class="product-desc" v-if="product.description">{{ product.description }}</text>
|
||||
<view class="product-bottom">
|
||||
<view class="product-points">
|
||||
<text class="points-num">{{ product.points_required }}</text>
|
||||
<text class="points-unit">积分</text>
|
||||
</view>
|
||||
<text class="product-stock">库存{{ product.stock }}件</text>
|
||||
<text class="product-original" v-if="product.original_price">¥{{ product.original_price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="!loading && filteredProducts.length === 0">
|
||||
<text class="empty-text">暂无可兑换商品</text>
|
||||
</view>
|
||||
|
||||
<view class="loading-state" v-if="loading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="exchange-popup" v-if="showPopup" @click="closePopup">
|
||||
<view class="popup-content" @click.stop>
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">确认兑换</text>
|
||||
<text class="popup-close" @click="closePopup">×</text>
|
||||
</view>
|
||||
|
||||
<view class="popup-product" v-if="selectedProduct != null">
|
||||
<image
|
||||
class="popup-product-image"
|
||||
:src="selectedProduct.image_url || defaultImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="popup-product-info">
|
||||
<text class="popup-product-name">{{ selectedProduct.name }}</text>
|
||||
<view class="popup-product-points">
|
||||
<text class="popup-points-num">{{ selectedProduct.points_required }}</text>
|
||||
<text class="popup-points-unit">积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="popup-quantity">
|
||||
<text class="quantity-label">兑换数量</text>
|
||||
<view class="quantity-control">
|
||||
<text class="quantity-btn" @click="decreaseQuantity">-</text>
|
||||
<text class="quantity-value">{{ exchangeQuantity }}</text>
|
||||
<text class="quantity-btn" @click="increaseQuantity">+</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="popup-summary">
|
||||
<view class="summary-row">
|
||||
<text class="summary-label">消耗积分</text>
|
||||
<text class="summary-value">{{ totalPointsCost }}</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text class="summary-label">当前积分</text>
|
||||
<text class="summary-value">{{ totalPoints }}</text>
|
||||
</view>
|
||||
<view class="summary-row" v-if="totalPoints < totalPointsCost">
|
||||
<text class="summary-label insufficient">积分不足</text>
|
||||
<text class="summary-value insufficient">差{{ totalPointsCost - totalPoints }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="popup-btn"
|
||||
:class="{ disabled: totalPoints < totalPointsCost }"
|
||||
:disabled="totalPoints < totalPointsCost || exchanging"
|
||||
@click="confirmExchange"
|
||||
>
|
||||
{{ exchanging ? '兑换中...' : '确认兑换' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="success-popup" v-if="showSuccess" @click="closeSuccess">
|
||||
<view class="success-content" @click.stop>
|
||||
<view class="success-icon">✓</view>
|
||||
<text class="success-title">兑换成功</text>
|
||||
<text class="success-desc">消耗 {{ totalPointsCost }} 积分</text>
|
||||
<button class="success-btn" @click="closeSuccess">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type PointProduct = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
image_url: string | null
|
||||
product_type: string
|
||||
points_required: number
|
||||
original_price: number | null
|
||||
stock: number
|
||||
status: number
|
||||
}
|
||||
|
||||
const totalPoints = ref<number>(0)
|
||||
const products = ref<PointProduct[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const activeTab = ref<string>('all')
|
||||
const showPopup = ref<boolean>(false)
|
||||
const showSuccess = ref<boolean>(false)
|
||||
const selectedProduct = ref<PointProduct | null>(null)
|
||||
const exchangeQuantity = ref<number>(1)
|
||||
const exchanging = ref<boolean>(false)
|
||||
|
||||
const defaultImage: string = '/static/images/default-product.png'
|
||||
|
||||
const filteredProducts = computed((): PointProduct[] => {
|
||||
if (activeTab.value === 'all') {
|
||||
return products.value
|
||||
}
|
||||
const filtered: PointProduct[] = []
|
||||
for (let i = 0; i < products.value.length; i++) {
|
||||
if (products.value[i].product_type === activeTab.value) {
|
||||
filtered.push(products.value[i])
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
})
|
||||
|
||||
const totalPointsCost = computed((): number => {
|
||||
if (selectedProduct.value == null) return 0
|
||||
return selectedProduct.value.points_required * exchangeQuantity.value
|
||||
})
|
||||
|
||||
const loadProducts = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const points = await supabaseService.getUserPoints()
|
||||
totalPoints.value = points
|
||||
|
||||
const productList = await supabaseService.getPointProducts()
|
||||
const parsed: PointProduct[] = []
|
||||
for (let i = 0; i < productList.length; i++) {
|
||||
const item = productList[i]
|
||||
const itemAny = item as any
|
||||
|
||||
let id = ''
|
||||
let name = ''
|
||||
let description: string | null = null
|
||||
let image_url: string | null = null
|
||||
let product_type = 'coupon'
|
||||
let points_required = 0
|
||||
let original_price: number | null = null
|
||||
let stock = 0
|
||||
let status = 1
|
||||
|
||||
// UTSJSONObject2 需要使用 _getValue 方法
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
id = (itemAny._getValue('id') as string) ?? ''
|
||||
name = (itemAny._getValue('name') as string) ?? ''
|
||||
description = itemAny._getValue('description') as string | null
|
||||
image_url = itemAny._getValue('image_url') as string | null
|
||||
product_type = (itemAny._getValue('product_type') as string) ?? 'coupon'
|
||||
points_required = (itemAny._getValue('points_required') as number) ?? 0
|
||||
original_price = itemAny._getValue('original_price') as number | null
|
||||
stock = (itemAny._getValue('stock') as number) ?? 0
|
||||
status = (itemAny._getValue('status') as number) ?? 1
|
||||
} else {
|
||||
id = itemAny['id'] ?? ''
|
||||
name = itemAny['name'] ?? ''
|
||||
description = itemAny['description'] ?? null
|
||||
image_url = itemAny['image_url'] ?? null
|
||||
product_type = itemAny['product_type'] ?? 'coupon'
|
||||
points_required = itemAny['points_required'] ?? 0
|
||||
original_price = itemAny['original_price'] ?? null
|
||||
stock = itemAny['stock'] ?? 0
|
||||
status = itemAny['status'] ?? 1
|
||||
}
|
||||
|
||||
const product: PointProduct = {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
image_url,
|
||||
product_type,
|
||||
points_required,
|
||||
original_price,
|
||||
stock,
|
||||
status
|
||||
}
|
||||
parsed.push(product)
|
||||
}
|
||||
products.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载商品失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchTab = (tab: string): void => {
|
||||
activeTab.value = tab
|
||||
}
|
||||
|
||||
const showExchangePopup = (product: PointProduct): void => {
|
||||
selectedProduct.value = product
|
||||
exchangeQuantity.value = 1
|
||||
showPopup.value = true
|
||||
}
|
||||
|
||||
const closePopup = (): void => {
|
||||
showPopup.value = false
|
||||
selectedProduct.value = null
|
||||
}
|
||||
|
||||
const increaseQuantity = (): void => {
|
||||
if (selectedProduct.value != null && exchangeQuantity.value < selectedProduct.value.stock) {
|
||||
exchangeQuantity.value++
|
||||
}
|
||||
}
|
||||
|
||||
const decreaseQuantity = (): void => {
|
||||
if (exchangeQuantity.value > 1) {
|
||||
exchangeQuantity.value--
|
||||
}
|
||||
}
|
||||
|
||||
const confirmExchange = async (): Promise<void> => {
|
||||
if (selectedProduct.value == null) return
|
||||
if (totalPoints.value < totalPointsCost.value) return
|
||||
|
||||
exchanging.value = true
|
||||
|
||||
try {
|
||||
const result = await supabaseService.exchangeProduct(
|
||||
selectedProduct.value.id,
|
||||
exchangeQuantity.value,
|
||||
null
|
||||
)
|
||||
|
||||
if (result.getBoolean('success') === true) {
|
||||
showPopup.value = false
|
||||
totalPoints.value -= totalPointsCost.value
|
||||
showSuccess.value = true
|
||||
loadProducts()
|
||||
} else {
|
||||
const message = result.getString('message') ?? '兑换失败'
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('兑换异常:', e)
|
||||
uni.showToast({ title: '兑换异常', icon: 'none' })
|
||||
} finally {
|
||||
exchanging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeSuccess = (): void => {
|
||||
showSuccess.value = false
|
||||
}
|
||||
|
||||
const goToRecords = (): void => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/points/exchange-records'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.exchange-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.points-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.records-link {
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
border-bottom-color: #ff6b35;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-text {
|
||||
color: #ff6b35;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.product-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
width: calc(50% - 8px);
|
||||
margin: 4px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
lines: 2;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
lines: 1;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.product-bottom {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.product-points {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.points-num {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.points-unit {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.product-original {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.exchange-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
background-color: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.popup-product {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 12px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-product-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.popup-product-info {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.popup-product-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
lines: 2;
|
||||
}
|
||||
|
||||
.popup-product-points {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.popup-points-num {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.popup-points-unit {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.popup-quantity {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.quantity-label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.quantity-control {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quantity-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.quantity-value {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup-summary {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.summary-label.insufficient {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.summary-value.insufficient {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.popup-btn {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border-radius: 24px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.popup-btn.disabled {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.success-popup {
|
||||
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: 1001;
|
||||
}
|
||||
|
||||
.success-content {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: #52c41a;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
color: white;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.success-desc {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.success-btn {
|
||||
background-color: #ff6b35;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="points-page">
|
||||
<template>
|
||||
<scroll-view class="points-page" scroll-y>
|
||||
<view class="points-header">
|
||||
<view class="points-info">
|
||||
<text class="points-label">当前积分</text>
|
||||
@@ -9,7 +9,53 @@
|
||||
<button class="exchange-btn" @click="handleExchange">积分兑换</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
<view class="quick-actions">
|
||||
<view class="action-item" @click="goToSignin">
|
||||
<view class="action-icon signin-icon">📅</view>
|
||||
<text class="action-text">每日签到</text>
|
||||
<view class="action-badge" v-if="!signedToday">
|
||||
<text class="badge-text">+5</text>
|
||||
</view>
|
||||
<view class="signed-badge" v-else>
|
||||
<text class="signed-text">已签</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="action-item" @click="handleExchange">
|
||||
<view class="action-icon exchange-icon">🎁</view>
|
||||
<text class="action-text">积分兑换</text>
|
||||
</view>
|
||||
<view class="action-item" @click="goToMyReviews">
|
||||
<view class="action-icon review-icon">⭐</view>
|
||||
<text class="action-text">我的评价</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="signin-card" v-if="!signedToday">
|
||||
<view class="signin-info">
|
||||
<text class="signin-title">今日未签到</text>
|
||||
<text class="signin-desc">连续签到可获得额外奖励</text>
|
||||
</view>
|
||||
<button class="signin-btn" @click="goToSignin">去签到</button>
|
||||
</view>
|
||||
|
||||
<view class="signin-card signed" v-else>
|
||||
<view class="signin-info">
|
||||
<text class="signin-title">今日已签到</text>
|
||||
<text class="signin-desc">已连续签到 {{ continuousDays }} 天</text>
|
||||
</view>
|
||||
<text class="signed-icon">✓</text>
|
||||
</view>
|
||||
|
||||
<view class="expiring-card" v-if="expiringPoints > 0" @click="showExpiringDetails">
|
||||
<view class="expiring-icon">⚠️</view>
|
||||
<view class="expiring-info">
|
||||
<text class="expiring-title">{{ expiringPoints }} 积分即将过期</text>
|
||||
<text class="expiring-date">过期日期:{{ expiringDate }}</text>
|
||||
</view>
|
||||
<text class="expiring-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<view class="records-section">
|
||||
<text class="section-title">积分明细</text>
|
||||
|
||||
@@ -35,7 +81,31 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="expiring-popup" v-if="showExpiringPopup" @click="closeExpiringPopup">
|
||||
<view class="popup-content" @click.stop>
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">即将过期积分</text>
|
||||
<text class="popup-close" @click="closeExpiringPopup">×</text>
|
||||
</view>
|
||||
<view class="popup-list">
|
||||
<view class="popup-item" v-for="(detail, index) in expiringDetails" :key="index">
|
||||
<view class="popup-item-info">
|
||||
<text class="popup-item-points">+{{ detail.points }} 积分</text>
|
||||
<text class="popup-item-desc">{{ detail.description ?? '积分获取' }}</text>
|
||||
</view>
|
||||
<view class="popup-item-expire">
|
||||
<text class="popup-item-date">{{ formatDate(detail.expires_at) }}</text>
|
||||
<text class="popup-item-label">过期</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="popup-tip">
|
||||
<text class="tip-text">积分有效期为获取后365天,请及时使用避免过期</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
@@ -51,9 +121,22 @@ type PointRecord = {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type ExpiringDetail = {
|
||||
points: number
|
||||
description: string | null
|
||||
expires_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const totalPoints = ref<number>(0)
|
||||
const records = ref<PointRecord[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const signedToday = ref<boolean>(false)
|
||||
const continuousDays = ref<number>(0)
|
||||
const expiringPoints = ref<number>(0)
|
||||
const expiringDate = ref<string>('')
|
||||
const expiringDetails = ref<ExpiringDetail[]>([])
|
||||
const showExpiringPopup = ref<boolean>(false)
|
||||
|
||||
const loadPoints = async (): Promise<void> => {
|
||||
try {
|
||||
@@ -67,16 +150,62 @@ const loadPoints = async (): Promise<void> => {
|
||||
const loadRecords = async (): Promise<void> => {
|
||||
try {
|
||||
const list = await supabaseService.getPointRecords()
|
||||
records.value = list as PointRecord[]
|
||||
records.value = list as PointRecord[]
|
||||
} catch (e) {
|
||||
console.error('获取积分记录失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadSigninStatus = async (): Promise<void> => {
|
||||
try {
|
||||
const status = await supabaseService.getTodaySigninStatus()
|
||||
signedToday.value = status.getBoolean('signed') ?? false
|
||||
continuousDays.value = status.getNumber('continuous_days') ?? 0
|
||||
} catch (e) {
|
||||
console.error('获取签到状态失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadExpiringPoints = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.getExpiringPoints()
|
||||
expiringPoints.value = result.getNumber('expiring_points') ?? 0
|
||||
expiringDate.value = result.getString('expiring_date') ?? ''
|
||||
|
||||
const detailsRaw = result.get('details')
|
||||
if (detailsRaw != null && Array.isArray(detailsRaw)) {
|
||||
const details: ExpiringDetail[] = []
|
||||
const arr = detailsRaw as any[]
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item = arr[i]
|
||||
let itemObj: UTSJSONObject
|
||||
if (item instanceof UTSJSONObject) {
|
||||
itemObj = item
|
||||
} else {
|
||||
itemObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
|
||||
}
|
||||
details.push({
|
||||
points: itemObj.getNumber('points') ?? 0,
|
||||
description: itemObj.getString('description'),
|
||||
expires_at: itemObj.getString('expires_at') ?? '',
|
||||
created_at: itemObj.getString('created_at') ?? ''
|
||||
})
|
||||
}
|
||||
expiringDetails.value = details
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取即将过期积分失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
await loadPoints()
|
||||
await loadRecords()
|
||||
await Promise.all([
|
||||
loadPoints(),
|
||||
loadRecords(),
|
||||
loadSigninStatus(),
|
||||
loadExpiringPoints()
|
||||
])
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -84,15 +213,33 @@ onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
const handleExchange = () => {
|
||||
uni.showToast({
|
||||
title: '积分商城开发中',
|
||||
icon: 'none'
|
||||
const handleExchange = (): void => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/points/exchange'
|
||||
})
|
||||
}
|
||||
|
||||
const goToSignin = (): void => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/points/signin'
|
||||
})
|
||||
}
|
||||
|
||||
const goToMyReviews = (): void => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/mall/consumer/my-reviews'
|
||||
})
|
||||
}
|
||||
|
||||
const showExpiringDetails = (): void => {
|
||||
showExpiringPopup.value = true
|
||||
}
|
||||
|
||||
const closeExpiringPopup = (): void => {
|
||||
showExpiringPopup.value = false
|
||||
}
|
||||
|
||||
const getTypeText = (type: string): string => {
|
||||
// 不支持 Record<string, string>,使用 if-else
|
||||
if (type == 'signin') {
|
||||
return '每日签到'
|
||||
} else if (type == 'shopping') {
|
||||
@@ -103,6 +250,8 @@ const getTypeText = (type: string): string => {
|
||||
return '系统调整'
|
||||
} else if (type == 'register') {
|
||||
return '注册赠送'
|
||||
} else if (type == 'expire') {
|
||||
return '积分过期'
|
||||
} else {
|
||||
return '积分变动'
|
||||
}
|
||||
@@ -118,15 +267,26 @@ const formatTime = (timeStr: string): string => {
|
||||
const mm = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (dateStr == '') return ''
|
||||
const date = new Date(dateStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.points-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.points-header {
|
||||
background-color: #ff5000;
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
padding: 30px 20px;
|
||||
color: white;
|
||||
display: flex;
|
||||
@@ -161,11 +321,174 @@ const formatTime = (timeStr: string): string => {
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.signin-icon {
|
||||
background-color: #fff5f0;
|
||||
}
|
||||
|
||||
.exchange-icon {
|
||||
background-color: #f0f5ff;
|
||||
}
|
||||
|
||||
.review-icon {
|
||||
background-color: #fff5f0;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.action-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 20px;
|
||||
background-color: #ff6b35;
|
||||
border-radius: 8px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.signed-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 20px;
|
||||
background-color: #52c41a;
|
||||
border-radius: 8px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.signed-text {
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.signin-card {
|
||||
background-color: white;
|
||||
margin: 0 12px 8px;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.signin-card.signed {
|
||||
background: linear-gradient(135deg, #f6ffed 0%, #e6fffb 100%);
|
||||
}
|
||||
|
||||
.signin-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.signin-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.signin-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.signin-btn {
|
||||
background-color: #ff6b35;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
border-radius: 16px;
|
||||
padding: 0 20px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.signed-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: #52c41a;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.expiring-card {
|
||||
background: linear-gradient(135deg, #fff7e6 0%, #ffe7ba 100%);
|
||||
margin: 0 12px 8px;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expiring-icon {
|
||||
font-size: 24px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.expiring-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.expiring-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.expiring-date {
|
||||
font-size: 12px;
|
||||
color: #ad8b00;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.expiring-arrow {
|
||||
font-size: 20px;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.records-section {
|
||||
background-color: white;
|
||||
margin-top: 10px;
|
||||
padding: 0 16px;
|
||||
min-height: 500px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -187,7 +510,10 @@ const formatTime = (timeStr: string): string => {
|
||||
.record-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.record-title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
}
|
||||
@@ -204,7 +530,7 @@ const formatTime = (timeStr: string): string => {
|
||||
}
|
||||
|
||||
.record-amount.positive {
|
||||
color: #ff5000;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.record-amount.negative {
|
||||
@@ -221,4 +547,108 @@ const formatTime = (timeStr: string): string => {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.loading-state {
|
||||
padding: 40px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expiring-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
background-color: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
width: 100%;
|
||||
max-height: 60%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.popup-list {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.popup-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.popup-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popup-item-points {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.popup-item-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.popup-item-expire {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.popup-item-date {
|
||||
font-size: 12px;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.popup-item-label {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.popup-tip {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
|
||||
562
pages/mall/consumer/points/signin.uvue
Normal file
562
pages/mall/consumer/points/signin.uvue
Normal file
@@ -0,0 +1,562 @@
|
||||
<template>
|
||||
<view class="signin-page">
|
||||
<view class="header">
|
||||
<view class="header-content">
|
||||
<text class="title">每日签到</text>
|
||||
<text class="subtitle">连续签到可获得额外奖励</text>
|
||||
</view>
|
||||
<view class="points-display">
|
||||
<text class="points-label">当前积分</text>
|
||||
<text class="points-value">{{ totalPoints }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="calendar-section">
|
||||
<view class="calendar-header">
|
||||
<view class="month-nav">
|
||||
<text class="nav-btn" @click="prevMonth"><</text>
|
||||
<text class="current-month">{{ currentYear }}年{{ currentMonth }}月</text>
|
||||
<text class="nav-btn" @click="nextMonth">></text>
|
||||
</view>
|
||||
<view class="continuous-info">
|
||||
<text class="continuous-label">已连续签到</text>
|
||||
<text class="continuous-value">{{ continuousDays }}天</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="calendar-weekdays">
|
||||
<text class="weekday" v-for="day in weekdays" :key="day">{{ day }}</text>
|
||||
</view>
|
||||
|
||||
<view class="calendar-days">
|
||||
<view
|
||||
v-for="(day, index) in calendarDays"
|
||||
:key="index"
|
||||
class="day-cell"
|
||||
:class="{
|
||||
'empty': day.day === 0,
|
||||
'signed': day.signed,
|
||||
'today': day.isToday
|
||||
}"
|
||||
>
|
||||
<text v-if="day.day > 0" class="day-number">{{ day.day }}</text>
|
||||
<view v-if="day.signed" class="signed-mark">
|
||||
<text class="check-icon">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="signin-btn-section">
|
||||
<button
|
||||
class="signin-btn"
|
||||
:class="{ 'signed-today': signedToday }"
|
||||
:disabled="signedToday"
|
||||
@click="doSignin"
|
||||
>
|
||||
{{ signedToday ? '今日已签到' : '立即签到' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="rules-section">
|
||||
<text class="section-title">签到规则</text>
|
||||
<view class="rule-list">
|
||||
<view class="rule-item">
|
||||
<text class="rule-icon">📅</text>
|
||||
<text class="rule-text">每日签到可获得5积分</text>
|
||||
</view>
|
||||
<view class="rule-item">
|
||||
<text class="rule-icon">🔥</text>
|
||||
<text class="rule-text">连续签到7天额外奖励20积分</text>
|
||||
</view>
|
||||
<view class="rule-item">
|
||||
<text class="rule-icon">🏆</text>
|
||||
<text class="rule-text">连续签到30天额外奖励100积分</text>
|
||||
</view>
|
||||
<view class="rule-item">
|
||||
<text class="rule-icon">⚠️</text>
|
||||
<text class="rule-text">中断签到后连续天数将重置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="signin-popup" v-if="showPopup" @click="closePopup">
|
||||
<view class="popup-content" @click.stop>
|
||||
<view class="popup-icon">🎉</view>
|
||||
<text class="popup-title">签到成功</text>
|
||||
<view class="popup-points">
|
||||
<text class="popup-points-label">获得积分</text>
|
||||
<text class="popup-points-value">+{{ popupPoints }}</text>
|
||||
</view>
|
||||
<view class="popup-bonus" v-if="popupBonus > 0">
|
||||
<text class="popup-bonus-label">连续签到奖励</text>
|
||||
<text class="popup-bonus-value">+{{ popupBonus }}</text>
|
||||
</view>
|
||||
<view class="popup-continuous">
|
||||
<text>已连续签到 {{ popupContinuousDays }} 天</text>
|
||||
</view>
|
||||
<button class="popup-btn" @click="closePopup">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type CalendarDay = {
|
||||
day: number
|
||||
signed: boolean
|
||||
isToday: boolean
|
||||
}
|
||||
|
||||
const totalPoints = ref<number>(0)
|
||||
const continuousDays = ref<number>(0)
|
||||
const signedToday = ref<boolean>(false)
|
||||
const currentYear = ref<number>(new Date().getFullYear())
|
||||
const currentMonth = ref<number>(new Date().getMonth() + 1)
|
||||
const signinRecords = ref<string[]>([])
|
||||
const showPopup = ref<boolean>(false)
|
||||
const popupPoints = ref<number>(0)
|
||||
const popupBonus = ref<number>(0)
|
||||
const popupContinuousDays = ref<number>(0)
|
||||
|
||||
const weekdays: string[] = ['日', '一', '二', '三', '四', '五', '六']
|
||||
|
||||
const calendarDays = computed((): CalendarDay[] => {
|
||||
const days: CalendarDay[] = []
|
||||
const year = currentYear.value
|
||||
const month = currentMonth.value
|
||||
|
||||
const firstDay = new Date(year, month - 1, 1).getDay()
|
||||
const daysInMonth = new Date(year, month, 0).getDate()
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = today.toISOString().split('T')[0]
|
||||
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push({ day: 0, signed: false, isToday: false })
|
||||
}
|
||||
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
const dateStr = `${year}-${month.toString().padStart(2, '0')}-${i.toString().padStart(2, '0')}`
|
||||
const isToday = dateStr === todayStr
|
||||
const signed = signinRecords.value.includes(dateStr)
|
||||
days.push({ day: i, signed, isToday })
|
||||
}
|
||||
|
||||
return days
|
||||
})
|
||||
|
||||
const loadSigninData = async (): Promise<void> => {
|
||||
uni.showLoading({ title: '加载中...' })
|
||||
|
||||
try {
|
||||
const points = await supabaseService.getUserPoints()
|
||||
totalPoints.value = points
|
||||
|
||||
const status = await supabaseService.getTodaySigninStatus()
|
||||
signedToday.value = status.getBoolean('signed') ?? false
|
||||
continuousDays.value = status.getNumber('continuous_days') ?? 0
|
||||
|
||||
const records = await supabaseService.getSigninRecords(currentYear.value, currentMonth.value)
|
||||
const dates: string[] = []
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i]
|
||||
let dateStr = ''
|
||||
if (record instanceof UTSJSONObject) {
|
||||
dateStr = record.getString('signin_date') ?? ''
|
||||
} else {
|
||||
const rObj = JSON.parse(JSON.stringify(record)) as UTSJSONObject
|
||||
dateStr = rObj.getString('signin_date') ?? ''
|
||||
}
|
||||
if (dateStr !== '') {
|
||||
dates.push(dateStr)
|
||||
}
|
||||
}
|
||||
signinRecords.value = dates
|
||||
} catch (e) {
|
||||
console.error('加载签到数据失败:', e)
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const doSignin = async (): Promise<void> => {
|
||||
if (signedToday.value) return
|
||||
|
||||
uni.showLoading({ title: '签到中...' })
|
||||
|
||||
try {
|
||||
const result = await supabaseService.signin()
|
||||
|
||||
if (result.getBoolean('success') === true) {
|
||||
popupPoints.value = result.getNumber('points') ?? 0
|
||||
popupBonus.value = result.getNumber('bonus_points') ?? 0
|
||||
popupContinuousDays.value = result.getNumber('continuous_days') ?? 0
|
||||
totalPoints.value = result.getNumber('total_points') ?? 0
|
||||
continuousDays.value = popupContinuousDays.value
|
||||
signedToday.value = true
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
signinRecords.value.push(today)
|
||||
|
||||
showPopup.value = true
|
||||
} else {
|
||||
const message = result.getString('message') ?? '签到失败'
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('签到异常:', e)
|
||||
uni.showToast({ title: '签到异常', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const closePopup = (): void => {
|
||||
showPopup.value = false
|
||||
}
|
||||
|
||||
const prevMonth = (): void => {
|
||||
if (currentMonth.value === 1) {
|
||||
currentYear.value--
|
||||
currentMonth.value = 12
|
||||
} else {
|
||||
currentMonth.value--
|
||||
}
|
||||
loadSigninData()
|
||||
}
|
||||
|
||||
const nextMonth = (): void => {
|
||||
if (currentMonth.value === 12) {
|
||||
currentYear.value++
|
||||
currentMonth.value = 1
|
||||
} else {
|
||||
currentMonth.value++
|
||||
}
|
||||
loadSigninData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSigninData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.signin-page {
|
||||
flex: 1;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.points-display {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calendar-section {
|
||||
background-color: white;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.month-nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.current-month {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.continuous-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: #fff5f0;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.continuous-label {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.continuous-value {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.calendar-weekdays {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.calendar-days {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
width: 14.28%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.day-cell.empty {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.day-cell.today .day-number {
|
||||
color: #ff6b35;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.day-cell.signed {
|
||||
background-color: #fff5f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.signed-mark {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #ff6b35;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.signin-btn-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.signin-btn {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%);
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
border-radius: 24px;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
.signin-btn.signed-today {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.rules-section {
|
||||
background-color: white;
|
||||
margin: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.rule-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.rule-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.signin-popup {
|
||||
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;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.popup-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-points {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.popup-points-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.popup-points-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.popup-bonus {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.popup-bonus-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.popup-bonus-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.popup-continuous {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-btn {
|
||||
background-color: #ff6b35;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 消费者端 - 商品详情页 -->
|
||||
<!-- 消费者端 - 商品详情页 -->
|
||||
<template>
|
||||
<view class="product-detail-page">
|
||||
<scroll-view class="page-scroll" scroll-y="true">
|
||||
@@ -16,8 +16,12 @@
|
||||
<view class="product-info">
|
||||
<view class="price-section">
|
||||
<text class="current-price">¥{{ product.price }}</text>
|
||||
<text v-if="memberPrice > 0 && memberPrice < product.price" class="member-price-tag">会员价 ¥{{ memberPrice }}</text>
|
||||
<text v-if="product.original_price" class="original-price">¥{{ product.original_price }}</text>
|
||||
</view>
|
||||
<view v-if="memberDiscount > 0" class="member-discount-row">
|
||||
<text class="member-discount-text">会员专享 {{ memberDiscount }}折优惠</text>
|
||||
</view>
|
||||
<text class="product-name">{{ product.name }}</text>
|
||||
<text class="sales-info">已售{{ product.sales }}件 · 库存{{ product.stock }}件</text>
|
||||
</view>
|
||||
@@ -270,7 +274,11 @@ export default {
|
||||
showParams: false,
|
||||
// 新增: 优惠券相关
|
||||
coupons: [] as Array<CouponTemplateType>,
|
||||
showCoupons: false
|
||||
showCoupons: false,
|
||||
// 会员价相关
|
||||
memberPrice: 0 as number,
|
||||
memberDiscount: 0 as number,
|
||||
memberLevelName: '' as string
|
||||
}
|
||||
},
|
||||
onLoad(options: any) {
|
||||
@@ -461,6 +469,9 @@ export default {
|
||||
if (this.product.id != null && this.product.id !== '') {
|
||||
this.loadProductSkus(this.product.id)
|
||||
}
|
||||
|
||||
// 加载会员价
|
||||
this.loadMemberPrice()
|
||||
|
||||
uni.hideLoading()
|
||||
},
|
||||
@@ -559,6 +570,28 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async loadMemberPrice() {
|
||||
try {
|
||||
const memberInfo = await supabaseService.getUserMemberInfo()
|
||||
const levelNameRaw = memberInfo.get('level_name')
|
||||
const discountRaw = memberInfo.get('discount')
|
||||
|
||||
if (levelNameRaw != null) {
|
||||
this.memberLevelName = levelNameRaw as string
|
||||
}
|
||||
|
||||
if (discountRaw != null) {
|
||||
const discount = discountRaw as number
|
||||
if (discount > 0 && discount < 10) {
|
||||
this.memberDiscount = discount
|
||||
this.memberPrice = Math.round(this.product.price * discount) / 10
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('获取会员信息失败,可能未登录或非会员:', e)
|
||||
}
|
||||
},
|
||||
|
||||
// 新增:加载优惠券
|
||||
async loadCoupons() {
|
||||
if (this.product.merchant_id == '') return
|
||||
@@ -958,6 +991,25 @@ export default {
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.member-price-tag {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #52c41a;
|
||||
background-color: #f6ffed;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.member-discount-row {
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.member-discount-text {
|
||||
font-size: 24rpx;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.original-price {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
|
||||
749
pages/mall/consumer/product-reviews.uvue
Normal file
749
pages/mall/consumer/product-reviews.uvue
Normal file
@@ -0,0 +1,749 @@
|
||||
<template>
|
||||
<view class="reviews-page">
|
||||
<view class="stats-section" v-if="stats.total_count > 0">
|
||||
<view class="stats-header">
|
||||
<view class="stats-main">
|
||||
<text class="stats-avg">{{ stats.avg_rating }}</text>
|
||||
<text class="stats-label">综合评分</text>
|
||||
</view>
|
||||
<view class="stats-detail">
|
||||
<view class="stats-row">
|
||||
<text class="stats-good">{{ stats.good_rate }}%</text>
|
||||
<text class="stats-good-label">好评率</text>
|
||||
</view>
|
||||
<view class="stats-row">
|
||||
<text class="stats-total">{{ stats.total_count }}</text>
|
||||
<text class="stats-total-label">评价数</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="rating-bars">
|
||||
<view class="rating-bar" v-for="i in 5" :key="i">
|
||||
<text class="rating-label">{{ 6 - i }}星</text>
|
||||
<view class="rating-progress">
|
||||
<view
|
||||
class="rating-fill"
|
||||
:style="{ width: getRatingPercent(6 - i) + '%' }"
|
||||
></view>
|
||||
</view>
|
||||
<text class="rating-count">{{ getRatingCount(6 - i) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="filter-section">
|
||||
<scroll-view scroll-x class="filter-scroll">
|
||||
<view class="filter-list">
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ active: filterRating === 0 }"
|
||||
@click="setFilterRating(0)"
|
||||
>
|
||||
<text class="filter-text">全部({{ stats.total_count }})</text>
|
||||
</view>
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ active: filterRating === 5 }"
|
||||
@click="setFilterRating(5)"
|
||||
>
|
||||
<text class="filter-text">好评({{ getRatingCount(5) }})</text>
|
||||
</view>
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ active: filterRating === 4 }"
|
||||
@click="setFilterRating(4)"
|
||||
>
|
||||
<text class="filter-text">中评({{ getRatingCount(4) + getRatingCount(3) }})</text>
|
||||
</view>
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ active: filterRating === 2 }"
|
||||
@click="setFilterRating(2)"
|
||||
>
|
||||
<text class="filter-text">差评({{ getRatingCount(2) + getRatingCount(1) }})</text>
|
||||
</view>
|
||||
<view
|
||||
class="filter-item"
|
||||
:class="{ active: hasImageFilter }"
|
||||
@click="toggleHasImage"
|
||||
>
|
||||
<text class="filter-text">有图</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="review-list">
|
||||
<view class="review-item" v-for="review in reviews" :key="review.id">
|
||||
<view class="review-header">
|
||||
<image
|
||||
class="user-avatar"
|
||||
:src="review.user_avatar || defaultAvatar"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ review.user_name }}</text>
|
||||
<view class="rating-stars">
|
||||
<text
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
class="star"
|
||||
:class="{ filled: star <= review.rating }"
|
||||
>★</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="review-time">{{ formatTime(review.created_at) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-content">
|
||||
<text class="review-text">{{ review.content }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-images" v-if="review.images.length > 0">
|
||||
<image
|
||||
v-for="(img, idx) in review.images.slice(0, 3)"
|
||||
:key="idx"
|
||||
class="review-image"
|
||||
:src="img"
|
||||
mode="aspectFill"
|
||||
@click="previewImage(review.images, idx)"
|
||||
/>
|
||||
<view class="more-images" v-if="review.images.length > 3">
|
||||
<text>+{{ review.images.length - 3 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="review-append" v-if="review.append_content">
|
||||
<text class="append-label">追评</text>
|
||||
<text class="append-text">{{ review.append_content }}</text>
|
||||
<text class="append-time">{{ formatTime(review.append_at) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-reply" v-if="review.reply">
|
||||
<text class="reply-label">商家回复:</text>
|
||||
<text class="reply-text">{{ review.reply }}</text>
|
||||
</view>
|
||||
|
||||
<view class="review-footer">
|
||||
<view
|
||||
class="like-btn"
|
||||
:class="{ liked: review.is_liked }"
|
||||
@click="toggleLike(review)"
|
||||
>
|
||||
<text class="like-icon">{{ review.is_liked ? '❤' : '♡' }}</text>
|
||||
<text class="like-count">{{ review.like_count || 0 }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="!loading && reviews.length === 0">
|
||||
<text class="empty-text">暂无评价</text>
|
||||
</view>
|
||||
|
||||
<view class="loading-state" v-if="loading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="load-more" v-if="!loading && hasMore && reviews.length > 0" @click="loadMore">
|
||||
<text class="load-more-text">加载更多</text>
|
||||
</view>
|
||||
|
||||
<view class="no-more" v-if="!loading && !hasMore && reviews.length > 0">
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type ReviewItem = {
|
||||
id: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_avatar: string
|
||||
rating: number
|
||||
content: string
|
||||
images: string[]
|
||||
is_anonymous: boolean
|
||||
like_count: number
|
||||
is_liked: boolean
|
||||
append_content: string | null
|
||||
append_at: string | null
|
||||
reply: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type StatsType = {
|
||||
total_count: number
|
||||
avg_rating: number
|
||||
good_rate: number
|
||||
rating_distribution: Map<string, number>
|
||||
}
|
||||
|
||||
const productId = ref<string>('')
|
||||
const reviews = ref<ReviewItem[]>([])
|
||||
const stats = ref<StatsType>({
|
||||
total_count: 0,
|
||||
avg_rating: 0,
|
||||
good_rate: 0,
|
||||
rating_distribution: new Map<string, number>()
|
||||
})
|
||||
const loading = ref<boolean>(true)
|
||||
const hasMore = ref<boolean>(true)
|
||||
const page = ref<number>(1)
|
||||
const pageSize = 10
|
||||
const filterRating = ref<number>(0)
|
||||
const hasImageFilter = ref<boolean>(false)
|
||||
|
||||
const defaultAvatar: string = '/static/images/default-avatar.png'
|
||||
|
||||
const getRatingCount = (rating: number): number => {
|
||||
return stats.value.rating_distribution.get(rating.toString()) ?? 0
|
||||
}
|
||||
|
||||
const getRatingPercent = (rating: number): number => {
|
||||
if (stats.value.total_count === 0) return 0
|
||||
const count = getRatingCount(rating)
|
||||
return Math.round((count / stats.value.total_count) * 100)
|
||||
}
|
||||
|
||||
const loadStats = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.getReviewStats(productId.value)
|
||||
const distMap = new Map<string, number>()
|
||||
|
||||
const dist = result.get('rating_distribution')
|
||||
if (dist != null && dist instanceof UTSJSONObject) {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
distMap.set(i.toString(), dist.getNumber(i.toString()) ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
stats.value = {
|
||||
total_count: result.getNumber('total_count') ?? 0,
|
||||
avg_rating: result.getNumber('avg_rating') ?? 0,
|
||||
good_rate: result.getNumber('good_rate') ?? 0,
|
||||
rating_distribution: distMap
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载统计失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadReviews = async (pageNum: number = 1): Promise<void> => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const result = await supabaseService.getProductReviews(
|
||||
productId.value,
|
||||
pageNum,
|
||||
pageSize,
|
||||
filterRating.value,
|
||||
hasImageFilter.value
|
||||
)
|
||||
|
||||
const total = result.getNumber('total') ?? 0
|
||||
const data = result.get('data')
|
||||
const reviewList: ReviewItem[] = []
|
||||
|
||||
if (data != null && Array.isArray(data)) {
|
||||
const rawList = data as any[]
|
||||
for (let i = 0; i < rawList.length; i++) {
|
||||
const item = rawList[i]
|
||||
let reviewObj: UTSJSONObject
|
||||
if (item instanceof UTSJSONObject) {
|
||||
reviewObj = item
|
||||
} else {
|
||||
reviewObj = JSON.parse(JSON.stringify(item)) as UTSJSONObject
|
||||
}
|
||||
|
||||
let images: string[] = []
|
||||
const imagesRaw = reviewObj.get('images')
|
||||
if (imagesRaw != null && typeof imagesRaw === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(imagesRaw as string)
|
||||
if (Array.isArray(parsed)) {
|
||||
images = parsed as string[]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析图片失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const review: ReviewItem = {
|
||||
id: reviewObj.getString('id') ?? '',
|
||||
user_id: reviewObj.getString('user_id') ?? '',
|
||||
user_name: reviewObj.getString('user_name') ?? '匿名用户',
|
||||
user_avatar: reviewObj.getString('user_avatar') ?? '',
|
||||
rating: reviewObj.getNumber('rating') ?? 5,
|
||||
content: reviewObj.getString('content') ?? '',
|
||||
images: images,
|
||||
is_anonymous: reviewObj.getBoolean('is_anonymous') ?? false,
|
||||
like_count: reviewObj.getNumber('like_count') ?? 0,
|
||||
is_liked: reviewObj.getBoolean('is_liked') ?? false,
|
||||
append_content: reviewObj.getString('append_content'),
|
||||
append_at: reviewObj.getString('append_at'),
|
||||
reply: reviewObj.getString('reply'),
|
||||
created_at: reviewObj.getString('created_at') ?? ''
|
||||
}
|
||||
reviewList.push(review)
|
||||
}
|
||||
}
|
||||
|
||||
if (pageNum === 1) {
|
||||
reviews.value = reviewList
|
||||
} else {
|
||||
reviews.value = [...reviews.value, ...reviewList]
|
||||
}
|
||||
|
||||
hasMore.value = reviews.value.length < total
|
||||
page.value = pageNum
|
||||
} catch (e) {
|
||||
console.error('加载评价失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = (): void => {
|
||||
if (!loading.value && hasMore.value) {
|
||||
loadReviews(page.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const setFilterRating = (rating: number): void => {
|
||||
filterRating.value = rating
|
||||
hasImageFilter.value = false
|
||||
page.value = 1
|
||||
loadReviews(1)
|
||||
}
|
||||
|
||||
const toggleHasImage = (): void => {
|
||||
hasImageFilter.value = !hasImageFilter.value
|
||||
filterRating.value = 0
|
||||
page.value = 1
|
||||
loadReviews(1)
|
||||
}
|
||||
|
||||
const toggleLike = async (review: ReviewItem): Promise<void> => {
|
||||
try {
|
||||
const result = await supabaseService.toggleReviewLike(review.id)
|
||||
if (result.getBoolean('success') === true) {
|
||||
review.is_liked = result.getBoolean('is_liked') ?? false
|
||||
review.like_count = result.getNumber('like_count') ?? 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('点赞失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const previewImage = (images: string[], index: number): void => {
|
||||
uni.previewImage({
|
||||
urls: images,
|
||||
current: index
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (timeStr: string | null): string => {
|
||||
if (timeStr == null || timeStr === '') return ''
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (24 * 60 * 60 * 1000))
|
||||
|
||||
if (days === 0) {
|
||||
const hours = Math.floor(diff / (60 * 60 * 1000))
|
||||
if (hours === 0) {
|
||||
const minutes = Math.floor(diff / (60 * 1000))
|
||||
return minutes <= 1 ? '刚刚' : `${minutes}分钟前`
|
||||
}
|
||||
return `${hours}小时前`
|
||||
} else if (days < 7) {
|
||||
return `${days}天前`
|
||||
} else {
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const options = (currentPage as any).options
|
||||
if (options != null && options.product_id != null) {
|
||||
productId.value = options.product_id as string
|
||||
loadStats()
|
||||
loadReviews(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.reviews-page {
|
||||
flex: 1;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats-avg {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.stats-detail {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.stats-good {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.stats-good-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.stats-total {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stats-total-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.rating-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rating-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rating-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.rating-progress {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
margin: 0 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rating-fill {
|
||||
height: 100%;
|
||||
background-color: #ff6b35;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.rating-count {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background-color: white;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
padding: 6px 16px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 16px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.filter-item.active {
|
||||
background-color: #fff5f0;
|
||||
}
|
||||
|
||||
.filter-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.filter-item.active .filter-text {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.review-list {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.review-item {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.review-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rating-stars {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.star {
|
||||
font-size: 12px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.star.filled {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.review-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.review-content {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.review-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.review-images {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.review-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.more-images {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.more-images text {
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.review-append {
|
||||
background-color: #f9f9f9;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.append-label {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.append-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.append-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.review-reply {
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reply-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.reply-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.review-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.like-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.like-btn.liked .like-icon {
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.like-icon {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.like-count {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.no-more-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
603
pages/mall/consumer/share/detail.uvue
Normal file
603
pages/mall/consumer/share/detail.uvue
Normal file
@@ -0,0 +1,603 @@
|
||||
<template>
|
||||
<scroll-view class="share-detail-page" scroll-y>
|
||||
<view class="product-section">
|
||||
<image class="product-image" :src="shareRecord.product_image || defaultImage" mode="aspectFill" />
|
||||
<view class="product-info">
|
||||
<text class="product-name">{{ shareRecord.product_name }}</text>
|
||||
<text class="product-price">¥{{ shareRecord.product_price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="progress-section">
|
||||
<view class="progress-header">
|
||||
<text class="progress-title">免单进度</text>
|
||||
<text class="progress-status" :class="getStatusClass(shareRecord.status)">{{ getStatusText(shareRecord.status) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="progress-content">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: getProgressPercent() + '%' }"></view>
|
||||
</view>
|
||||
<view class="progress-numbers">
|
||||
<text class="current-count">{{ shareRecord.current_count }}</text>
|
||||
<text class="divider">/</text>
|
||||
<text class="required-count">{{ shareRecord.required_count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="progress-tip" v-if="shareRecord.status === 0">
|
||||
<text class="tip-text">还需 {{ shareRecord.required_count - shareRecord.current_count }} 人购买即可免单</text>
|
||||
</view>
|
||||
|
||||
<view class="reward-info" v-if="shareRecord.status === 1">
|
||||
<text class="reward-label">已获得免单奖励</text>
|
||||
<text class="reward-amount">¥{{ shareRecord.reward_amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="share-code-section">
|
||||
<view class="code-header">
|
||||
<text class="code-title">分享码</text>
|
||||
<text class="copy-btn" @click="copyShareCode">复制</text>
|
||||
</view>
|
||||
<view class="code-content">
|
||||
<text class="code-value">{{ shareRecord.share_code }}</text>
|
||||
</view>
|
||||
<view class="code-tip">
|
||||
<text class="tip-text">将分享码告诉好友,好友下单时填写即可</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="buyers-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">购买记录</text>
|
||||
<text class="section-count">({{ buyers.length }}人)</text>
|
||||
</view>
|
||||
|
||||
<view v-if="buyersLoading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="buyers.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无购买记录</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="buyer-list">
|
||||
<view class="buyer-item" v-for="buyer in buyers" :key="buyer.id">
|
||||
<view class="buyer-avatar">
|
||||
<text class="avatar-text">{{ getBuyerInitial(buyer.buyer_name) }}</text>
|
||||
</view>
|
||||
<view class="buyer-info">
|
||||
<text class="buyer-name">{{ maskName(buyer.buyer_name) }}</text>
|
||||
<text class="buyer-time">{{ formatTime(buyer.created_at) }}</text>
|
||||
</view>
|
||||
<view class="buyer-count">
|
||||
<text class="count-text">购买 {{ buyer.quantity }} 件</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="time-section">
|
||||
<view class="time-item">
|
||||
<text class="time-label">创建时间</text>
|
||||
<text class="time-value">{{ formatTime(shareRecord.created_at) }}</text>
|
||||
</view>
|
||||
<view class="time-item" v-if="shareRecord.completed_at">
|
||||
<text class="time-label">完成时间</text>
|
||||
<text class="time-value">{{ formatTime(shareRecord.completed_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type ShareRecordType = {
|
||||
id: string
|
||||
product_name: string
|
||||
product_image: string | null
|
||||
product_price: number
|
||||
share_code: string
|
||||
required_count: number
|
||||
current_count: number
|
||||
status: number
|
||||
reward_amount: number | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
type BuyerType = {
|
||||
id: string
|
||||
buyer_id: string
|
||||
buyer_name: string
|
||||
quantity: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const shareId = ref<string>('')
|
||||
const shareRecord = ref<ShareRecordType>({
|
||||
id: '',
|
||||
product_name: '',
|
||||
product_image: null,
|
||||
product_price: 0,
|
||||
share_code: '',
|
||||
required_count: 4,
|
||||
current_count: 0,
|
||||
status: 0,
|
||||
reward_amount: null,
|
||||
created_at: '',
|
||||
completed_at: null
|
||||
})
|
||||
|
||||
const buyers = ref<BuyerType[]>([])
|
||||
const buyersLoading = ref<boolean>(false)
|
||||
const defaultImage: string = '/static/images/default-product.png'
|
||||
|
||||
const loadShareDetail = async (): Promise<void> => {
|
||||
if (shareId.value === '') return
|
||||
|
||||
try {
|
||||
const result = await supabaseService.getShareDetail(shareId.value)
|
||||
|
||||
const recordRaw = result.get('share_record')
|
||||
if (recordRaw != null) {
|
||||
const recordAny = recordRaw as any
|
||||
if (typeof recordAny._getValue === 'function') {
|
||||
shareRecord.value = {
|
||||
id: (recordAny._getValue('id') as string) ?? '',
|
||||
product_name: (recordAny._getValue('product_name') as string) ?? '',
|
||||
product_image: recordAny._getValue('product_image') as string | null,
|
||||
product_price: (recordAny._getValue('product_price') as number) ?? 0,
|
||||
share_code: (recordAny._getValue('share_code') as string) ?? '',
|
||||
required_count: (recordAny._getValue('required_count') as number) ?? 4,
|
||||
current_count: (recordAny._getValue('current_count') as number) ?? 0,
|
||||
status: (recordAny._getValue('status') as number) ?? 0,
|
||||
reward_amount: recordAny._getValue('reward_amount') as number | null,
|
||||
created_at: (recordAny._getValue('created_at') as string) ?? '',
|
||||
completed_at: recordAny._getValue('completed_at') as string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const purchasesRaw = result.get('secondary_purchases')
|
||||
if (purchasesRaw != null && Array.isArray(purchasesRaw)) {
|
||||
const parsed: BuyerType[] = []
|
||||
const arr = purchasesRaw as any[]
|
||||
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item = arr[i]
|
||||
const itemAny = item as any
|
||||
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
parsed.push({
|
||||
id: (itemAny._getValue('id') as string) ?? '',
|
||||
buyer_id: (itemAny._getValue('buyer_id') as string) ?? '',
|
||||
buyer_name: '用户' + (i + 1),
|
||||
quantity: (itemAny._getValue('quantity') as number) ?? 1,
|
||||
created_at: (itemAny._getValue('created_at') as string) ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
buyers.value = parsed
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载分享详情失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const getProgressPercent = (): number => {
|
||||
if (shareRecord.value.required_count <= 0) return 0
|
||||
return Math.min(100, Math.round((shareRecord.value.current_count / shareRecord.value.required_count) * 100))
|
||||
}
|
||||
|
||||
const getStatusText = (status: number): string => {
|
||||
if (status === 0) return '进行中'
|
||||
if (status === 1) return '已免单'
|
||||
if (status === 2) return '已失效'
|
||||
if (status === 3) return '已过期'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const getStatusClass = (status: number): string => {
|
||||
if (status === 0) return 'status-progress'
|
||||
if (status === 1) return 'status-completed'
|
||||
if (status === 2) return 'status-invalid'
|
||||
if (status === 3) return 'status-expired'
|
||||
return ''
|
||||
}
|
||||
|
||||
const copyShareCode = (): void => {
|
||||
uni.setClipboardData({
|
||||
data: shareRecord.value.share_code,
|
||||
success: () => {
|
||||
uni.showToast({ title: '已复制分享码', icon: 'success' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getBuyerInitial = (name: string): string => {
|
||||
if (name.length > 0) {
|
||||
return name.charAt(0)
|
||||
}
|
||||
return '用'
|
||||
}
|
||||
|
||||
const maskName = (name: string): string => {
|
||||
if (name.length <= 2) {
|
||||
return name.charAt(0) + '*'
|
||||
}
|
||||
return name.charAt(0) + '***' + name.charAt(name.length - 1)
|
||||
}
|
||||
|
||||
const formatTime = (timeStr: string | null): string => {
|
||||
if (timeStr == null || timeStr === '') return ''
|
||||
const date = new Date(timeStr)
|
||||
const y = date.getFullYear()
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const d = date.getDate().toString().padStart(2, '0')
|
||||
const hh = date.getHours().toString().padStart(2, '0')
|
||||
const mm = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${d} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0) {
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const options = (currentPage as any).options
|
||||
if (options != null && options.id != null) {
|
||||
shareId.value = options.id as string
|
||||
loadShareDetail()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.share-detail-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.product-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
lines: 2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.progress-status {
|
||||
font-size: 14px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
background-color: #fff5f0;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-invalid {
|
||||
background-color: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
background-color: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 12px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ff6b35 0%, #ff8c42 100%);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.progress-numbers {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.current-count {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.required-count {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.progress-tip {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background-color: #fff5f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
font-size: 13px;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.reward-info {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #f6ffed 0%, #e6fffb 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reward-label {
|
||||
font-size: 15px;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.reward-amount {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.share-code-section {
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.code-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
font-size: 14px;
|
||||
color: #ff6b35;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #ff6b35;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
background-color: #f9f9f9;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.code-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
letter-spacing: 8px;
|
||||
}
|
||||
|
||||
.code-tip {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.buyers-section {
|
||||
background-color: white;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 30px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.buyer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.buyer-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
}
|
||||
|
||||
.buyer-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background-color: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.buyer-info {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.buyer-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.buyer-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.buyer-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.time-section {
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.time-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
409
pages/mall/consumer/share/index.uvue
Normal file
409
pages/mall/consumer/share/index.uvue
Normal file
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<scroll-view class="share-page" scroll-y>
|
||||
<view class="share-summary">
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ totalShares }}</text>
|
||||
<text class="summary-label">分享次数</text>
|
||||
</view>
|
||||
<view class="summary-divider"></view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ completedShares }}</text>
|
||||
<text class="summary-label">免单成功</text>
|
||||
</view>
|
||||
<view class="summary-divider"></view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-value">{{ totalReward }}</text>
|
||||
<text class="summary-label">累计奖励(元)</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="rules-section">
|
||||
<view class="rules-header" @click="toggleRules">
|
||||
<text class="rules-title">免单规则</text>
|
||||
<text class="rules-arrow">{{ showRules ? '▲' : '▼' }}</text>
|
||||
</view>
|
||||
<view class="rules-content" v-if="showRules">
|
||||
<text class="rules-text">1. 购买商品后可生成分享链接</text>
|
||||
<text class="rules-text">2. 分享给好友,好友通过链接购买</text>
|
||||
<text class="rules-text">3. 累计4人购买后,即可免单</text>
|
||||
<text class="rules-text">4. 免单金额存入余额,可联系商家提现</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="share-list-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">我的分享</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="shares.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无分享记录</text>
|
||||
<text class="empty-tip">购买商品后可以分享免单哦~</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="share-list">
|
||||
<view class="share-item" v-for="share in shares" :key="share.id" @click="goToShareDetail(share.id)">
|
||||
<image class="product-image" :src="share.product_image || defaultImage" mode="aspectFill" />
|
||||
<view class="share-info">
|
||||
<text class="product-name">{{ share.product_name }}</text>
|
||||
<view class="progress-section">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: getProgressPercent(share.current_count, share.required_count) + '%' }"></view>
|
||||
</view>
|
||||
<text class="progress-text">{{ share.current_count }}/{{ share.required_count }}</text>
|
||||
</view>
|
||||
<view class="share-bottom">
|
||||
<text class="share-code">分享码: {{ share.share_code }}</text>
|
||||
<text class="share-status" :class="getStatusClass(share.status)">{{ getStatusText(share.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="share-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { supabaseService } from '@/utils/supabaseService.uts'
|
||||
|
||||
type ShareRecord = {
|
||||
id: string
|
||||
product_id: string
|
||||
product_name: string
|
||||
product_image: string | null
|
||||
product_price: number
|
||||
share_code: string
|
||||
required_count: number
|
||||
current_count: number
|
||||
status: number
|
||||
reward_amount: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const shares = ref<ShareRecord[]>([])
|
||||
const loading = ref<boolean>(true)
|
||||
const showRules = ref<boolean>(false)
|
||||
const defaultImage: string = '/static/images/default-product.png'
|
||||
|
||||
const totalShares = computed((): number => shares.value.length)
|
||||
|
||||
const completedShares = computed((): number => {
|
||||
let count = 0
|
||||
for (let i = 0; i < shares.value.length; i++) {
|
||||
if (shares.value[i].status === 1) count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
const totalReward = computed((): number => {
|
||||
let total = 0
|
||||
for (let i = 0; i < shares.value.length; i++) {
|
||||
if (shares.value[i].reward_amount != null) {
|
||||
total += shares.value[i].reward_amount!
|
||||
}
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
const loadShares = async (): Promise<void> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await supabaseService.getMyShareRecords()
|
||||
const parsed: ShareRecord[] = []
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i]
|
||||
const itemAny = item as any
|
||||
|
||||
if (typeof itemAny._getValue === 'function') {
|
||||
parsed.push({
|
||||
id: (itemAny._getValue('id') as string) ?? '',
|
||||
product_id: (itemAny._getValue('product_id') as string) ?? '',
|
||||
product_name: (itemAny._getValue('product_name') as string) ?? '',
|
||||
product_image: itemAny._getValue('product_image') as string | null,
|
||||
product_price: (itemAny._getValue('product_price') as number) ?? 0,
|
||||
share_code: (itemAny._getValue('share_code') as string) ?? '',
|
||||
required_count: (itemAny._getValue('required_count') as number) ?? 4,
|
||||
current_count: (itemAny._getValue('current_count') as number) ?? 0,
|
||||
status: (itemAny._getValue('status') as number) ?? 0,
|
||||
reward_amount: itemAny._getValue('reward_amount') as number | null,
|
||||
created_at: (itemAny._getValue('created_at') as string) ?? ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
shares.value = parsed
|
||||
} catch (e) {
|
||||
console.error('加载分享记录失败:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRules = (): void => {
|
||||
showRules.value = !showRules.value
|
||||
}
|
||||
|
||||
const getProgressPercent = (current: number, required: number): number => {
|
||||
if (required <= 0) return 0
|
||||
return Math.min(100, Math.round((current / required) * 100))
|
||||
}
|
||||
|
||||
const getStatusText = (status: number): string => {
|
||||
if (status === 0) return '进行中'
|
||||
if (status === 1) return '已免单'
|
||||
if (status === 2) return '已失效'
|
||||
if (status === 3) return '已过期'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const getStatusClass = (status: number): string => {
|
||||
if (status === 0) return 'status-progress'
|
||||
if (status === 1) return 'status-completed'
|
||||
if (status === 2) return 'status-invalid'
|
||||
if (status === 3) return 'status-expired'
|
||||
return ''
|
||||
}
|
||||
|
||||
const goToShareDetail = (shareId: string): void => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/mall/consumer/share/detail?id=${shareId}`
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadShares()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.share-page {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.share-summary {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: white;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.summary-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.rules-section {
|
||||
background-color: white;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rules-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rules-title {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rules-arrow {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.rules-content {
|
||||
padding: 0 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rules-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.share-list-section {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 40px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.share-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.share-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f9f9f9;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.share-info {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
lines: 2;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: #ff6b35;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 12px;
|
||||
color: #ff6b35;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.share-bottom {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.share-code {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.share-status {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
background-color: #fff5f0;
|
||||
color: #ff6b35;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.status-invalid {
|
||||
background-color: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
background-color: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.share-arrow {
|
||||
font-size: 20px;
|
||||
color: #ccc;
|
||||
margin-left: 8px;
|
||||
align-self: center;
|
||||
}
|
||||
</style>
|
||||
@@ -411,3 +411,275 @@ export type FootprintItemType = {
|
||||
shopName: string
|
||||
viewTime: number
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 积分相关类型
|
||||
// =========================
|
||||
|
||||
// 签到记录类型
|
||||
export type SigninRecordType = {
|
||||
id: string
|
||||
user_id: string
|
||||
signin_date: string
|
||||
points_earned: number
|
||||
bonus_points: number
|
||||
continuous_days: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 签到结果类型
|
||||
export type SigninResultType = {
|
||||
success: boolean
|
||||
points: number
|
||||
continuous_days: number
|
||||
bonus_points: number
|
||||
total_points: number
|
||||
message: string
|
||||
}
|
||||
|
||||
// 积分兑换商品类型
|
||||
export type PointProductType = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
image_url: string | null
|
||||
product_type: string
|
||||
points_required: number
|
||||
original_price: number | null
|
||||
stock: number
|
||||
status: number
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 积分兑换记录类型
|
||||
export type PointExchangeType = {
|
||||
id: string
|
||||
user_id: string
|
||||
product_id: string
|
||||
quantity: number
|
||||
points_used: number
|
||||
status: number
|
||||
tracking_no: string | null
|
||||
address_snapshot: UTSJSONObject | null
|
||||
created_at: string
|
||||
product: PointProductType | null
|
||||
}
|
||||
|
||||
// 积分规则类型
|
||||
export type PointRuleType = {
|
||||
id: string
|
||||
rule_type: string
|
||||
rule_name: string
|
||||
points: number
|
||||
description: string | null
|
||||
config: UTSJSONObject | null
|
||||
status: number
|
||||
}
|
||||
|
||||
// 积分概览类型
|
||||
export type PointsOverviewType = {
|
||||
current_points: number
|
||||
total_earned: number
|
||||
total_used: number
|
||||
expiring_points: number
|
||||
expiring_date: string | null
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 评价相关类型
|
||||
// =========================
|
||||
|
||||
// 商品评价类型(扩展)
|
||||
export type ProductReviewType = {
|
||||
id: string
|
||||
user_id: string
|
||||
product_id: string
|
||||
order_id: string
|
||||
order_item_id: string | null
|
||||
rating: number
|
||||
content: string | null
|
||||
images: string[]
|
||||
videos: string[]
|
||||
tags: string[]
|
||||
is_anonymous: boolean
|
||||
like_count: number
|
||||
is_edited: boolean
|
||||
append_content: string | null
|
||||
append_at: string | null
|
||||
append_images: string[]
|
||||
reply: string | null
|
||||
reply_time: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user_name: string | null
|
||||
user_avatar: string | null
|
||||
is_liked: boolean
|
||||
}
|
||||
|
||||
// 评价统计类型
|
||||
export type ReviewStatsType = {
|
||||
total_count: number
|
||||
avg_rating: number
|
||||
good_rate: number
|
||||
rating_distribution: Map<string, number>
|
||||
tags: ReviewTagType[]
|
||||
}
|
||||
|
||||
// 评价标签类型
|
||||
export type ReviewTagType = {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
// 评价点赞类型
|
||||
export type ReviewLikeType = {
|
||||
id: string
|
||||
review_id: string
|
||||
user_id: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 评价举报类型
|
||||
export type ReviewReportType = {
|
||||
id: string
|
||||
review_id: string
|
||||
user_id: string
|
||||
reason: string
|
||||
description: string | null
|
||||
status: number
|
||||
handle_result: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 配送员评价类型
|
||||
export type DeliveryRatingType = {
|
||||
id: string
|
||||
order_id: string
|
||||
delivery_user_id: string
|
||||
user_id: string
|
||||
rating: number
|
||||
content: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 我的评价列表项类型
|
||||
export type MyReviewItemType = {
|
||||
id: string
|
||||
product_id: string
|
||||
product_name: string
|
||||
product_image: string
|
||||
rating: number
|
||||
content: string | null
|
||||
images: string[]
|
||||
created_at: string
|
||||
can_append: boolean
|
||||
can_edit: boolean
|
||||
}
|
||||
|
||||
// =========================
|
||||
// 推销模式相关类型
|
||||
// =========================
|
||||
|
||||
// 用户余额类型
|
||||
export type UserBalanceType = {
|
||||
id: string
|
||||
user_id: string
|
||||
balance: number
|
||||
frozen_balance: number
|
||||
total_earned: number
|
||||
total_withdrawn: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 余额变动记录类型
|
||||
export type BalanceRecordType = {
|
||||
id: string
|
||||
user_id: string
|
||||
type: string
|
||||
amount: number
|
||||
balance_before: number
|
||||
balance_after: number
|
||||
related_id: string | null
|
||||
description: string | null
|
||||
operator_id: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 分享记录类型
|
||||
export type ShareRecordType = {
|
||||
id: string
|
||||
user_id: string
|
||||
product_id: string
|
||||
order_id: string
|
||||
order_item_id: string | null
|
||||
share_code: string
|
||||
product_name: string
|
||||
product_image: string | null
|
||||
product_price: number
|
||||
required_count: number
|
||||
current_count: number
|
||||
status: number
|
||||
reward_amount: number | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
expired_at: string | null
|
||||
}
|
||||
|
||||
// 二级购买记录类型
|
||||
export type SecondaryPurchaseType = {
|
||||
id: string
|
||||
share_record_id: string
|
||||
buyer_id: string
|
||||
order_id: string
|
||||
quantity: number
|
||||
unit_price: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 免单奖励记录类型
|
||||
export type FreeOrderRewardType = {
|
||||
id: string
|
||||
user_id: string
|
||||
share_record_id: string
|
||||
amount: number
|
||||
status: number
|
||||
balance_record_id: string | null
|
||||
cleared_at: string | null
|
||||
cleared_by: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 会员等级类型
|
||||
export type MemberLevelType = {
|
||||
id: number
|
||||
name: string
|
||||
min_amount: number
|
||||
discount: number
|
||||
icon: string | null
|
||||
description: string | null
|
||||
sort_order: number
|
||||
status: number
|
||||
}
|
||||
|
||||
// 用户会员信息类型
|
||||
export type UserMemberInfoType = {
|
||||
member_level: number
|
||||
level_name: string
|
||||
discount: number
|
||||
total_spent: number
|
||||
next_level: MemberLevelType | null
|
||||
progress_percent: number
|
||||
manual_level: boolean
|
||||
}
|
||||
|
||||
// 会员等级变更记录类型
|
||||
export type MemberLevelLogType = {
|
||||
id: string
|
||||
user_id: string
|
||||
old_level: number
|
||||
new_level: number
|
||||
reason: string | null
|
||||
operator_id: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"code":"// Supabase 配置\n// 内网环境 - 本地部署的 Supabase\n// IP: 192.168.1.62\n// Kong HTTP Port: 8000\n//export const SUPA_URL: string = 'http://192.168.1.61:18000'\n//export const SUPA_KEY: string = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'\nexport const SUPA_URL = 'http://119.146.131.237:9126';\nexport const SUPA_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLTEiLCJpYXQiOjE3Njk2NzY0OTgsImV4cCI6MTkyNzM1NjQ5OH0.ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';\n// WebSocket 实时连接(内网使用 ws:// 而非 wss://)\nexport const WS_URL = 'ws://119.146.131.237:9126/realtime/v1/websocket';\n//export const WS_URL: string = 'ws://localhost:18000/realtime/v1/websocket'\n// 备用配置(已注释,如需切换可取消注释)\n// 开发环境 - 其他内网地址\n// export const SUPA_URL: string = 'http://192.168.0.150:8080'\n// export const SUPA_KEY: string = 'your-anon-key'\n// export const WS_URL: string = 'ws://192.168.0.150:8080/realtime/v1/websocket'\n// 生产环境 - Supabase 云服务(已注释)\n// export const SUPA_URL: string = 'https://ak3.oulog.com'\n// export const SUPA_KEY: string = 'your-anon-key'\n// export const WS_URL: string = 'wss://ak3.oulog.com/realtime/v1/websocket'\n// 指向你的 Supabase 服务(开发/私有部署)\n// export const SUPA_URL: string = 'http://192.168.1.64:3000'\n// export const SUPA_KEY: string = 'your-anon-key'\n// export const WS_URL: string = 'ws://192.168.1.64:3000/realtime/v1'\n// 路由配置\nexport const HOME_REDIRECT = '/pages/main/index';\nexport const TABORPAGE = '/pages/main/index';\n// 测试模式:放开任意跳转(禁用启动页/登录/401 的强制重定向)\nexport const IS_TEST_MODE = true;\n//# sourceMappingURL=D:/companyproject/mall/ak/config.uts.js.map","references":[],"uniExtApis":[],"map":"{\"version\":3,\"file\":\"config.uts.js\",\"sourceRoot\":\"\",\"sources\":[\"config.uts\"],\"names\":[],\"mappings\":\"AAAA,cAAc;AACd,wBAAwB;AACxB,mBAAmB;AACnB,uBAAuB;AAEvB,6DAA6D;AAC7D,yMAAyM;AACzM,MAAM,CAAC,MAAM,QAAQ,GAAW,6BAA6B,CAAA;AAC7D,MAAM,CAAC,MAAM,QAAQ,GAAW,uKAAuK,CAAA;AAEvM,uCAAuC;AACvC,MAAM,CAAC,MAAM,MAAM,GAAW,iDAAiD,CAAA;AAC/E,4EAA4E;AAE5E,sBAAsB;AACtB,gBAAgB;AAChB,8DAA8D;AAC9D,kDAAkD;AAClD,gFAAgF;AAEhF,2BAA2B;AAC3B,0DAA0D;AAC1D,kDAAkD;AAClD,4EAA4E;AAE5E,4BAA4B;AAC5B,6DAA6D;AAC7D,kDAAkD;AAClD,qEAAqE;AAErE,OAAO;AACP,MAAM,CAAC,MAAM,aAAa,GAAW,mBAAmB,CAAA;AACxD,MAAM,CAAC,MAAM,SAAS,GAAW,mBAAmB,CAAA;AAEpD,mCAAmC;AACnC,MAAM,CAAC,MAAM,YAAY,GAAY,IAAI,CAAA\"}"}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user