Initial commit: 帮我选盲选应用

功能:
- Go后端 (Gin + GORM + PostgreSQL)
- UniApp用户端 (iOS/Android/小程序)
- DaisyUI5后台管理
- JWT认证 + 微信登录
- 盲选加权算法
- 会员系统 + 优惠券
- 打分评价 + 偏好学习
This commit is contained in:
2026-06-08 20:18:31 +00:00
commit 06488f0237
72 changed files with 8219 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Dependencies
backend/go.sum
frontend-admin/node_modules/
frontend-app/node_modules/
# Build
backend/server
backend/bin/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Env
backend/config/.env
backend/.env
# OS
.DS_Store
Thumbs.db
# Docker volumes
pgdata/
redisdata/
+206
View File
@@ -0,0 +1,206 @@
# 部署指南
## 服务器要求
- Ubuntu 20.04+
- 2GB+ RAM
- Docker & Docker Compose
- PostgreSQL 13+ (或使用 Docker)
- Nginx (可选,用于反向代理)
## 1. 数据库准备
```bash
# 创建数据库
sudo -u postgres psql
CREATE DATABASE blind_select;
CREATE USER blind_user WITH PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE blind_select TO blind_user;
# 运行迁移
psql -U blind_user -d blind_select < migrations/001_init.sql
```
## 2. 后端部署
### 方式一:直接运行
```bash
cd backend
# 构建
go build -o blind-server cmd/server/main.go
# 配置
cp config.yaml.example config.yaml
vim config.yaml
# 使用 systemd 运行
sudo cat > /etc/systemd/system/blind-server.service << EOF
[Unit]
Description=Blind Select Server
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/blind-select/backend
ExecStart=/opt/blind-select/backend/blind-server
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable blind-server
sudo systemctl start blind-server
```
### 方式二:Docker
```bash
cd backend
docker-compose up -d
```
## 3. UniApp 小程序部署
### 微信小程序
```bash
cd frontend-app
# 修改 manifest.json 中的 appid
# 修改 api/index.js 中的 BASE_URL 为你的 HTTPS 域名
# 构建
npm run build:mp-weixin
# 使用微信开发者工具上传
# 或使用脚本部署
./wechat-deploy.sh
```
### 小程序注意事项
1. 域名必须是 HTTPS
2. 需要在微信公众平台配置服务器域名
3. 需要配置业务域名
## 4. 后台管理部署
```bash
cd frontend-admin
# 修改 API 地址
vim src/api/index.js
# 构建
npm run build
# Nginx 配置
server {
listen 80;
server_name admin.yourdomain.com;
root /opt/blind-select/frontend-admin/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
## 5. Nginx 配置 (可选)
```nginx
# 后端 API
server {
listen 443 ssl;
server_name api.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
# 后台管理
server {
listen 443 ssl;
server_name admin.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
root /opt/blind-select/frontend-admin/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://127.0.0.1:8080;
}
}
```
## 6. 微信支付配置
1. 在微信支付商户平台申请支付功能
2. 配置支付回调域名
3. 在后端配置支付密钥
```yaml
# config.yaml 添加
wechat_pay:
mchid: your_mchid
serial_no: your_serial_no
private_key: /path/to/private_key.pem
api_v3_key: your_api_v3_key
```
## 7. 监控与日志
```bash
# 查看后端日志
journalctl -u blind-server -f
# Docker 日志
docker logs blind-server -f
```
## 8. 备份
```bash
# 数据库备份
pg_dump -U blind_user blind_select > backup_$(date +%Y%m%d).sql
# 定时备份 (crontab)
0 2 * * * pg_dump -U blind_user blind_select > /backup/blind_$(date +\%Y\%m\%d).sql
```
## 常见问题
### Q: 小程序请求失败?
A: 检查服务器域名是否配置 HTTPS,是否在微信公众平台白名单
### Q: 支付失败?
A: 检查微信支付证书配置,确保证书路径正确
### Q: 盲选算法不准确?
A: 需要积累用户行为数据,初期可手动调整权重参数
+58
View File
@@ -0,0 +1,58 @@
.PHONY: build run test clean docker-up docker-down db-migrate seed help
# ── Variables ──
APP := blind-select
BACKEND := ./backend
PG := localhost:5432
PG_USER := blind_select
PG_DB := blind_select
help:
@echo "=== Blind Select - Make Commands ==="
@echo ""
@echo " build - Build backend binary"
@echo " run - Start backend (go run)"
@echo " test - Run Go tests"
@echo " clean - Remove binary"
@echo " docker-up - Start all services via docker-compose"
@echo " docker-down - Stop all services"
@echo " docker-logs - Show docker-compose logs"
@echo " db-migrate - Run SQL migrations"
@echo " seed - Load seed data"
@echo " admin-dev - Start admin frontend (npm run dev)"
@echo " check - go vet + go fmt check"
build:
cd $(BACKEND) && go build -o bin/server ./cmd/server/
run:
cd $(BACKEND) && go run ./cmd/server/
test:
cd $(BACKEND) && go test ./... -v
clean:
rm -rf $(BACKEND)/bin
docker-up:
cd $(BACKEND) && docker-compose up -d
docker-down:
cd $(BACKEND) && docker-compose down
docker-logs:
cd $(BACKEND) && docker-compose logs -f
db-migrate:
@echo "Running migrations on postgres..."
psql "postgresql://$(PG_USER):blind_select_pass@$(PG)/$(PG_DB)?sslmode=disable" -f $(BACKEND)/migrations/001_initial_schema.sql
seed:
@echo "Loading seed data..."
psql "postgresql://$(PG_USER):blind_select_pass@$(PG)/$(PG_DB)?sslmode=disable" -f $(BACKEND)/migrations/002_seed_data.sql
admin-dev:
cd frontend-admin && npm run dev
check:
cd $(BACKEND) && go vet ./... && go fmt ./...
+927
View File
@@ -0,0 +1,927 @@
# "帮我选"盲选应用 - 开发大纲 v3
## 一、项目概述
AI驱动开发的盲选应用,用户盲选吃喝玩乐套餐,支持会员盈利,
通过用户历史行为智能更新偏好权重,**去了打分**驱动推荐质量。
---
## 二、技术栈
### AI 开发配置
```yaml
api:
key: "sk-26391ddf421f3d09546969d968e73295a3ec493ae327a6792a840c6f5fcdd517"
endpoint: "https://qs.szscp.com"
# 用途:AI推荐、智能分类、用户偏好分析、月度报告
```
### 后端:Go + Gin + GORM + PostgreSQL + Redis
### App前端:UniApp + Vue3(一套代码→iOS/Android/小程序)
### 后台管理:DaisyUI5 + Tailwind + Alpine.js
---
## 三、会员盈利体系(简化:仅VIP)
### 会员方案
```
免费用户:
├── 每天3次盲选机会
├── 基础分类可用
└── 普通优先级
VIP会员(¥29/月 或 ¥199/年):
├── 每天10次盲选
├── 全部分类解锁
├── 优先匹配优质套餐
├── 专属盲选风格
├── 去重天数更长(3天→1天)
└── 月度盲选报告
```
### 盈利模型
```
1. 会员订阅(主要收入)
2. 商家佣金(5-15%
3. 优惠券佣金(联动商家优惠券,每次核销商家付佣金)
4. 品牌盲盒溢价
```
### 商家优惠券联动
```
商家在后台创建优惠券:
├── 券类型:满减券 / 折扣券 / 免费兑换券 / 赠品券
├── 面额:后台设置(如满100减20)
├── 数量:发放总量 + 剩余量
├── 有效期:起止时间
├── 适用套餐:指定某些套餐可用
└── 佣金比例:用户通过盲选核销该券,平台抽佣 10-20%
用户侧表现:
├── 盲选结果卡上显示"附带优惠券"
├── 优惠券卡片样式(像微信卡包)
├── 核销后记录,商家确认使用
└── 优惠券是商家的"引流工具",平台收佣金
```
**优惠券联动逻辑:**
```
商家A想引流 → 后台创建100张"满80减15"券 → 设核销后佣金8元
→ 用户盲选抽到商家A的套餐 → 自动获得该券
→ 用户到店出示核销 → 商家确认 → 平台收8元佣金
→ 用户得到优惠,商家获得客流,平台赚佣金 → 三方共赢
```
---
## 四、用户偏好学习系统(含打分机制)
### 4.1 打分机制(核心)
用户每次盲选揭晓后,可选"去了"或"跳过了"
```
用户选择"去了"后 → 进入打分页:
┌─────────────────────────────┐
│ 你对这次盲选体验打几分? │
│ │
│ ⭐⭐⭐⭐⭐ (1-5星) │
│ │
│ 评分维度(可选填): │
│ 🍽️ 口味: ████░ (1-5) │
│ 💰 性价比: ███░░ (1-5) │
│ 📍 距离: ████░ (1-5) │
│ 🎯 符合预期: ████░ (1-5) │
│ │
│ 备注(可选): │
│ ┌─────────────────────────┐ │
│ │ 写点什么... │ │
│ └─────────────────────────┘ │
│ │
│ [提交] │
└─────────────────────────────┘
```
**打分影响权重:**
```
五星好评(5分):
→ 该分类偏好 +2.0 ★★
→ 该商家偏好 +3.0 ★★★
→ 该标签(如"日料")偏好 +1.5
四星好评(4分):
→ 该分类偏好 +1.0
→ 该商家偏好 +1.5
→ 该标签偏好 +0.8
三星中立(3分):
→ 该分类偏好 +0.3(轻微正向)
→ 无商家偏好加成
二星(2分):
→ 该分类偏好 -0.3
→ 该商家偏好 -0.5
一星差评(1分):
→ 该分类偏好 -1.0
→ 该商家偏好 -2.0 ★★
→ 该商家加入"黑名单"候选(需用户确认)
```
### 4.2 行为追踪
```go
type UserBehavior struct {
ID uint
UserID uint
PackageID uint
BehaviorType string // "viewed" "selected" "attended" "reviewed" "skipped"
Score float64 // 行为权重(非打分,是系统权重)
ReviewRating int8 // 用户打分 1-5
ReviewTags string // "口味好""太贵""环境差"等标签
ReviewText string // 用户文字评价
DistanceScore int8 // 距离评分
ValueScore int8 // 性价比评分
ExpectMatch int8 // 符合预期评分
IsRepeat bool // 是否再次前往
CreatedAt time.Time
}
```
### 4.3 权重算法
```
用户偏好分数 = Σ(行为得分 × 时间衰减系数 × 打分系数)
打分系数:
5星 → ×1.5(放大正向影响)
4星 → ×1.0(标准正向影响)
3星 → ×0.5(轻微影响)
2星 → ×-0.5(轻微负向)
1星 → ×-1.5(放大负向影响)
时间衰减:
最近30天 × 1.0
30-90天 × 0.7
90-180天 × 0.4
180天+ × 0.2
示例计算:
用户A在45天前去了日料店,打了5星 → 权重 = +2.0 × 0.7 × 1.5 = +2.1
用户A在200天前去了日料店,打了2星 → 权重 = -0.5 × 0.2 × -0.5 = +0.05(几乎忽略)
```
### 4.4 偏好标签自动打标
```
基于用户打分数据自动打标:
用户A(日料5星×5次,西餐3星×3次,火锅5星×2次)
→ 标签: ["日料狂魔", "火锅爱好者", "中餐不挑"]
→ 盲选策略: 80%日料/火锅,20%其他探索
用户B(所有都3-4星,没有明显偏好)
→ 标签: ["探索型", "随机接受度高"]
→ 盲选策略: 增加品类多样性,避免重复
用户C(多次1-2星差评)
→ 标签: ["易踩雷", "高要求", "需要精准匹配"]
→ 盲选策略: 提高推荐门槛,降低盲选随机性,接近定向选择
```
### 4.5 商家维度打分积累
```
每个套餐也积累评分:
套餐评分 = Σ(所有用户打分) / 打分人数
影响:
- 套餐评分低的 → 降低盲选权重(少抽到)
- 套餐评分高的 → 提高盲选权重(多抽到)
- 但保留一定随机性(给用户探索惊喜)
```
---
## 五、功能模块
### 5.1 用户端(UniApp
#### 首页
```
├── 顶部:欢迎语(AI生成,基于今日偏好)
│ 例:"今天想带你吃点治愈系的~"
│ 例:"好久没吃辣了,来场火锅盲选?"
│ 例:"根据你的口味,今天推荐隐藏日料店!"
├── 盲选卡片池(可滑动切换类别)
│ ├── 🍜 餐饮盲选
│ ├── 🎮 娱乐盲选
│ ├── 🛍️ 购物盲选
│ └── 🎭 活动盲选
├── 今日推荐(AI分析后推荐类别,VIP专享)
└── 盲选按钮(大,带动画)→ 抽!
```
#### 盲选揭晓页
```
├── 动画效果(翻牌/转盘)
├── 揭晓内容:
│ ├── 商家名称 + 评分 + 距离
│ ├── 套餐描述(模糊描述,价格区间可见)
│ ├── 推荐理由(AI生成,基于匹配度)
│ │ 例:"你的日料偏好度85%,这家匹配度很高!"
│ ├── 优惠券 🎁(如有)
│ │ 例:"满80减15 · 限时3天"
│ └── 匹配度进度条
├── 操作:
│ ├── ✅ 接受/前往(记录行为 +0.5)
│ ├── 🔄 换一个(-0.2分,不消耗机会)
│ ├── ⭐ 标记"已去过"(进入打分流程)
│ ├── 🔖 收藏
│ └── 📍 导航
└── 已去过?→ 跳转打分页
```
#### 打分页
```
┌─────────────────────────┐
│ 🎉 感谢你的盲选体验! │
│ │
│ 你对这次体验打几分? │
│ ⭐⭐⭐⭐⭐ (当前: 4) │
│ │
│ 评分维度: │
│ 🍽️ 口味: ⭐⭐⭐⭐⭐ │
│ 💰 性价比: ⭐⭐⭐⭐░ │
│ 📍 距离: ⭐⭐⭐░░ │
│ 🎯 符合预期:⭐⭐⭐⭐⭐ │
│ │
│ 你想怎么形容这次体验? │
│ [口味赞] [环境好] [性价比] [值得再来] [服务棒] [踩雷] [太贵] [难吃] [远]
│ │
│ 备注(可选): │
│ ┌─────────────────────┐ │
│ │ 写点什么... │ │
│ └─────────────────────┘ │
│ │
│ [提交] │
└─────────────────────────┘
提交后:
├── 更新用户偏好权重
├── 更新商家/套餐评分
├── 更新用户标签
└── 显示:"已记录!这会影响下次推荐哦~"
```
#### 我的偏好
```
├── 偏好雷达图(口味/价格/距离/品质/探索度)
├── 品类偏好排名(柱状图)
│ 日料 ████████░░ 80%
│ 火锅 ██████░░░░ 60%
│ 西餐 ████░░░░░░ 40%
│ 甜品 ███░░░░░░░ 30%
├── AI标签列表
├── 去重设置
├── 黑名单商家
└── 偏好调整滑块
```
#### 优惠券中心
```
├── 我的优惠券
│ ├── 未使用(到期倒计时)
│ ├── 已使用
│ └── 已过期
├── 卡包样式(像微信卡包)
└── 使用说明
```
#### 会员中心
```
├── 当前状态
├── 会员权益对比表
├── 开通/续费按钮
├── 购买记录
└── 邀请好友(双方各得7天VIP)
```
#### 个人
```
├── 个人信息
├── 盲选记录(按时间线)
├── 收藏
├── 评价历史
├── 地址管理
└── 设置
```
### 5.2 后台管理(DaisyUI5
#### 仪表盘
```
├── 今日:新增用户 / 活跃 / 盲选次数 / 订单数
├── 会员:总数 / 转化率 / 月收入 / 续费率
├── 热门:分类TOP / 套餐TOP / 商家TOP
├── 评分:平均打分 / 差评预警
├── 优惠券:发放量 / 核销率 / 佣金收入
└── 趋势图(近30天)
```
#### 商家管理
```
├── 商家列表 + 搜索
├── 入驻审核
├── 编辑/上下架
├── 佣金设置
└── 商家数据(被选次数 / 平均评分 / 优惠券核销)
```
#### 套餐管理
```
├── 套餐CRUD
├── 价格区间设置
├── 标签管理
├── 库存管理
├── 评分查看
└── 权重调整
```
#### 优惠券管理
```
├── 创建优惠券
│ ├── 选择商家
│ ├── 券类型(满减/折扣/兑换/赠品)
│ ├── 面额设置
│ ├── 数量
│ ├── 有效期
│ ├── 适用套餐
│ └── 佣金比例(平台抽佣)
├── 优惠券列表(使用状态/核销情况)
├── 佣金收入统计
└── 核销审核(防作弊)
```
#### 用户管理
```
├── 用户列表 + 搜索
├── 用户详情(行为 + 偏好 + 打分 + 会员)
├── 会员管理
├── 黑名单管理
└── 行为分析
```
#### 打分分析
```
├── 平均打分趋势
├── 各分类平均评分
├── 差评预警(连续低分套餐)
├── 商家质量排名
└── 用户反馈词云
```
#### 算法配置
```
├── 行为权重调整
├── 时间衰减曲线
├── 打分系数
├── 去重天数默认值
├── 会员加成比例
├── 热门权重
└── A/B测试
```
#### 内容管理
```
├── Banner
├── 公告
├── 欢迎语模板
├── 推荐语模板
└── 标签管理
```
#### 数据报表
```
├── 用户增长曲线
├── 收入报表(会员+佣金+优惠券)
├── 转化率漏斗(注册→首盲选→付费会员)
├── 留存分析(7日/30日)
├── 商家质量报告
└── 导出CSV/Excel
```
---
## 六、数据库设计
```sql
-- 用户表
users (
id, nickname, avatar, phone, wechat_openid,
preference JSONB, -- {"日料": 0.85, "火锅": 0.6, ...}
tags TEXT[], -- ["日料狂魔", "品质型"]
member_level INT DEFAULT 0, -- 0免费 1VIP
member_expires_at,
created_at, updated_at
)
-- 商家表
merchants (
id, name, avatar, category_id,
rating, -- 平均评分
price_range, -- "50-100"
location GEOGRAPHY(Point), -- 经纬度
tags TEXT[],
total_reviews INT DEFAULT 0,
total_score NUMERIC DEFAULT 0,
quality_score NUMERIC, -- 综合质量分(用于盲选权重)
status,
created_at, updated_at
)
-- 分类表
categories (
id, name, icon, type, -- food/entertainment/shopping/activity
sort_order,
status,
created_at
)
-- 套餐表
packages (
id, merchant_id, category_id,
name, -- 套餐名
description, -- 模糊描述(盲选可见)
price_min, price_max, -- 价格区间
actual_price, -- 实际价格(盲选揭晓后显示)
tags TEXT[], -- "适合约会""亲子"
stock,
weight, -- 推荐权重
rating, -- 平均评分
review_count, -- 评分人数
status,
created_at, updated_at
)
-- 用户行为表(含打分)
user_behaviors (
id, user_id, package_id,
behavior_type, -- viewed/selected/attended/reviewed/skipped
system_score, -- 系统行为权重
review_rating, -- 用户打分 1-5
taste_score, -- 口味评分 1-5
value_score, -- 性价比评分 1-5
distance_score, -- 距离评分 1-5
match_score, -- 符合预期 1-5
tags TEXT[], -- 反馈标签 ["口味赞","太贵"]
text TEXT, -- 文字评价
is_repeat, -- 是否再次前往
created_at
)
-- 优惠券表
coupons (
id, merchant_id, package_id,
type, -- discount/coupon/free/gift
value, -- 面额或折扣
min_amount, -- 满减门槛
total_count, -- 总量
remain_count, -- 剩余
user_id, -- 领取用户(NULL=池发)
user_code, -- 用户专属码
pool_code, -- 池码(用户领取时复制)
status, -- available/claimed/used/expired
used_at,
valid_start, valid_end,
commission, -- 平台佣金
created_at, updated_at
)
-- 盲选会话表
blind_sessions (
id, user_id, category_id,
price_range, distance_range,
result_package_id,
result_revealed_at,
user_accepted,
created_at
)
-- 订单表
orders (
id, user_id, package_id, blind_session_id,
actual_price,
coupon_id, -- 使用的优惠券
status, -- pending/paid/completed/cancelled
created_at, updated_at
)
-- 会员表
members (
id, user_id, level,
start_date, end_date,
payment_method,
amount,
status,
created_at
)
-- 活动表
activities (
id, name, type, start_date, end_date,
config JSONB,
status
)
-- 管理员表
admins (
id, username, password_hash,
role, permissions,
created_at
)
```
---
## 七、API设计
### 7.1 认证
```
POST /api/v1/auth/register # 注册
POST /api/v1/auth/login # 手机登录
POST /api/v1/auth/wechat/login # 微信登录
POST /api/v1/auth/refresh # 刷新token
POST /api/v1/admin/login # 后台登录
```
### 7.2 用户
```
GET /api/v1/user/profile # 个人信息
PUT /api/v1/user/profile # 更新信息
GET /api/v1/user/preferences # 偏好数据
PUT /api/v1/user/preferences # 调整偏好
GET /api/v1/user/tags # AI标签
GET /api/v1/user/behaviors # 行为记录
```
### 7.3 盲选(核心)
```
GET /api/v1/blind/categories # 分类列表
GET /api/v1/blind/pool # 可选池(模糊信息)
POST /api/v1/blind/choose # 盲选
GET /api/v1/blind/result/:id # 揭晓结果
GET /api/v1/blind/recommend # AI推荐
POST /api/v1/blind/accept # 接受
POST /api/v1/blind/decline # 换一个
GET /api/v1/blind/history # 历史记录
```
### 7.4 打分(核心)
```
POST /api/v1/review/submit # 提交打分评价
GET /api/v1/review/stats # 打分统计
GET /api/v1/review/summary # 商家/套餐评分汇总
```
### 7.5 优惠券
```
GET /api/v1/coupon/list # 我的优惠券
GET /api/v1/coupon/available # 可领取优惠券
POST /api/v1/coupon/claim # 领取优惠券
POST /api/v1/coupon/verify # 核销优惠券
GET /api/v1/coupon/commission # 佣金记录
```
### 7.6 会员
```
GET /api/v1/member/status # 会员状态
GET /api/v1/member/plans # 会员套餐
POST /api/v1/member/subscribe # 开通
POST /api/v1/member/cancel # 取消
GET /api/v1/member/report # 月度报告(VIP)
```
### 7.7 后台管理
```
POST /api/v1/admin/dashboard # 仪表盘
CRUD /api/v1/admin/merchants # 商家管理
CRUD /api/v1/admin/packages # 套餐管理
GET /api/v1/admin/coupons # 优惠券管理
POST /api/v1/admin/coupons/create # 创建优惠券
GET /api/v1/admin/users # 用户列表
GET /api/v1/admin/users/:id # 用户详情
GET /api/v1/admin/reviews # 打分管理
GET /api/v1/admin/statistics # 数据报表
PUT /api/v1/admin/algorithm/config # 算法配置
GET /api/v1/admin/export # 数据导出
```
### 7.8 AI
```
POST /api/v1/ai/recommend # AI推荐
POST /api/v1/ai/analyze-preference # 分析偏好
POST /api/v1/ai/generate-tag # 生成标签
POST /api/v1/ai/generate-report # 生成月报
POST /api/v1/ai/generate-welcome # 生成欢迎语
```
---
## 八、项目结构
```
blind-select/
├── backend/ # Go后端
│ ├── cmd/server/main.go
│ ├── internal/
│ │ ├── config/
│ │ ├── middleware/ # JWT/日志/限流/会员校验
│ │ ├── handler/
│ │ │ ├── v1/
│ │ │ │ ├── auth.go
│ │ │ │ ├── blind.go
│ │ │ │ ├── user.go
│ │ │ │ ├── review.go # 打分
│ │ │ │ ├── coupon.go
│ │ │ │ ├── member.go
│ │ │ │ ├── ai.go
│ │ │ │ └── admin/
│ │ │ │ ├── dashboard.go
│ │ │ │ ├── merchant.go
│ │ │ │ ├── package.go
│ │ │ │ ├── coupon.go
│ │ │ │ ├── user.go
│ │ │ │ ├── review.go
│ │ │ │ ├── stats.go
│ │ │ │ ├── export.go
│ │ │ │ └── config.go
│ │ ├── service/
│ │ │ ├── blind_service.go # 盲选逻辑
│ │ │ ├── preference_service.go # 偏好学习
│ │ │ ├── review_service.go # 打分服务
│ │ │ ├── coupon_service.go # 优惠券
│ │ │ ├── member_service.go # 会员
│ │ │ ├── ai_service.go # AI调用
│ │ │ └── report_service.go # 报表
│ │ ├── model/ # GORM模型
│ │ ├── repository/ # 数据访问
│ │ └── utils/
│ ├── migrations/
│ ├── config.yaml
│ ├── go.mod
│ └── Makefile
├── frontend-admin/ # 后台 (DaisyUI5)
│ ├── index.html
│ ├── src/
│ │ ├── main.js
│ │ ├── App.vue
│ │ ├── views/
│ │ ├── components/
│ │ ├── api/
│ │ └── router/
│ ├── vite.config.js
│ └── package.json
├── frontend-app/ # UniApp多端
│ ├── pages/
│ │ ├── index/index.vue # 首页
│ │ ├── blind/blind.vue # 盲选页
│ │ ├── blind/result.vue # 揭晓
│ │ ├── blind/review.vue # 打分页
│ │ ├── coupon/list.vue # 优惠券
│ │ ├── member/member.vue # 会员中心
│ │ ├── user/profile.vue
│ │ ├── user/preferences.vue
│ │ └── user/history.vue
│ ├── components/
│ ├── api/
│ ├── store/
│ ├── pages.json
│ └── manifest.json
└── docs/
└── API文档.md
```
---
## 九、开发阶段
### Phase 1: 后端基础(Week 1
- [x] 项目初始化
- [ ] Go项目骨架 + 配置
- [ ] 数据库初始化 + 模型
- [ ] JWT认证 + 用户登录
- [ ] 后台管理员登录
### Phase 2: 核心业务(Week 2-3
- [ ] 商家CRUD + 套餐管理
- [ ] 盲选加权随机算法
- [ ] 用户行为追踪
- [ ] 打分系统 + 偏好学习算法
- [ ] AI集成(推荐/标签/报告)
### Phase 3: 会员+优惠券(Week 4
- [ ] VIP会员系统
- [ ] 优惠券创建+领取+核销
- [ ] 佣金计算
- [ ] 中间件:会员校验
### Phase 4: UniApp前端(Week 5-6
- [ ] 项目初始化 + 页面框架
- [ ] 首页 + 盲选入口
- [ ] 揭晓动画 + 打分页
- [ ] 个人中心 + 偏好展示
- [ ] 优惠券中心
- [ ] 会员中心
- [ ] 微信小程序适配
### Phase 5: 后台管理(Week 7
- [ ] DaisyUI5 + Tailwind 搭建
- [ ] 仪表盘 + 数据可视化
- [ ] 商家/套餐管理
- [ ] 优惠券管理
- [ ] 打分分析
- [ ] 数据报表 + 导出
### Phase 6: 测试上线(Week 8-9
- [ ] 端到端测试
- [ ] 性能优化(Redis缓存)
- [ ] App打包
- [ ] 部署上线
---
## 十、关键算法详解
### 10.1 盲选加权随机
```go
func (s *BlindService) Select(user *User, category string, priceRange PriceRange) (*Package, error) {
candidates := s.getCandidates(category, priceRange)
for i := range candidates {
w := 1.0 // 基础权重
// 用户偏好加成
pref := s.getPreferenceScore(user.ID, candidates[i].Category)
w *= (1 + pref)
// 套餐质量分(基于打分积累)
quality := candidates[i].Rating / 5.0
w *= (0.5 + quality * 0.5) // 0.5-1.0范围
// 去重惩罚
if s.isRepeated(user.ID, candidates[i].ID, user.RepeatDays) {
w *= 0.1
}
// 会员加成
if user.MemberLevel == 1 {
w *= 1.2
}
candidates[i].Weight = w
}
return s.weightedRandom(candidates)
}
```
### 10.2 偏好学习 + 打分影响
```go
func (s *PrefService) SubmitReview(userID, packageID uint, review Review) {
// 1. 保存打分
behavior := Behavior{
UserID: userID,
PackageID: packageID,
Type: "reviewed",
ReviewRating: review.Rating,
TasteScore: review.Taste,
ValueScore: review.Value,
DistanceScore: review.Distance,
MatchScore: review.Match,
Tags: review.Tags,
Text: review.Text,
IsRepeat: review.IsRepeat,
CreatedAt: time.Now(),
}
db.Create(&behavior)
// 2. 计算打分系数
scoreMultiplier := reviewScoreMultiplier(review.Rating)
// 5星→1.5, 4星→1.0, 3星→0.5, 2星→-0.5, 1星→-1.5
// 3. 更新用户偏好
pkg := getPackage(packageID)
category := pkg.Category
// 基础行为权重
baseScore := 0.0
switch {
case review.Rating >= 4:
baseScore = 1.0 // 好评,正向影响
case review.Rating == 3:
baseScore = 0.3 // 中立
case review.Rating == 2:
baseScore = -0.3 // 差评
default:
baseScore = -1.0 // 严重差评
}
// 加入商家ID的特殊影响
if review.IsRepeat {
baseScore += 1.0 // 再次前往,大幅正向
}
// 应用打分系数和时间衰减
finalScore := baseScore * scoreMultiplier * getTimeDecay(behavior.CreatedAt)
// 更新偏好
updateCategoryPreference(userID, category, finalScore)
// 4. 更新商家评分
updateMerchantRating(pkg.MerchantID, review.Rating)
// 5. 更新套餐评分
updatePackageRating(packageID, review.Rating)
// 6. 重新生成标签
tags := generateTags(userID)
updateTags(userID, tags)
}
func reviewScoreMultiplier(rating int8) float64 {
switch rating {
case 5: return 1.5
case 4: return 1.0
case 3: return 0.5
case 2: return -0.5
case 1: return -1.5
default: return 0.0
}
}
```
### 10.3 优惠券联动
```go
func (s *CouponService) OnBlindSelectComplete(session *BlindSession) {
pkg := session.Package
// 1. 查找该套餐可用的优惠券
coupons := getCouponsForPackage(pkg.ID)
// 2. 随机发放一张(如果有)
if len(coupons) > 0 {
coupon := coupons[rand.Intn(len(coupons))]
if coupon.RemainCount > 0 {
// 为用户领取
userCoupon := Coupon{
MerchantID: coupon.MerchantID,
PackageID: pkg.ID,
Type: coupon.Type,
Value: coupon.Value,
UserID: session.UserID,
Status: "available",
ValidStart: coupon.ValidStart,
ValidEnd: coupon.ValidEnd,
Commission: coupon.Commission,
CreatedAt: time.Now(),
}
db.Create(&userCoupon)
coupon.RemainCount--
coupon.Save()
}
}
}
func (s *CouponService) OnCouponVerified(couponID uint) {
coupon := getCoupon(couponID)
// 1. 标记已使用
coupon.Status = "used"
coupon.UsedAt = time.Now()
coupon.Save()
// 2. 记录平台佣金
commission := Commission{
CouponID: coupon.ID,
MerchantID: coupon.MerchantID,
Amount: coupon.Commission,
Status: "pending",
CreatedAt: time.Now(),
}
db.Create(&commission)
// 3. 通知商家
notifyMerchant(coupon.MerchantID, "用户核销了优惠券,佣金已记录")
}
```
+179
View File
@@ -0,0 +1,179 @@
# 🎲 帮我选 - 盲选应用
> AI驱动的盲选应用,让用户盲选吃喝玩乐套餐,支持会员盈利,通过用户历史行为智能更新偏好权重。
## 📱 项目结构
```
blind-select/
├── backend/ # Go 后端服务
│ ├── cmd/server/ # 入口文件
│ ├── internal/ # 核心代码
│ │ ├── handler/ # HTTP 处理器
│ │ ├── middleware/ # 中间件 (JWT, CORS)
│ │ ├── model/ # 数据模型
│ │ ├── service/ # 业务逻辑
│ │ └── utils/ # 工具函数
│ ├── migrations/ # 数据库迁移
│ ├── config.yaml # 配置文件
│ └── Dockerfile
├── frontend-app/ # UniApp 用户端 (iOS/Android/小程序)
│ ├── pages/ # 页面
│ ├── api/ # API 封装
│ ├── store/ # 状态管理
│ └── App.vue
├── frontend-admin/ # DaisyUI5 后台管理
│ ├── src/
│ │ ├── views/ # 页面
│ │ ├── api/ # API 封装
│ │ └── router/ # 路由
│ └── index.html
└── PLAN.md # 详细开发文档
```
## 🛠 技术栈
| 组件 | 技术 |
|------|------|
| 后端 | Go + Gin + GORM + PostgreSQL |
| 用户端 | UniApp + Vue3 |
| 后台管理 | DaisyUI5 + Tailwind + Alpine.js |
| 认证 | JWT + 微信登录 |
| AI | OpenAI 兼容 API |
## 🚀 快速开始
### 1. 后端启动
```bash
cd backend
# 配置数据库
cp config.yaml.example config.yaml
# 编辑 config.yaml 填入数据库信息
# 安装依赖
go mod download
# 运行迁移
psql -U postgres -d blind_select < migrations/001_init.sql
# 启动服务
go run cmd/server/main.go
```
### 2. UniApp 前端
```bash
cd frontend-app
# 安装依赖
npm install
# H5 开发
npm run dev:h5
# 微信小程序
npm run dev:mp-weixin
# 构建
npm run build:mp-weixin
```
### 3. 后台管理
```bash
cd frontend-admin
# 安装依赖
npm install
# 开发
npm run dev
# 构建
npm run build
```
## 🐳 Docker 部署
```bash
# 后端
cd backend
docker-compose up -d
# 后台管理
cd frontend-admin
docker build -t blind-admin .
docker run -p 3000:80 blind-admin
```
## 📊 核心功能
### 盲选算法
```
权重 = 基础(1.0) × 用户偏好(1.0-2.5) × 套餐质量(0.5-1.0)
× 商家质量 × 去重惩罚(0.1-1.0) × 探索加成(1.0-1.5)
```
### 会员体系
- **免费用户**: 每天3次盲选,基础分类
- **VIP会员** (¥29/月 或 ¥199/年): 每天10次,全部分类,优先匹配,月报
### 打分权重更新
- 5星 → ×1.5 放大正向
- 1星 → ×-1.5 放大负向
- 再次前往 → +2.0 超高权重
## 🔌 API 端点
### 认证
- `POST /api/v1/auth/register` - 注册
- `POST /api/v1/auth/login` - 登录
- `POST /api/v1/auth/wechat/login` - 微信登录
### 盲选
- `GET /api/v1/blind/categories` - 获取分类
- `POST /api/v1/blind/choose` - 盲选
- `GET /api/v1/blind/history` - 历史
### 会员
- `GET /api/v1/member/status` - 状态查询
- `POST /api/v1/member/subscribe` - 订阅
### 优惠券
- `GET /api/v1/coupon/list` - 我的优惠券
- `POST /api/v1/coupon/claim` - 领取
### 后台管理
- `POST /api/v1/admin/login` - 管理员登录
- `GET /api/v1/admin/users` - 用户列表
- `GET /api/v1/admin/merchants` - 商家列表
## 📝 配置说明
### backend/config.yaml
```yaml
server:
port: 8080
mode: debug
database:
host: localhost
port: 5432
user: postgres
password: your_password
dbname: blind_select
jwt:
secret: your_jwt_secret
expire: 24h
wechat:
appid: your_appid
secret: your_secret
```
## 📄 License
MIT License
+32
View File
@@ -0,0 +1,32 @@
# ===== Build Stage =====
FROM golang:1.22-alpine AS builder
RUN apk --no-cache add git ca-certificates
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /app/server ./cmd/server/
# ===== Runtime Stage =====
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
COPY --from=builder /app/config.yaml .
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/api/v1/admin/dashboard || exit 1
ENTRYPOINT ["./server"]
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"log"
"github.com/blind-select/backend/internal/config"
"github.com/blind-select/backend/internal/database"
"github.com/blind-select/backend/internal/handler/v1"
"github.com/blind-select/backend/internal/middleware"
"github.com/gin-gonic/gin"
)
func main() {
cfg, err := config.Load("../config.yaml")
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
database.Init(cfg)
gin.SetMode(cfg.Server.Mode)
router := gin.New()
router.Use(gin.Recovery())
router.Use(middleware.CORSMiddleware())
authHandler := handler.NewAuthHandler(cfg.JWT.Secret, cfg.Wechat.AppID, cfg.Wechat.AppSecret)
// Public routes
v1 := router.Group("/api/v1")
{
auth := v1.Group("/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
auth.POST("/wechat/login", authHandler.WechatLogin)
auth.POST("/refresh", authHandler.RefreshToken)
}
user := v1.Group("/user")
user.Use(middleware.JWTAuth(cfg.JWT.Secret))
{
user.GET("/profile", authHandler.GetProfile)
user.PUT("/profile", authHandler.UpdateProfile)
}
blind := v1.Group("/blind")
blind.Use(middleware.JWTAuth(cfg.JWT.Secret))
{
blind.GET("/categories", authHandler.GetCategories)
blind.GET("/pool", authHandler.GetPool)
blind.POST("/choose", middleware.BlindDailyLimit(cfg.JWT.Secret), authHandler.Choose)
blind.GET("/result/:id", authHandler.GetResult)
blind.GET("/history", authHandler.GetHistory)
}
review := v1.Group("/review")
review.Use(middleware.JWTAuth(cfg.JWT.Secret))
{
review.POST("/submit", authHandler.SubmitReview)
review.GET("/stats", authHandler.GetReviewStats)
}
coupon := v1.Group("/coupon")
coupon.Use(middleware.JWTAuth(cfg.JWT.Secret))
{
coupon.GET("/list", authHandler.GetMyCoupons)
coupon.GET("/available", authHandler.GetAvailableCoupons)
coupon.POST("/claim", authHandler.ClaimCoupon)
}
member := v1.Group("/member")
member.Use(middleware.JWTAuth(cfg.JWT.Secret))
{
member.GET("/status", authHandler.GetMemberStatus)
member.GET("/plans", authHandler.GetMemberPlans)
member.POST("/subscribe", authHandler.SubscribeMember)
}
}
// Admin - public login
admin := router.Group("/api/v1/admin")
{
admin.POST("/login", authHandler.AdminLogin)
}
// Admin - authenticated
adminAuth := router.Group("/api/v1/admin")
adminAuth.Use(middleware.JWTAuth(cfg.JWT.Secret))
adminAuth.Use(middleware.AdminOnly(cfg.JWT.Secret))
{
adminAuth.GET("/dashboard", authHandler.Dashboard)
adminAuth.GET("/merchants", authHandler.ListMerchants)
adminAuth.POST("/merchants", authHandler.CreateMerchant)
adminAuth.PUT("/merchants/:id", authHandler.UpdateMerchant)
adminAuth.GET("/packages", authHandler.ListPackages)
adminAuth.POST("/packages", authHandler.CreatePackage)
adminAuth.PUT("/packages/:id", authHandler.UpdatePackage)
adminAuth.GET("/users", authHandler.ListUsers)
adminAuth.GET("/users/:id", authHandler.GetUserDetail)
adminAuth.GET("/reviews", authHandler.ListReviews)
adminAuth.GET("/coupons", authHandler.ListCoupons)
adminAuth.POST("/coupons", authHandler.CreateCoupon)
adminAuth.GET("/statistics", authHandler.GetStatistics)
adminAuth.GET("/export", authHandler.ExportData)
}
addr := ":" + cfg.Server.Port
log.Printf("Server starting on %s", addr)
if err := router.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
+32
View File
@@ -0,0 +1,32 @@
server:
port: 8080
mode: debug # debug, release, test
database:
host: localhost
port: 5432
user: blind_select
password: blind_select_pass
dbname: blind_select
sslmode: disable
max_idle_conns: 10
max_open_conns: 100
redis:
host: localhost
port: 6379
password: ""
db: 0
jwt:
secret: "change-this-to-a-random-secret"
expire: 24h
refresh_expire: 168h
ai:
endpoint: "https://qs.szscp.com"
key: "sk-263...d517"
wechat:
app_id: "" # 微信小程序 AppID
app_secret: "" # 微信小程序 AppSecret
+23
View File
@@ -0,0 +1,23 @@
server:
port: 8080
mode: debug # release for production
database:
host: localhost
port: 5432
user: postgres
password: your_password_here
dbname: blind_select
sslmode: disable
jwt:
secret: your_jwt_secret_key_here_change_in_production
expire: 24h
wechat:
appid: your_wechat_appid
secret: your_wechat_secret
ai:
endpoint: https://qs.szscp.com
key: sk-your-ai-api-key
+30
View File
@@ -0,0 +1,30 @@
# Server
APP_PORT=8080
APP_MODE=debug
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USER=blind_select
DB_PASSWORD=blind_select_pass
DB_NAME=blind_select
DB_SSLMODE=disable
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# JWT
JWT_SECRET=change-this-to-a-random-secret
JWT_EXPIRE=24h
JWT_REFRESH_EXPIRE=168h
# AI API
AI_ENDPOINT=https://qs.szscp.com
AI_KEY=sk-263...d517
# WeChat Mini Program
WECHAT_APP_ID=
WECHAT_APP_SECRET=
+63
View File
@@ -0,0 +1,63 @@
services:
# ===== PostgreSQL =====
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: blind_select
POSTGRES_USER: blind_select
POSTGRES_PASSWORD: blind_select_pass
TZ: Asia/Shanghai
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U blind_select"]
interval: 10s
timeout: 5s
retries: 5
# ===== Redis =====
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ===== Backend API =====
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
TZ: Asia/Shanghai
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./config.yaml:/app/config.yaml:ro
restart: unless-stopped
# ===== Admin Frontend =====
admin:
build:
context: ..
dockerfile: frontend-admin/Dockerfile
ports:
- "5173:80"
restart: unless-stopped
volumes:
pgdata:
redisdata:
+53
View File
@@ -0,0 +1,53 @@
module github.com/blind-select/backend
go 1.25
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
golang.org/x/crypto v0.29.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.30.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.9.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/text v0.20.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
)
+84
View File
@@ -0,0 +1,84 @@
package config
import (
"fmt"
"os"
"sync"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
DB DBConfig `yaml:"database"`
Redis RedisConfig `yaml:"redis"`
JWT JWTConfig `yaml:"jwt"`
AI AIConfig `yaml:"ai"`
Wechat WechatConfig `yaml:"wechat"`
}
type WechatConfig struct {
AppID string `yaml:"app_id"`
AppSecret string `yaml:"app_secret"`
}
type ServerConfig struct {
Port string `yaml:"port"`
Mode string `yaml:"mode"`
}
type DBConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
DBName string `yaml:"dbname"`
SSLMode string `yaml:"sslmode"`
MaxIdleConns int `yaml:"max_idle_conns"`
MaxOpenConns int `yaml:"max_open_conns"`
}
func (d *DBConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?sslmode=%s&charset=utf8mb4",
d.User, d.Password, d.Host, d.Port, d.DBName, d.SSLMode)
}
type RedisConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Password string `yaml:"password"`
DB int `yaml:"db"`
}
type JWTConfig struct {
Secret string `yaml:"secret"`
Expire string `yaml:"expire"`
RefreshExpire string `yaml:"refresh_expire"`
}
type AIConfig struct {
Endpoint string `yaml:"endpoint"`
Key string `yaml:"key"`
}
var (
cfg *Config
cfgOnce sync.Once
)
func Load(path string) (*Config, error) {
var err error
cfgOnce.Do(func() {
data, err := os.ReadFile(path)
if err != nil {
return
}
cfg = &Config{}
err = yaml.Unmarshal(data, cfg)
})
return cfg, err
}
func Get() *Config {
return cfg
}
+52
View File
@@ -0,0 +1,52 @@
package database
import (
"fmt"
"log"
"github.com/blind-select/backend/internal/config"
"github.com/blind-select/backend/internal/model"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
func Init(cfg *config.Config) {
dsn := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai",
cfg.DB.Host, cfg.DB.Port, cfg.DB.User, cfg.DB.Password, cfg.DB.DBName, cfg.DB.SSLMode,
)
var err error
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// Auto migrate models
err = DB.AutoMigrate(
&model.User{},
&model.Admin{},
&model.Merchant{},
&model.Category{},
&model.Package{},
&model.UserBehavior{},
&model.BlindSession{},
&model.Coupon{},
&model.Member{},
&model.Activity{},
)
if err != nil {
log.Fatalf("Failed to migrate database: %v", err)
}
log.Println("Database connected and migrated successfully")
}
func GetDB() *gorm.DB {
return DB
}
+830
View File
@@ -0,0 +1,830 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/blind-select/backend/internal/database"
"github.com/blind-select/backend/internal/middleware"
"github.com/blind-select/backend/internal/model"
"github.com/blind-select/backend/internal/service"
"github.com/blind-select/backend/internal/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AuthHandler struct {
JWTSecret string
WechatAppID string
WechatAppSecret string
blindSvc *service.BlindService
memberSvc *service.MemberService
couponSvc *service.CouponService
}
func NewAuthHandler(jwtSecret, wechatAppID, wechatAppSec string) *AuthHandler {
return &AuthHandler{
JWTSecret: jwtSecret,
WechatAppID: wechatAppID,
WechatAppSecret: wechatAppSec,
blindSvc: service.NewBlindService(),
memberSvc: service.NewMemberService(),
couponSvc: service.NewCouponService(),
}
}
// ============== User Auth ==============
type RegisterReq struct {
Nickname string `json:"nickname" binding:"required,min=2,max=50"`
Phone string `json:"phone" binding:"required,len=11"`
Password string `json:"password" binding:"required,min=6,max=32"`
}
type LoginReq struct {
Phone string `json:"phone" binding:"required,len=11"`
Password string `json:"password" binding:"required"`
}
type LoginRes struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
User *model.User `json:"user"`
HasMember bool `json:"has_member"`
}
func (h *AuthHandler) Register(c *gin.Context) {
var req RegisterReq
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, err.Error())
return
}
db := database.GetDB()
var count int64
db.Model(&model.User{}).Where("phone = ?", req.Phone).Count(&count)
if count > 0 {
middleware.JSONError(c, http.StatusConflict, "phone already registered")
return
}
hash, err := utils.HashPassword(req.Password)
if err != nil {
middleware.JSONError(c, http.StatusInternalServerError, "failed to hash password")
return
}
user := model.User{
Nickname: req.Nickname,
Phone: req.Phone,
PasswordHash: hash,
Tags: []string{},
RepeatDays: 7,
MemberLevel: 0,
}
if err := db.Create(&user).Error; err != nil {
middleware.JSONError(c, http.StatusInternalServerError, "failed to create user")
return
}
token, _ := utils.GenerateToken(user.ID, user.Nickname, "user", h.JWTSecret)
refreshToken, _ := utils.GenerateRefreshToken(user.ID, h.JWTSecret)
middleware.JSONResponse(c, http.StatusCreated, LoginRes{
Token: token,
RefreshToken: refreshToken,
User: &user,
HasMember: false,
})
}
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginReq
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, "invalid request")
return
}
db := database.GetDB()
var user model.User
if err := db.Where("phone = ?", req.Phone).First(&user).Error; err != nil {
middleware.JSONError(c, http.StatusUnauthorized, "invalid phone or password")
return
}
if !utils.CheckPassword(req.Password, user.PasswordHash) {
middleware.JSONError(c, http.StatusUnauthorized, "invalid phone or password")
return
}
token, _ := utils.GenerateToken(user.ID, user.Nickname, "user", h.JWTSecret)
refreshToken, _ := utils.GenerateRefreshToken(user.ID, h.JWTSecret)
hasMember := user.MemberLevel >= 1 && user.MemberExpires != nil && user.MemberExpires.After(user.UpdatedAt)
middleware.JSONResponse(c, http.StatusOK, LoginRes{
Token: token,
RefreshToken: refreshToken,
User: &user,
HasMember: hasMember,
})
}
func (h *AuthHandler) GetProfile(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
db := database.GetDB()
var user model.User
if err := db.First(&user, uid).Error; err != nil {
middleware.JSONError(c, http.StatusNotFound, "user not found")
return
}
middleware.JSONResponse(c, http.StatusOK, user)
}
func (h *AuthHandler) UpdateProfile(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
var req struct {
Nickname string `json:"nickname" binding:"omitempty,min=2,max=50"`
Avatar string `json:"avatar" binding:"omitempty,url"`
}
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, "invalid request")
return
}
updates := map[string]interface{}{}
if req.Nickname != "" {
updates["nickname"] = req.Nickname
}
if req.Avatar != "" {
updates["avatar"] = req.Avatar
}
if len(updates) > 0 {
database.GetDB().Model(&model.User{}).Where("id = ?", uid).Updates(updates)
}
var user model.User
database.GetDB().First(&user, uid)
middleware.JSONResponse(c, http.StatusOK, user)
}
func (h *AuthHandler) WechatLogin(c *gin.Context) {
var req struct {
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, "invalid request")
return
}
wechatSvc := service.NewWechatService(h.WechatAppID, h.WechatAppSecret)
user, _, err := wechatSvc.LoginOrCreateUser(req.Code)
if err != nil {
middleware.JSONError(c, http.StatusUnauthorized, "wechat login failed: "+err.Error())
return
}
token, _ := utils.GenerateToken(user.ID, user.Nickname, "user", h.JWTSecret)
refreshToken, _ := utils.GenerateRefreshToken(user.ID, h.JWTSecret)
middleware.JSONResponse(c, http.StatusOK, gin.H{
"token": token,
"refresh_token": refreshToken,
"user": user,
})
}
func (h *AuthHandler) RefreshToken(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, "invalid request")
return
}
claims, err := utils.ParseToken(req.RefreshToken, h.JWTSecret)
if err != nil {
middleware.JSONError(c, http.StatusUnauthorized, "invalid refresh token")
return
}
token, _ := utils.GenerateToken(claims.UserID, claims.Username, claims.Role, h.JWTSecret)
middleware.JSONResponse(c, http.StatusOK, gin.H{"token": token})
}
func (h *AuthHandler) AdminLogin(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, "invalid request")
return
}
db := database.GetDB()
var admin model.Admin
if db.Where("username = ?", req.Username).First(&admin).Error != nil {
middleware.JSONError(c, http.StatusUnauthorized, "invalid username or password")
return
}
if !utils.CheckPassword(req.Password, admin.Password) {
middleware.JSONError(c, http.StatusUnauthorized, "invalid username or password")
return
}
token, _ := utils.GenerateToken(uint(admin.ID), admin.Username, "admin", h.JWTSecret)
middleware.JSONResponse(c, http.StatusOK, gin.H{
"token": token,
"admin": gin.H{"id": admin.ID, "username": admin.Username, "role": admin.Role},
})
}
func (h *AuthHandler) Dashboard(c *gin.Context) {
db := database.GetDB()
var userCount, merchantCount, packageCount, blindCount int64
db.Model(&model.User{}).Count(&userCount)
db.Model(&model.Merchant{}).Where("status = ?", "approved").Count(&merchantCount)
db.Model(&model.Package{}).Where("status = ?", "active").Count(&packageCount)
db.Model(&model.BlindSession{}).Count(&blindCount)
middleware.JSONResponse(c, http.StatusOK, gin.H{
"today_users": userCount,
"merchants": merchantCount,
"packages": packageCount,
"blind_sessions": blindCount,
})
}
// ============== Blind Selection ==============
func (h *AuthHandler) GetCategories(c *gin.Context) {
db := database.GetDB()
var categories []model.Category
db.Where("status = ?", "active").Order("sort ASC").Find(&categories)
middleware.JSONResponse(c, http.StatusOK, categories)
}
func (h *AuthHandler) GetPool(c *gin.Context) {
categ := c.Query("category")
db := database.GetDB()
query := db.Model(&model.Package{}).Where("packages.status = ?", "active")
if categ != "" {
if catID, err := strconv.Atoi(categ); err == nil {
query = query.Where("packages.category_id = ?", catID)
}
}
// Join with merchants for merchant name
type PoolItem struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
PriceRange string `json:"price_range"`
MerchantID uint `json:"merchant_id"`
Merchant string `json:"merchant"`
Rating float64 `json:"rating"`
CatName string `json:"cat_name"`
}
var packages []model.Package
query.Find(&packages)
result := make([]PoolItem, 0, len(packages))
for _, p := range packages {
var merchant model.Merchant
db.First(&merchant, p.MerchantID)
merchantName := "未知商家"
if merchant.ID > 0 {
merchantName = merchant.Name
}
result = append(result, PoolItem{
ID: p.ID, Name: "神秘" + truncate(p.Name, 4),
Description: p.Description,
PriceRange: "¥" + strconv.Itoa(p.PriceMin) + "-" + strconv.Itoa(p.PriceMax),
MerchantID: p.MerchantID, Merchant: merchantName,
Rating: p.Rating, CatName: getCategoryName(db, p.CategoryID),
})
}
middleware.JSONResponse(c, http.StatusOK, result)
}
func (h *AuthHandler) Choose(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
var req struct {
CategoryID uint `json:"category_id" binding:"required"`
PriceRange string `json:"price_range" binding:"required"`
DistanceRange string `json:"distance_range"`
}
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, err.Error())
return
}
// Perform blind selection
pkg, merchant, matchScore, err := h.blindSvc.Select(uid, req.CategoryID, req.PriceRange)
if err != nil {
middleware.JSONError(c, http.StatusNotFound, "no available packages in this category/price range")
return
}
// Record selection
session, _, err := h.blindSvc.RecordSelection(uid, req.CategoryID, req.PriceRange, pkg.ID, merchant.ID, matchScore)
if err != nil {
middleware.JSONError(c, http.StatusInternalServerError, "failed to record selection")
return
}
// Build response
resp := gin.H{
"session_id": session.ID,
"result": gin.H{
"package_name": pkg.Name,
"merchant_name": merchant.Name,
"merchant_rating": merchant.Rating,
"description": pkg.Description,
"price_range": "¥" + strconv.Itoa(pkg.PriceMin) + "-" + strconv.Itoa(pkg.PriceMax),
"actual_price": pkg.ActualPrice,
"match_score": matchScore,
"has_coupon": false,
},
}
middleware.JSONResponse(c, http.StatusOK, resp)
}
func (h *AuthHandler) GetResult(c *gin.Context) {
sessionID, _ := strconv.Atoi(c.Param("id"))
db := database.GetDB()
var result model.BlindResult
if err := db.First(&result, sessionID).Error; err != nil {
middleware.JSONError(c, http.StatusNotFound, "result not found")
return
}
middleware.JSONResponse(c, http.StatusOK, result)
}
func (h *AuthHandler) GetHistory(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
db := database.GetDB()
var sessions []model.BlindSession
db.Where("user_id = ?", uid).Order("created_at DESC").Limit(20).Find(&sessions)
type HistoryItem struct {
SessionID uint `json:"session_id"`
PackageName string `json:"package_name"`
Merchant string `json:"merchant"`
PriceRange string `json:"price_range"`
MatchScore float64 `json:"match_score"`
Accepted bool `json:"accepted"`
CreatedAt time.Time `json:"created_at"`
}
result := make([]HistoryItem, 0, len(sessions))
for _, s := range sessions {
var br model.BlindResult
db.First(&br, s.ID)
result = append(result, HistoryItem{
SessionID: s.ID,
PackageName: br.PackageName,
Merchant: br.MerchantName,
PriceRange: br.PriceRange,
MatchScore: br.MatchScore,
Accepted: s.Accepted,
CreatedAt: s.CreatedAt,
})
}
middleware.JSONResponse(c, http.StatusOK, result)
}
// ============== Review ==============
func (h *AuthHandler) SubmitReview(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
var req struct {
SessionID uint `json:"session_id" binding:"required"`
PackageID uint `json:"package_id" binding:"required"`
Rating int8 `json:"rating" binding:"required,min=1,max=5"`
Taste int8 `json:"taste" binding:"required,min=1,max=5"`
Value int8 `json:"value" binding:"required,min=1,max=5"`
Distance int8 `json:"distance" binding:"required,min=1,max=5"`
Match int8 `json:"match" binding:"required,min=1,max=5"`
Tags []string `json:"tags"`
Text string `json:"text"`
IsRepeat bool `json:"is_repeat"`
}
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, err.Error())
return
}
if err := h.blindSvc.SubmitReview(uid, req.SessionID, req.PackageID,
req.Rating, req.Taste, req.Value, req.Distance, req.Match, req.Tags, req.Text, req.IsRepeat); err != nil {
middleware.JSONError(c, http.StatusInternalServerError, "failed to submit review")
return
}
middleware.JSONResponse(c, http.StatusOK, gin.H{"message": "review submitted successfully"})
}
func (h *AuthHandler) GetReviewStats(c *gin.Context) {
stats, err := h.blindSvc.GetReviewStats()
if err != nil {
middleware.JSONError(c, http.StatusInternalServerError, "failed to get stats")
return
}
middleware.JSONResponse(c, http.StatusOK, stats)
}
// ============== Coupon ==============
func (h *AuthHandler) GetMyCoupons(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
db := database.GetDB()
var coupons []model.Coupon
db.Where("user_id = ? AND status IN ?", uid, []string{"available", "claimed", "used"}).
Order("created_at DESC").Find(&coupons)
middleware.JSONResponse(c, http.StatusOK, coupons)
}
func (h *AuthHandler) GetAvailableCoupons(c *gin.Context) {
db := database.GetDB()
var coupons []model.Coupon
db.Where("status = ? AND remain_count > 0", "available").Limit(20).Find(&coupons)
middleware.JSONResponse(c, http.StatusOK, coupons)
}
func (h *AuthHandler) ClaimCoupon(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
var req struct {
PoolCode string `json:"pool_code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, err.Error())
return
}
coupon, err := h.couponSvc.ClaimCoupon(uid, req.PoolCode)
if err != nil {
middleware.JSONError(c, http.StatusBadRequest, "failed to claim coupon")
return
}
middleware.JSONResponse(c, http.StatusOK, gin.H{"message": "coupon claimed", "coupon": coupon})
}
// ============== Member ==============
func (h *AuthHandler) GetMemberStatus(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
active, member := h.memberSvc.CheckMembership(uid)
var level int
if active {
level = 1
}
middleware.JSONResponse(c, http.StatusOK, gin.H{
"level": level,
"active": active,
"expires_at": member.EndDate,
"daily_limit": 10,
})
}
func dailyLimit(level int) int {
if level >= 1 {
return 10
}
return 3
}
func (h *AuthHandler) GetMemberPlans(c *gin.Context) {
middleware.JSONResponse(c, http.StatusOK, []gin.H{
{"id": 1, "name": "VIP月卡", "price": 29, "period": "monthly", "blindLimit": 10, "features": []string{"全部分类", "优先匹配", "月度报告"}},
{"id": 2, "name": "VIP年卡", "price": 199, "period": "yearly", "blindLimit": 10, "features": []string{"全部分类", "优先匹配", "月度报告", "省¥149"}},
})
}
func (h *AuthHandler) SubscribeMember(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
var req struct {
PlanID uint `json:"plan_id" binding:"required"`
PaymentMethod string `json:"payment_method"`
}
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, err.Error())
return
}
member, err := h.memberSvc.Subscribe(uid, req.PlanID, req.PaymentMethod)
if err != nil {
middleware.JSONError(c, http.StatusInternalServerError, err.Error())
return
}
middleware.JSONResponse(c, http.StatusOK, gin.H{"message": "subscription successful", "member": member})
}
// ============== Admin CRUD ==============
func (h *AuthHandler) ListMerchants(c *gin.Context) {
db := database.GetDB()
var merchants []model.Merchant
db.Order("created_at DESC").Find(&merchants)
middleware.JSONResponse(c, http.StatusOK, merchants)
}
func (h *AuthHandler) CreateMerchant(c *gin.Context) {
var req model.Merchant
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, err.Error())
return
}
db := database.GetDB()
if req.Tags == "" {
req.Tags = "[]"
}
if req.Status == "" {
req.Status = "approved"
}
if req.Rating == 0 {
req.Rating = 4.0
}
if req.QualityScore == 0 {
req.QualityScore = 0.5
}
db.Create(&req)
middleware.JSONResponse(c, http.StatusCreated, req)
}
func (h *AuthHandler) UpdateMerchant(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
db := database.GetDB()
var req struct {
Name string `json:"name"`
CategoryID uint `json:"category_id"`
Rating float64 `json:"rating"`
PriceRange string `json:"price_range"`
Location string `json:"location"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Tags string `json:"tags"`
QualityScore float64 `json:"quality_score"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, err.Error())
return
}
updates := map[string]interface{}{}
if req.Name != "" {
updates["name"] = req.Name
}
if req.CategoryID > 0 {
updates["category_id"] = req.CategoryID
}
if req.Rating > 0 {
updates["rating"] = req.Rating
}
if req.PriceRange != "" {
updates["price_range"] = req.PriceRange
}
if req.Location != "" {
updates["location"] = req.Location
}
if req.Lat > 0 {
updates["lat"] = req.Lat
}
if req.Lng > 0 {
updates["lng"] = req.Lng
}
if req.Tags != "" {
updates["tags"] = req.Tags
}
if req.QualityScore > 0 {
updates["quality_score"] = req.QualityScore
}
if req.Status != "" {
updates["status"] = req.Status
}
db.Model(&model.Merchant{}).Where("id = ?", id).Updates(updates)
var m model.Merchant
db.First(&m, id)
middleware.JSONResponse(c, http.StatusOK, m)
}
func (h *AuthHandler) ListPackages(c *gin.Context) {
db := database.GetDB()
var packages []model.Package
db.Preload("Merchant").Order("created_at DESC").Find(&packages)
middleware.JSONResponse(c, http.StatusOK, packages)
}
func (h *AuthHandler) CreatePackage(c *gin.Context) {
db := database.GetDB()
var req model.Package
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, err.Error())
return
}
if req.Tags == "" {
req.Tags = "[]"
}
if req.Status == "" {
req.Status = "active"
}
if req.Weight == 0 {
req.Weight = 1.0
}
db.Create(&req)
middleware.JSONResponse(c, http.StatusCreated, req)
}
func (h *AuthHandler) UpdatePackage(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
db := database.GetDB()
var req struct {
Name string `json:"name"`
Description string `json:"description"`
PriceMin int `json:"price_min"`
PriceMax int `json:"price_max"`
ActualPrice int `json:"actual_price"`
Tags string `json:"tags"`
Stock int `json:"stock"`
Weight float64 `json:"weight"`
Status string `json:"status"`
CategoryID uint `json:"category_id"`
MerchantID uint `json:"merchant_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, err.Error())
return
}
updates := map[string]interface{}{}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Description != "" {
updates["description"] = req.Description
}
if req.PriceMin > 0 {
updates["price_min"] = req.PriceMin
}
if req.PriceMax > 0 {
updates["price_max"] = req.PriceMax
}
if req.ActualPrice > 0 {
updates["actual_price"] = req.ActualPrice
}
if req.Tags != "" {
updates["tags"] = req.Tags
}
updates["stock"] = req.Stock
updates["weight"] = req.Weight
if req.Status != "" {
updates["status"] = req.Status
}
if req.CategoryID > 0 {
updates["category_id"] = req.CategoryID
}
if req.MerchantID > 0 {
updates["merchant_id"] = req.MerchantID
}
db.Model(&model.Package{}).Where("id = ?", id).Updates(updates)
var p model.Package
db.Preload("Merchant").First(&p, id)
middleware.JSONResponse(c, http.StatusOK, p)
}
func (h *AuthHandler) ListUsers(c *gin.Context) {
db := database.GetDB()
var users []model.User
db.Order("created_at DESC").Limit(50).Find(&users)
middleware.JSONResponse(c, http.StatusOK, users)
}
func (h *AuthHandler) GetUserDetail(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
db := database.GetDB()
var user model.User
db.First(&user, id)
if user.ID == 0 {
middleware.JSONError(c, http.StatusNotFound, "user not found")
return
}
// Get blind session count
var sessionCount int64
db.Model(&model.BlindSession{}).Where("user_id = ?", id).Count(&sessionCount)
// Get review count
var reviewCount int64
db.Model(&model.UserBehavior{}).Where("user_id = ? AND review_rating > 0", id).Count(&reviewCount)
middleware.JSONResponse(c, http.StatusOK, gin.H{
"user": user,
"session_count": sessionCount,
"review_count": reviewCount,
})
}
func (h *AuthHandler) ListReviews(c *gin.Context) {
db := database.GetDB()
type ReviewRow struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
PkgName string `json:"package_name"`
Merchant string `json:"merchant"`
Rating int8 `json:"rating"`
Taste int8 `json:"taste"`
Value int8 `json:"value"`
Distance int8 `json:"distance"`
Match int8 `json:"match"`
IsRepeat bool `json:"is_repeat"`
CreatedAt time.Time `json:"created_at"`
}
var reviews []model.UserBehavior
db.Where("review_rating > 0").Order("created_at DESC").Limit(50).Find(&reviews)
result := make([]ReviewRow, 0, len(reviews))
for _, r := range reviews {
var pkg model.Package
db.First(&pkg, r.PackageID)
var merchant model.Merchant
db.First(&merchant, pkg.MerchantID)
result = append(result, ReviewRow{
ID: r.ID, UserID: r.UserID, PkgName: pkg.Name,
Merchant: merchant.Name, Rating: r.ReviewRating,
Taste: r.TasteScore, Value: r.ValueScore,
Distance: r.DistanceScore, Match: r.MatchScore,
IsRepeat: r.IsRepeat, CreatedAt: r.CreatedAt,
})
}
middleware.JSONResponse(c, http.StatusOK, result)
}
func (h *AuthHandler) ListCoupons(c *gin.Context) {
db := database.GetDB()
var coupons []model.Coupon
db.Preload("Merchant").Order("created_at DESC").Limit(50).Find(&coupons)
middleware.JSONResponse(c, http.StatusOK, coupons)
}
func (h *AuthHandler) CreateCoupon(c *gin.Context) {
db := database.GetDB()
var req model.Coupon
if err := c.ShouldBindJSON(&req); err != nil {
middleware.JSONError(c, http.StatusBadRequest, err.Error())
return
}
if req.Status == "" {
req.Status = "available"
}
db.Create(&req)
middleware.JSONResponse(c, http.StatusCreated, req)
}
func (h *AuthHandler) GetStatistics(c *gin.Context) {
db := database.GetDB()
var userCount, activeUsers int64
db.Model(&model.User{}).Count(&userCount)
db.Model(&model.UserBehavior{}).Distinct("user_id").Where("created_at >= ?", time.Now().Add(-24*time.Hour)).Count(&activeUsers)
var blindCount, reviewCount int64
db.Model(&model.BlindSession{}).Count(&blindCount)
db.Model(&model.UserBehavior{}).Where("review_rating > 0").Count(&reviewCount)
var avgRating float64
db.Model(&model.UserBehavior{}).Where("review_rating > 0").Pluck("AVG(review_rating)", &avgRating)
var memberCount int64
db.Model(&model.User{}).Where("member_level >= 1").Count(&memberCount)
middleware.JSONResponse(c, http.StatusOK, gin.H{
"total_users": userCount,
"daily_active": activeUsers,
"total_blinds": blindCount,
"total_reviews": reviewCount,
"avg_rating": avgRating,
"member_count": memberCount,
})
}
func (h *AuthHandler) ExportData(c *gin.Context) {
middleware.JSONResponse(c, http.StatusOK, gin.H{"message": "export started", "format": "csv"})
}
// ============== Helpers ==============
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func getCategoryName(db *gorm.DB, categoryID uint) string {
var cat model.Category
if err := db.First(&cat, categoryID).Error; err == nil {
return cat.Name
}
return ""
}
func generateUserCode(userID, couponID uint) string {
return "CPN" + strconv.FormatUint(uint64(userID), 10) + strconv.FormatUint(uint64(couponID), 10)
}
+80
View File
@@ -0,0 +1,80 @@
package middleware
import (
"net/http"
"strings"
"github.com/blind-select/backend/internal/utils"
"github.com/gin-gonic/gin"
)
// JWTAuth requires valid JWT
func JWTAuth(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
JSONError(c, http.StatusUnauthorized, "missing authorization header")
c.Abort()
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if tokenStr == authHeader {
JSONError(c, http.StatusUnauthorized, "invalid authorization format")
c.Abort()
return
}
claims, err := utils.ParseToken(tokenStr, secret)
if err != nil {
JSONError(c, http.StatusUnauthorized, "invalid or expired token")
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Next()
}
}
// AdminOnly restricts access to admin role only
func AdminOnly(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists || role != "admin" {
JSONError(c, http.StatusForbidden, "admin access required")
c.Abort()
return
}
c.Next()
}
}
// BlindDailyLimit limits blind selections per day
func BlindDailyLimit(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
limit := getDailyLimit(uid)
used := getTodayBlindCount(uid)
if used >= limit {
JSONError(c, http.StatusTooManyRequests, "daily blind selection limit reached. Upgrade to VIP for more!")
c.Abort()
return
}
c.Next()
}
}
func getDailyLimit(userID uint) int {
return 3 // TODO: check membership
}
func getTodayBlindCount(userID uint) int {
return 0 // TODO: check Redis
}
+22
View File
@@ -0,0 +1,22 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// CORSMiddleware handles CORS headers
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
+39
View File
@@ -0,0 +1,39 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// JSONResponse helper
func JSONResponse(c *gin.Context, status int, data interface{}) {
c.JSON(status, gin.H{
"code": status / 100,
"message": getMessage(status),
"data": data,
})
}
// JSONError sends a JSON error response
func JSONError(c *gin.Context, status int, msg string) {
c.JSON(status, gin.H{
"code": status / 100,
"message": msg,
"data": nil,
})
}
func getMessage(status int) string {
switch status {
case 200: return "ok"
case 201: return "created"
case 400: return "bad request"
case 401: return "unauthorized"
case 403: return "forbidden"
case 404: return "not found"
case 409: return "conflict"
case 422: return "unprocessable"
case 429: return "too many requests"
case 500: return "internal error"
default: return "error"
}
}
+17
View File
@@ -0,0 +1,17 @@
package model
import (
"time"
)
type Admin struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:100;uniqueIndex" json:"username"`
Password string `gorm:"size:255" json:"-"`
Role string `gorm:"size:50;default:editor" json:"role"` // admin/editor/viewer
PermJSON string `gorm:"type:text" json:"permissions"` // JSON string of permissions
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Admin) TableName() string { return "admins" }
+24
View File
@@ -0,0 +1,24 @@
package model
import (
"time"
)
type UserBehavior struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `json:"user_id"`
PackageID uint `json:"package_id"`
BehaviorType string `gorm:"size:50" json:"behavior_type"` // viewed/selected/attended/reviewed/skipped
SystemScore float64 `gorm:"default:0" json:"system_score"`
ReviewRating int8 `json:"review_rating"`
TasteScore int8 `json:"taste_score"`
ValueScore int8 `json:"value_score"`
DistanceScore int8 `json:"distance_score"`
MatchScore int8 `json:"match_score"`
FeedbackTags string `gorm:"type:text[]" json:"feedback_tags"`
Text string `gorm:"type:text" json:"text"`
IsRepeat bool `gorm:"default:false" json:"is_repeat"`
CreatedAt time.Time `json:"created_at"`
}
func (UserBehavior) TableName() string { return "user_behaviors" }
+37
View File
@@ -0,0 +1,37 @@
package model
import (
"time"
)
type BlindSession struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `json:"user_id"`
CategoryID uint `json:"category_id"`
PriceRange string `gorm:"size:50" json:"price_range"`
DistanceRange string `gorm:"size:50" json:"distance_range"`
ResultPackageID uint `json:"result_package_id"`
RevealedAt time.Time `json:"revealed_at"`
Accepted bool `gorm:"default:false" json:"accepted"`
CreatedAt time.Time `json:"created_at"`
}
func (BlindSession) TableName() string { return "blind_sessions" }
type BlindResult struct {
SessionID uint `gorm:"primaryKey" json:"id"`
UserID uint `json:"user_id"`
PackageName string `gorm:"size:200" json:"package_name"`
MerchantName string `gorm:"size:200" json:"merchant_name"`
MerchantRating float64 `json:"merchant_rating"`
MatchScore float64 `json:"match_score"`
Description string `gorm:"type:text" json:"description"`
PriceRange string `json:"price_range"`
ActualPrice int `json:"actual_price"`
HasCoupon bool `json:"has_coupon"`
CouponValue string `json:"coupon_value"`
MatchedTags string `gorm:"type:text[]" json:"matched_tags"`
CreatedAt time.Time `json:"created_at"`
}
func (BlindResult) TableName() string { return "blind_results" }
+15
View File
@@ -0,0 +1,15 @@
package model
import "time"
type Category struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100" json:"name"`
Icon string `gorm:"size:50" json:"icon"`
Type string `gorm:"size:50" json:"type"` // food/entertainment/shopping/activity
Sort int `gorm:"default:0" json:"sort"`
Status string `gorm:"size:20;default:active" json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func (Category) TableName() string { return "categories" }
+31
View File
@@ -0,0 +1,31 @@
package model
import (
"time"
"gorm.io/gorm"
)
type Coupon struct {
ID uint `gorm:"primaryKey" json:"id"`
MerchantID uint `json:"merchant_id"`
PackageID uint `json:"package_id"`
UserID *uint `json:"user_id"`
Type string `gorm:"size:20" json:"type"` // discount/coupon/free/gift
Value float64 `json:"value"`
MinAmount int `json:"min_amount"`
TotalCount int `gorm:"default:100" json:"total_count"`
RemainCount int `gorm:"default:100" json:"remain_count"`
UserCode string `gorm:"size:50;uniqueIndex" json:"user_code"`
PoolCode string `gorm:"size:50" json:"pool_code"`
Status string `gorm:"size:20;default:available" json:"status"` // available/claimed/used/expired
UsedAt *time.Time `json:"used_at"`
ValidStart time.Time `json:"valid_start"`
ValidEnd time.Time `json:"valid_end"`
Commission float64 `gorm:"default:0" json:"commission"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (Coupon) TableName() string { return "coupons" }
+37
View File
@@ -0,0 +1,37 @@
package model
import (
"time"
"gorm.io/gorm"
)
type Member struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_user_level" json:"user_id"`
Level int `gorm:"default:0" json:"level"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
PaymentMethod string `gorm:"size:50" json:"payment_method"`
Amount float64 `json:"amount"`
OrderNo string `gorm:"size:100;uniqueIndex" json:"order_no"`
Status string `gorm:"size:20;default:active" json:"status"` // active/expired/cancelled
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (Member) TableName() string { return "members" }
type Activity struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:200" json:"name"`
Type string `gorm:"size:50" json:"type"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
ConfigJSON string `gorm:"type:text" json:"config"`
Status string `gorm:"size:20;default:active" json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func (Activity) TableName() string { return "activities" }
+32
View File
@@ -0,0 +1,32 @@
package model
import (
"time"
"gorm.io/gorm"
)
type Merchant struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:200" json:"name"`
Avatar string `gorm:"size:255" json:"avatar"`
CategoryID uint `json:"category_id"`
CategoryName string `gorm:"column:category_name" json:"category_name"`
Rating float64 `gorm:"default:0" json:"rating"`
PriceRange string `gorm:"size:50" json:"price_range"`
Location string `gorm:"size:100" json:"location"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Tags string `gorm:"type:text[]" json:"tags"`
Commission float64 `gorm:"default:0.1" json:"commission"`
TotalReviews int `gorm:"default:0" json:"total_reviews"`
TotalScore float64 `gorm:"default:0" json:"total_score"`
QualityScore float64 `gorm:"default:0.5" json:"quality_score"`
Status string `gorm:"size:20;default:pending" json:"status"` // pending/approved/rejected/offline
Description string `gorm:"type:text" json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (Merchant) TableName() string { return "merchants" }
+29
View File
@@ -0,0 +1,29 @@
package model
import (
"time"
"gorm.io/gorm"
)
type Package struct {
ID uint `gorm:"primaryKey" json:"id"`
MerchantID uint `json:"merchant_id"`
CategoryID uint `json:"category_id"`
Name string `gorm:"size:200" json:"name"`
Description string `gorm:"type:text" json:"description"`
PriceMin int `json:"price_min"`
PriceMax int `json:"price_max"`
ActualPrice int `json:"actual_price"`
Tags string `gorm:"type:text[]" json:"tags"`
Stock int `gorm:"default:9999" json:"stock"`
Weight float64 `gorm:"default:1.0" json:"weight"`
Rating float64 `gorm:"default:0" json:"rating"`
ReviewCount int `gorm:"default:0" json:"review_count"`
Status string `gorm:"size:20;default:active" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (Package) TableName() string { return "packages" }
+28
View File
@@ -0,0 +1,28 @@
package model
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Nickname string `gorm:"size:100" json:"nickname"`
Avatar string `gorm:"size:255" json:"avatar"`
Phone string `gorm:"size:20;uniqueIndex" json:"phone"`
WechatOpenID string `gorm:"size:100;uniqueIndex" json:"wechat_openid"`
PasswordHash string `gorm:"size:255" json:"-"`
PrefScore datatypes.JSON `gorm:"type:jsonb" json:"pref_score,omitempty"`
Tags []string `gorm:"type:text[]" json:"tags"`
RepeatDays int `gorm:"default:7" json:"repeat_days"`
Blacklist []uint `gorm:"type:bigint[]" json:"blacklist"`
MemberLevel int `gorm:"default:0" json:"member_level"`
MemberExpires *time.Time `json:"member_expires,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (User) TableName() string { return "users" }
+374
View File
@@ -0,0 +1,374 @@
package service
import (
"math"
"math/rand"
"time"
"github.com/blind-select/backend/internal/database"
"github.com/blind-select/backend/internal/model"
"gorm.io/gorm"
)
// BlindService handles the core blind selection algorithm
type BlindService struct{}
func NewBlindService() *BlindService {
return &BlindService{}
}
// blindResult represents a weighted candidate for blind selection
type blindCandidate struct {
Pkg *model.Package
Weight float64
Merchant *model.Merchant
}
// Select performs a weighted random blind selection for a user
func (s *BlindService) Select(userID uint, categoryID uint, priceRange string) (*model.Package, *model.Merchant, float64, error) {
db := database.GetDB()
// 1. Get user's preference scores
prefScores := s.getUserPreferenceScores(userID)
// 2. Parse price range
priceMin, priceMax := parsePriceRange(priceRange)
// 3. Get active packages matching category and price range
var packages []model.Package
db.Where("status = ? AND category_id = ? AND price_min >= ? AND price_max <= ?",
"active", categoryID, priceMin, priceMax).Find(&packages)
if len(packages) == 0 {
// Fallback: relax price constraints
db.Where("status = ? AND category_id = ?", "active", categoryID).Find(&packages)
}
if len(packages) == 0 {
return nil, nil, 0, gorm.ErrRecordNotFound
}
// 4. Calculate weights for each package
candidates := make([]blindCandidate, 0, len(packages))
var totalWeight float64
for i := range packages {
pkg := &packages[i]
// Get merchant info
var merchant model.Merchant
db.First(&merchant, pkg.MerchantID)
c := blindCandidate{Pkg: pkg, Merchant: &merchant}
// Base weight
c.Weight = 1.0
// Factor 1: Category preference from user history
catPref := prefScores[getCategoryNameFromID(db, categoryID)]
c.Weight *= (1.0 + catPref) // e.g., 1.0 to 2.5
// Factor 2: Package rating (quality score)
quality := pkg.Rating / 5.0
c.Weight *= (0.5 + quality*0.5) // 0.5 to 1.0
// Factor 3: Merchant quality score
c.Weight *= merchant.QualityScore // 0.3 to 1.0
// Factor 4: No-repeat (check last selection date)
lastUsed := s.getLastUsedDate(db, userID, pkg.ID)
if lastUsed != nil {
daysSince := time.Since(*lastUsed).Hours() / 24
if daysSince < 7 {
c.Weight *= 0.1 // Heavy penalty for recent selections
} else if daysSince < 14 {
c.Weight *= 0.5
}
}
// Factor 5: Exploration bonus (packages rarely selected get a boost)
totalSelections := float64(pkg.ReviewCount)
if totalSelections < 5 {
c.Weight *= 1.5 // Boost for less-explored packages
}
candidates = append(candidates, c)
totalWeight += c.Weight
}
// 5. Weighted random selection
r := rand.Float64() * totalWeight
var cumulative float64
for _, c := range candidates {
cumulative += c.Weight
if r <= cumulative {
// Calculate match score for this selection
matchScore := s.calculateMatchScore(c.Pkg, userID, prefScores)
return c.Pkg, c.Merchant, matchScore, nil
}
}
// Fallback: return first candidate
return candidates[0].Pkg, candidates[0].Merchant, 0.5, nil
}
// getUserPreferenceScores returns category preference scores for a user
func (s *BlindService) getUserPreferenceScores(userID uint) map[string]float64 {
db := database.GetDB()
var behaviors []model.UserBehavior
db.Where("user_id = ? AND review_rating >= 3", userID).Find(&behaviors)
scores := make(map[string]float64)
for _, b := range behaviors {
pkg := model.Package{}
db.First(&pkg, b.PackageID)
category := getCategoryNameFromID(db, pkg.CategoryID)
if category == "" {
continue
}
// Time decay factor
decay := math.Exp(-0.01 * time.Since(b.CreatedAt).Hours()/24)
// Review rating multiplier
ratingMult := float64(b.ReviewRating) / 5.0
// Repeat visit bonus
repeatBonus := 0.0
if b.IsRepeat {
repeatBonus = 2.0
}
score := (1.0 + ratingMult + repeatBonus) * decay
scores[category] += score
}
return scores
}
// calculateMatchScore computes how well a package matches user preferences
func (s *BlindService) calculateMatchScore(pkg *model.Package, userID uint, prefScores map[string]float64) float64 {
db := database.GetDB()
category := getCategoryNameFromID(db, pkg.CategoryID)
baseScore := 0.5
if score, ok := prefScores[category]; ok {
baseScore = math.Min(0.5+score*0.3, 1.0)
}
// Add quality component
baseScore += (pkg.Rating / 5.0) * 0.2
baseScore = math.Min(baseScore, 1.0)
return math.Round(baseScore*100) / 100
}
// getLastUsedDate returns the last time a package was selected for a user
func (s *BlindService) getLastUsedDate(db *gorm.DB, userID, packageID uint) *time.Time {
var session model.BlindSession
if err := db.Where("user_id = ? AND result_package_id = ?", userID, packageID).
Order("created_at DESC").First(&session).Error; err == nil {
return &session.CreatedAt
}
return nil
}
// recordBlindSelection records the result of a blind selection
func (s *BlindService) RecordSelection(userID uint, categoryID uint, priceRange string,
packageID uint, merchantID uint, matchScore float64) (*model.BlindSession, *model.BlindResult, error) {
db := database.GetDB()
// Create blind session
session := model.BlindSession{
UserID: userID,
CategoryID: categoryID,
PriceRange: priceRange,
ResultPackageID: packageID,
RevealedAt: time.Now(),
CreatedAt: time.Now(),
}
if err := db.Create(&session).Error; err != nil {
return nil, nil, err
}
// Get package and merchant for result
var pkg model.Package
db.First(&pkg, packageID)
var merchant model.Merchant
db.First(&merchant, merchantID)
// Create blind result
result := model.BlindResult{
SessionID: session.ID,
UserID: userID,
PackageName: pkg.Name,
MerchantName: merchant.Name,
MerchantRating: merchant.Rating,
MatchScore: matchScore,
Description: pkg.Description,
PriceRange: priceRange,
ActualPrice: pkg.ActualPrice,
CreatedAt: time.Now(),
}
db.Create(&result)
return &session, &result, nil
}
// SubmitReview records a user's review after blind selection
func (s *BlindService) SubmitReview(userID, sessionID, packageID uint,
rating, taste, value, distance, match int8, tags []string, text string, isRepeat bool) error {
db := database.GetDB()
behavior := model.UserBehavior{
UserID: userID,
PackageID: packageID,
BehaviorType: "reviewed",
ReviewRating: rating,
TasteScore: taste,
ValueScore: value,
DistanceScore: distance,
MatchScore: match,
FeedbackTags: formatTags(tags),
Text: text,
IsRepeat: isRepeat,
CreatedAt: time.Now(),
}
db.Create(&behavior)
// Update package rating
s.updatePackageRating(db, packageID, int8(rating))
// Update user preference scores
s.updateUserPreference(db, userID, packageID, rating, isRepeat)
// Update blind session
db.Model(&model.BlindSession{}).Where("id = ?", sessionID).Updates(map[string]interface{}{
"accepted": true,
})
return nil
}
// updatePackageRating recalculates package average rating
func (s *BlindService) updatePackageRating(db *gorm.DB, packageID uint, rating int8) {
db.Model(&model.Package{}).Where("id = ?", packageID).
Update("review_count", gorm.Expr("review_count + 1"))
// Recalculate average
var avgRating float64
db.Model(&model.UserBehavior{}).Where("package_id = ? AND review_rating > 0", packageID).
Pluck("AVG(review_rating)", &avgRating)
db.Model(&model.Package{}).Where("id = ?", packageID).Update("rating", avgRating)
}
// updateUserPreference updates the user's category preference scores based on review
func (s *BlindService) updateUserPreference(db *gorm.DB, userID, packageID uint, rating int8, isRepeat bool) {
var pkg model.Package
db.First(&pkg, packageID)
category := getCategoryNameFromID(db, pkg.CategoryID)
if category == "" {
return
}
// Get existing preference scores
var prefScores map[string]float64
var user model.User
db.First(&user, userID)
// Parse pref_score JSONB
// In production, use proper JSON unmarshaling
// Calculate new score
baseScore := 0.0
switch {
case rating >= 4:
baseScore = 1.0
case rating == 3:
baseScore = 0.3
case rating == 2:
baseScore = -0.3
default:
baseScore = -1.0
}
if isRepeat {
baseScore += 2.0 // Big bonus for revisiting
}
// Update category preference
// In production: merge with existing scores, apply time decay
_ = prefScores
_ = category
_ = baseScore
}
// GetReviewStats returns aggregate review statistics
func (s *BlindService) GetReviewStats() (map[string]interface{}, error) {
db := database.GetDB()
var avgRating float64
db.Model(&model.UserBehavior{}).Where("review_rating > 0").Pluck("AVG(review_rating)", &avgRating)
var totalReviews int64
db.Model(&model.UserBehavior{}).Where("review_rating > 0").Count(&totalReviews)
// Count by rating
type RatingCount struct {
Rating int8
Count int64
}
var ratingCounts []RatingCount
db.Model(&model.UserBehavior{}).Where("review_rating > 0").
Group("review_rating").Pluck("review_rating, COUNT(*)", &ratingCounts)
ratingDist := make(map[int8]int64)
for _, rc := range ratingCounts {
ratingDist[rc.Rating] = rc.Count
}
return map[string]interface{}{
"avg_rating": avgRating,
"total_reviews": totalReviews,
"rating_dist": ratingDist,
}, nil
}
func parsePriceRange(rangeStr string) (int, int) {
// Parse "50-100" -> 50, 100
// Simplified: return defaults if parsing fails
return 0, 10000
}
func getCategoryNameFromID(db *gorm.DB, categoryID uint) string {
var cat model.Category
if err := db.First(&cat, categoryID).Error; err == nil {
return cat.Name
}
return ""
}
func formatTags(tags []string) string {
if len(tags) == 0 {
return "[]"
}
return tagsToString(tags)
}
func tagsToString(tags []string) string {
result := "["
for i, t := range tags {
if i > 0 {
result += ","
}
result += `"` + t + `"`
}
result += "]"
return result
}
+239
View File
@@ -0,0 +1,239 @@
package service
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/blind-select/backend/internal/database"
"github.com/blind-select/backend/internal/model"
)
type MemberService struct{}
func NewMemberService() *MemberService {
return &MemberService{}
}
// Subscribe activates VIP membership for a user
func (s *MemberService) Subscribe(userID uint, planID uint, paymentMethod string) (*model.Member, error) {
db := database.GetDB()
plan := getPlan(planID)
if plan == nil {
return nil, fmt.Errorf("invalid plan")
}
// Cancel existing membership
db.Model(&model.Member{}).Where("user_id = ? AND status = ?", userID, "active").
Update("status", "cancelled")
// Create new membership
var startDate, endDate time.Time
var duration time.Duration
switch plan.Period {
case "monthly":
duration = 30 * 24 * time.Hour
case "yearly":
duration = 365 * 24 * time.Hour
default:
duration = 30 * 24 * time.Hour
}
startDate = time.Now()
endDate = startDate.Add(duration)
member := model.Member{
UserID: userID,
Level: 1,
StartDate: startDate,
EndDate: endDate,
PaymentMethod: paymentMethod,
Amount: plan.Price,
OrderNo: generateOrderNo(),
Status: "active",
}
if err := db.Create(&member).Error; err != nil {
return nil, err
}
// Update user member level and expiration
db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"member_level": 1,
"member_expires": endDate,
})
return &member, nil
}
// CheckMembership checks if user has active membership
func (s *MemberService) CheckMembership(userID uint) (bool, *model.Member) {
db := database.GetDB()
var member model.Member
if err := db.Where("user_id = ? AND status = ?", userID, "active").First(&member).Error; err != nil {
return false, nil
}
if time.Now().After(member.EndDate) {
db.Model(&member).Update("status", "expired")
db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"member_level": 0,
})
return false, nil
}
return true, &member
}
// GetPlan returns plan info
func (s *MemberService) GetPlan(planID uint) *MemberPlan {
return getPlan(planID)
}
func getPlan(planID uint) *MemberPlan {
switch planID {
case 1:
return &MemberPlan{ID: 1, Name: "VIP月卡", Price: 29, Period: "monthly", BlindLimit: 10}
case 2:
return &MemberPlan{ID: 2, Name: "VIP年卡", Price: 199, Period: "yearly", BlindLimit: 10}
default:
return nil
}
}
type MemberPlan struct {
ID uint `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Period string `json:"period"`
BlindLimit int `json:"blind_limit"`
}
// ============== Coupon Service ==============
type CouponService struct{}
func NewCouponService() *CouponService {
return &CouponService{}
}
// CreateCoupon creates a coupon for a merchant
func (s *CouponService) CreateCoupon(req CreateCouponReq) (*model.Coupon, error) {
db := database.GetDB()
poolCode := generatePoolCode()
coupon := model.Coupon{
MerchantID: req.MerchantID,
PackageID: req.PackageID,
Type: req.Type,
Value: req.Value,
MinAmount: req.MinAmount,
TotalCount: req.TotalCount,
RemainCount: req.TotalCount,
PoolCode: poolCode,
Status: "available",
ValidStart: req.ValidStart,
ValidEnd: req.ValidEnd,
Commission: req.Commission,
}
if err := db.Create(&coupon).Error; err != nil {
return nil, err
}
return &coupon, nil
}
type CreateCouponReq struct {
MerchantID uint `json:"merchant_id" binding:"required"`
PackageID uint `json:"package_id"`
Type string `json:"type" binding:"required"` // discount/coupon/free/gift
Value float64 `json:"value" binding:"required"`
MinAmount int `json:"min_amount"`
TotalCount int `json:"total_count" binding:"required"`
ValidStart time.Time `json:"valid_start" binding:"required"`
ValidEnd time.Time `json:"valid_end" binding:"required"`
Commission float64 `json:"commission"`
}
// ClaimCoupon allows a user to claim a pool coupon
func (s *CouponService) ClaimCoupon(userID uint, poolCode string) (*model.Coupon, error) {
db := database.GetDB()
var poolCoupon model.Coupon
if err := db.Where("pool_code = ? AND status = ? AND remain_count > 0", poolCode, "available").First(&poolCoupon).Error; err != nil {
return nil, err
}
// Deduct count
poolCoupon.RemainCount--
db.Save(&poolCoupon)
// Create user-specific coupon
userCode := "UCP" + generateRandomString(8)
userCoupon := model.Coupon{
MerchantID: poolCoupon.MerchantID,
PackageID: poolCoupon.PackageID,
UserID: &userID,
Type: poolCoupon.Type,
Value: poolCoupon.Value,
MinAmount: poolCoupon.MinAmount,
TotalCount: 1,
RemainCount: 1,
UserCode: userCode,
Status: "claimed",
ValidStart: poolCoupon.ValidStart,
ValidEnd: poolCoupon.ValidEnd,
Commission: poolCoupon.Commission,
}
if err := db.Create(&userCoupon).Error; err != nil {
// Rollback pool count
poolCoupon.RemainCount++
db.Save(&poolCoupon)
return nil, err
}
return &userCoupon, nil
}
// VerifyCoupon marks a coupon as used (merchant verification)
func (s *CouponService) VerifyCoupon(userCode string) error {
db := database.GetDB()
var coupon model.Coupon
if err := db.Where("user_code = ? AND status = ?", userCode, "claimed").First(&coupon).Error; err != nil {
return err
}
coupon.Status = "used"
coupon.UsedAt = ptrTime(time.Now())
db.Save(&coupon)
return nil
}
func ptrTime(t time.Time) *time.Time {
return &t
}
func generateOrderNo() string {
return "ORD" + time.Now().Format("20060102150405") + generateRandomString(4)
}
func generatePoolCode() string {
b := make([]byte, 8)
rand.Read(b)
return "CP" + hex.EncodeToString(b)
}
func generateRandomString(n int) string {
b := make([]byte, n/2)
rand.Read(b)
return hex.EncodeToString(b)[:n]
}
+164
View File
@@ -0,0 +1,164 @@
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/blind-select/backend/internal/database"
"github.com/blind-select/backend/internal/model"
)
// WechatTokenResp is the response from wechat auth.code2Session
type WechatTokenResp struct {
OpenID string `json:"openid"`
SessionKey string `json:"session_key"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
// WechatUserInfoResp for openid-only login (no phone)
type WechatUserInfoResp struct {
OpenID string `json:"openid"`
Nickname string `json:"nickname"`
Gender int `json:"gender"`
City string `json:"city"`
Province string `json:"province"`
Country string `json:"country"`
HeadImgUrl string `json:"headimgurl"`
}
// WechatService handles WeChat OAuth login
type WechatService struct {
AppID string
AppSecret string
}
func NewWechatService(appID, appSecret string) *WechatService {
return &WechatService{AppID: appID, AppSecret: appSecret}
}
// Code2Session exchanges login code for OpenID
func (s *WechatService) Code2Session(code string) (*WechatTokenResp, error) {
url := fmt.Sprintf(
"https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
s.AppID, s.AppSecret, code,
)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("wechat api call failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read wechat response: %w", err)
}
var result WechatTokenResp
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse wechat response: %w", err)
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("wechat error: %d - %s", result.ErrCode, result.ErrMsg)
}
return &result, nil
}
// LoginOrCreateUser handles WeChat login: find existing user or create new
func (s *WechatService) LoginOrCreateUser(code string) (*model.User, string, error) {
token, err := s.Code2Session(code)
if err != nil {
return nil, "", err
}
db := database.GetDB()
var user model.User
// Try to find by OpenID
if err := db.Where("wechat_openid = ?", token.OpenID).First(&user).Error; err != nil {
// User doesn't exist, create new
nickname := "微信用户"
avatar := ""
// Try to get user info for better nickname/avatar
userInfo, infoErr := s.GetUserInfo(token.SessionKey)
if infoErr == nil {
nickname = userInfo.Nickname
avatar = userInfo.HeadImgUrl
}
user = model.User{
WechatOpenID: token.OpenID,
Nickname: nickname,
Avatar: avatar,
Tags: []string{},
RepeatDays: 7,
MemberLevel: 0,
}
if err := db.Create(&user).Error; err != nil {
return nil, "", fmt.Errorf("create user: %w", err)
}
}
return &user, token.SessionKey, nil
}
// GetUserInfo fetches user profile (requires session_key)
func (s *WechatService) GetUserInfo(sessionKey string) (*WechatUserInfoResp, error) {
// Note: real implementation needs encrypted data from mini-program
// This is a placeholder for now
return &WechatUserInfoResp{Nickname: "微信用户"}, nil
}
// GenerateUnionID returns the union ID if available (for multi-app ecosystem)
func (s *WechatService) GetUnionID(code string) (string, error) {
token, err := s.Code2Session(code)
if err != nil {
return "", err
}
return token.UnionID, nil
}
// SendTemplateMessage sends a WeChat template message to user
func (s *WechatService) SendTemplateMessage(openID, templateID, data map[string]interface{}) error {
// Get access token
tokenURL := fmt.Sprintf(
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
s.AppID, s.AppSecret,
)
resp, err := http.Get(tokenURL)
if err != nil {
return err
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return err
}
// Build message payload
msg := map[string]interface{}{
"touser": openID,
"template_id": templateID,
"data": data,
}
payload, _ := json.Marshal(msg)
msgURL := fmt.Sprintf(
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=%s",
tokenResp.AccessToken,
)
http.Post(msgURL, "application/json", strings.NewReader(string(payload)))
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package utils
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateToken(userID uint, username, role string, secret string) (string, error) {
expire := time.Now().Add(24 * time.Hour)
claims := Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expire),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "blind-select",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func GenerateRefreshToken(userID uint, secret string) (string, error) {
expire := time.Now().Add(7 * 24 * time.Hour)
claims := jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expire),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "blind-select-refresh",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ParseToken(tokenString, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
+17
View File
@@ -0,0 +1,17 @@
package utils
import (
"golang.org/x/crypto/bcrypt"
)
// HashPassword hashes a password using bcrypt
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPassword compares a password with a hash
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
+196
View File
@@ -0,0 +1,196 @@
-- Initial schema for blind-select
-- Categories
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
icon VARCHAR(50),
type VARCHAR(50) NOT NULL, -- food/entertainment/shopping/activity
sort INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW()
);
-- Admins
CREATE TABLE IF NOT EXISTS admins (
id SERIAL PRIMARY KEY,
username VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'editor', -- admin/editor/viewer
perm_json TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Users
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
nickname VARCHAR(100),
avatar VARCHAR(255),
phone VARCHAR(20) UNIQUE,
wechat_openid VARCHAR(100) UNIQUE,
password_hash VARCHAR(255),
pref_score JSONB,
tags TEXT[],
repeat_days INT DEFAULT 7,
blacklist BIGINT[],
member_level INT DEFAULT 0,
member_expires TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP
);
-- Merchants
CREATE TABLE IF NOT EXISTS merchants (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
avatar VARCHAR(255),
category_id INT,
category_name VARCHAR(100),
rating FLOAT8 DEFAULT 0,
price_range VARCHAR(50),
location VARCHAR(100),
lat FLOAT8,
lng FLOAT8,
tags TEXT[],
commission FLOAT8 DEFAULT 0.1,
total_reviews INT DEFAULT 0,
total_score FLOAT8 DEFAULT 0,
quality_score FLOAT8 DEFAULT 0.5,
status VARCHAR(20) DEFAULT 'pending',
description TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP
);
-- Packages
CREATE TABLE IF NOT EXISTS packages (
id SERIAL PRIMARY KEY,
merchant_id INT NOT NULL,
category_id INT,
name VARCHAR(200) NOT NULL,
description TEXT,
price_min INT,
price_max INT,
actual_price INT,
tags TEXT[],
stock INT DEFAULT 9999,
weight FLOAT8 DEFAULT 1.0,
rating FLOAT8 DEFAULT 0,
review_count INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP
);
-- User behaviors (includes review scores)
CREATE TABLE IF NOT EXISTS user_behaviors (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
package_id INT NOT NULL,
behavior_type VARCHAR(50), -- viewed/selected/attended/reviewed/skipped
system_score FLOAT8 DEFAULT 0,
review_rating INT8,
taste_score INT8,
value_score INT8,
distance_score INT8,
match_score INT8,
feedback_tags TEXT[],
text TEXT,
is_repeat BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
-- Blind sessions
CREATE TABLE IF NOT EXISTS blind_sessions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
category_id INT,
price_range VARCHAR(50),
distance_range VARCHAR(50),
result_package_id INT,
revealed_at TIMESTAMP,
accepted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
-- Blind results (revealed details)
CREATE TABLE IF NOT EXISTS blind_results (
session_id INT PRIMARY KEY,
user_id INT NOT NULL,
package_name VARCHAR(200),
merchant_name VARCHAR(200),
merchant_rating FLOAT8,
match_score FLOAT8,
description TEXT,
price_range VARCHAR(50),
actual_price INT,
has_coupon BOOLEAN DEFAULT FALSE,
coupon_value VARCHAR(100),
matched_tags TEXT[],
created_at TIMESTAMP DEFAULT NOW()
);
-- Coupons
CREATE TABLE IF NOT EXISTS coupons (
id SERIAL PRIMARY KEY,
merchant_id INT NOT NULL,
package_id INT,
user_id INT,
type VARCHAR(20), -- discount/coupon/free/gift
value FLOAT8,
min_amount INT,
total_count INT DEFAULT 100,
remain_count INT DEFAULT 100,
user_code VARCHAR(50) UNIQUE,
pool_code VARCHAR(50),
status VARCHAR(20) DEFAULT 'available', -- available/claimed/used/expired
used_at TIMESTAMP,
valid_start TIMESTAMP,
valid_end TIMESTAMP,
commission FLOAT8 DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP
);
-- Members
CREATE TABLE IF NOT EXISTS members (
id SERIAL PRIMARY KEY,
user_id INT,
level INT DEFAULT 0,
start_date TIMESTAMP,
end_date TIMESTAMP,
payment_method VARCHAR(50),
amount FLOAT8,
order_no VARCHAR(100) UNIQUE,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP
);
-- Activities
CREATE TABLE IF NOT EXISTS activities (
id SERIAL PRIMARY KEY,
name VARCHAR(200),
type VARCHAR(50),
start TIMESTAMP,
end TIMESTAMP,
config_json TEXT,
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_users_phone ON users(phone);
CREATE INDEX idx_users_wechat ON users(wechat_openid);
CREATE INDEX idx_merchants_status ON merchants(status);
CREATE INDEX idx_packages_merchant ON packages(merchant_id);
CREATE INDEX idx_packages_status ON packages(status);
CREATE INDEX idx_behaviors_user ON user_behaviors(user_id);
CREATE INDEX idx_blind_user ON blind_sessions(user_id);
CREATE INDEX idx_coupons_status ON coupons(status);
+45
View File
@@ -0,0 +1,45 @@
-- Seed data for blind-select
-- Insert categories
INSERT INTO categories (name, icon, type, sort) VALUES
('中餐', '🍜', 'food', 1),
('日料', '🍣', 'food', 2),
('西餐', '🥩', 'food', 3),
('甜品', '🍰', 'food', 4),
('火锅', '🍲', 'food', 5),
('KTV', '🎤', 'entertainment', 6),
('剧本杀', '🔍', 'entertainment', 7),
('SPA/按摩', '💆', 'entertainment', 8),
('运动', '', 'entertainment', 9),
('盲盒', '🎁', 'shopping', 10),
('演出', '🎭', 'activity', 11),
('展览', '🖼️', 'activity', 12);
-- Insert admin (password: admin123)
INSERT INTO admins (username, password, role) VALUES
('admin', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'admin');
-- Insert sample merchants
INSERT INTO merchants (name, avatar, category_id, category_name, rating, price_range, location, lat, lng, tags, description, status) VALUES
('松阪牛料理', '/avatar1.jpg', 2, '日料', 4.8, '200-400', '朝阳区建国路88号', 39.9042, 116.4074, '["日料","高品质","约会"]', '正宗日式 Omakase,主厨有15年东京银座经验', 'approved'),
('渝味晓宇火锅', '/avatar2.jpg', 5, '火锅', 4.5, '100-200', '朝阳区三里屯路19号', 39.9336, 116.4530, '["火锅","麻辣","聚餐"]', '重庆老火锅底料,现炒底料', 'approved'),
('Bistro L''Atelier', '/avatar3.jpg', 3, '西餐', 4.6, '300-500', '朝阳区亮马河路43号', 39.9489, 116.4675, '["西餐","浪漫","情侣"]', '法意融合菜,米其林推荐餐厅', 'approved'),
('喜茶', '/avatar4.jpg', 4, '甜品', 4.3, '20-40', '朝阳区万达广场', 39.8967, 116.4120, '["甜品","奶茶","轻食"]', '人气新茶饮品牌', 'approved'),
('迷雾剧场剧本杀', '/avatar5.jpg', 7, '剧本杀', 4.7, '80-150', '朝阳区工体北路8号', 39.9320, 116.4460, '["剧本杀","聚会","推理"]', '沉浸式实景剧本杀,50+剧本可选', 'approved'),
('纯K量贩KTV', '/avatar6.jpg', 6, 'KTV', 4.2, '50-100', '朝阳区建国路93号', 39.9070, 116.4750, '["KTV","唱歌","派对"]', '高端量贩KTV,新鲜果盘', 'approved'),
('花间堂SPA', '/avatar7.jpg', 8, 'SPA/按摩', 4.9, '200-400', '朝阳区亮马桥路2号', 39.9510, 116.4600, '["SPA","放松","情侣"]', '日式庭园风格SPA,泰式精油按摩', 'approved'),
('乐刻健身', '/avatar8.jpg', 9, '运动', 4.1, '30-80', '朝阳区大望路', 39.8980, 116.4720, '["运动","健身","自助"]', '24小时自助健身,私教课程', 'approved');
-- Insert sample packages
INSERT INTO packages (merchant_id, category_id, name, description, price_min, price_max, actual_price, tags, status) VALUES
(1, 2, '主厨精选7道式', ' seasonal食材,当日最新鲜海产', 298, 398, 358, '["omakase","日料","高品质","约会"]', 'active'),
(1, 2, '人气套餐2-3人', '包含招牌刺身拼盘、烤鳗鱼、寿司等', 598, 798, 698, '["日料","套餐","聚餐"]', 'active'),
(2, 5, '双人麻辣火锅套餐', '含精选肉类拼盘、时蔬、底料', 168, 228, 198, '["火锅","双人","麻辣"]', 'active'),
(2, 5, '4-6人欢聚套餐', '含毛肚三脆、肥牛、虾滑及甜品', 328, 428, 388, '["火锅","多人","聚餐"]', 'active'),
(3, 3, '情侣浪漫晚餐', '3道式套餐,含红酒一杯', 398, 498, 458, '["西餐","情侣","浪漫"]', 'active'),
(3, 3, '商务套餐', '5道式,含前菜、主菜、甜品', 498, 598, 558, '["西餐","商务","高端"]', 'active'),
(4, 4, '四季新品奶茶套装', '当季限定口味组合', 35, 45, 39, '["奶茶","甜品","新品"]', 'active'),
(5, 7, '沉浸式推理剧本', '4-6人本,含道具和服装', 128, 168, 148, '["剧本杀","推理","聚会"]', 'active'),
(6, 6, '商务KTV欢唱套餐', '4小时+果盘+饮料', 88, 128, 98, '["KTV","商务","唱歌"]', 'active'),
(7, 8, '泰式精油SPA体验', '60分钟泰式传统按摩', 198, 298, 238, '["SPA","放松","泰式"]', 'active'),
(8, 9, '周卡健身通票', '7天不限时畅练', 68, 88, 78, '["健身","周卡","运动"]', 'active');
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY frontend-admin/package.json frontend-admin/package-lock.json* ./
RUN npm install --frozen-lockfile 2>/dev/null || npm install
COPY frontend-admin .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# Proxy API requests to backend
RUN echo 'server { \
listen 80; \
location / { \
root /usr/share/nginx/html; \
try_files $uri $uri/ /index.html; \
} \
location /api/ { \
proxy_pass http://api:8080; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>帮我选 - 后台管理</title>
<link href="/src/style.css" rel="stylesheet" />
</head>
<body>
<div id="app">
<!-- SPA挂载点 -->
<div class="min-h-screen bg-base-200 flex items-center justify-center">
<div class="card w-96 bg-base-100 shadow-xl">
<div class="card-body items-center text-center">
<h1 class="text-4xl">🎲</h1>
<h2 class="text-2xl font-bold text-primary">帮我选</h2>
<p class="text-base-content/60">后台管理系统</p>
<!-- 登录表单 -->
<div class="w-full mt-4">
<input type="text" placeholder="用户名" class="input input-bordered w-full" />
<input type="password" placeholder="密码" class="input input-bordered w-full mt-2" />
<button class="btn btn-primary w-full mt-4">登录</button>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "blind-select-admin",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"axios": "^1.7.0",
"pinia": "^2.2.0",
"alpinejs": "^3.14.0",
"chart.js": "^4.4.0",
"vue-chartjs": "^5.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"vite": "^5.4.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0",
"daisyui": "^4.12.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+62
View File
@@ -0,0 +1,62 @@
<template>
<div class="drawer lg:drawer-open">
<!-- 侧边栏 -->
<input id="sidebar" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<!-- 顶部栏 -->
<div class="navbar bg-base-100 shadow-sm">
<div class="flex-none lg:hidden">
<label for="sidebar" class="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-6 h-6 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</label>
</div>
<div class="flex-1">
<a class="btn btn-ghost text-xl">🎲 帮我选 · 管理后台</a>
</div>
<div class="flex-none">
<div class="dropdown dropdown-end">
<div tabindex="0" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full bg-primary text-white flex items-center justify-center">
<span></span>
</div>
</div>
<ul tabindex="0" class="dropdown-content menu menu-sm z-[1] p-2 shadow bg-base-100 rounded-box w-52 mt-4">
<li><a>管理员</a></li>
<li><a @click="logout">退出登录</a></li>
</ul>
</div>
</div>
</div>
<!-- 内容区 -->
<div class="p-6">
<router-view />
</div>
</div>
<!-- 侧边栏菜单 -->
<div class="drawer-side z-50">
<label for="sidebar" class="drawer-overlay"></label>
<ul class="menu p-4 w-64 min-h-full bg-base-100 text-base-content">
<li class="mb-4">
<a class="text-xl font-bold text-primary">🎲 帮我选</a>
</li>
<li><router-link to="/dashboard" class="menu-title">📊 仪表盘</router-link></li>
<li><router-link to="/merchants" class="menu-title">🏪 商家管理</router-link></li>
<li><router-link to="/packages" class="menu-title">📦 套餐管理</router-link></li>
<li><router-link to="/coupons" class="menu-title">🎫 优惠券</router-link></li>
<li><router-link to="/users" class="menu-title">👥 用户管理</router-link></li>
<li><router-link to="/reviews" class="menu-title"> 打分管理</router-link></li>
<li><router-link to="/statistics" class="menu-title">📈 数据报表</router-link></li>
</ul>
</div>
</div>
</template>
<script setup>
function logout() {
localStorage.removeItem('admin_token')
window.location.href = '/login'
}
</script>
+65
View File
@@ -0,0 +1,65 @@
const BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8080/api/v1'
const token = () => localStorage.getItem('admin_token') || ''
async function request(url, opts = {}) {
const headers = { 'Content-Type': 'application/json' }
if (token()) headers['Authorization'] = `Bearer ${token()}`
const res = await fetch(BASE + url, { ...opts, headers: { ...headers, ...opts.headers } })
const data = await res.json()
if (!res.ok) throw new Error(data.message || res.statusText)
return data.data ?? data
}
// ── Auth ──
export const authApi = {
login: (body) => request('/admin/login', { method: 'POST', body: JSON.stringify(body) }),
}
// ── Dashboard ──
export const dashboardApi = {
stats: () => request('/admin/dashboard'),
trends: (days = 30) => request(`/admin/dashboard/trends?days=${days}`),
}
// ── Merchants ──
export const merchantApi = {
list: (page = 1, size = 20) => request(`/admin/merchants?page=${page}&size=${size}`),
detail: (id) => request(`/admin/merchants/${id}`),
create: (body) => request('/admin/merchants', { method: 'POST', body: JSON.stringify(body) }),
update: (id, body) => request(`/admin/merchants/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
toggle: (id) => request(`/admin/merchants/${id}/toggle`, { method: 'POST' }),
}
// ── Packages ──
export const packageApi = {
list: (page = 1, size = 20) => request(`/admin/packages?page=${page}&size=${size}`),
create: (body) => request('/admin/packages', { method: 'POST', body: JSON.stringify(body) }),
update: (id, body) => request(`/admin/packages/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
toggle: (id) => request(`/admin/packages/${id}/toggle`, { method: 'POST' }),
}
// ── Coupons ──
export const couponApi = {
list: (page = 1, size = 20) => request(`/admin/coupons?page=${page}&size=${size}`),
create: (body) => request('/admin/coupons', { method: 'POST', body: JSON.stringify(body) }),
toggle: (id) => request(`/admin/coupons/${id}/toggle`, { method: 'POST' }),
}
// ── Users ──
export const userApi = {
list: (page = 1, size = 20) => request(`/admin/users?page=${page}&size=${size}`),
detail: (id) => request(`/admin/users/${id}`),
}
// ── Reviews ──
export const reviewApi = {
list: (page = 1, size = 20) => request(`/admin/reviews?page=${page}&size=${size}`),
stats: () => request('/admin/reviews/stats'),
}
// ── Statistics ──
export const statsApi = {
overview: () => request('/admin/statistics'),
export: (type) => request(`/admin/export?type=${type}`),
}
+64
View File
@@ -0,0 +1,64 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/dashboard' },
{
path: '/login',
component: () => import('./views/Login.vue'),
},
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue'),
meta: { requiresAuth: true },
},
{
path: '/merchants',
component: () => import('./views/Merchants.vue'),
meta: { requiresAuth: true },
},
{
path: '/packages',
component: () => import('./views/Packages.vue'),
meta: { requiresAuth: true },
},
{
path: '/coupons',
component: () => import('./views/Coupons.vue'),
meta: { requiresAuth: true },
},
{
path: '/users',
component: () => import('./views/Users.vue'),
meta: { requiresAuth: true },
},
{
path: '/reviews',
component: () => import('./views/Reviews.vue'),
meta: { requiresAuth: true },
},
{
path: '/statistics',
component: () => import('./views/Statistics.vue'),
meta: { requiresAuth: true },
},
]
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('admin_token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else {
next()
}
})
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+32
View File
@@ -0,0 +1,32 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/login', name: 'Login', component: () => import('../views/Login.vue') },
{
path: '/',
component: () => import('../App.vue'),
children: [
{ path: '', redirect: '/dashboard' },
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
{ path: 'merchants', name: 'Merchants', component: () => import('../views/Merchants.vue') },
{ path: 'packages', name: 'Packages', component: () => import('../views/Packages.vue') },
{ path: 'coupons', name: 'Coupons', component: () => import('../views/Coupons.vue') },
{ path: 'users', name: 'Users', component: () => import('../views/Users.vue') },
{ path: 'reviews', name: 'Reviews', component: () => import('../views/Reviews.vue') },
{ path: 'statistics', name: 'Statistics', component: () => import('../views/Statistics.vue') },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('admin_token')
if (to.name !== 'Login' && !token) next({ name: 'Login' })
else next()
})
export default router
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
}
+76
View File
@@ -0,0 +1,76 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">🎫 优惠券管理</h1>
<button class="btn btn-primary">+ 创建优惠券</button>
</div>
<!-- 统计 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="stats shadow">
<div class="stat">
<div class="stat-title">已发放</div>
<div class="stat-value text-primary">{{ stats.total }}</div>
</div>
</div>
<div class="stats shadow">
<div class="stat">
<div class="stat-title">已核销</div>
<div class="stat-value text-success">{{ stats.used }}</div>
</div>
<div class="stat-desc">核销率: {{ stats.verificationRate }}%</div>
</div>
<div class="stats shadow">
<div class="stat">
<div class="stat-title">佣金收入</div>
<div class="stat-value text-accent">¥{{ stats.commission }}</div>
</div>
</div>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>商家</th>
<th>类型</th>
<th>面额</th>
<th>剩余</th>
<th>有效期</th>
<th>佣金</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="c in coupons" :key="c.id">
<td>{{ c.id }}</td>
<td>{{ c.merchant }}</td>
<td>{{ c.type }}</td>
<td>¥{{ c.value }} {{ c.minAmount ? '(满' + c.minAmount + '减)' : '' }}</td>
<td>
<progress class="progress progress-primary w-32" :value="c.remain" :max="c.total"></progress>
{{ c.remain }}/{{ c.total }}
</td>
<td class="text-sm">{{ c.validTo }}</td>
<td>¥{{ c.commission }}</td>
<td><button class="btn btn-ghost btn-xs">编辑</button></td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const stats = ref({ total: 1250, used: 856, verificationRate: 68.5, commission: '3,420' })
const coupons = ref([
{ id: 1, merchant: '松阪牛料理', type: '满减券', value: 15, minAmount: 80, total: 200, remain: 87, validTo: '2026-07-01', commission: 5 },
{ id: 2, merchant: '渝味晓宇火锅', type: '满减券', value: 20, minAmount: 100, total: 150, remain: 45, validTo: '2026-06-20', commission: 6 },
{ id: 3, merchant: '迷雾剧场', type: '兑换券', value: 0, minAmount: 0, total: 50, remain: 12, validTo: '2026-06-30', commission: 0 },
{ id: 4, merchant: '纯K', type: '折扣券', value: 8, minAmount: 0, total: 100, remain: 35, validTo: '2026-07-15', commission: 4 },
])
</script>
+141
View File
@@ -0,0 +1,141 @@
<template>
<div>
<h1 class="text-3xl font-bold mb-6">📊 仪表盘</h1>
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="stats shadow">
<div class="stat">
<div class="stat-figure text-primary">👤</div>
<div class="stat-title">总用户</div>
<div class="stat-value text-primary">{{ stats.users }}</div>
<div class="stat-desc">今日新增: <span class="text-success">+{{ stats.newUsers }}</span></div>
</div>
</div>
<div class="stats shadow">
<div class="stat">
<div class="stat-figure text-secondary">🏪</div>
<div class="stat-title">商家</div>
<div class="stat-value text-secondary">{{ stats.merchants }}</div>
<div class="stat-desc">待审核: <span class="text-warning">{{ stats.pending }}</span></div>
</div>
</div>
<div class="stats shadow">
<div class="stat">
<div class="stat-figure text-accent">📦</div>
<div class="stat-title">套餐</div>
<div class="stat-value text-accent">{{ stats.packages }}</div>
<div class="stat-desc">上架中: {{ stats.active }}</div>
</div>
</div>
<div class="stats shadow">
<div class="stat">
<div class="stat-figure">🎲</div>
<div class="stat-title">盲选次数</div>
<div class="stat-value">{{ stats.blinds }}</div>
<div class="stat-desc">今日: {{ stats.todayBlinds }}</div>
</div>
</div>
</div>
<!-- 会员收入 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">💰 会员收入</h2>
<div class="flex items-baseline">
<span class="text-4xl font-bold text-success">¥{{ stats.monthRevenue }}</span>
<span class="text-sm text-base-content/50 ml-2">本月</span>
</div>
<div class="divider my-0"></div>
<div class="flex justify-between text-sm">
<span>付费会员: <b>{{ stats.payingMembers }}</b></span>
<span>转化率: <b class="text-success">{{ stats.conversionRate }}%</b></span>
</div>
</div>
</div>
<!-- 热门分类 -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">🔥 热门盲选分类</h2>
<div class="space-y-3">
<div v-for="item in hotCategories" :key="item.name">
<div class="flex justify-between text-sm mb-1">
<span>{{ item.icon }} {{ item.name }}</span>
<span>{{ item.count }} </span>
</div>
<progress class="progress progress-primary" :value="item.percent" max="100"></progress>
</div>
</div>
</div>
</div>
</div>
<!-- 最近用户 -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">👥 最近注册用户</h2>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>#</th>
<th>昵称</th>
<th>手机号</th>
<th>会员</th>
<th>注册时间</th>
</tr>
</thead>
<tbody>
<tr v-for="(user, i) in recentUsers" :key="i">
<td>{{ i + 1 }}</td>
<td>{{ user.nickname }}</td>
<td>{{ user.phone }}</td>
<td>
<div class="badge" :class="user.member ? 'badge-success' : 'badge-ghost'">
{{ user.member ? 'VIP' : '免费' }}
</div>
</td>
<td class="text-sm text-base-content/50">{{ user.time }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const stats = ref({
users: 12847,
newUsers: 156,
merchants: 328,
pending: 12,
packages: 1256,
active: 1198,
blinds: 45623,
todayBlinds: 892,
monthRevenue: '14,500',
payingMembers: 523,
conversionRate: 4.1,
})
const hotCategories = ref([
{ name: '日料', icon: '🍣', count: 8923, percent: 85 },
{ name: '火锅', icon: '🍲', count: 7654, percent: 73 },
{ name: '剧本杀', icon: '🔍', count: 6234, percent: 59 },
{ name: '西餐', icon: '🥩', count: 5123, percent: 49 },
{ name: '甜品', icon: '🍰', count: 3890, percent: 37 },
])
const recentUsers = ref([
{ nickname: '小明同学', phone: '138****1234', member: true, time: '2026-06-06 10:30' },
{ nickname: '吃货王', phone: '139****5678', member: false, time: '2026-06-06 09:15' },
{ nickname: '探店达人', phone: '137****9012', member: true, time: '2026-06-06 08:42' },
{ nickname: 'Luna', phone: '136****3456', member: false, time: '2026-06-05 22:10' },
])
</script>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="flex min-h-screen items-center justify-center bg-gradient-to-br from-primary/10 to-secondary/10">
<div class="card w-96 bg-base-100 shadow-2xl">
<div class="card-body items-center text-center">
<h1 class="text-5xl mb-2">🎲</h1>
<h2 class="text-2xl font-bold text-primary">帮我选</h2>
<p class="text-base-content/50 text-sm">后台管理系统</p>
<div class="w-full mt-6">
<div class="form-control">
<label class="label"><span class="label-text">用户名</span></label>
<input type="text" v-model="username" placeholder="admin" class="input input-bordered w-full" />
</div>
<div class="form-control mt-4">
<label class="label"><span class="label-text">密码</span></label>
<input type="password" v-model="password" placeholder="admin123" class="input input-bordered w-full" @keyup.enter="handleLogin" />
</div>
<div class="card-actions mt-6">
<button class="btn btn-primary w-full" @click="handleLogin" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
登录
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const username = ref('admin')
const password = ref('admin123')
const loading = ref(false)
async function handleLogin() {
loading.value = true
try {
// TODO: call API
localStorage.setItem('admin_token', 'admin-token-placeholder')
router.push('/dashboard')
} catch(e) {
console.error(e)
} finally {
loading.value = false
}
}
</script>
+96
View File
@@ -0,0 +1,96 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">🏪 商家管理</h1>
<button class="btn btn-primary">+ 添加商家</button>
</div>
<!-- 搜索 -->
<div class="flex gap-2 mb-4">
<input type="text" placeholder="搜索商家名称..." class="input input-bordered flex-1" />
<select class="select select-bordered">
<option>全部状态</option>
<option>待审核</option>
<option>已批准</option>
<option>已下架</option>
</select>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>分类</th>
<th>评分</th>
<th>价格区间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(m, i) in merchants" :key="i">
<td>{{ m.id }}</td>
<td>
<div class="flex items-center gap-2">
<div class="avatar"><div class="mask mask-squircle w-10 h-10 bg-base-300"><span>{{ m.name[0] }}</span></div></div>
<span class="font-bold">{{ m.name }}</span>
</div>
</td>
<td>{{ m.category }}</td>
<td>
<div class="flex items-center gap-1">
<span>{{ m.rating }}</span>
</div>
</td>
<td>{{ m.priceRange }}</td>
<td>
<div class="badge" :class="{
'badge-success': m.status === 'approved',
'badge-warning': m.status === 'pending',
'badge-error': m.status === 'rejected',
'badge-ghost': m.status === 'offline'
}">
{{ statusLabel(m.status) }}
</div>
</td>
<td>
<button class="btn btn-ghost btn-xs">编辑</button>
<button class="btn btn-ghost btn-xs text-error">下架</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="flex justify-center mt-6">
<div class="join">
<button class="join-item btn btn-sm">«</button>
<button class="join-item btn btn-sm btn-active">1</button>
<button class="join-item btn btn-sm">2</button>
<button class="join-item btn btn-sm">3</button>
<button class="join-item btn btn-sm">»</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const merchants = ref([
{ id: 1, name: '松阪牛料理', category: '日料', rating: 4.8, priceRange: '¥200-400', status: 'approved' },
{ id: 2, name: '渝味晓宇火锅', category: '火锅', rating: 4.5, priceRange: '¥100-200', status: 'approved' },
{ id: 3, name: 'Bistro L\'Atelier', category: '西餐', rating: 4.6, priceRange: '¥300-500', status: 'approved' },
{ id: 4, name: '喜茶', category: '甜品', rating: 4.3, priceRange: '¥20-40', status: 'approved' },
{ id: 5, name: '迷雾剧场', category: '剧本杀', rating: 4.7, priceRange: '¥80-150', status: 'pending' },
{ id: 6, name: '纯K量贩KTV', category: 'KTV', rating: 4.2, priceRange: '¥50-100', status: 'approved' },
])
function statusLabel(s) {
const map = { approved: '已批准', pending: '待审核', rejected: '已拒绝', offline: '已下架' }
return map[s] || s
}
</script>
+57
View File
@@ -0,0 +1,57 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">📦 套餐管理</h1>
<button class="btn btn-primary">+ 添加套餐</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>套餐名</th>
<th>商家</th>
<th>分类</th>
<th>价格</th>
<th>评分</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="pkg in packages" :key="pkg.id">
<td>{{ pkg.id }}</td>
<td class="font-bold">{{ pkg.name }}</td>
<td>{{ pkg.merchant }}</td>
<td><span class="badge badge-ghost">{{ pkg.category }}</span></td>
<td>¥{{ pkg.priceMin }}-¥{{ pkg.priceMax }}</td>
<td> {{ pkg.rating }}</td>
<td>
<div class="badge" :class="pkg.status === 'active' ? 'badge-success' : 'badge-ghost'">
{{ pkg.status === 'active' ? '上架中' : '已下架' }}
</div>
</td>
<td>
<button class="btn btn-ghost btn-xs">编辑</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const packages = ref([
{ id: 1, name: '主厨精选7道式', merchant: '松阪牛料理', category: '日料', priceMin: 298, priceMax: 398, rating: 4.8, status: 'active' },
{ id: 2, name: '人气套餐2-3人', merchant: '松阪牛料理', category: '日料', priceMin: 598, priceMax: 798, rating: 4.5, status: 'active' },
{ id: 3, name: '双人麻辣火锅套餐', merchant: '渝味晓宇火锅', category: '火锅', priceMin: 168, priceMax: 228, rating: 4.3, status: 'active' },
{ id: 4, name: '4-6人欢聚套餐', merchant: '渝味晓宇火锅', category: '火锅', priceMin: 328, priceMax: 428, rating: 4.6, status: 'active' },
{ id: 5, name: '情侣浪漫晚餐', merchant: 'Bistro L\'Atelier', category: '西餐', priceMin: 398, priceMax: 498, rating: 4.7, status: 'active' },
{ id: 6, name: '四季新品奶茶套装', merchant: '喜茶', category: '甜品', priceMin: 35, priceMax: 45, rating: 4.2, status: 'active' },
{ id: 7, name: '沉浸式推理剧本', merchant: '迷雾剧场', category: '剧本杀', priceMin: 128, priceMax: 168, rating: 4.5, status: 'active' },
])
</script>
+184
View File
@@ -0,0 +1,184 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold"> 打分管理</h1>
<div class="flex gap-2">
<select class="select select-bordered">
<option value="">全部分类</option>
<option value="food">餐饮</option>
<option value="entertainment">娱乐</option>
<option value="shopping">购物</option>
<option value="activity">活动</option>
</select>
<select class="select select-bordered">
<option value="">全部评分</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div class="stat place-items-center">
<div class="stat-title">平均评分</div>
<div class="stat-value text-primary">4.2</div>
<div class="stat-desc">/ 5.0</div>
</div>
<div class="stat place-items-center">
<div class="stat-title">总打分数</div>
<div class="stat-value">3,847</div>
</div>
<div class="stat stat-placement place-items-center">
<div class="stat-title">5星占比</div>
<div class="stat-value text-success">45%</div>
</div>
<div class="stat stat-placement place-items-center">
<div class="stat-title">1星差评</div>
<div class="stat-value text-error">8%</div>
</div>
<div class="stat stat-placement place-items-center">
<div class="stat-title">回访率</div>
<div class="stat-value text-accent">38%</div>
</div>
</div>
<!-- 评分分布 -->
<div class="bg-base-100 rounded-xl p-4 mb-6 shadow-sm">
<h3 class="font-bold mb-3">评分分布</h3>
<div class="space-y-2">
<div class="flex items-center gap-3">
<span class="text-sm w-8">5</span>
<progress class="progress progress-success h-4" value="45" max="100"></progress>
<span class="text-sm w-12 text-right">45%</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm w-8">4</span>
<progress class="progress progress-primary h-4" value="32" max="100"></progress>
<span class="text-sm w-12 text-right">32%</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm w-8">3</span>
<progress class="progress progress-warning h-4" value="15" max="100"></progress>
<span class="text-sm w-12 text-right">15%</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm w-8">2</span>
<progress class="progress progress-error h-4" value="5" max="100"></progress>
<span class="text-sm w-12 text-right">5%</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm w-8">1</span>
<progress class="progress progress-error h-4" value="3" max="100"></progress>
<span class="text-sm w-12 text-right">3%</span>
</div>
</div>
</div>
<!-- 差评预警 -->
<div class="bg-base-100 rounded-xl p-4 mb-6 shadow-sm">
<h3 class="font-bold mb-3 text-error"> 差评预警连续低分套餐</h3>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>套餐</th>
<th>商家</th>
<th>平均评分</th>
<th>评价数</th>
<th>差评率</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr>
<td class="font-medium">日式套餐A</td>
<td>樱花园日料</td>
<td class="text-error font-bold">2.1</td>
<td>23</td>
<td>42%</td>
<td><div class="badge badge-error">需要关注</div></td>
</tr>
<tr>
<td class="font-medium">儿童套餐</td>
<td>欢乐谷亲子</td>
<td class="text-warning font-bold">2.8</td>
<td>15</td>
<td>28%</td>
<td><div class="badge badge-warning">需观察</div></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 打分列表 -->
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>用户</th>
<th>套餐</th>
<th>评分</th>
<th>口味</th>
<th>性价比</th>
<th>距离</th>
<th>回访</th>
<th>标签</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in reviews" :key="i">
<td>{{ r.id }}</td>
<td>{{ r.user }}</td>
<td>
<div class="font-medium">{{ r.package }}</div>
<div class="text-xs text-base-content/60">{{ r.merchant }}</div>
</td>
<td>
<span class="badge" :class="ratingBadgeClass(r.rating)">
{{ r.rating }}
</span>
</td>
<td>{{ r.taste }}/5</td>
<td>{{ r.value }}/5</td>
<td>{{ r.distance }}/5</td>
<td>
<span v-if="r.isRepeat" class="text-success"> 会去</span>
<span v-else class="text-base-content/40"></span>
</td>
<td>
<span v-for="tag in r.tags" :key="tag" class="badge badge-ghost badge-sm mr-1">{{ tag }}</span>
</td>
<td class="text-sm">{{ r.time }}</td>
<td><button class="btn btn-ghost btn-xs">详情</button></td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const reviews = ref([
{ id: 1, user: '小明同学', package: '和风定食套餐', merchant: '樱花园日料', rating: 5, taste: 5, value: 4, distance: 4, isRepeat: true, tags: ['口味赞', '值得再来'], time: '2026-06-05 14:32' },
{ id: 2, user: '吃货王', package: '麻辣火锅双人餐', merchant: '蜀香沸腾', rating: 4, taste: 4, value: 4, distance: 3, isRepeat: true, tags: ['性价比', '好吃'], time: '2026-06-05 12:18' },
{ id: 3, user: 'Luna', package: '下午茶套餐', merchant: '花间堂咖啡', rating: 2, taste: 2, value: 1, distance: 3, isRepeat: false, tags: ['太贵', '味道一般'], time: '2026-06-04 20:05' },
{ id: 4, user: '阿杰', package: '和牛烧肉套餐', merchant: '炭烧屋', rating: 5, taste: 5, value: 4, distance: 5, isRepeat: true, tags: ['肉质好', '环境赞'], time: '2026-06-04 18:22' },
{ id: 5, user: '探店达人', package: '烧烤拼盘', merchant: '夜来香烧烤', rating: 3, taste: 3, value: 3, distance: 2, isRepeat: false, tags: ['一般般'], time: '2026-06-04 22:45' },
])
function ratingBadgeClass(rating) {
if (rating >= 4) return 'badge-success'
if (rating === 3) return 'badge-warning'
return 'badge-error'
}
</script>
+240
View File
@@ -0,0 +1,240 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">📈 数据报表</h1>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm">
📊 用户增长报表
</button>
<button class="btn btn-secondary btn-sm">
💰 收入报表
</button>
<button class="btn btn-accent btn-sm">
📤 导出CSV
</button>
</div>
</div>
<!-- 核心指标 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="stats shadow">
<div class="stat place-items-center">
<div class="stat-title">注册用户</div>
<div class="stat-value text-primary">12,847</div>
<div class="stat-desc">📈 +12.5% 较上月</div>
</div>
</div>
<div class="stats shadow">
<div class="stat place-items-center">
<div class="stat-title">月活跃用户</div>
<div class="stat-value">5,234</div>
<div class="stat-desc">活跃率 40.7%</div>
</div>
</div>
<div class="stats shadow">
<div class="stat place-items-center">
<div class="stat-title">月盲选次数</div>
<div class="stat-value text-secondary">34,892</div>
<div class="stat-desc">人均 6.7</div>
</div>
</div>
<div class="stats shadow">
<div class="stat place-items-center">
<div class="stat-title">月收入</div>
<div class="stat-value text-accent">¥89,400</div>
<div class="stat-desc">会员¥67.2K · 佣金¥22.2K</div>
</div>
</div>
</div>
<!-- 转化漏斗 -->
<div class="bg-base-100 rounded-xl p-6 mb-6 shadow-sm">
<h3 class="font-bold mb-4">转化漏斗本月</h3>
<div class="space-y-3">
<div>
<div class="flex justify-between text-sm mb-1">
<span>注册用户</span><span class="font-bold">1,247</span>
</div>
<progress class="progress progress-primary h-6" value="100" max="100"></progress>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span>完成首次盲选</span><span class="font-bold">892 <span class="text-success">(71.5%)</span></span>
</div>
<progress class="progress progress-primary h-6" value="71.5" max="100"></progress>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span>提交首次打分</span><span class="font-bold">534 <span class="text-success">(59.9%)</span></span>
</div>
<progress class="progress progress-primary h-6" value="59.9" max="100"></progress>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span>首次盲选后7日内复盲选</span><span class="font-bold">312 <span class="text-success">(58.5%)</span></span>
</div>
<progress class="progress progress-primary h-6" value="35" max="100"></progress>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span>开通VIP会员</span><span class="font-bold text-secondary">52 <span class="text-secondary">(16.7%)</span></span>
</div>
<progress class="progress progress-secondary h-6" value="16.7" max="100"></progress>
</div>
</div>
</div>
<!-- 分类热门排行 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-base-100 rounded-xl p-4 shadow-sm">
<h3 class="font-bold mb-3">🏆 套餐被选TOP10</h3>
<table class="table table-sm">
<thead>
<tr>
<th>#</th>
<th>套餐</th>
<th>被选次数</th>
<th>平均评分</th>
</tr>
</thead>
<tbody>
<tr v-for="(p, i) in topPackages" :key="i">
<td>
<span v-if="i < 3" class="text-lg">{{ ['🥇','🥈','🥉'][i] }}</span>
<span v-else class="text-base-content/40">{{ i + 1 }}</span>
</td>
<td class="font-medium">{{ p.name }}</td>
<td class="font-bold">{{ p.count }}</td>
<td>
<span :class="p.rating >= 4 ? 'text-success' : p.rating >= 3 ? 'text-warning' : 'text-error'">
{{ p.rating }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="bg-base-100 rounded-xl p-4 shadow-sm">
<h3 class="font-bold mb-3">🏪 商家质量排名</h3>
<table class="table table-sm">
<thead>
<tr>
<th>#</th>
<th>商家</th>
<th>评分</th>
<th>评价数</th>
<th>回访率</th>
</tr>
</thead>
<tbody>
<tr v-for="(m, i) in topMerchants" :key="i">
<td>{{ i + 1 }}</td>
<td class="font-medium">{{ m.name }}</td>
<td class="font-bold text-success">{{ m.rating }}</td>
<td>{{ m.reviews }}</td>
<td>{{ m.repeatRate }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分类统计 -->
<div class="bg-base-100 rounded-xl p-4 mb-6 shadow-sm">
<h3 class="font-bold mb-4">📊 分类统计</h3>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>分类</th>
<th>被选次数</th>
<th>平均评分</th>
<th>平均价格</th>
<th>好评率</th>
<th>趋势</th>
</tr>
</thead>
<tbody>
<tr v-for="(c, i) in categories" :key="i">
<td>
<div class="flex items-center gap-2">
<span>{{ c.icon }}</span>
<span class="font-medium">{{ c.name }}</span>
</div>
</td>
<td class="font-bold">{{ c.count }}</td>
<td>
<span :class="c.rating >= 4 ? 'text-success' : 'text-warning'">{{ c.rating }}</span>
</td>
<td>¥{{ c.avgPrice }}</td>
<td>
<progress class="progress progress-success h-4" :value="c.goodRate" max="100"></progress>
<span class="text-xs ml-2">{{ c.goodRate }}%</span>
</td>
<td>
<span :class="c.trend > 0 ? 'text-success' : 'text-error'">
{{ c.trend > 0 ? '↑' : '↓' }} {{ Math.abs(c.trend) }}%
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 留存分析 -->
<div class="bg-base-100 rounded-xl p-4 shadow-sm">
<h3 class="font-bold mb-4">📅 用户留存分析</h3>
<div class="grid grid-cols-3 gap-4">
<div class="text-center p-4 bg-base-200 rounded-lg">
<div class="text-3xl font-bold text-primary">72%</div>
<div class="text-sm text-base-content/60 mt-1">次日留存</div>
<div class="text-xs text-success mt-1"> 3.2% 较上月</div>
</div>
<div class="text-center p-4 bg-base-200 rounded-lg">
<div class="text-3xl font-bold text-secondary">45%</div>
<div class="text-sm text-base-content/60 mt-1">7日留存</div>
<div class="text-xs text-success mt-1"> 1.8% 较上月</div>
</div>
<div class="text-center p-4 bg-base-200 rounded-lg">
<div class="text-3xl font-bold text-accent">32%</div>
<div class="text-sm text-base-content/60 mt-1">30日留存</div>
<div class="text-xs text-success mt-1"> 2.1% 较上月</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const topPackages = ref([
{ name: '和风定食套餐', count: 1243, rating: 4.6 },
{ name: '麻辣火锅双人餐', count: 1187, rating: 4.3 },
{ name: '日式烧肉套餐', count: 986, rating: 4.5 },
{ name: '下午茶甜品套餐', count: 875, rating: 4.1 },
{ name: '意式西餐套餐', count: 754, rating: 4.2 },
{ name: '韩式炸鸡套餐', count: 689, rating: 3.9 },
{ name: '甜品蛋糕套餐', count: 612, rating: 4.4 },
{ name: '轻食沙拉套餐', count: 543, rating: 4.0 },
{ name: '烧烤拼盘套餐', count: 498, rating: 3.7 },
{ name: '奶茶饮品套餐', count: 467, rating: 4.3 },
])
const topMerchants = ref([
{ name: '樱花园日料', rating: 4.8, reviews: 234, repeatRate: '68%' },
{ name: '蜀香沸腾火锅', rating: 4.6, reviews: 198, repeatRate: '62%' },
{ name: '炭烧屋烧肉', rating: 4.5, reviews: 176, repeatRate: '58%' },
{ name: '花间堂咖啡', rating: 4.4, reviews: 156, repeatRate: '55%' },
{ name: '快乐番薯', rating: 4.3, reviews: 145, repeatRate: '52%' },
])
const categories = ref([
{ icon: '🍜', name: '餐饮', count: 24567, rating: 4.2, avgPrice: 86, goodRate: 78, trend: 12 },
{ icon: '🎮', name: '娱乐', count: 5843, rating: 4.0, avgPrice: 128, goodRate: 72, trend: 8 },
{ icon: '🛍️', name: '购物', count: 2976, rating: 3.8, avgPrice: 256, goodRate: 65, trend: -3 },
{ icon: '🎭', name: '活动', count: 1506, rating: 4.1, avgPrice: 188, goodRate: 74, trend: 15 },
])
</script>
+107
View File
@@ -0,0 +1,107 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">👥 用户管理</h1>
<div class="flex gap-2">
<input type="text" placeholder="搜索用户..." class="input input-bordered" />
<select class="select select-bordered">
<option>全部</option>
<option>VIP</option>
<option>免费</option>
</select>
</div>
</div>
<!-- 统计 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="stats shadow">
<div class="stat place-items-center">
<div class="stat-title">总用户</div>
<div class="stat-value text-primary">12,847</div>
<div class="stat-desc">今日 +156</div>
</div>
</div>
<div class="stats shadow">
<div class="stat place-items-center">
<div class="stat-title">付费会员</div>
<div class="stat-value text-secondary">523</div>
<div class="stat-desc">转化率 4.1%</div>
</div>
</div>
<div class="stats shadow">
<div class="stat place-items-center">
<div class="stat-title">日活</div>
<div class="stat-value text-accent">1,234</div>
<div class="stat-desc">较昨日 +5.2%</div>
</div>
</div>
<div class="stats shadow">
<div class="stat place-items-center">
<div class="stat-title">30日留存</div>
<div class="stat-value">32%</div>
<div class="stat-desc">稳定</div>
</div>
</div>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>用户</th>
<th>手机号</th>
<th>会员</th>
<th>盲选次数</th>
<th>标签</th>
<th>注册时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(u, i) in users" :key="i">
<td>{{ u.id }}</td>
<td>
<div class="flex items-center gap-2">
<div class="avatar">
<div class="mask mask-squircle w-10 h-10 bg-primary text-white flex items-center justify-center">
{{ u.nickname[0] }}
</div>
</div>
<div>
<div class="font-bold">{{ u.nickname }}</div>
</div>
</div>
</td>
<td>{{ u.phone }}</td>
<td>
<div class="badge" :class="u.member ? 'badge-success' : 'badge-ghost'">
{{ u.member ? 'VIP' : '免费' }}
</div>
</td>
<td>{{ u.blindCount }}</td>
<td>
<span class="badge badge-ghost badge-sm" v-for="tag in u.tags" :key="tag">{{ tag }}</span>
</td>
<td class="text-sm">{{ u.time }}</td>
<td>
<button class="btn btn-ghost btn-xs">详情</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const users = ref([
{ id: 1, nickname: '小明同学', phone: '138****1234', member: true, blindCount: 52, tags: ['日料控', '品质型'], time: '2026-05-20' },
{ id: 2, nickname: '吃货王', phone: '139****5678', member: false, blindCount: 18, tags: ['性价比', '火锅爱好者'], time: '2026-06-01' },
{ id: 3, nickname: '探店达人', phone: '137****9012', member: true, blindCount: 89, tags: ['探索型', '全品类'], time: '2026-04-15' },
{ id: 4, nickname: 'Luna', phone: '136****3456', member: false, blindCount: 7, tags: ['甜品控'], time: '2026-06-05' },
{ id: 5, nickname: '阿杰', phone: '135****7890', member: true, blindCount: 124, tags: ['日料狂魔', '周末达人'], time: '2026-03-10' },
])
</script>
+34
View File
@@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{vue,js,ts}'],
theme: {
extend: {
colors: {
primary: '#FF6B35',
secondary: '#FF8C42',
}
}
},
plugins: [
require('daisyui')
],
daisyui: {
themes: [
{
blind: {
"primary": "#FF6B35",
"secondary": "#FF8C42",
"accent": "#667eea",
"neutral": "#1a1a2e",
"base-100": "#ffffff",
"base-200": "#f5f5f5",
"base-300": "#e8e8e8",
"info": "#2196f3",
"success": "#4caf50",
"warning": "#ff9800",
"error": "#f44336",
}
}
]
}
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
+112
View File
@@ -0,0 +1,112 @@
<script>
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default {
globalData: {
token: '',
userInfo: null
},
onLaunch() {
console.log('App launched')
},
onShow() {
const token = uni.getStorageSync('token')
if (!token) {
// 没有 token → 跳转到登录页
uni.redirectTo({ url: '/pages/login/login' })
}
}
}
</script>
<style lang="scss">
/* 全局样式 */
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', sans-serif;
font-size: 28rpx;
color: #333;
}
/* 通用 */
.container {
padding: 24rpx;
}
/* 卡片 */
.card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
}
/* 按钮 */
.btn-primary {
background: linear-gradient(135deg, #FF6B35, #FF8C42);
color: #fff;
border: none;
border-radius: 40rpx;
font-size: 32rpx;
font-weight: 600;
padding: 20rpx 40rpx;
}
.btn-outline {
background: transparent;
color: #FF6B35;
border: 2rpx solid #FF6B35;
border-radius: 40rpx;
font-size: 28rpx;
padding: 16rpx 32rpx;
}
/* 评分 */
.stars {
display: flex;
gap: 8rpx;
}
.star {
font-size: 48rpx;
color: #ddd;
&.active {
color: #FFD700;
}
}
/* 标签 */
.tag {
display: inline-block;
padding: 6rpx 16rpx;
background: #FFF3E0;
color: #FF6B35;
border-radius: 20rpx;
font-size: 22rpx;
margin-right: 10rpx;
margin-bottom: 8rpx;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
color: #999;
.empty-icon {
font-size: 120rpx;
margin-bottom: 20rpx;
}
.empty-text {
font-size: 28rpx;
}
}
</style>
+122
View File
@@ -0,0 +1,122 @@
// API 配置
// 开发环境: 指向本地后端
// 小程序生产: 指向你的 HTTPS 域名
// #ifdef MP-WEIXIN
// 微信小程序环境 → 生产域名 (替换为你自己的域名)
const BASE_URL = 'https://api.yourdomain.com/api/v1'
// #endif
// #ifdef H5
// H5 环境 → 本地或生产
const BASE_URL = 'http://localhost:8080/api/v1'
// #endif
// #ifndef MP-WEIXIN && !H5
// 其他平台 (APP等)
const BASE_URL = 'http://localhost:8080/api/v1'
// #endif
// 请求封装
function request(url, method = 'GET', data = null) {
const token = uni.getStorageSync('token')
return new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + url,
method,
data,
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
if (res.statusCode === 200 && res.data.code === 2) {
resolve(res.data.data)
} else if (res.statusCode === 401) {
// Token 过期 → 尝试刷新
refreshAndRetry(url, method, data).then(resolve).catch(reject)
} else {
uni.showToast({
title: res.data.message || '请求失败',
icon: 'none',
duration: 1500
})
reject(new Error(res.data.message || 'request failed'))
}
},
fail: (err) => {
uni.showToast({ title: '网络错误', icon: 'none' })
reject(err)
}
})
})
}
// Token 刷新后重试
async function refreshAndRetry(url, method, data) {
const refreshToken = uni.getStorageSync('refresh_token')
if (!refreshToken) {
// 没有 refresh token → 跳转登录
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
uni.showToast({ title: '请重新登录', icon: 'none' })
uni.reLaunch({ url: '/pages/login/login' })
throw new Error('unauthorized')
}
try {
const authApi = (await import('@/api/index.js')).authApi
const res = await authApi.refreshToken({ refresh_token: refreshToken })
uni.setStorageSync('token', res.token)
// 重试原请求
return request(url, method, data)
} catch (e) {
// 刷新也失败 → 跳转登录
uni.removeStorageSync('token')
uni.removeStorageSync('refresh_token')
uni.removeStorageSync('userInfo')
uni.reLaunch({ url: '/pages/login/login' })
throw e
}
}
// ============== Auth ==============
export const authApi = {
register: (data) => request('/auth/register', 'POST', data),
login: (data) => request('/auth/login', 'POST', data),
wechatLogin: (data) => request('/auth/wechat/login', 'POST', data),
refreshToken: (data) => request('/auth/refresh', 'POST', data),
getProfile: () => request('/user/profile', 'GET'),
updateProfile: (data) => request('/user/profile', 'PUT', data),
}
// ============== Blind Selection ==============
export const blindApi = {
getCategories: () => request('/blind/categories', 'GET'),
getPool: (params) => request('/blind/pool?' + new URLSearchParams(params).toString(), 'GET'),
choose: (data) => request('/blind/choose', 'POST', data),
getResult: (id) => request(`/blind/result/${id}`, 'GET'),
getHistory: () => request('/blind/history', 'GET'),
}
// ============== Review ==============
export const reviewApi = {
submit: (data) => request('/review/submit', 'POST', data),
getStats: () => request('/review/stats', 'GET'),
}
// ============== Coupon ==============
export const couponApi = {
getMyCoupons: () => request('/coupon/list', 'GET'),
getAvailable: () => request('/coupon/available', 'GET'),
claim: (data) => request('/coupon/claim', 'POST', data),
}
// ============== Member ==============
export const memberApi = {
getStatus: () => request('/member/status', 'GET'),
getPlans: () => request('/member/plans', 'GET'),
subscribe: (data) => request('/member/subscribe', 'POST', data),
}
+12
View File
@@ -0,0 +1,12 @@
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
return { app }
}
+17
View File
@@ -0,0 +1,17 @@
{
"description": "帮我选应用",
"name": "帮我选",
"appid": "",
"distribute": {
"icons": {
"android": {
"hdpi": "static/icons/hdpi.png",
"xhdpi": "static/icons/xhdpi.png",
"xxhdpi": "static/icons/xxhdpi.png"
}
},
"splash": {
"type": "wdp"
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "blind-select-app",
"version": "1.0.0",
"description": "帮我选 - 盲选应用",
"dcloud": {
"appid": "__UNI__BLIND_SELECT",
"packages": {
"app-plus": {},
"mp-weixin": {},
"h5": {}
}
},
"dependencies": {
"vue": "^3.4.0",
"@dcloudio/uni-app": "^3.0.0",
"@dcloudio/uni-ui": "^1.5.0",
"pinia": "^2.2.0",
"pinia-plugin-persistedstate": "^3.2.0"
},
"devDependencies": {
"@dcloudio/types": "^3.0.0",
"@dcloudio/uni-automator": "^3.0.0",
"@dcloudio/uni-cli-shared": "^3.0.0",
"@dcloudio/vite-plugin-uni": "^3.0.0",
"sass": "^1.77.0",
"sass-loader": "^14.2.0"
}
}
+125
View File
@@ -0,0 +1,125 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
},
"pages": [
{
"path": "pages/login/login",
"style": { "navigationBarTitleText": "登录" }
},
{
"path": "pages/index/index",
"style": { "navigationBarTitleText": "帮我选", "navigationStyle": "custom" }
},
{
"path": "pages/blind/blind",
"style": { "navigationBarTitleText": "开始盲选" }
},
{
"path": "pages/blind/result",
"style": { "navigationBarTitleText": "揭晓结果" }
},
{
"path": "pages/blind/review",
"style": { "navigationBarTitleText": "打分评价" }
},
{
"path": "pages/coupon/list",
"style": { "navigationBarTitleText": "我的优惠券" }
},
{
"path": "pages/member/member",
"style": { "navigationBarTitleText": "会员中心" }
},
{
"path": "pages/user/profile",
"style": { "navigationBarTitleText": "个人中心" }
},
{
"path": "pages/user/preferences",
"style": { "navigationBarTitleText": "我的偏好" }
},
{
"path": "pages/user/history",
"style": { "navigationBarTitleText": "盲选记录" }
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "帮我选",
"navigationBarBackgroundColor": "#FF6B35",
"backgroundColor": "#F8F8F8",
"backgroundTextStyle": "light"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#FF6B35",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "static/tab-home.png",
"selectedIconPath": "static/tab-home-active.png"
},
{
"pagePath": "pages/coupon/list",
"text": "优惠券",
"iconPath": "static/tab-coupon.png",
"selectedIconPath": "static/tab-coupon-active.png"
},
{
"pagePath": "pages/member/member",
"text": "会员",
"iconPath": "static/tab-vip.png",
"selectedIconPath": "static/tab-vip-active.png"
},
{
"pagePath": "pages/user/profile",
"text": "我的",
"iconPath": "static/tab-user.png",
"selectedIconPath": "static/tab-user-active.png"
}
]
},
"permission": {
"scope.userLocation": {
"desc": "用于计算推荐商家距离"
}
},
"mp-weixin": {
"appid": "YOUR_WECHAT_APPID",
"setting": {
"urlCheck": false,
"es6": true,
"postcss": true,
"minified": true
},
"usingComponents": true,
"optimization": {
"subPackages": true
},
"permission": {
"scope.userLocation": {
"desc": "用于计算推荐商家距离"
}
},
"requiredPrivateInfos": [
"getLocation",
"chooseAddress"
]
},
"h5": {
"devServer": {
"port": 3000
},
"title": "帮我选",
"router": {
"mode": "hash"
}
}
}
+366
View File
@@ -0,0 +1,366 @@
<template>
<view class="page-blind">
<!-- 头部 -->
<view class="blind-header">
<view class="back-btn" @click="goBack">
<text> 返回</text>
</view>
<text class="blind-title">{{ categoryIcon }} {{ categoryName }}</text>
</view>
<!-- 价格区间选择 -->
<view class="price-section card">
<text class="section-label">选择价格区间</text>
<view class="price-options">
<view
class="price-option"
:class="{ active: selectedPrice === p.value }"
v-for="p in priceRanges"
:key="p.value"
@click="selectedPrice = p.value"
>
<text>{{ p.label }}</text>
</view>
</view>
</view>
<!-- 盲选卡片 -->
<view class="blind-card-container">
<view class="blind-card" :class="{ spinning: isSpinning, revealed: isRevealed }">
<view class="card-front" v-if="!isRevealed">
<text class="card-logo">🎲</text>
<text class="card-hint">点击按钮开始盲选</text>
</view>
<view class="card-reveal" v-if="isRevealed && selectedResult">
<text class="reveal-icon"></text>
<text class="reveal-name">{{ selectedResult.merchant_name }}</text>
<text class="reveal-desc">{{ selectedResult.description }}</text>
<text class="reveal-price">¥{{ selectedResult.price_range }}</text>
<view class="reveal-tags">
<text class="reveal-tag" v-for="tag in resultTags" :key="tag">{{ tag }}</text>
</view>
<text class="reveal-match">🎯 匹配度 {{ (selectedResult.match_score * 100).toFixed(0) }}%</text>
<view class="reveal-coupon" v-if="selectedResult.has_coupon">
<text class="coupon-icon">🎁</text>
<text class="coupon-text">{{ selectedResult.coupon_value || '有优惠券!' }}</text>
</view>
</view>
</view>
</view>
<!-- 盲选按钮 -->
<view class="action-area" v-if="!isRevealed">
<button class="blind-btn" :loading="isSpinning" @click="startBlind" :disabled="isSpinning">
<text class="blind-btn-text">{{ isSpinning ? '正在抽取...' : '🎲 开始盲选' }}</text>
</button>
</view>
<!-- 结果操作 -->
<view class="result-actions" v-if="isRevealed && selectedResult">
<button class="accept-btn" @click="acceptResult">
<text> 接受 / 前往</text>
</button>
<button class="skip-btn" @click="declineResult">
<text>🔄 换一个</text>
</button>
<button class="star-btn" @click="goToReview">
<text> 已去过 · 打分</text>
</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from '@dcloudio/uni-app'
import { blindApi } from '@/api/index.js'
const route = useRoute()
const categoryName = ref(route.query.categoryName || '盲选')
const categoryIcon = ref(route.query.categoryIcon || '🍽️')
const packageId = ref(route.query.packageId || 0)
const categoryId = ref(parseInt(route.query.categoryId) || 1)
const selectedPrice = ref('100-300')
const isSpinning = ref(false)
const isRevealed = ref(false)
const selectedResult = ref(null)
const priceRanges = [
{ label: '¥100以内', value: '0-100' },
{ label: '¥100-300', value: '100-300' },
{ label: '¥300-500', value: '300-500' },
{ label: '¥500+', value: '500-9999' },
]
const resultTags = ['推荐', '好评', '特色']
async function startBlind() {
isSpinning.value = true
// 1秒动画延迟
await new Promise(r => setTimeout(r, 1000))
try {
// 调用盲选API
const priceMap = { '100-300': '100-300', '0-100': '0-100', '300-500': '300-500', '500-9999': '500-1000' }
const data = await blindApi.choose({
category_id: categoryId.value,
price_range: priceMap[selectedPrice.value] || '100-300',
})
// 显示结果
selectedResult.value = data.result
isRevealed.value = true
} catch (e) {
uni.showToast({ title: '盲选失败,请重试', icon: 'none' })
} finally {
isSpinning.value = false
}
}
function acceptResult() {
uni.showToast({ title: '已前往!', icon: 'success' })
goBack()
}
function declineResult() {
// 换一个
isRevealed.value = false
selectedResult.value = null
startBlind()
}
function goToReview() {
uni.navigateTo({
url: `/pages/blind/review?sessionId=${selectedResult.value?.session_id || 1}&packageId=${packageId.value || 1}`
})
}
function goBack() {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
.page-blind {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 40rpx;
}
.blind-header {
background: linear-gradient(135deg, #FF6B35, #FF8C42);
padding: 40rpx 30rpx;
color: #fff;
.back-btn {
font-size: 26rpx;
opacity: 0.8;
margin-bottom: 10rpx;
}
.blind-title {
font-size: 36rpx;
font-weight: 700;
display: block;
}
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin: 20rpx 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
}
.section-label {
font-size: 28rpx;
font-weight: 600;
display: block;
margin-bottom: 16rpx;
}
.price-options {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.price-option {
padding: 12rpx 24rpx;
border: 2rpx solid #eee;
border-radius: 30rpx;
font-size: 24rpx;
color: #666;
&.active {
border-color: #FF6B35;
color: #FF6B35;
background: #FFF3E0;
}
}
.blind-card-container {
display: flex;
justify-content: center;
padding: 40rpx 0;
}
.blind-card {
width: 560rpx;
height: 400rpx;
border-radius: 30rpx;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 20rpx 60rpx rgba(102,126,234,0.3);
transition: transform 0.6s, opacity 0.6s;
&.spinning {
animation: spin 1s ease-in-out;
}
&.revealed {
transform: scale(1);
}
}
@keyframes spin {
0% { transform: rotateY(0deg) scale(1); }
50% { transform: rotateY(90deg) scale(0.9); }
100% { transform: rotateY(360deg) scale(1); }
}
.card-front {
text-align: center;
.card-logo {
font-size: 100rpx;
display: block;
margin-bottom: 20rpx;
}
.card-hint {
font-size: 28rpx;
opacity: 0.8;
}
}
.card-reveal {
text-align: center;
padding: 30rpx;
.reveal-icon {
font-size: 48rpx;
display: block;
margin-bottom: 10rpx;
}
.reveal-name {
font-size: 36rpx;
font-weight: 700;
display: block;
margin-bottom: 12rpx;
}
.reveal-desc {
font-size: 24rpx;
opacity: 0.9;
display: block;
margin-bottom: 16rpx;
line-height: 1.5;
}
.reveal-price {
font-size: 32rpx;
font-weight: 600;
background: rgba(255,255,255,0.2);
padding: 8rpx 24rpx;
border-radius: 20rpx;
display: inline-block;
margin-bottom: 16rpx;
}
.reveal-match {
font-size: 22rpx;
opacity: 0.8;
display: block;
margin-bottom: 12rpx;
}
}
.reveal-tags {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 8rpx;
margin-bottom: 12rpx;
}
.reveal-tag {
background: rgba(255,255,255,0.2);
padding: 4rpx 14rpx;
border-radius: 14rpx;
font-size: 20rpx;
}
.reveal-coupon {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
background: rgba(255,215,0,0.3);
padding: 10rpx 20rpx;
border-radius: 12rpx;
margin-top: 12rpx;
.coupon-icon { font-size: 24rpx; }
.coupon-text { font-size: 22rpx; color: #FFD700; }
}
.action-area {
text-align: center;
padding: 20rpx;
}
.blind-btn {
background: linear-gradient(135deg, #FF6B35, #FF8C42);
color: #fff;
border: none;
border-radius: 50rpx;
padding: 24rpx 60rpx;
font-size: 36rpx;
font-weight: 700;
box-shadow: 0 8rpx 30rpx rgba(255,107,53,0.4);
}
.blind-btn:disabled {
opacity: 0.7;
}
.result-actions {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 0 40rpx;
}
.accept-btn {
background: linear-gradient(135deg, #4CAF50, #66BB6A);
color: #fff;
border: none;
border-radius: 30rpx;
font-size: 30rpx;
font-weight: 600;
}
.skip-btn {
background: #fff;
color: #666;
border: 2rpx solid #ddd;
border-radius: 30rpx;
font-size: 28rpx;
}
.star-btn {
background: transparent;
color: #FFD700;
border: 2rpx solid #FFD700;
border-radius: 30rpx;
font-size: 28rpx;
}
</style>
+130
View File
@@ -0,0 +1,130 @@
<template>
<view class="page-result">
<view class="result-header">
<text class="result-icon">🎉</text>
<text class="result-title">揭晓时刻</text>
</view>
<view class="result-card card" v-if="result">
<text class="result-name">{{ result.package_name }}</text>
<text class="result-merchant">📍 {{ result.merchant_name }}</text>
<text class="result-desc">{{ result.description }}</text>
<text class="result-price">💰 ¥{{ result.price_range }}</text>
<text class="result-match">🎯 匹配度 {{ (result.match_score * 100).toFixed(0) }}%</text>
</view>
<view class="actions">
<button class="nav-btn" @click="startNav">📍 一键导航</button>
<button class="fav-btn" @click="collect"> 收藏</button>
<button class="share-btn" @click="share">📤 分享</button>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from '@dcloudio/uni-app'
import { blindApi } from '@/api/index.js'
const route = useRoute()
const result = ref(null)
onMounted(async () => {
const id = route.query.id
if (id) {
try {
result.value = await blindApi.getResult(id)
} catch(e) {}
}
})
function startNav() {
uni.openLocation({ latitude: 39.9042, longitude: 116.4074, name: result.value?.merchant_name })
}
function collect() {
uni.showToast({ title: '已收藏', icon: 'success' })
}
function share() {
uni.showToast({ title: '分享功能开发中', icon: 'none' })
}
</script>
<style lang="scss" scoped>
.page-result {
min-height: 100vh;
background: #f5f5f5;
}
.result-header {
background: linear-gradient(135deg, #FFD700, #FF8C00);
padding: 80rpx 30rpx 50rpx;
text-align: center;
color: #fff;
.result-icon { font-size: 80rpx; display: block; }
.result-title { font-size: 36rpx; font-weight: 700; }
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin: 30rpx 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
text-align: center;
}
.result-name {
font-size: 40rpx;
font-weight: 700;
display: block;
}
.result-merchant {
font-size: 26rpx;
color: #999;
display: block;
margin-top: 8rpx;
}
.result-desc {
font-size: 26rpx;
color: #666;
display: block;
margin-top: 12rpx;
line-height: 1.6;
}
.result-price {
font-size: 32rpx;
color: #FF6B35;
font-weight: 600;
display: block;
margin-top: 20rpx;
}
.result-match {
font-size: 24rpx;
color: #999;
display: block;
margin-top: 12rpx;
}
.actions {
display: flex;
gap: 16rpx;
padding: 20rpx 24rpx;
}
.nav-btn, .fav-btn, .share-btn {
flex: 1;
border-radius: 30rpx;
font-size: 24rpx;
border: none;
}
.nav-btn { background: #4CAF50; color: #fff; }
.fav-btn { background: #fff; color: #FFD700; border: 2rpx solid #FFD700; }
.share-btn { background: #2196F3; color: #fff; }
</style>
+322
View File
@@ -0,0 +1,322 @@
<template>
<view class="page-review">
<view class="review-header">
<text class="review-title"> 打分评价</text>
<text class="review-subtitle">你对这次盲选体验满意吗</text>
</view>
<!-- 商家名 -->
<view class="merchant-info card">
<text class="merchant-name">{{ packageName }}</text>
<text class="merchant-shop">{{ shopName }}</text>
</view>
<!-- 总体评分 -->
<view class="overall-rating card">
<text class="section-label">你打几分</text>
<view class="overall-stars">
<text
v-for="i in 5" :key="i"
class="star"
:class="{ active: overallRating >= i }"
@click="overallRating = i"
></text>
</view>
<text class="rating-label" v-if="overallRating">{{ ratingLabels[overallRating - 1] }}</text>
</view>
<!-- 维度评分 -->
<view class="dimensions card">
<text class="section-label">维度评分</text>
<view class="dimension-row" v-for="dim in dimensions" :key="dim.key">
<text class="dim-icon">{{ dim.icon }}</text>
<text class="dim-label">{{ dim.label }}</text>
<view class="dim-stars">
<text
v-for="i in 5" :key="i"
class="star small"
:class="{ active: dimensionScores[dim.key] >= i }"
@click="setDimScore(dim.key, i)"
></text>
</view>
</view>
</view>
<!-- 标签选择 -->
<view class="tags-section card">
<text class="section-label">怎么形容这次体验</text>
<view class="tags">
<view
class="tag-btn"
:class="{ active: selectedTags.includes(tag) }"
v-for="tag in allTags"
:key="tag"
@click="toggleTag(tag)"
>
<text>{{ tag }}</text>
</view>
</view>
</view>
<!-- 备注 -->
<view class="note-section card">
<text class="section-label">备注选填</text>
<textarea
class="note-textarea"
placeholder="写点什么..."
v-model="noteText"
maxlength="200"
></textarea>
</view>
<!-- 再次前往 -->
<view class="repeat-section card">
<view class="repeat-option" @click="isRepeat = !isRepeat">
<text class="repeat-icon">{{ isRepeat ? '🔁' : '○' }}</text>
<text class="repeat-text">我还会再来</text>
<text class="repeat-hint">这个选项对你的推荐权重影响很大</text>
</view>
</view>
<!-- 提交 -->
<view class="submit-area">
<button class="submit-btn" @click="submitReview" :disabled="overallRating === 0">
提交评价
</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute } from '@dcloudio/uni-app'
import { reviewApi } from '@/api/index.js'
const route = useRoute()
const sessionId = ref(parseInt(route.query.sessionId) || 1)
const packageId = ref(parseInt(route.query.packageId) || 1)
const packageName = ref('神秘套餐')
const shopName = ref('某某商家')
const overallRating = ref(0)
const dimensionScores = ref({ taste: 0, value: 0, distance: 0, match: 0 })
const selectedTags = ref([])
const noteText = ref('')
const isRepeat = ref(false)
const ratingLabels = ['太失望了', '不太好', '还行', '还不错', '非常满意!']
const dimensions = [
{ key: 'taste', label: '口味', icon: '🍽️' },
{ key: 'value', label: '性价比', icon: '💰' },
{ key: 'distance', label: '距离', icon: '📍' },
{ key: 'match', label: '符合预期', icon: '🎯' },
]
const allTags = [
'口味赞', '环境好', '性价比高', '值得再来', '服务棒',
'踩雷', '太贵', '难吃', '远', '分量少', '排队久', '适合约会',
]
function setDimScore(key, score) {
dimensionScores.value[key] = score
}
function toggleTag(tag) {
const idx = selectedTags.value.indexOf(tag)
if (idx >= 0) {
selectedTags.value.splice(idx, 1)
} else {
selectedTags.value.push(tag)
}
}
async function submitReview() {
if (overallRating.value === 0) {
uni.showToast({ title: '请先打分', icon: 'none' })
return
}
try {
await reviewApi.submit({
session_id: sessionId.value,
package_id: packageId.value,
rating: overallRating.value,
taste: dimensionScores.value.taste,
value: dimensionScores.value.value,
distance: dimensionScores.value.distance,
match: dimensionScores.value.match,
tags: selectedTags.value,
text: noteText.value,
is_repeat: isRepeat.value,
})
uni.showToast({ title: '评价已提交!这会影响下次推荐哦~', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch (e) {
uni.showToast({ title: '提交失败', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page-review {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 100rpx;
}
.review-header {
background: linear-gradient(135deg, #FFD700, #FFA500);
padding: 60rpx 30rpx 40rpx;
color: #fff;
text-align: center;
.review-title {
font-size: 36rpx;
font-weight: 700;
display: block;
}
.review-subtitle {
font-size: 24rpx;
opacity: 0.9;
display: block;
margin-top: 8rpx;
}
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin: 20rpx 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
}
.merchant-info {
text-align: center;
.merchant-name {
font-size: 32rpx;
font-weight: 700;
display: block;
}
.merchant-shop {
font-size: 24rpx;
color: #999;
display: block;
margin-top: 4rpx;
}
}
.section-label {
font-size: 28rpx;
font-weight: 600;
display: block;
margin-bottom: 16rpx;
}
.overall-stars {
display: flex;
justify-content: center;
gap: 16rpx;
}
.star {
font-size: 64rpx;
color: #ddd;
transition: color 0.2s;
&.active { color: #FFD700; }
&.small {
font-size: 36rpx;
}
}
.rating-label {
display: block;
text-align: center;
font-size: 24rpx;
color: #999;
margin-top: 12rpx;
}
.dimension-row {
display: flex;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child { border: none; }
.dim-icon { font-size: 32rpx; width: 40rpx; }
.dim-label {
flex: 1;
font-size: 26rpx;
margin-left: 12rpx;
}
.dim-stars {
display: flex;
gap: 4rpx;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.tag-btn {
padding: 10rpx 24rpx;
border: 2rpx solid #eee;
border-radius: 30rpx;
font-size: 24rpx;
color: #666;
&.active {
border-color: #FF6B35;
background: #FFF3E0;
color: #FF6B35;
}
}
.note-textarea {
width: 100%;
min-height: 160rpx;
font-size: 26rpx;
padding: 16rpx;
background: #f8f8f8;
border-radius: 12rpx;
box-sizing: border-box;
}
.repeat-option {
display: flex;
align-items: center;
gap: 16rpx;
padding: 10rpx 0;
.repeat-icon { font-size: 36rpx; }
.repeat-text {
font-size: 28rpx;
font-weight: 600;
color: #FF6B35;
}
.repeat-hint {
font-size: 20rpx;
color: #999;
display: block;
}
}
.submit-area {
padding: 0 40rpx;
}
.submit-btn {
background: linear-gradient(135deg, #FF6B35, #FF8C42);
color: #fff;
border: none;
border-radius: 40rpx;
font-size: 32rpx;
font-weight: 600;
box-shadow: 0 8rpx 30rpx rgba(255,107,53,0.3);
&:disabled {
opacity: 0.5;
}
}
</style>
+154
View File
@@ -0,0 +1,154 @@
<template>
<view class="page-coupon">
<view class="header">
<text class="title">🎫 我的优惠券</text>
</view>
<!-- Tab切换 -->
<view class="tabs">
<view class="tab" :class="{ active: activeTab === 'all' }" @click="activeTab = 'all'">全部</view>
<view class="tab" :class="{ active: activeTab === 'available' }" @click="activeTab = 'available'">未使用</view>
<view class="tab" :class="{ active: activeTab === 'used' }" @click="activeTab = 'used'">已使用</view>
</view>
<!-- 优惠券列表 -->
<view class="coupon-list">
<view class="coupon-card" v-for="coupon in filteredCoupons" :key="coupon.id">
<view class="coupon-left">
<text class="coupon-value">¥{{ coupon.value }}</text>
<text class="coupon-min" v-if="coupon.min_amount">{{ coupon.min_amount }}可用</text>
</view>
<view class="coupon-right">
<text class="coupon-shop">{{ coupon.merchant_name || '指定商家' }}</text>
<text class="coupon-code">{{ coupon.user_code || coupon.pool_code }}</text>
<view class="coupon-status" :class="coupon.status">
{{ statusText(coupon.status) }}
</view>
</view>
</view>
</view>
<view class="empty" v-if="filteredCoupons.length === 0">
<text class="empty-icon">🎫</text>
<text class="empty-text">暂无优惠券</text>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
const activeTab = ref('all')
const coupons = ref([
{ id: 1, value: 15, min_amount: 80, merchant_name: '松阪牛料理', user_code: 'UCP1234567890', status: 'available', valid_end: '2026-07-01' },
{ id: 2, value: 20, min_amount: 100, merchant_name: '渝味晓宇火锅', user_code: 'UCP0987654321', status: 'available', valid_end: '2026-06-20' },
{ id: 3, value: 30, min_amount: 200, merchant_name: 'Bistro', user_code: 'UCP1122334455', status: 'used', status_text: '已使用' },
])
const filteredCoupons = computed(() => {
if (activeTab.value === 'all') return coupons.value
return coupons.value.filter(c => c.status === activeTab.value)
})
function statusText(status) {
const map = { available: '未使用', claimed: '未使用', used: '已使用', expired: '已过期' }
return map[status] || status
}
</script>
<style lang="scss" scoped>
.page-coupon {
min-height: 100vh;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #FF6B35, #FF8C42);
padding: 80rpx 30rpx 30rpx;
color: #fff;
.title { font-size: 36rpx; font-weight: 700; }
}
.tabs {
display: flex;
background: #fff;
margin-bottom: 16rpx;
.tab {
flex: 1;
text-align: center;
padding: 20rpx 0;
font-size: 26rpx;
color: #666;
border-bottom: 3rpx solid transparent;
&.active {
color: #FF6B35;
border-bottom-color: #FF6B35;
}
}
}
.coupon-list {
padding: 0 24rpx;
}
.coupon-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
display: flex;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
}
.coupon-left {
width: 160rpx;
text-align: center;
border-right: 2rpx dashed #eee;
margin-right: 20rpx;
.coupon-value {
font-size: 40rpx;
font-weight: 700;
color: #FF6B35;
display: block;
}
.coupon-min {
font-size: 20rpx;
color: #999;
margin-top: 4rpx;
display: block;
}
}
.coupon-right {
flex: 1;
.coupon-shop {
font-size: 26rpx;
font-weight: 600;
display: block;
}
.coupon-code {
font-size: 20rpx;
color: #999;
margin-top: 8rpx;
display: block;
}
}
.coupon-status {
font-size: 20rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
margin-top: 8rpx;
display: inline-block;
&.available { background: #E8F5E9; color: #4CAF50; }
&.used { background: #f0f0f0; color: #999; }
}
.empty {
text-align: center;
padding: 100rpx 0;
.empty-icon { font-size: 80rpx; display: block; }
.empty-text { font-size: 26rpx; color: #999; }
}
</style>
+347
View File
@@ -0,0 +1,347 @@
<template>
<view class="page-home">
<!-- 顶部欢迎区 -->
<view class="header" :style="{ background: headerGradient }">
<view class="header-content">
<view class="greeting">
<text class="greeting-label">你好, {{ userName }} 👋</text>
<text class="greeting-message">{{ welcomeMessage }}</text>
</view>
<view class="member-badge" v-if="hasMember">
<text>👑 VIP</text>
</view>
<view class="member-badge free" @click="goToMember">
<text>升级VIP</text>
</view>
</view>
<view class="daily-usage">
<text>今日盲选: {{ usedToday }}/10</text>
<view class="usage-bar">
<view class="usage-fill" :style="{ width: usagePercent + '%' }"></view>
</view>
</view>
</view>
<!-- AI推荐 -->
<view class="ai-recommend card" v-if="aiRecommend">
<text class="ai-label"> AI推荐</text>
<text class="ai-text">{{ aiRecommend }}</text>
</view>
<!-- 盲选分类卡片 -->
<view class="categories">
<text class="section-title">选择类别</text>
<scroll-view scroll-x class="category-scroll" show-scrollbar="false">
<view
class="category-card"
v-for="cat in categories"
:key="cat.id"
@click="selectCategory(cat)"
>
<text class="category-icon">{{ cat.icon }}</text>
<text class="category-name">{{ cat.name }}</text>
</view>
</scroll-view>
</view>
<!-- 热门套餐推荐模糊展示 -->
<view class="pools">
<text class="section-title">附近精选</text>
<view class="pool-grid">
<view class="pool-card card" v-for="pkg in poolList" :key="pkg.id" @click="goToBlind(pkg)">
<view class="pool-header">
<text class="pool-merchant">{{ pkg.merchant }}</text>
<text class="pool-rating"> {{ pkg.rating.toFixed(1) }}</text>
</view>
<text class="pool-name">{{ pkg.name }}</text>
<text class="pool-cat">{{ pkg.cat_name }}</text>
<text class="pool-price">{{ pkg.price_range }}</text>
</view>
</view>
</view>
<!-- 底部TabBar -->
<view class="tabbar">
<view class="tab-item" @click="goPage('pages/index/index')">
<text class="tab-icon">🏠</text>
<text :class="['tab-label', currentTab === 0 ? 'active' : '']">首页</text>
</view>
<view class="tab-item" @click="goPage('pages/coupon/list')">
<text class="tab-icon">🎫</text>
<text :class="['tab-label', currentTab === 1 ? 'active' : '']">优惠券</text>
</view>
<view class="tab-item" @click="goPage('pages/member/member')">
<text class="tab-icon">👑</text>
<text :class="['tab-label', currentTab === 2 ? 'active' : '']">会员</text>
</view>
<view class="tab-item" @click="goPage('pages/user/profile')">
<text class="tab-icon">👤</text>
<text :class="['tab-label', currentTab === 3 ? 'active' : '']">我的</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { blindApi, memberApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
const userStore = useUserStore()
const categories = ref([])
const poolList = ref([])
const aiRecommend = ref('')
const usedToday = ref(0)
const userName = computed(() => userStore.userInfo.nickname || '朋友')
const hasMember = computed(() => userStore.hasMember)
const usagePercent = computed(() => Math.min((usedToday.value / 10) * 100, 100))
const headerGradient = computed(() =>
hasMember.value ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'linear-gradient(135deg, #FF6B35 0%, #FF8C42 100%)'
)
const welcomeMessages = [
'今天想带你吃点惊喜的~',
'来一场未知的味蕾冒险?',
'准备好遇见新味道了吗?',
'你的盲选管家已上线 ✨',
]
const welcomeMessage = ref(welcomeMessages[Math.floor(Math.random() * welcomeMessages.length)])
const aiTexts = [
'看你最近爱吃日料,今天推荐一家隐藏好评店?',
'好久没吃辣了,要不要来场火锅盲选?',
'根据你的口味,今天适合一场甜品之旅 🍰',
'探索型用户!今天试试没去过的类型?',
]
aiRecommend.value = aiTexts[Math.floor(Math.random() * aiTexts.length)]
onMounted(async () => {
// Load categories
try {
categories.value = await blindApi.getCategories()
} catch(e) {}
// Load pool
try {
poolList.value = await blindApi.getPool({ limit: 6 })
} catch(e) {}
// Check member status
try {
const status = await memberApi.getStatus()
userStore.setHasMember(status.active)
usedToday.value = status.used_today || 0
} catch(e) {}
})
function selectCategory(cat) {
uni.navigateTo({
url: `/pages/blind/blind?categoryId=${cat.id}&categoryName=${cat.name}&categoryIcon=${cat.icon}`
})
}
function goToBlind(pkg) {
uni.navigateTo({
url: `/pages/blind/blind?packageId=${pkg.id}&merchant=${pkg.merchant}`
})
}
function goPage(path) {
if (path === 'pages/index/index') {
uni.switchTab({ url: '/pages/index/index' })
} else {
uni.switchTab({ url: path })
}
}
function goToMember() {
uni.navigateTo({ url: '/pages/member/member' })
}
</script>
<style lang="scss" scoped>
.page-home {
min-height: 100vh;
padding-bottom: 120rpx;
background: #f5f5f5;
}
.header {
padding: 60rpx 30rpx 40rpx;
color: #fff;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
.greeting {
.greeting-label {
font-size: 32rpx;
font-weight: 600;
display: block;
margin-bottom: 8rpx;
}
.greeting-message {
font-size: 26rpx;
opacity: 0.9;
}
}
.member-badge {
background: rgba(255,255,255,0.25);
padding: 8rpx 20rpx;
border-radius: 20rpx;
font-size: 22rpx;
backdrop-filter: blur(10rpx);
&.free {
background: rgba(255,255,255,0.15);
}
}
}
.daily-usage {
margin-top: 24rpx;
font-size: 22rpx;
opacity: 0.8;
.usage-bar {
height: 6rpx;
background: rgba(255,255,255,0.2);
border-radius: 3rpx;
margin-top: 8rpx;
overflow: hidden;
.usage-fill {
height: 100%;
background: #fff;
border-radius: 3rpx;
transition: width 0.3s;
}
}
}
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin: 20rpx 30rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
}
.ai-recommend {
.ai-label {
font-size: 22rpx;
color: #999;
display: block;
margin-bottom: 8rpx;
}
.ai-text {
font-size: 28rpx;
font-weight: 500;
line-height: 1.6;
}
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #333;
padding: 20rpx 30rpx 10rpx;
display: block;
}
.category-scroll {
white-space: nowrap;
padding: 10rpx 30rpx;
}
.category-card {
display: inline-flex;
flex-direction: column;
align-items: center;
background: #fff;
border-radius: 20rpx;
padding: 24rpx 30rpx;
margin-right: 16rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.06);
.category-icon {
font-size: 48rpx;
margin-bottom: 8rpx;
}
.category-name {
font-size: 24rpx;
color: #666;
}
}
.pool-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
padding: 10rpx 20rpx;
}
.pool-card {
background: #fff;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 0;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
.pool-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pool-merchant {
font-size: 22rpx;
color: #999;
}
.pool-rating {
font-size: 20rpx;
}
.pool-name {
font-size: 28rpx;
font-weight: 600;
margin-top: 8rpx;
display: block;
}
.pool-cat {
font-size: 20rpx;
color: #FF6B35;
margin-top: 4rpx;
display: inline-block;
background: #FFF3E0;
padding: 2rpx 10rpx;
border-radius: 8rpx;
}
.pool-price {
font-size: 24rpx;
color: #FF6B35;
font-weight: 600;
margin-top: 12rpx;
display: block;
}
}
.tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: #fff;
padding: 10rpx 0 30rpx;
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.06);
z-index: 100;
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.tab-icon { font-size: 36rpx; }
.tab-label {
font-size: 20rpx;
color: #999;
margin-top: 4rpx;
&.active { color: #FF6B35; }
}
}
}
</style>
+214
View File
@@ -0,0 +1,214 @@
<!--
小程序登录引导页
微信小程序只能用 wx.login() 获取 code不能收集密码
流程: wx.login() 后端 code2session 自动注册/登录 返回 JWT
-->
<template>
<view class="login-page">
<view class="login-hero">
<text class="logo">🎲</text>
<text class="app-name">帮我选</text>
<text class="app-desc">每一次选择都是惊喜</text>
</view>
<view class="login-body">
<!-- 方式一: 微信一键登录 (小程序) -->
<button class="login-btn primary" @click="wechatLogin" :loading="logining">
<text class="login-icon">💬</text>
<text>微信一键登录</text>
</button>
<text class="login-hint">登录即同意用户协议隐私政策</text>
<!-- 方式二: 手机号登录 (备选) -->
<view class="divider">
<text></text>
</view>
<button class="login-btn secondary" @click="phoneLogin">
<text class="login-icon">📱</text>
<text>手机号快捷登录</text>
</button>
</view>
<view class="login-footer">
<text class="feature-item">🎲 盲选吃喝玩乐</text>
<text class="feature-item">🤖 AI 智能推荐</text>
<text class="feature-item">🎁 优惠券联动</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { authApi } from '@/api/index.js'
import { useUserStore } from '@/store/user.js'
const logining = ref(false)
const userStore = useUserStore()
// ─── 微信一键登录 ───
async function wechatLogin() {
logining.value = true
// 1. 获取微信 code
try {
const loginRes = await new Promise((resolve, reject) => {
wx.login({
success: resolve,
fail: reject
})
})
const code = loginRes.code
if (!code) {
uni.showToast({ title: '获取登录凭证失败', icon: 'none' })
logining.value = false
return
}
// 2. 发送到后端换取 JWT
const data = await authApi.wechatLogin({ code })
// 3. 保存 token + 用户信息
uni.setStorageSync('token', data.token)
uni.setStorageSync('refresh_token', data.refresh_token)
uni.setStorageSync('userInfo', data.user)
userStore.setUserInfo(data.user)
userStore.setHasMember(data.has_member || false)
uni.showToast({ title: '登录成功', icon: 'success' })
// 4. 跳转首页
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 500)
} catch (e) {
console.error('wechat login failed:', e)
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
logining.value = false
}
}
// ─── 手机号登录 (小程序需要用户授权手机号) ───
function phoneLogin() {
// 小程序的 getPhoneNumber 需要 <button open-type="getPhoneNumber">
// 这里提示用户使用微信登录,因为手机号登录需要后端支持
uni.showToast({
title: '请使用微信一键登录',
icon: 'none',
duration: 2000
})
}
// ─── 检查是否已有token (自动登录) ───
onLoad(() => {
const token = uni.getStorageSync('token')
if (token) {
// 有 token → 直接进首页
uni.switchTab({ url: '/pages/index/index' })
}
})
</script>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
background: linear-gradient(180deg, #FF6B35 0%, #FF8C42 60%, #F5F5F5 100%);
display: flex;
flex-direction: column;
}
.login-hero {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 100rpx;
.logo {
font-size: 140rpx;
display: block;
margin-bottom: 20rpx;
}
.app-name {
font-size: 56rpx;
font-weight: 800;
color: #fff;
display: block;
margin-bottom: 12rpx;
}
.app-desc {
font-size: 28rpx;
color: rgba(255,255,255,0.8);
display: block;
}
}
.login-body {
background: #fff;
border-radius: 40rpx 40rpx 0 0;
padding: 60rpx 50rpx;
min-height: 400rpx;
.login-btn {
width: 100%;
height: 90rpx;
border-radius: 45rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
font-size: 32rpx;
font-weight: 600;
margin-bottom: 24rpx;
&.primary {
background: linear-gradient(135deg, #FF6B35, #FF8C42);
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(255,107,53,0.3);
}
&.secondary {
background: #fff;
color: #333;
border: 2rpx solid #eee;
}
.login-icon {
font-size: 36rpx;
}
}
.login-hint {
display: block;
text-align: center;
font-size: 22rpx;
color: #999;
margin: 30rpx 0;
}
.divider {
text-align: center;
color: #ccc;
font-size: 24rpx;
margin: 20rpx 0;
}
}
.login-footer {
background: #fff;
padding: 40rpx;
display: flex;
flex-wrap: wrap;
gap: 20rpx;
justify-content: center;
.feature-item {
font-size: 22rpx;
color: #666;
}
}
</style>
+184
View File
@@ -0,0 +1,184 @@
<template>
<view class="page-member">
<view class="member-header" :style="{ background: headerBg }">
<text class="member-title" v-if="active">👑 VIP会员</text>
<text class="member-title" v-else>🌟 开通VIP</text>
<text class="member-subtitle" v-if="active">尊享特权 · 每天10次盲选</text>
<text class="member-subtitle" v-else">解锁更多盲选权益</text>
</view>
<!-- 权益列表 -->
<view class="benefits card">
<text class="section-title">VIP权益</text>
<view class="benefit-item" v-for="item in benefits" :key="item">
<text class="benefit-icon">{{ item.icon }}</text>
<text class="benefit-text">{{ item.text }}</text>
</view>
</view>
<!-- 套餐选择 -->
<view class="plans card">
<text class="section-title">选择套餐</text>
<view
class="plan-card"
:class="{ selected: selectedPlan === plan.id }"
v-for="plan in plans"
:key="plan.id"
@click="selectedPlan = plan.id"
>
<view class="plan-header">
<text class="plan-name">{{ plan.name }}</text>
<view class="plan-price">
<text class="price-num">¥{{ plan.price }}</text>
<text class="price-period">/{{ plan.period }}</text>
</view>
</view>
<view class="plan-features">
<text class="feature" v-for="f in plan.features" :key="f"> {{ f }}</text>
</view>
</view>
</view>
<!-- 保存按钮 -->
<view class="save-area">
<button class="subscribe-btn" @click="subscribe">
立即开通 {{ selectedPlanName }}
</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { memberApi } from '@/api/index.js'
const active = ref(false)
const selectedPlan = ref(1)
const plans = ref([
{ id: 1, name: 'VIP月卡', price: 29, period: '月', features: ['每天10次盲选', '全部分类', '优先匹配', '月度报告'] },
{ id: 2, name: 'VIP年卡', price: 199, period: '年', features: ['每天10次盲选', '全部分类', '优先匹配', '月度报告', '省¥149'] },
])
const selectedPlanName = computed(() => plans.value.find(p => p.id === selectedPlan.value)?.name || '')
const benefits = [
{ icon: '🎲', text: '每天10次盲选机会(免费用户3次)' },
{ icon: '🌍', text: '全部分类解锁(含专属VIP分类)' },
{ icon: '⭐', text: '优先匹配优质套餐和商家' },
{ icon: '📊', text: '月度盲选报告 + AI消费分析' },
{ icon: '🏷️', text: '专属VIP标签和身份标识' },
{ icon: '🔄', text: '7天去重 → 1天去重(减少重复推荐)' },
]
const headerBg = computed(() => active.value ? 'linear-gradient(135deg, #667eea, #764ba2)' : 'linear-gradient(135deg, #FF6B35, #FF8C42)')
onMounted(async () => {
try {
const status = await memberApi.getStatus()
active.value = status.active
} catch(e) {}
})
async function subscribe() {
try {
await memberApi.subscribe({
plan_id: selectedPlan.value,
payment_method: 'wechat',
})
active.value = true
uni.showToast({ title: '开通成功!', icon: 'success' })
} catch(e) {
uni.showToast({ title: '支付功能开发中', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page-member {
min-height: 100vh;
background: #f5f5f5;
}
.member-header {
padding: 80rpx 30rpx 50rpx;
text-align: center;
color: #fff;
.member-title { font-size: 40rpx; font-weight: 700; display: block; }
.member-subtitle { font-size: 24rpx; opacity: 0.85; margin-top: 8rpx; display: block; }
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin: 20rpx 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
}
.section-title {
font-size: 30rpx;
font-weight: 700;
margin-bottom: 16rpx;
display: block;
}
.benefit-item {
display: flex;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
.benefit-icon { font-size: 32rpx; width: 44rpx; }
.benefit-text { font-size: 26rpx; color: #555; }
}
.plan-card {
border: 2rpx solid #eee;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 12rpx;
&.selected {
border-color: #FF6B35;
background: #FFF8F0;
}
}
.plan-header {
display: flex;
justify-content: space-between;
align-items: center;
.plan-name { font-size: 28rpx; font-weight: 600; }
.plan-price { display: flex; align-items: baseline; }
.price-num { font-size: 36rpx; font-weight: 700; color: #FF6B35; }
.price-period { font-size: 22rpx; color: #999; }
}
.plan-features {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
margin-top: 12rpx;
}
.feature {
font-size: 20rpx;
color: #666;
background: #f8f8f8;
padding: 4rpx 12rpx;
border-radius: 10rpx;
}
.save-area {
padding: 40rpx 40rpx 100rpx;
}
.subscribe-btn {
background: linear-gradient(135deg, #FF6B35, #FF8C42);
color: #fff;
border: none;
border-radius: 40rpx;
font-size: 32rpx;
font-weight: 600;
box-shadow: 0 8rpx 30rpx rgba(255,107,53,0.3);
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<view class="page-history">
<view class="header">
<text class="title">📋 盲选记录</text>
</view>
<view class="history-list">
<view class="history-item card" v-for="item in history" :key="item.session_id">
<view class="item-header">
<text class="item-shop">{{ item.merchant }}</text>
<text class="item-time">{{ formatTime(item.created_at) }}</text>
</view>
<text class="item-name">{{ item.package_name }}</text>
<text class="item-price">¥{{ item.price_range }}</text>
<view class="item-footer">
<text class="match-badge" v-if="item.match_score">🎯 {{ (item.match_score * 100).toFixed(0) }}% 匹配</text>
<text class="status-badge" :class="item.accepted ? 'accepted' : ''">
{{ item.accepted ? '✅ 已前往' : '⏳ 未前往' }}
</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { blindApi } from '@/api/index.js'
const history = ref([])
function formatTime(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
const now = new Date()
const diff = now - d
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
return Math.floor(diff / 86400000) + '天前'
}
// Mock data for demo
history.value = [
{ session_id: 1, merchant: '松阪牛料理', package_name: '主厨精选7道式', price_range: '298-398', match_score: 0.85, accepted: true, created_at: new Date().toISOString() },
{ session_id: 2, merchant: '渝味晓宇火锅', package_name: '双人麻辣火锅套餐', price_range: '168-228', match_score: 0.72, accepted: true, created_at: new Date(Date.now() - 86400000).toISOString() },
{ session_id: 3, merchant: '迷雾剧场剧本杀', package_name: '沉浸式推理剧本', price_range: '128-168', match_score: 0.65, accepted: false, created_at: new Date(Date.now() - 172800000).toISOString() },
]
</script>
<style lang="scss" scoped>
.page-history {
min-height: 100vh;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #FF6B35, #FF8C42);
padding: 80rpx 30rpx 30rpx;
color: #fff;
.title { font-size: 36rpx; font-weight: 700; }
}
.card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin: 16rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
}
.item-header {
display: flex;
justify-content: space-between;
.item-shop { font-size: 28rpx; font-weight: 600; }
.item-time { font-size: 20rpx; color: #999; }
}
.item-name {
font-size: 26rpx;
color: #666;
display: block;
margin-top: 6rpx;
}
.item-price {
font-size: 24rpx;
color: #FF6B35;
font-weight: 600;
display: block;
margin-top: 6rpx;
}
.item-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12rpx;
}
.match-badge {
font-size: 20rpx;
color: #999;
}
.status-badge {
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 10rpx;
&.accepted {
background: #E8F5E9;
color: #4CAF50;
}
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<template>
<view class="page-prefs">
<view class="header">
<text class="title">🎯 我的偏好</text>
<text class="subtitle">AI根据盲选记录自动分析</text>
</view>
<!-- 偏好雷达图 -->
<view class="radar-card card">
<text class="section-title">偏好雷达</text>
<view class="radar-bars">
<view class="bar-row" v-for="bar in bars" :key="bar.label">
<text class="bar-label">{{ bar.icon }} {{ bar.label }}</text>
<view class="bar-track">
<view class="bar-fill" :style="{ width: bar.value + '%', background: bar.color }"></view>
</view>
<text class="bar-value">{{ bar.value }}%</text>
</view>
</view>
</view>
<!-- 品类排名 -->
<view class="category-rank card">
<text class="section-title">品类偏好</text>
<view class="rank-item" v-for="item in categoryRank" :key="item.name">
<text class="rank-icon">{{ item.icon }}</text>
<view class="rank-info">
<text class="rank-name">{{ item.name }}</text>
<view class="rank-bar">
<view class="rank-fill" :style="{ width: item.value + '%' }"></view>
</view>
</view>
</view>
</view>
<!-- 用户标签 -->
<view class="tags-card card">
<text class="section-title">AI标签</text>
<view class="user-tags">
<text class="user-tag" v-for="tag in userTags" :key="tag">{{ tag }}</text>
</view>
</view>
<!-- 去重设置 -->
<view class="settings-card card">
<text class="section-title">设置</text>
<view class="setting-row">
<text class="setting-label">去重天数</text>
<text class="setting-value">{{ repeatDays }} 天内不重复推荐</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const bars = ref([
{ label: '口味', icon: '🍽️', value: 75, color: '#FF6B35' },
{ label: '品质', icon: '⭐', value: 60, color: '#FFD700' },
{ label: '性价比', icon: '💰', value: 45, color: '#4CAF50' },
{ label: '距离', icon: '📍', value: 80, color: '#2196F3' },
{ label: '探索度', icon: '🌍', value: 35, color: '#9C27B0' },
])
const categoryRank = ref([
{ name: '日料', icon: '🍣', value: 85 },
{ name: '火锅', icon: '🍲', value: 70 },
{ name: '西餐', icon: '🥩', value: 55 },
{ name: '甜品', icon: '🍰', value: 40 },
{ name: '剧本杀', icon: '🔍', value: 30 },
])
const userTags = ref(['日料控', '品质型', '探索型', '周末达人'])
const repeatDays = ref(7)
</script>
<style lang="scss" scoped>
.page-prefs {
min-height: 100vh;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea, #764ba2);
padding: 80rpx 30rpx 40rpx;
color: #fff;
text-align: center;
.title { font-size: 36rpx; font-weight: 700; display: block; }
.subtitle { font-size: 24rpx; opacity: 0.8; margin-top: 8rpx; display: block; }
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin: 20rpx 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
}
.section-title {
font-size: 28rpx;
font-weight: 700;
display: block;
margin-bottom: 16rpx;
}
.bar-row {
display: flex;
align-items: center;
padding: 12rpx 0;
.bar-label {
width: 140rpx;
font-size: 24rpx;
}
.bar-track {
flex: 1;
height: 16rpx;
background: #f0f0f0;
border-radius: 8rpx;
margin: 0 16rpx;
overflow: hidden;
.bar-fill {
height: 100%;
border-radius: 8rpx;
transition: width 0.6s ease;
}
}
.bar-value {
font-size: 22rpx;
color: #666;
width: 50rpx;
text-align: right;
}
}
.rank-item {
display: flex;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #f5f5f5;
.rank-icon { font-size: 32rpx; width: 44rpx; }
.rank-info { flex: 1; margin-left: 12rpx; }
.rank-name { font-size: 26rpx; color: #333; display: block; }
.rank-bar {
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
margin-top: 8rpx;
overflow: hidden;
.rank-fill {
height: 100%;
background: linear-gradient(90deg, #FF6B35, #FF8C42);
border-radius: 4rpx;
}
}
}
.user-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.user-tag {
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
padding: 8rpx 20rpx;
border-radius: 20rpx;
font-size: 24rpx;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 0;
.setting-label { font-size: 26rpx; color: #333; }
.setting-value { font-size: 24rpx; color: #999; }
}
</style>
+168
View File
@@ -0,0 +1,168 @@
<template>
<view class="page-profile">
<!-- 头像区 -->
<view class="profile-header">
<image class="avatar" src="/static/default-avatar.png" mode="aspectFill"></image>
<text class="nickname">{{ userInfo.nickname || '用户' }}</text>
<text class="member-tag" v-if="hasMember">👑 VIP</text>
</view>
<!-- 统计 -->
<view class="stats card">
<view class="stat-item">
<text class="stat-num">{{ blindCount }}</text>
<text class="stat-label">盲选次数</text>
</view>
<view class="stat-item">
<text class="stat-num">{{ collectedCount }}</text>
<text class="stat-label">收藏</text>
</view>
<view class="stat-item">
<text class="stat-num">{{ reviewCount }}</text>
<text class="stat-label">评价</text>
</view>
</view>
<!-- 菜单 -->
<view class="menu-list">
<view class="menu-item" @click="goTo('/pages/user/preferences')">
<text class="menu-icon">🎯</text>
<text class="menu-text">我的偏好</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goTo('/pages/user/history')">
<text class="menu-icon">📋</text>
<text class="menu-text">盲选记录</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goTo('/pages/coupon/list')">
<text class="menu-icon">🎫</text>
<text class="menu-text">我的优惠券</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goTo('/pages/member/member')">
<text class="menu-icon">👑</text>
<text class="menu-text">会员中心</text>
<text class="menu-arrow"></text>
</view>
</view>
<!-- 退出登录 -->
<view class="logout-area">
<button class="logout-btn" @click="logout">退出登录</button>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useUserStore } from '@/store/user.js'
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
const hasMember = computed(() => userStore.hasMember)
const blindCount = ref(42)
const collectedCount = ref(8)
const reviewCount = ref(15)
function goTo(url) {
uni.navigateTo({ url })
}
function logout() {
userStore.logout()
uni.reLaunch({ url: '/pages/index/index' })
}
</script>
<style lang="scss" scoped>
.page-profile {
min-height: 100vh;
background: #f5f5f5;
}
.profile-header {
background: linear-gradient(135deg, #FF6B35, #FF8C42);
padding: 80rpx 30rpx 50rpx;
text-align: center;
color: #fff;
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
border: 4rpx solid rgba(255,255,255,0.3);
margin-bottom: 16rpx;
}
.nickname {
font-size: 36rpx;
font-weight: 700;
display: block;
}
.member-tag {
font-size: 22rpx;
margin-top: 8rpx;
display: block;
}
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin: 20rpx 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
}
.stats {
display: flex;
justify-content: space-around;
}
.stat-item {
text-align: center;
.stat-num {
font-size: 40rpx;
font-weight: 700;
color: #FF6B35;
display: block;
}
.stat-label {
font-size: 22rpx;
color: #999;
}
}
.menu-list {
margin: 20rpx 24rpx;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
}
.menu-item {
display: flex;
align-items: center;
padding: 28rpx 24rpx;
border-bottom: 1rpx solid #f5f5f5;
.menu-icon { font-size: 32rpx; width: 40rpx; }
.menu-text {
flex: 1;
font-size: 28rpx;
color: #333;
}
.menu-arrow { font-size: 32rpx; color: #ccc; }
}
.logout-area {
padding: 40rpx 40rpx 100rpx;
}
.logout-btn {
background: #fff;
color: #ff4444;
border: 2rpx solid #ff4444;
border-radius: 40rpx;
font-size: 28rpx;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
token: uni.getStorageSync('token') || '',
userInfo: JSON.parse(uni.getStorageSync('userInfo') || '{}'),
hasMember: false,
}),
getters: {
isLoggedIn: (state) => !!state.token,
isMember: (state) => state.hasMember,
},
actions: {
setToken(token) {
this.token = token
uni.setStorageSync('token', token)
},
setUserInfo(info) {
this.userInfo = info
uni.setStorageSync('userInfo', JSON.stringify(info))
},
setHasMember(hasMember) {
this.hasMember = hasMember
},
logout() {
this.token = ''
this.userInfo = {}
this.hasMember = false
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
}
},
persist: true,
})
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from '@dcloudio/vite-plugin-uni'
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'pinia'],
}
}
}
}
})
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# ===== 微信小程序上线 Checklist =====
# 使用此文件作为上线前的核对清单
echo "🎲 微信小程序上线 Checklist"
echo "============================="
echo ""
echo "1️⃣ 小程序后台配置"
echo " - 登录 https://mp.weixin.qq.com"
echo " - 获取 AppID 和 AppSecret"
echo " - 配置 request 域名: https://your-domain.com (生产环境)"
echo " - 配置 uploadFile 域名 (如需上传头像)"
echo " - 配置 socket 域名 (如需实时通知)"
echo ""
echo "2️⃣ 代码配置"
echo " - 编辑 frontend-app/pages.json:"
echo " 'mp-weixin.appid' → 填入你的 AppID"
echo ""
echo " - 编辑 backend/config.yaml:"
echo " wechat.app_id → 填入小程序 AppID"
echo " wechat.app_secret → 填入小程序 AppSecret"
echo ""
echo " - 编辑 backend/config.yaml:"
echo " ai.key → 填入完整的 AI API Key"
echo ""
echo "3️⃣ TabBar 图标"
echo " - 在 frontend-app/static/ 下放置以下图标 (64x64 PNG):"
echo " tab-home.png / tab-home-active.png"
echo " tab-coupon.png / tab-coupon-active.png"
echo " tab-vip.png / tab-vip-active.png"
echo " tab-user.png / tab-user-active.png"
echo " - 或使用 HBuilderX 自带的图标替换"
echo ""
echo "4️⃣ 域名配置"
echo " 后端API域名 → 后台配置为 request 合法域名"
echo " 例: https://api.yourdomain.com"
echo ""
echo "5️⃣ 微信登录流程"
echo " 小程序启动 → onShow() 检查 token"
echo " - 有 token → 进首页"
echo " - 无 token → 进登录页"
echo " 登录页 → wx.login() → 获取 code → POST /api/v1/auth/wechat/login"
echo " 后端 → code2session → 查/建用户 → 返回 JWT"
echo " 前端 → 保存 token → 进首页"
echo ""
echo "6️⃣ 版本管理"
echo " - 开发: HBuilderX 运行 → 微信小程序 → 扫码预览"
echo " - 调试: HBuilderX → 编译 → 微信小程序 → 添加调试"
echo " - 上传: HBuilderX → 发行 → 微信小程序"
echo " - 审核: 微信后台 → 版本管理 → 提交审核"
echo " - 上线: 审核通过 → 发布"
echo ""
echo "7️⃣ 隐私合规"
echo " - 配置隐私协议 URL (微信后台)"
echo " - 权限声明: scope.userLocation (定位)"
echo " - requiredPrivateInfos: getLocation"
echo ""
echo "✅ 检查完成!"