Initial commit: 帮我选盲选应用
功能: - Go后端 (Gin + GORM + PostgreSQL) - UniApp用户端 (iOS/Android/小程序) - DaisyUI5后台管理 - JWT认证 + 微信登录 - 盲选加权算法 - 会员系统 + 优惠券 - 打分评价 + 偏好学习
This commit is contained in:
+26
@@ -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/
|
||||
@@ -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: 需要积累用户行为数据,初期可手动调整权重参数
|
||||
@@ -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 ./...
|
||||
@@ -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, "用户核销了优惠券,佣金已记录")
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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:
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}`),
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'pinia'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 "✅ 检查完成!"
|
||||
Reference in New Issue
Block a user