feat(admin): 添加积分发放管理和邀请奖励功能

- 新增积分发放相关模型 PointsGrant 和 InviteReward
- 实现管理员积分发放接口(单个、批量、全员发放)
- 添加积分发放记录查询和统计功能
- 集成邀请奖励机制,在用户充值时自动发放邀请奖励
- 在用户注册流程中集成邀请码功能
- 扩展用户信息返回积分和创建时间字段
- 添加前端邀请码处理和邀请统计功能
```
This commit is contained in:
24024 2026-01-23 21:46:08 +08:00
parent 2ef673d0d6
commit 0da71bc439
10 changed files with 1833 additions and 526 deletions

View File

@ -1,7 +1,7 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from datetime import timedelta from datetime import timedelta
from extensions import db from extensions import db
from models import User, Role, Permission, SystemDict, SystemNotification, Order, to_bj_time, get_bj_now from models import User, Role, Permission, SystemDict, SystemNotification, Order, PointsGrant, InviteReward, to_bj_time, get_bj_now
from middlewares.auth import permission_required from middlewares.auth import permission_required
from services.logger import system_logger from services.logger import system_logger
@ -91,7 +91,9 @@ def get_users():
"phone": u.phone, "phone": u.phone,
"role": u.role.name if u.role else "未分配", "role": u.role.name if u.role else "未分配",
"role_id": u.role.id if u.role else None, "role_id": u.role.id if u.role else None,
"is_banned": u.is_banned "is_banned": u.is_banned,
"points": u.points,
"created_at": u.created_at_bj.strftime('%Y-%m-%d %H:%M:%S')
} for u in pagination.items], } for u in pagination.items],
"total": pagination.total, "total": pagination.total,
"pages": pagination.pages, "pages": pagination.pages,
@ -300,3 +302,297 @@ def get_orders():
"paid_at": o.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') if o.paid_at else None "paid_at": o.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') if o.paid_at else None
} for o in orders] } for o in orders]
}) })
# --- 积分发放管理 ---
@admin_bp.route('/points/grant', methods=['POST'])
@permission_required('manage_system')
def grant_points():
"""给用户发放积分"""
from flask import session
data = request.json
user_id = data.get('user_id')
points = data.get('points')
reason = data.get('reason', '管理员手动发放')
if not user_id or not points:
return jsonify({"error": "请提供用户ID和积分数"}), 400
try:
points = int(points)
if points <= 0:
return jsonify({"error": "积分数必须为正整数"}), 400
except ValueError:
return jsonify({"error": "积分数必须为整数"}), 400
user = db.session.get(User, user_id)
if not user:
return jsonify({"error": "用户不存在"}), 404
admin_id = session.get('user_id')
# 发放积分
user.points += points
# 记录发放记录
grant = PointsGrant(
user_id=user_id,
points=points,
reason=reason,
admin_id=admin_id
)
db.session.add(grant)
db.session.commit()
system_logger.info(f"管理员发放积分", target_user_id=user_id, points=points, reason=reason)
return jsonify({"message": f"成功发放 {points} 积分给用户 {user.phone}"})
@admin_bp.route('/points/batch_grant', methods=['POST'])
@permission_required('manage_system')
def batch_grant_points():
"""批量给用户发放积分"""
from flask import session
data = request.json
phones_str = data.get('phones', '')
points = data.get('points')
reason = data.get('reason', '管理员批量发放')
if not phones_str or not points:
return jsonify({"error": "请提供手机号列表和积分数"}), 400
try:
points = int(points)
if points <= 0:
return jsonify({"error": "积分数必须为正整数"}), 400
except ValueError:
return jsonify({"error": "积分数必须为整数"}), 400
# 处理手机号列表(支持换行、逗号、空格分隔)
import re
phone_list = list(set(re.split(r'[,\n\s]+', phones_str.strip())))
phone_list = [p for p in phone_list if re.match(r'^1[3-9]\d{9}$', p)]
if not phone_list:
return jsonify({"error": "未识别到有效的手机号"}), 400
admin_id = session.get('user_id')
success_count = 0
fail_phones = []
for phone in phone_list:
user = User.query.filter_by(phone=phone).first()
if user:
user.points += points
grant = PointsGrant(
user_id=user.id,
points=points,
reason=reason,
admin_id=admin_id
)
db.session.add(grant)
success_count += 1
else:
fail_phones.append(phone)
db.session.commit()
msg = f"操作完成。成功: {success_count}"
if fail_phones:
msg += f",失败: {len(fail_phones)} 人 (用户不存在)"
system_logger.info(f"管理员批量发放积分", count=success_count, points=points)
return jsonify({
"message": msg,
"success_count": success_count,
"fail_count": len(fail_phones),
"fail_phones": fail_phones
})
@admin_bp.route('/points/batch_grant_ids', methods=['POST'])
@permission_required('manage_system')
def batch_grant_by_ids():
"""通过用户ID列表批量发放积分"""
from flask import session
data = request.json
user_ids = data.get('user_ids', [])
points = data.get('points')
reason = data.get('reason', '管理员批量发放')
if not user_ids or not points:
return jsonify({"error": "请选择用户并提供积分数"}), 400
try:
points = int(points)
if points <= 0:
return jsonify({"error": "积分数必须为正整数"}), 400
except ValueError:
return jsonify({"error": "积分数必须为整数"}), 400
admin_id = session.get('user_id')
success_count = 0
for uid in user_ids:
user = db.session.get(User, uid)
if user:
user.points += points
grant = PointsGrant(
user_id=user.id,
points=points,
reason=reason,
admin_id=admin_id
)
db.session.add(grant)
success_count += 1
db.session.commit()
system_logger.info(f"管理员批量发放积分(按ID)", count=success_count, points=points)
return jsonify({"message": f"成功为 {success_count} 名用户发放了积分"})
@admin_bp.route('/points/global_grant', methods=['POST'])
@permission_required('manage_system')
def global_grant_points():
"""给全员发放积分"""
from flask import session
data = request.json
points = data.get('points')
reason = data.get('reason', '全员普惠')
if not points:
return jsonify({"error": "请提供积分数"}), 400
try:
points = int(points)
if points <= 0:
return jsonify({"error": "积分数必须为正整数"}), 400
except ValueError:
return jsonify({"error": "积分数必须为整数"}), 400
admin_id = session.get('user_id')
# 获取所有用户
users = User.query.all()
count = len(users)
for user in users:
user.points += points
# 记录每人的发放明细(虽然量大,但为了对账建议记录;若用户量极大可优化为汇总记录)
grant = PointsGrant(
user_id=user.id,
points=points,
reason=reason,
admin_id=admin_id
)
db.session.add(grant)
db.session.commit()
system_logger.info(f"管理员执行全员发放", user_count=count, points=points)
return jsonify({"message": f"成功为全站 {count} 名用户每人发放了 {points} 积分!"})
@admin_bp.route('/points/grants', methods=['GET'])
@permission_required('manage_system')
def get_points_grants():
"""获取积分发放记录"""
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
search = request.args.get('q', '').strip()
query = PointsGrant.query.join(User, PointsGrant.user_id == User.id)
if search:
query = query.filter(User.phone.like(f"%{search}%"))
pagination = query.order_by(PointsGrant.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
"grants": [{
"id": g.id,
"user_id": g.user_id,
"user_phone": g.user.phone if g.user else "未知",
"points": g.points,
"reason": g.reason,
"admin_phone": g.admin.phone if g.admin else "系统",
"created_at": g.created_at_bj.strftime('%Y-%m-%d %H:%M:%S')
} for g in pagination.items],
"total": pagination.total,
"pages": pagination.pages,
"current_page": pagination.page
})
@admin_bp.route('/invite/rewards', methods=['GET'])
@permission_required('manage_system')
def get_invite_rewards():
"""获取邀请奖励记录"""
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
search = request.args.get('q', '').strip()
# 创建别名以处理多个关联
from sqlalchemy.orm import aliased
Inviter = aliased(User)
Invitee = aliased(User)
query = db.session.query(InviteReward).join(Inviter, InviteReward.inviter_id == Inviter.id).join(Invitee, InviteReward.invitee_id == Invitee.id)
if search:
query = query.filter(db.or_(
Inviter.phone.like(f"%{search}%"),
Invitee.phone.like(f"%{search}%")
))
pagination = query.order_by(InviteReward.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
"rewards": [{
"id": r.id,
"inviter_id": r.inviter_id,
"inviter_phone": r.inviter.phone if r.inviter else "未知",
"invitee_id": r.invitee_id,
"invitee_phone": r.invitee.phone if r.invitee else "未知",
"reward_points": r.reward_points,
"recharge_count": r.recharge_count,
"created_at": r.created_at_bj.strftime('%Y-%m-%d %H:%M:%S')
} for r in pagination.items],
"total": pagination.total,
"pages": pagination.pages,
"current_page": pagination.page
})
@admin_bp.route('/invite/stats', methods=['GET'])
@permission_required('manage_system')
def get_invite_stats():
"""获取邀请统计数据"""
# 总邀请人数
total_invited = User.query.filter(User.invited_by.isnot(None)).count()
# 总发放邀请奖励
total_rewards = db.session.query(db.func.sum(InviteReward.reward_points)).scalar() or 0
# 总手动发放积分
total_grants = db.session.query(db.func.sum(PointsGrant.points)).scalar() or 0
# 邀请排行榜前10
top_inviters = db.session.query(
User.id,
User.phone,
db.func.count(User.id).label('invite_count')
).join(User, User.invited_by == User.id, isouter=True).filter(
User.invited_by.isnot(None)
).group_by(User.invited_by).order_by(
db.desc('invite_count')
).limit(10).all()
return jsonify({
"total_invited": total_invited,
"total_rewards": int(total_rewards),
"total_grants": int(total_grants),
"top_inviters": [{
"user_id": t[0],
"phone": t[1],
"invite_count": t[2]
} for t in top_inviters] if top_inviters else []
})

View File

@ -47,6 +47,12 @@ def admin_orders_page():
"""全员订单管理页面""" """全员订单管理页面"""
return render_template('orders.html') return render_template('orders.html')
@auth_bp.route('/admin/points')
@admin_required
def admin_points_page():
"""积分发放管理页面"""
return render_template('points.html')
@auth_bp.route('/buy') @auth_bp.route('/buy')
def buy_page(): def buy_page():
"""购买积分页面""" """购买积分页面"""
@ -88,6 +94,7 @@ def buy_page():
import datetime as dt_module import datetime as dt_module
return render_template('buy.html', return render_template('buy.html',
user=user,
personal_orders=personal_orders, personal_orders=personal_orders,
admin_orders=admin_orders, admin_orders=admin_orders,
is_admin=is_admin, is_admin=is_admin,
@ -95,6 +102,23 @@ def buy_page():
success=success, success=success,
order=order) order=order)
@auth_bp.route('/recharge_history')
def recharge_history_page():
"""充值历史记录页面"""
if 'user_id' not in session:
return redirect(url_for('auth.login_page'))
from models import Order, User
user_id = session['user_id']
user = db.session.get(User, user_id)
# 获取用户所有充值记录
orders = Order.query.filter_by(user_id=user_id).order_by(Order.created_at.desc()).all()
return render_template('recharge_history.html',
user=user,
orders=orders)
@auth_bp.route('/api/auth/captcha') @auth_bp.route('/api/auth/captcha')
def get_captcha(): def get_captcha():
"""获取图形验证码并存入 Redis""" """获取图形验证码并存入 Redis"""
@ -177,6 +201,7 @@ def register():
phone = data.get('phone') phone = data.get('phone')
code = data.get('code') code = data.get('code')
password = data.get('password') password = data.get('password')
invite_code = data.get('invite_code', '').strip().upper() # 获取邀请码
import re import re
if not phone or not re.match(r'^1[3-9]\d{9}$', phone): if not phone or not re.match(r'^1[3-9]\d{9}$', phone):
@ -192,15 +217,36 @@ def register():
system_logger.warning(f"注册失败: 手机号已存在", phone=phone) system_logger.warning(f"注册失败: 手机号已存在", phone=phone)
return jsonify({"error": "该手机号已注册"}), 400 return jsonify({"error": "该手机号已注册"}), 400
# 验证邀请码(如果提供)
inviter = None
if invite_code:
inviter = User.query.filter_by(invite_code=invite_code).first()
if not inviter:
return jsonify({"error": "邀请码无效,请检查后重试"}), 400
# 获取默认角色:普通用户 # 获取默认角色:普通用户
from models import Role from models import Role
user_role = Role.query.filter_by(name='普通用户').first() user_role = Role.query.filter_by(name='普通用户').first()
user = User(phone=phone, role=user_role) # 生成唯一邀请码
import random
import string
def generate_invite_code():
while True:
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
if not User.query.filter_by(invite_code=code).first():
return code
user = User(phone=phone, role=user_role, invite_code=generate_invite_code())
if inviter:
user.invited_by = inviter.id
user.set_password(password) user.set_password(password)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
if inviter:
system_logger.info(f"用户注册成功(通过邀请码)", phone=phone, user_id=user.id, inviter_id=inviter.id)
else:
system_logger.info(f"用户注册成功", phone=phone, user_id=user.id) system_logger.info(f"用户注册成功", phone=phone, user_id=user.id)
return jsonify({"message": "注册成功"}) return jsonify({"message": "注册成功"})
@ -314,7 +360,216 @@ def me():
"full_phone": phone, # 某些场景可能需要完整号 "full_phone": phone, # 某些场景可能需要完整号
"api_key": user.api_key, "api_key": user.api_key,
"points": user.points, "points": user.points,
"hide_custom_key": (not user.api_key) or user.has_used_points # Key为空或使用过积分则隐藏自定义Key入口 "hide_custom_key": (not user.api_key) or user.has_used_points, # Key为空或使用过积分则隐藏自定义Key入口
"invite_code": user.invite_code # 用户邀请码
})
@auth_bp.route('/api/auth/invite_stats', methods=['GET'])
def get_my_invite_stats():
"""获取当前用户的邀请统计"""
user_id = session.get('user_id')
if not user_id:
return jsonify({"error": "请先登录"}), 401
user = db.session.get(User, user_id)
if not user:
return jsonify({"error": "用户不存在"}), 404
from models import InviteReward, Order
# 我邀请的用户数
invited_count = User.query.filter_by(invited_by=user_id).count()
# 我获得的邀请奖励总积分
total_rewards = db.session.query(db.func.sum(InviteReward.reward_points)).filter(
InviteReward.inviter_id == user_id
).scalar() or 0
# 我邀请的用户列表(脱敏)
invited_users = User.query.filter_by(invited_by=user_id).order_by(User.created_at.desc()).limit(20).all()
invited_list = []
for u in invited_users:
# 脱敏手机号
masked = u.phone[:3] + "****" + u.phone[-4:] if len(u.phone) >= 11 else u.phone
# 计算该用户给我带来的奖励
user_rewards = db.session.query(db.func.sum(InviteReward.reward_points)).filter(
InviteReward.inviter_id == user_id,
InviteReward.invitee_id == u.id
).scalar() or 0
invited_list.append({
"phone": masked,
"registered_at": u.created_at_bj.strftime('%Y-%m-%d') if u.created_at else None,
"rewards_earned": int(user_rewards)
})
return jsonify({
"invite_code": user.invite_code,
"invited_count": invited_count,
"total_rewards": int(total_rewards),
"invited_users": invited_list
})
@auth_bp.route('/api/auth/point_history', methods=['GET'])
def get_point_history():
"""获取当前用户的积分变动历史记录"""
user_id = session.get('user_id')
if not user_id:
return jsonify({"error": "请先登录"}), 401
from models import Order, PointsGrant, InviteReward, User
history = []
# 1. 充值记录
orders = Order.query.filter_by(user_id=user_id, status='PAID').all()
for o in orders:
history.append({
"type": "recharge",
"type_label": "账户充值",
"amount": f"+{o.points}",
"desc": f"在线支付 ¥{o.amount}",
"time": o.paid_at_bj.strftime('%Y-%m-%d %H:%M') if o.paid_at_bj else o.created_at_bj.strftime('%Y-%m-%d %H:%M')
})
# 2. 手动发放记录
grants = PointsGrant.query.filter_by(user_id=user_id).all()
for g in grants:
history.append({
"type": "grant",
"type_label": "系统发放",
"amount": f"+{g.points}",
"desc": g.reason or "管理员手动调账",
"time": g.created_at_bj.strftime('%Y-%m-%d %H:%M')
})
# 3. 邀请奖励记录
rewards = InviteReward.query.filter_by(inviter_id=user_id).all()
for r in rewards:
# 查找被邀请人信息
invitee = db.session.get(User, r.invitee_id)
phone = invitee.phone if invitee else "未知用户"
masked = phone[:3] + "****" + phone[-4:] if len(phone) >= 11 else phone
history.append({
"type": "reward",
"type_label": "邀请奖励",
"amount": f"+{r.reward_points}",
"desc": f"好友({masked})充值返利",
"time": r.created_at_bj.strftime('%Y-%m-%d %H:%M')
})
# 4. 消费记录 (GenerationRecord)
from models import GenerationRecord
consumptions = GenerationRecord.query.filter_by(user_id=user_id).order_by(GenerationRecord.created_at.desc()).limit(100).all()
for c in consumptions:
# 截断 prompt
action = c.prompt[:20] + '...' if c.prompt and len(c.prompt) > 20 else (c.prompt or "AI生成")
history.append({
"type": "consumption",
"type_label": "积分消费",
"amount": f"-{c.cost}",
"desc": f"[{c.model or 'Default'}] {action}",
"time": c.created_at_bj.strftime('%Y-%m-%d %H:%M')
})
# 按时间降序排序
history.sort(key=lambda x: x['time'], reverse=True)
# 手动分页
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 5, type=int)
total = len(history)
start = (page - 1) * per_page
end = start + per_page
import math
return jsonify({
"details": history[start:end],
"total": total,
"pages": math.ceil(total / per_page),
"current_page": page
})
@auth_bp.route('/api/auth/point_stats', methods=['GET'])
def get_point_stats():
"""获取用户过去 7 天的积分变动统计(充值 vs 消费)"""
user_id = session.get('user_id')
if not user_id:
return jsonify({"error": "请先登录"}), 401
from models import Order, GenerationRecord, get_bj_now
from datetime import timedelta
end_date = get_bj_now().date()
start_date = end_date - timedelta(days=6)
labels = []
consumption_data = []
recharge_data = []
curr = start_date
while curr <= end_date:
labels.append(curr.strftime('%m-%d'))
# 当天充值 (PAID 订单)
day_recharge = db.session.query(db.func.sum(Order.points)).filter(
Order.user_id == user_id,
Order.status == 'PAID',
db.func.date(Order.paid_at) == curr
).scalar() or 0
# 当天消费 (生成记录)
day_consumption = db.session.query(db.func.sum(GenerationRecord.cost)).filter(
GenerationRecord.user_id == user_id,
db.func.date(GenerationRecord.created_at) == curr
).scalar() or 0
recharge_data.append(int(day_recharge))
consumption_data.append(int(day_consumption))
curr += timedelta(days=1)
return jsonify({
"labels": labels,
"consumption": consumption_data,
"recharge": recharge_data
})
@auth_bp.route('/api/auth/consumption_details', methods=['GET'])
def get_consumption_details():
"""获取用户积分消费明细记录"""
user_id = session.get('user_id')
if not user_id:
return jsonify({"error": "请先登录"}), 401
from models import GenerationRecord
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
pagination = GenerationRecord.query.filter_by(user_id=user_id).order_by(GenerationRecord.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
details = []
for r in pagination.items:
# 截断 prompt 作为动作描述
action = r.prompt[:30] + '...' if r.prompt and len(r.prompt) > 30 else (r.prompt or "生成图片")
details.append({
"id": r.id,
"action": action,
"model": r.model or "Default",
"cost": f"-{r.cost}",
"time": r.created_at_bj.strftime('%Y-%m-%d %H:%M')
})
return jsonify({
"details": details,
"total": pagination.total,
"pages": pagination.pages,
"current_page": pagination.page
}) })
@auth_bp.route('/api/auth/change_password', methods=['POST']) @auth_bp.route('/api/auth/change_password', methods=['POST'])
@ -361,6 +616,7 @@ def get_user_menu(user):
{"name": "AI 视频创作", "icon": "video", "url": "/video", "perm": None}, {"name": "AI 视频创作", "icon": "video", "url": "/video", "perm": None},
{"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None}, {"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None},
{"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None}, {"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None},
{"name": "积分发放管理", "icon": "gift", "url": "/admin/points", "perm": "manage_system"},
{"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"}, {"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"},
{"name": "系统字典管理", "icon": "book-open", "url": "/dicts", "perm": "manage_dicts"}, {"name": "系统字典管理", "icon": "book-open", "url": "/dicts", "perm": "manage_dicts"},
{"name": "系统通知管理", "icon": "megaphone", "url": "/notifications", "perm": "manage_notifications"}, {"name": "系统通知管理", "icon": "megaphone", "url": "/notifications", "perm": "manage_notifications"},

View File

@ -161,6 +161,43 @@ def payment_notify():
user.points += order.points user.points += order.points
system_logger.info(f"订单支付成功", order_id=out_trade_no, points=order.points, user_id=user.id) system_logger.info(f"订单支付成功", order_id=out_trade_no, points=order.points, user_id=user.id)
# ========== 邀请奖励逻辑 ==========
if user.invited_by:
from models import InviteReward
# 统计该被邀请人之前已经完成多少次充值(不含本次)
paid_count = Order.query.filter(
Order.user_id == user.id,
Order.status == 'PAID',
Order.id != order.id # 排除本次订单
).count()
# 只有前3次充值才有奖励paid_count 是之前的次数0/1/2 表示这是第1/2/3次
if paid_count < 3:
inviter = db.session.get(User, user.invited_by)
if inviter:
# 计算10%奖励积分(四舍五入)
reward_points = round(order.points * 0.1)
if reward_points > 0:
inviter.points += reward_points
# 记录邀请奖励
invite_reward = InviteReward(
inviter_id=inviter.id,
invitee_id=user.id,
order_id=order.id,
reward_points=reward_points,
recharge_count=paid_count + 1 # 第几次充值
)
db.session.add(invite_reward)
system_logger.info(
f"邀请奖励发放成功",
inviter_id=inviter.id,
invitee_id=user.id,
reward_points=reward_points,
recharge_count=paid_count + 1
)
# ========== 邀请奖励逻辑结束 ==========
db.session.commit() db.session.commit()
return "success" return "success"
elif order: elif order:
@ -174,3 +211,4 @@ def payment_notify():
system_logger.error(f"处理异步通知异常: {str(e)}") system_logger.error(f"处理异步通知异常: {str(e)}")
db.session.rollback() db.session.rollback()
return "fail" return "fail"

View File

@ -58,6 +58,10 @@ class User(db.Model):
role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
created_at = db.Column(db.DateTime, default=get_bj_now) created_at = db.Column(db.DateTime, default=get_bj_now)
# 邀请系统字段
invite_code = db.Column(db.String(10), unique=True) # 用户专属邀请码
invited_by = db.Column(db.Integer, db.ForeignKey('users.id')) # 邀请人ID
@property @property
def created_at_bj(self): def created_at_bj(self):
return to_bj_time(self.created_at) return to_bj_time(self.created_at)
@ -203,3 +207,41 @@ class SavedPrompt(db.Model):
return to_bj_time(self.created_at) return to_bj_time(self.created_at)
user = db.relationship('User', backref=db.backref('saved_prompts', lazy='dynamic', order_by='SavedPrompt.created_at.desc()')) user = db.relationship('User', backref=db.backref('saved_prompts', lazy='dynamic', order_by='SavedPrompt.created_at.desc()'))
class PointsGrant(db.Model):
"""积分发放记录(管理员手动发放)"""
__tablename__ = 'points_grants'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
points = db.Column(db.Integer, nullable=False) # 发放积分数
reason = db.Column(db.String(200)) # 发放原因
admin_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # 操作管理员
created_at = db.Column(db.DateTime, default=get_bj_now)
@property
def created_at_bj(self):
return to_bj_time(self.created_at)
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('received_grants', lazy='dynamic'))
admin = db.relationship('User', foreign_keys=[admin_id])
class InviteReward(db.Model):
"""邀请奖励记录(被邀请人充值时,邀请人获得的奖励)"""
__tablename__ = 'invite_rewards'
id = db.Column(db.Integer, primary_key=True)
inviter_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # 邀请人ID
invitee_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # 被邀请人ID
order_id = db.Column(db.Integer, db.ForeignKey('orders.id'), nullable=False) # 触发奖励的订单
reward_points = db.Column(db.Integer, nullable=False) # 奖励积分数
recharge_count = db.Column(db.Integer, nullable=False) # 这是被邀请人的第几次充值1-3
created_at = db.Column(db.DateTime, default=get_bj_now)
@property
def created_at_bj(self):
return to_bj_time(self.created_at)
inviter = db.relationship('User', foreign_keys=[inviter_id], backref=db.backref('invite_rewards', lazy='dynamic'))
invitee = db.relationship('User', foreign_keys=[invitee_id])
order = db.relationship('Order', backref=db.backref('invite_reward', uselist=False))

View File

@ -16,6 +16,7 @@ const updateUI = () => {
const forgotBtn = getEl('forgotPwdBtn'); const forgotBtn = getEl('forgotPwdBtn');
const smsGroup = getEl('smsGroup'); const smsGroup = getEl('smsGroup');
const captchaGroup = getEl('captchaGroup'); const captchaGroup = getEl('captchaGroup');
const inviteGroup = getEl('inviteGroup');
if (!title || !sub || !submitBtnSpan || !switchBtn || !forgotBtn || !smsGroup || !captchaGroup) return; if (!title || !sub || !submitBtnSpan || !switchBtn || !forgotBtn || !smsGroup || !captchaGroup) return;
@ -27,6 +28,7 @@ const updateUI = () => {
forgotBtn.classList.add('hidden'); forgotBtn.classList.add('hidden');
smsGroup.classList.remove('hidden'); smsGroup.classList.remove('hidden');
captchaGroup.classList.remove('hidden'); // 注册模式默认显示图形验证码防止刷短信 captchaGroup.classList.remove('hidden'); // 注册模式默认显示图形验证码防止刷短信
if (inviteGroup) inviteGroup.classList.remove('hidden');
} else if (authMode === 2) { // Reset Password } else if (authMode === 2) { // Reset Password
title.innerText = "重置密码"; title.innerText = "重置密码";
sub.innerText = "验证短信以设置新密码"; sub.innerText = "验证短信以设置新密码";
@ -35,6 +37,7 @@ const updateUI = () => {
forgotBtn.classList.add('hidden'); forgotBtn.classList.add('hidden');
smsGroup.classList.remove('hidden'); smsGroup.classList.remove('hidden');
captchaGroup.classList.remove('hidden'); // 重置模式默认显示 captchaGroup.classList.remove('hidden'); // 重置模式默认显示
if (inviteGroup) inviteGroup.classList.add('hidden');
} else { // Login } else { // Login
title.innerText = "欢迎回来"; title.innerText = "欢迎回来";
sub.innerText = "请登录以开启 AI 创作之旅"; sub.innerText = "请登录以开启 AI 创作之旅";
@ -43,13 +46,23 @@ const updateUI = () => {
forgotBtn.classList.remove('hidden'); forgotBtn.classList.remove('hidden');
smsGroup.classList.add('hidden'); smsGroup.classList.add('hidden');
captchaGroup.classList.add('hidden'); // 登录默认隐藏,除非高频失败 captchaGroup.classList.add('hidden'); // 登录默认隐藏,除非高频失败
if (inviteGroup) inviteGroup.classList.add('hidden');
} }
}; };
const refreshCaptcha = () => { const refreshCaptcha = () => {
const phone = getEl('authPhone')?.value; const phone = getEl('authPhone')?.value;
const captchaImg = getEl('captchaImg'); const captchaImg = getEl('captchaImg');
if (!phone || !captchaImg) return; if (!captchaImg) return;
if (!phone) {
// 如果没有手机号,不加载,或者显示占位(此处保持空,等待用户输入)
return;
}
// 简单的手机号长度检查,避免无效请求
if (phone.length < 11) return;
captchaImg.src = `/api/auth/captcha?phone=${phone}&t=${Date.now()}`; captchaImg.src = `/api/auth/captcha?phone=${phone}&t=${Date.now()}`;
}; };
@ -67,6 +80,7 @@ const handleAuth = async () => {
if (authMode === 1) { if (authMode === 1) {
url = '/api/auth/register'; url = '/api/auth/register';
body.code = getEl('authCode')?.value; body.code = getEl('authCode')?.value;
body.invite_code = getEl('authInvite')?.value; // 获取邀请码
} else if (authMode === 0) { } else if (authMode === 0) {
url = '/api/auth/login'; url = '/api/auth/login';
if (!getEl('captchaGroup')?.classList.contains('hidden')) { if (!getEl('captchaGroup')?.classList.contains('hidden')) {
@ -149,6 +163,15 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
// 监听手机号输入,自动刷新验证码
const phoneInput = getEl('authPhone');
if (phoneInput) {
phoneInput.addEventListener('blur', refreshCaptcha);
phoneInput.addEventListener('input', (e) => {
if (e.target.value.length === 11) refreshCaptcha();
});
}
getEl('sendSmsBtn').onclick = async () => { getEl('sendSmsBtn').onclick = async () => {
const phone = getEl('authPhone')?.value; const phone = getEl('authPhone')?.value;
const captcha = getEl('authCaptcha')?.value; const captcha = getEl('authCaptcha')?.value;
@ -199,4 +222,20 @@ document.addEventListener('DOMContentLoaded', () => {
btn.disabled = false; btn.disabled = false;
} }
}; };
// 处理邀请链接逻辑
const urlParams = new URLSearchParams(window.location.search);
const inviteCode = urlParams.get('invite_code');
if (inviteCode) {
authMode = 1; // 切换到注册模式
updateUI();
refreshCaptcha(); // 注册需验证码
const inviteInput = getEl('authInvite');
if (inviteInput) {
inviteInput.value = inviteCode;
inviteInput.disabled = true; // 锁定邀请码
inviteInput.classList.add('bg-indigo-50', 'text-indigo-600', 'font-bold');
showToast('已自动填入邀请码', 'success');
}
}
}); });

View File

@ -755,7 +755,7 @@ window.addEventListener('message', (e) => {
} }
}); });
// --- 积分与钱包中心逻辑 --- // --- 积分与钱包中心逻辑 (Modern Modal Version) ---
let pointsChart = null; let pointsChart = null;
async function openPointsModal() { async function openPointsModal() {
@ -767,11 +767,12 @@ async function openPointsModal() {
modal.querySelector('div').classList.remove('scale-95'); modal.querySelector('div').classList.remove('scale-95');
}, 10); }, 10);
// 加载数据 // 加载核心数据
loadPointStats(); loadPointStats();
loadPointDetails(); loadPointDetails(1);
loadInviteStatsModal();
// 更新当前余额 // 实时更新余额显示
const r = await fetch('/api/auth/me'); const r = await fetch('/api/auth/me');
const d = await r.json(); const d = await r.json();
if (d.logged_in) { if (d.logged_in) {
@ -781,26 +782,23 @@ async function openPointsModal() {
function closePointsModal() { function closePointsModal() {
const modal = document.getElementById('pointsModal'); const modal = document.getElementById('pointsModal');
if (!modal) return;
modal.classList.remove('opacity-100'); modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95'); modal.querySelector('div').classList.add('scale-95');
setTimeout(() => modal.classList.add('hidden'), 300); setTimeout(() => modal.classList.add('hidden'), 300);
} }
async function loadPointStats() { async function loadPointStats() {
const r = await fetch('/api/stats/points?days=7'); try {
const r = await fetch('/api/auth/point_stats');
const d = await r.json(); const d = await r.json();
const canvas = document.getElementById('pointsChart'); const canvas = document.getElementById('modalTrendChart');
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (pointsChart) pointsChart.destroy(); if (pointsChart) pointsChart.destroy();
if (typeof Chart === 'undefined') {
console.error('Chart.js not loaded');
return;
}
pointsChart = new Chart(ctx, { pointsChart = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
@ -808,11 +806,10 @@ async function loadPointStats() {
datasets: [ datasets: [
{ {
label: '消耗积分', label: '消耗积分',
data: d.deductions, data: d.consumption,
borderColor: '#6366f1', borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)', backgroundColor: 'rgba(99, 102, 241, 0.05)',
borderWidth: 3, borderWidth: 3,
fill: true, fill: true,
tension: 0.4, tension: 0.4,
pointRadius: 4, pointRadius: 4,
@ -820,9 +817,9 @@ async function loadPointStats() {
}, },
{ {
label: '充值积分', label: '充值积分',
data: d.incomes, data: d.recharge,
borderColor: '#10b981', borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)', backgroundColor: 'rgba(16, 185, 129, 0.05)',
borderWidth: 3, borderWidth: 3,
fill: true, fill: true,
tension: 0.4, tension: 0.4,
@ -835,52 +832,130 @@ async function loadPointStats() {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { display: false } legend: { display: false },
tooltip: {
backgroundColor: '#1e293b',
padding: 12,
cornerRadius: 12,
titleFont: { weight: 'bold' },
bodyFont: { weight: '900' }
}
}, },
scales: { scales: {
y: { y: {
beginAtZero: true, beginAtZero: true,
grid: { color: 'rgba(241, 245, 249, 1)' }, grid: { color: '#f1f5f9' },
ticks: { font: { weight: 'bold' } } ticks: { font: { weight: 'bold', size: 10 }, color: '#94a3b8' }
}, },
x: { x: {
grid: { display: false }, grid: { display: false },
ticks: { font: { weight: 'bold' } } ticks: { font: { weight: 'bold', size: 10 }, color: '#94a3b8' }
} }
} }
} }
}); });
} catch (e) {
console.error('加载统计图失败:', e);
}
} }
async function loadPointDetails() { async function loadPointDetails(page = 1) {
const body = document.getElementById('pointDetailsBody'); const body = document.getElementById('pointDetailsBody');
const pagContainer = document.getElementById('pointDetailsPagination');
if (!body) return; if (!body) return;
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-400">正在加载明细...</td></tr>';
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-400">正在同步账户流水...</td></tr>';
try { try {
const r = await fetch('/api/stats/details?page=1'); const r = await fetch(`/api/auth/point_history?page=${page}&per_page=5`);
const d = await r.json(); const d = await r.json();
if (d.items.length === 0) { if (d.details.length === 0) {
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-400">暂无积分变动记录</td></tr>'; body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-300 italic">暂无资金变动记录</td></tr>';
if (pagContainer) pagContainer.innerHTML = '';
return; return;
} }
body.innerHTML = d.items.map(item => ` body.innerHTML = d.details.map(item => {
<tr class="hover:bg-slate-50 transition-colors border-b border-slate-50"> const isAdd = item.amount.startsWith('+');
const colorClass = isAdd ? 'text-emerald-500' : 'text-rose-500';
const iconColor = isAdd ? 'bg-emerald-500' : 'bg-rose-500';
return `
<tr class="hover:bg-slate-50 transition-colors">
<td class="px-8 py-5"> <td class="px-8 py-5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-3 text-slate-700">
<div class="w-2 h-2 rounded-full ${item.type === 'deduction' ? 'bg-indigo-500' : 'bg-emerald-500'}"></div> <div class="w-2 h-2 rounded-full ${iconColor}"></div>
<span class="truncate max-w-[200px]">${item.desc}</span> <div class="flex flex-col">
<span class="font-bold text-xs">${item.type_label}</span>
<span class="text-[10px] text-slate-400 truncate max-w-[200px]">${item.desc}</span>
</div>
</div> </div>
</td> </td>
<td class="px-8 py-5 text-slate-400 uppercase font-black text-[10px] tracking-widest">${item.model}</td> <td class="px-8 py-5">
<td class="px-8 py-5 font-black ${item.type === 'deduction' ? 'text-rose-500' : 'text-emerald-500'}">${item.change}</td> <span class="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[9px] font-black uppercase tracking-tighter border border-slate-200">
<td class="px-8 py-5 text-right font-mono text-slate-400">${item.time}</td> ${item.type === 'consumption' ? 'MODEL' : 'SYSTEM'}
</span>
</td>
<td class="px-8 py-5">
<span class="${colorClass} font-black">${item.amount}</span>
</td>
<td class="px-8 py-5 text-right font-bold text-slate-300 text-[10px] tracking-tighter">${item.time}</td>
</tr> </tr>
`).join(''); `;
}).join('');
// 渲染分页
if (pagContainer) {
if (d.pages > 1) {
let html = '';
if (d.current_page > 1) {
html += `<button onclick="loadPointDetails(${d.current_page - 1})" class="px-3 py-1.5 bg-white border border-slate-100 rounded-lg text-[10px] font-black hover:text-indigo-600 transition-all">Prev</button>`;
}
html += `<span class="px-4 py-1.5 text-[9px] font-black text-slate-400 uppercase">Page ${d.current_page} / ${d.pages}</span>`;
if (d.current_page < d.pages) {
html += `<button onclick="loadPointDetails(${d.current_page + 1})" class="px-3 py-1.5 bg-white border border-slate-100 rounded-lg text-[10px] font-black hover:text-indigo-600 transition-all">Next</button>`;
}
pagContainer.innerHTML = html;
} else {
pagContainer.innerHTML = '';
}
}
} catch (e) { } catch (e) {
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-rose-500">加载失败</td></tr>'; body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-rose-400 font-bold">流水加载异常</td></tr>';
}
}
async function loadInviteStatsModal() {
try {
const r = await fetch('/api/auth/invite_stats');
const d = await r.json();
if (d.invite_code) {
document.getElementById('modalInviteCode').innerText = d.invite_code;
document.getElementById('modalInvitedCount').innerText = d.invited_count;
document.getElementById('modalTotalRewards').innerText = d.total_rewards;
}
} catch (e) { console.error('加载邀请统计失败', e); }
}
function copyInviteCodeModal() {
const code = document.getElementById('modalInviteCode').innerText;
if (code === '---') return;
const link = `${window.location.origin}/login?invite_code=${code}`;
navigator.clipboard.writeText(link).then(() => showToast('专属邀请链接已复制', 'success'));
}
function scrollToModalRecharge() {
const area = document.getElementById('modalRechargeArea');
if (area) area.scrollIntoView({ behavior: 'smooth' });
}
function submitModalRecharge(pkgId) {
const form = document.getElementById('modalRechargeForm');
const input = document.getElementById('modalPackageId');
if (form && input) {
input.value = pkgId;
form.submit();
} }
} }

View File

@ -1,322 +1,284 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}购买积分 - AI 视界{% endblock %} {% block title %}充值与邀请 - AI 视界{% endblock %}
{% block content %} {% block content %}
<div class="w-full h-full overflow-y-auto p-8 lg:p-12 custom-scrollbar"> <div class="w-full h-full overflow-y-auto p-4 lg:p-10 custom-scrollbar bg-slate-50/20">
<div class="max-w-4xl mx-auto space-y-12"> <div class="max-w-6xl w-full mx-auto flex flex-col space-y-12 pb-20">
<div class="flex items-center gap-4">
<div
class="w-12 h-12 bg-amber-500 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-amber-200">
<i data-lucide="shopping-cart" class="w-7 h-7"></i>
</div>
<div>
<h1 class="text-3xl font-black text-slate-900 tracking-tight">充值积分</h1>
<p class="text-slate-400 text-sm">选择适合您的积分套餐,开启无限创作空间</p>
</div>
</div>
{% if success %} <!-- Top Header -->
<div class="flex items-center justify-between shrink-0">
<div class="flex items-center gap-5">
<div <div
class="bg-emerald-50 border border-emerald-100 rounded-3xl p-6 flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500"> class="w-14 h-14 bg-gradient-to-br from-indigo-500 to-indigo-700 text-white rounded-2xl flex items-center justify-center shadow-2xl shadow-indigo-100">
<div <i data-lucide="crown" class="w-8 h-8"></i>
class="w-12 h-12 bg-emerald-500 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-emerald-100">
<i data-lucide="check" class="w-7 h-7"></i>
</div> </div>
<div> <div>
<h3 class="text-emerald-900 font-black">支付成功!</h3> <h1 class="text-3xl font-black text-slate-900 tracking-tight">充值中心</h1>
<p class="text-emerald-600 text-xs font-bold">您的订单 #{{ order.out_trade_no if order else '' }} 已完成,积分已到账。 <p class="text-slate-400 text-xs font-black uppercase tracking-widest opacity-60">Recharge & Invite
</p> </p>
</div> </div>
</div> </div>
{% endif %} <div class="flex gap-2">
<div class="relative group">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6">
<!-- 套餐 1 -->
<div class="bg-white rounded-[2.5rem] border border-slate-100 p-8 shadow-xl group">
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4">体验测试</div>
<div class="flex items-baseline gap-1 mb-6">
<span class="text-4xl font-black text-slate-900">50</span>
<span class="text-sm text-slate-400 font-bold">积分</span>
</div>
<div class="space-y-4 mb-8">
<div class="flex items-center gap-2 text-xs font-bold text-slate-600">
<i data-lucide="check-circle-2" class="w-4 h-4 text-emerald-500"></i>
<span>快速开启 AI 体验</span>
</div>
</div>
<form action="/payment/create" method="POST">
<input type="hidden" name="package_id" value="50">
<button type="submit"
class="w-full py-4 rounded-2xl bg-slate-900 text-white font-black text-sm">
¥ 5.00 购买
</button>
</form>
</div>
<!-- 套餐 2 -->
<div class="bg-white rounded-[2.5rem] border border-slate-100 p-8 shadow-xl relative">
<div
class="absolute -top-3 right-6 bg-indigo-600 text-white text-[8px] font-black px-3 py-1 rounded-full shadow-lg uppercase">
推荐</div>
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4">普通创作</div>
<div class="flex items-baseline gap-1 mb-6">
<span class="text-4xl font-black text-slate-900">200</span>
<span class="text-sm text-slate-400 font-bold">积分</span>
</div>
<div class="space-y-4 mb-8">
<div class="flex items-center gap-2 text-xs font-bold text-slate-600">
<i data-lucide="check-circle-2" class="w-4 h-4 text-emerald-500"></i>
<span>满足日常生成需求</span>
</div>
</div>
<form action="/payment/create" method="POST">
<input type="hidden" name="package_id" value="200">
<button type="submit"
class="w-full py-4 rounded-2xl bg-indigo-600 text-white font-black text-sm">
¥ 20.00 购买
</button>
</form>
</div>
<!-- 套餐 3 -->
<div class="bg-indigo-900 rounded-[2.5rem] border border-indigo-800 p-8 shadow-2xl">
<div class="text-[10px] font-black text-indigo-300 uppercase tracking-widest mb-4">量大管饱</div>
<div class="flex items-baseline gap-1 mb-6">
<span class="text-4xl font-black text-white">1000</span>
<span class="text-sm text-indigo-300 font-bold">积分</span>
</div>
<div class="space-y-4 mb-8">
<div class="flex items-center gap-2 text-xs font-bold text-indigo-100">
<i data-lucide="check-circle-2" class="w-4 h-4 text-amber-400"></i>
<span>创作自由,无需等待</span>
</div>
</div>
<form action="/payment/create" method="POST">
<input type="hidden" name="package_id" value="1000">
<button type="submit"
class="w-full py-4 rounded-2xl bg-white text-indigo-900 font-black text-sm">
¥ 100.00 购买
</button>
</form>
</div>
<!-- 套餐 4 -->
<div class="bg-amber-500 rounded-[2.5rem] border border-amber-400 p-8 shadow-2xl">
<div class="text-[10px] font-black text-amber-900 uppercase tracking-widest mb-4">豪掷千金</div>
<div class="flex items-baseline gap-1 mb-6">
<span class="text-4xl font-black text-slate-900">5000</span>
<span class="text-sm text-amber-900 font-bold">积分</span>
</div>
<div class="space-y-4 mb-8">
<div class="flex items-center gap-2 text-xs font-bold text-amber-900">
<i data-lucide="check-circle-2" class="w-4 h-4 text-white"></i>
<span>至尊权限,顶级服务</span>
</div>
</div>
<form action="/payment/create" method="POST">
<input type="hidden" name="package_id" value="5000">
<button type="submit"
class="w-full py-4 rounded-2xl bg-slate-900 text-white font-black text-sm">
¥ 500.00 购买
</button>
</form>
</div>
</div>
</div>
<!-- 支付提示 -->
<div class="bg-slate-100/50 rounded-3xl p-6 text-center">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
<i data-lucide="shield-check" class="w-3 h-3 inline-block mr-1"></i>
支付安全保障 · 充值实时到账
</p>
</div>
<!-- 充值记录区域 -->
<div class="space-y-8 pt-8 border-t border-slate-200">
<!-- 标签页切换 -->
<div class="flex items-center gap-4 bg-slate-100 p-1.5 rounded-2xl w-fit">
<button onclick="switchTab('personal')" id="tabBtn-personal"
class="px-6 py-2.5 rounded-xl text-sm font-black transition-all bg-white text-slate-900 shadow-sm">
我的充值
</button>
{% if is_admin %} {% if is_admin %}
<button onclick="switchTab('admin')" id="tabBtn-admin" <a href="/admin/orders"
class="px-6 py-2.5 rounded-xl text-sm font-black transition-all text-slate-500 hover:text-slate-700"> class="p-3 bg-white/50 text-indigo-600 border border-indigo-100 rounded-2xl hover:bg-white transition-colors flex items-center gap-2 font-bold text-xs">
全员记录 (管理员) <i data-lucide="shield-check" class="w-4 h-4"></i>
</button> 管理后台
</a>
{% endif %} {% endif %}
<button onclick="window.history.back()"
class="p-3 text-slate-300 hover:text-slate-600 transition-colors">
<i data-lucide="x" class="w-7 h-7"></i>
</button>
</div>
</div> </div>
<!-- 我的充值列表 --> <!-- 1. Recharge Packages Section -->
<div id="tab-personal" <div class="space-y-6">
class="bg-white/70 backdrop-blur-xl rounded-[2.5rem] border border-slate-200/50 shadow-2xl overflow-hidden animate-in fade-in duration-500"> <div class="flex items-center justify-between px-2">
<div class="overflow-x-auto"> <h3 class="text-sm font-black text-slate-900 tracking-widest uppercase italic">选择充值套餐</h3>
<table class="w-full text-left border-collapse"> <div class="flex items-center gap-2">
<div class="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
<span class="text-[9px] font-black text-slate-400">即时到账</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Package 50 -->
<div onclick="submitRecharge('50')"
class="bg-white p-6 rounded-[2.5rem] border border-slate-100 shadow-xl shadow-slate-200/30 hover:border-indigo-400 cursor-pointer transition-all group relative overflow-hidden">
<div class="flex justify-between items-start mb-4 relative z-10">
<div
class="w-10 h-10 bg-slate-50 rounded-xl flex items-center justify-center text-slate-400 group-hover:text-indigo-600 transition-colors">
<i data-lucide="zap" class="w-5 h-5"></i>
</div>
<span class="text-lg font-black text-slate-900">¥5.00</span>
</div>
<div class="relative z-10">
<h5 class="text-xl font-black text-slate-800">50 <small
class="text-[10px] opacity-40">Pts</small></h5>
<p class="text-[9px] font-bold text-slate-400 mt-1">个人尝鲜包</p>
</div>
</div>
<!-- Package 200 (Hot) -->
<div onclick="submitRecharge('200')"
class="bg-white p-6 rounded-[2.5rem] border-2 border-indigo-500 shadow-xl shadow-indigo-100/40 hover:scale-105 cursor-pointer transition-all relative ring-4 ring-indigo-50/50">
<div
class="absolute -top-3 -right-3 px-3 py-1 bg-indigo-600 text-white rounded-full text-[9px] font-black shadow-lg z-20">
HOT</div>
<div class="flex justify-between items-start mb-4">
<div class="w-10 h-10 bg-indigo-50 rounded-xl flex items-center justify-center text-indigo-600">
<i data-lucide="zap" class="w-5 h-5"></i>
</div>
<span class="text-lg font-black text-slate-900">¥20.00</span>
</div>
<h5 class="text-xl font-black text-slate-800">200 <small class="text-[10px] opacity-40">Pts</small>
</h5>
<p class="text-[9px] font-bold text-indigo-500 mt-1 uppercase">全站最受欢迎</p>
</div>
<!-- Package 1000 -->
<div onclick="submitRecharge('1000')"
class="bg-white p-6 rounded-[2.5rem] border border-slate-100 shadow-xl shadow-slate-200/30 hover:border-indigo-400 cursor-pointer transition-all group">
<div class="flex justify-between items-start mb-4">
<div
class="w-10 h-10 bg-slate-50 rounded-xl flex items-center justify-center text-slate-400 group-hover:text-indigo-600 transition-colors">
<i data-lucide="zap" class="w-5 h-5"></i>
</div>
<span class="text-lg font-black text-slate-900">¥100.00</span>
</div>
<h5 class="text-xl font-black text-slate-800">1000 <small class="text-[10px] opacity-40">Pts</small>
</h5>
<p class="text-[9px] font-bold text-slate-400 mt-1">工作室/创作包</p>
</div>
<!-- Package 5000 -->
<div onclick="submitRecharge('5000')"
class="bg-slate-900 p-6 rounded-[2.5rem] border border-slate-800 shadow-2xl hover:bg-slate-800 cursor-pointer transition-all group text-white">
<div class="flex justify-between items-start mb-4">
<div class="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center text-indigo-400">
<i data-lucide="crown" class="w-5 h-5"></i>
</div>
<span class="text-lg font-black">¥500.00</span>
</div>
<h5 class="text-xl font-black">5000 <small class="text-[10px] opacity-40">Pts</small></h5>
<p class="text-[9px] font-bold text-indigo-300 mt-1">旗舰版海量包</p>
</div>
</div>
<form id="rechargeForm" action="/payment/create" method="POST" class="hidden">
<input type="hidden" name="package_id" id="packageIdInput">
</form>
</div>
<!-- 2. Invitation Section -->
<div id="invite-section"
class="bg-white rounded-[3rem] shadow-xl shadow-slate-200/50 border border-slate-100 overflow-hidden relative">
<div class="absolute top-0 right-0 p-6 opacity-5">
<i data-lucide="gift" class="w-64 h-64"></i>
</div>
<div class="p-10 relative z-10 flex flex-col md:flex-row items-center gap-12">
<div class="flex-1 space-y-6">
<div>
<h4 class="text-xl font-black text-slate-900 tracking-tight flex items-center gap-3">
<span class="text-indigo-600">邀请好友</span> 赚取佣金
</h4>
<p class="text-sm font-bold text-slate-400 mt-2 leading-relaxed">
每成功邀请一位好友注册,您将获得其前 3 次充值金额的 <span class="text-indigo-600 text-base">10%</span>
等值积分奖励。实时到账,无上限。
</p>
</div>
<div
class="bg-slate-50 border border-slate-100 p-6 rounded-[2rem] flex flex-col sm:flex-row items-center gap-6">
<div class="flex-1 w-full space-y-1">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">我的专属邀请码</p>
<div class="flex items-center gap-4">
<span id="myInviteCode"
class="text-3xl font-black text-indigo-900 tracking-widest cursor-pointer"
onclick="copyInviteLink()" title="点击复制链接">...</span>
<button onclick="copyInviteLink()"
class="p-2 bg-white border border-slate-200 rounded-xl hover:text-indigo-600 hover:border-indigo-200 transition-all">
<i data-lucide="link" class="w-4 h-4"></i>
</button>
</div>
</div>
<div class="h-10 w-px bg-slate-200 hidden sm:block"></div>
<div class="flex gap-8">
<div class="text-center">
<p class="text-[9px] font-black text-slate-400">已邀请</p>
<p id="myInvitedCount" class="text-xl font-black text-slate-800">0</p>
</div>
<div class="text-center">
<p class="text-[9px] font-black text-slate-400">累计获取</p>
<p id="myTotalRewards" class="text-xl font-black text-amber-500">0</p>
</div>
</div>
</div>
</div>
<!-- Illustration or Right Side -->
<div class="hidden md:flex items-center justify-center">
<div
class="w-20 h-20 bg-indigo-50 rounded-full flex items-center justify-center text-indigo-500 animate-bounce">
<i data-lucide="gift" class="w-8 h-8"></i>
</div>
</div>
</div>
</div>
<!-- 3. Recharge History (Order List) -->
<div class="space-y-6 pt-6">
<h3 class="text-sm font-black text-slate-900 tracking-widest uppercase italic px-2">最近充值记录</h3>
<div class="bg-white rounded-[2.5rem] shadow-sm border border-slate-100 overflow-hidden">
<table class="w-full text-left">
<thead> <thead>
<tr class="border-b border-slate-100 bg-slate-50/50"> <tr class="bg-slate-50/50 text-[10px] font-black text-slate-400 uppercase tracking-widest">
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest"> <th class="px-8 py-5">订单信息</th>
订单号</th> <th class="px-8 py-5">积分/金额</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest"> <th class="px-8 py-5">状态</th>
积分/金额</th> <th class="px-8 py-5 text-right">时间</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">状态
</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">
支付时间</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-50 text-sm font-bold text-slate-600">
{% if personal_orders %} {% if personal_orders %}
{% for order in personal_orders %} {% for order in personal_orders %}
<tr class="hover:bg-slate-50/50 transition-colors"> <tr class="hover:bg-slate-50/30 transition-colors">
<td class="px-8 py-5"> <td class="px-8 py-5">
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-xs font-bold text-slate-700">{{ order.out_trade_no }}</span> <span class="text-xs text-slate-900">{{ order.out_trade_no }}</span>
<span class="text-[10px] text-slate-400 font-mono">Ali: {{ order.trade_no or '-' <span class="text-[10px] text-slate-400 font-mono">Ali: {{ order.trade_no or '-'
}}</span> }}</span>
</div> </div>
</td> </td>
<td class="px-8 py-5"> <td class="px-8 py-5">
<div class="flex flex-col"> <div class="flex items-center gap-2">
<div class="flex items-center gap-1"> <span class="text-indigo-600">+{{ order.points }} Pts</span>
<i data-lucide="zap" class="w-3 h-3 text-amber-500"></i> <span class="text-[10px] text-slate-400">(¥{{ order.amount }})</span>
<span class="text-sm font-black text-slate-900">+{{ order.points }}</span>
</div>
<span class="text-[10px] font-bold text-slate-400">¥{{ order.amount }}</span>
</div> </div>
</td> </td>
<td class="px-8 py-5"> <td class="px-8 py-5">
{% if order.status == 'PAID' %} {% if order.status == 'PAID' %}
<span <span
class="px-2 py-1 bg-emerald-50 text-emerald-600 text-[10px] font-black rounded-lg border border-emerald-100">已完成</span> class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-50 text-emerald-600 text-[10px] font-black">
<span class="w-1.5 h-1.5 bg-emerald-500 rounded-full"></span> 已完成
</span>
{% elif order.status == 'PENDING' %} {% elif order.status == 'PENDING' %}
<span <span
class="px-2 py-1 bg-amber-50 text-amber-600 text-[10px] font-black rounded-lg border border-amber-100">待支付</span> class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-amber-50 text-amber-600 text-[10px] font-black">
<span class="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse"></span> 待支付
</span>
{% else %} {% else %}
<span <span class="text-slate-400 text-[10px]">已取消</span>
class="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-black rounded-lg border border-slate-100">已取消</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-8 py-5"> <td class="px-8 py-5 text-right text-slate-400 text-xs">
<span class="text-[10px] font-bold text-slate-400"> {{ order.paid_at_bj.strftime('%Y-%m-%d %H:%M') if order.paid_at_bj else
{% if order.paid_at_bj %} order.created_at_bj.strftime('%Y-%m-%d %H:%M') }}
{{ order.paid_at_bj.strftime('%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</span>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% else %} {% else %}
<tr> <tr>
<td colspan="4" class="px-8 py-16 text-center"> <td colspan="4" class="px-8 py-16 text-center text-slate-300 italic">
<p class="text-slate-300 font-bold text-sm">暂无记录</p> 暂无充值记录
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="p-6 bg-slate-50/50 border-t border-slate-100 text-center">
<a href="/payment/history"
class="text-[10px] font-black text-indigo-600 hover:text-indigo-700 uppercase tracking-widest flex items-center justify-center gap-1">
查看更多记录 <i data-lucide="chevron-right" class="w-3 h-3"></i>
</a>
</div>
</div>
{% if is_admin %} {% if personal_orders|length >= 10 %}
<!-- 管理员全员记录列表 --> <div class="text-center pt-2">
<div id="tab-admin" <a href="/recharge_history" class="text-xs font-bold text-indigo-500 hover:text-indigo-600">查看更多记录
class="hidden bg-white/70 backdrop-blur-xl rounded-[2.5rem] border border-slate-200/50 shadow-2xl overflow-hidden animate-in fade-in duration-500"> -></a>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-slate-100 bg-slate-50/50">
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">
用户信息</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">
订单号</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">
积分/金额</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">状态
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% if admin_orders %}
{% for order in admin_orders %}
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-8 py-5">
<div class="flex flex-col">
<span class="text-xs font-bold text-slate-700">{{ order.user.phone if order.user
else '未知' }}</span>
<span class="text-[10px] text-slate-400">UID: {{ order.user_id }}</span>
</div>
</td>
<td class="px-8 py-5">
<div class="flex flex-col">
<span class="text-[10px] font-bold text-slate-500">{{ order.out_trade_no
}}</span>
</div>
</td>
<td class="px-8 py-5">
<div class="flex flex-col">
<span class="text-xs font-black text-slate-900">+{{ order.points }}</span>
<span class="text-[10px] font-bold text-slate-400">¥{{ order.amount }}</span>
</div>
</td>
<td class="px-8 py-5">
{% if order.status == 'PAID' %}
<span
class="px-2 py-1 bg-emerald-50 text-emerald-600 text-[10px] font-black rounded-lg border border-emerald-100">已完成</span>
{% else %}
<span
class="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-black rounded-lg border border-slate-100">{{
order.status }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<div class="p-6 bg-slate-50/50 border-t border-slate-100 text-center">
<a href="/admin/orders"
class="text-[10px] font-black text-indigo-600 hover:text-indigo-700 uppercase tracking-widest flex items-center justify-center gap-1">
进入后台管理全员订单 <i data-lucide="chevron-right" class="w-3 h-3"></i>
</a>
</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
function switchTab(tab) { // --- Recharge Logic ---
// 切换内容显隐 function submitRecharge(pkgId) {
document.getElementById('tab-personal').classList.toggle('hidden', tab !== 'personal'); document.getElementById('packageIdInput').value = pkgId;
const adminTab = document.getElementById('tab-admin'); document.getElementById('rechargeForm').submit();
if (adminTab) adminTab.classList.toggle('hidden', tab !== 'admin');
// 切换按钮样式
const personalBtn = document.getElementById('tabBtn-personal');
const adminBtn = document.getElementById('tabBtn-admin');
if (tab === 'personal') {
personalBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all bg-white text-slate-900 shadow-sm";
if (adminBtn) adminBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all text-slate-500 hover:text-slate-700";
} else {
personalBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all text-slate-500 hover:text-slate-700";
if (adminBtn) adminBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all bg-white text-slate-900 shadow-sm";
} }
lucide.createIcons();
// --- Invite Logic ---
async function loadInviteStats() {
try {
const r = await fetch('/api/auth/invite_stats');
const d = await r.json();
if (d.invite_code) {
document.getElementById('myInviteCode').innerText = d.invite_code;
document.getElementById('myInvitedCount').innerText = d.invited_count;
document.getElementById('myTotalRewards').innerText = d.total_rewards;
} }
} catch (e) { console.error('加载邀请统计失败', e); }
}
function copyInviteLink() {
const code = document.getElementById('myInviteCode').innerText;
if (code === '...') return;
const link = `${window.location.origin}/login?invite_code=${code}`;
navigator.clipboard.writeText(link).then(() => {
showToast('专属邀请链接已复制', 'success');
});
}
// Init
document.addEventListener('DOMContentLoaded', () => {
loadInviteStats();
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -275,98 +275,112 @@
</div> </div>
</main> </main>
<!-- 积分与钱包中心弹窗 --> <!-- 财富与积分中心仪表盘弹窗 (Modern Dashboard Version) -->
<div id="pointsModal" <div id="pointsModal"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-md z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-300"> class="fixed inset-0 bg-slate-900/60 backdrop-blur-md z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div <div
class="bg-white w-full max-w-4xl rounded-[3rem] shadow-3xl overflow-hidden transform scale-95 transition-transform duration-300 flex flex-col h-[85vh]"> class="bg-white w-full max-w-5xl rounded-[3rem] shadow-3xl overflow-hidden transform scale-95 transition-transform duration-300 flex flex-col h-[90vh] lg:h-[85vh]">
<div
class="px-10 py-8 border-b border-slate-100 flex items-center justify-between flex-shrink-0 bg-slate-50/50"> <!-- Header -->
<div class="px-10 py-8 border-b border-slate-50 flex items-center justify-between flex-shrink-0 bg-white">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div <div
class="w-12 h-12 bg-amber-500 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-amber-200"> class="w-12 h-12 bg-amber-500 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-amber-200">
<i data-lucide="line-chart" class="w-6 h-6"></i> <i data-lucide="trending-up" class="w-6 h-6"></i>
</div> </div>
<div> <div>
<h2 class="text-2xl font-black text-slate-900 tracking-tight">财富与积分中心</h2> <h2 class="text-2xl font-black text-slate-900 tracking-tight">财富与积分中心</h2>
<p class="text-xs text-slate-400 font-bold uppercase tracking-widest mt-0.5">Wallet & Usage <p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5 opacity-60">Wallet &
Statistics</p> Usage Statistics</p>
</div> </div>
</div> </div>
<button onclick="closePointsModal()" <button onclick="closePointsModal()"
class="w-12 h-12 rounded-2xl hover:bg-slate-200/50 flex items-center justify-center text-slate-400 transition-colors"> class="w-12 h-12 rounded-2xl hover:bg-slate-100 flex items-center justify-center text-slate-400 transition-colors">
<i data-lucide="x" class="w-6 h-6"></i> <i data-lucide="x" class="w-6 h-6"></i>
</button> </button>
</div> </div>
<div class="flex-1 overflow-y-auto p-10 space-y-10 custom-scrollbar"> <!-- Scrollable Content -->
<!-- 统计概览卡片 --> <div class="flex-1 overflow-y-auto p-10 space-y-12 custom-scrollbar bg-slate-50/30">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 bg-slate-50 rounded-[2.5rem] p-8 border border-slate-100"> <!-- Top Dashboard: Trend Chart vs Balance Card -->
<div class="flex items-center justify-between mb-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-10">
<h3 class="text-sm font-black text-slate-900 uppercase tracking-widest">消费趋势 (近7日)</h3> <!-- Trend Chart Section -->
<div class="flex items-center gap-4 text-[10px] font-bold"> <div class="lg:col-span-2 bg-white rounded-[2.5rem] p-8 border border-slate-100 shadow-sm space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-sm font-black text-slate-800 tracking-widest uppercase italic">消费趋势 (近7日)</h3>
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5"><span <div class="flex items-center gap-1.5"><span
class="w-2 h-2 rounded-full bg-indigo-500"></span> 消耗积分</div> class="w-2 h-2 rounded-full bg-indigo-500"></span> <span
class="text-[9px] font-black text-slate-400 uppercase">消耗积分</span></div>
<div class="flex items-center gap-1.5"><span <div class="flex items-center gap-1.5"><span
class="w-2 h-2 rounded-full bg-emerald-500"></span> 充值积分</div> class="w-2 h-2 rounded-full bg-emerald-500"></span> <span
class="text-[9px] font-black text-slate-400 uppercase">充值积分</span></div>
</div> </div>
</div> </div>
<div class="h-64"> <div class="h-64 w-full">
<canvas id="pointsChart"></canvas> <canvas id="modalTrendChart"></canvas>
</div> </div>
</div> </div>
<div class="flex flex-col gap-6"> <!-- Balance Action Card -->
<div class="space-y-6">
<div <div
class="bg-indigo-600 rounded-[2rem] p-8 text-white relative overflow-hidden shadow-2xl shadow-indigo-200"> class="bg-gradient-to-br from-indigo-500 via-indigo-600 to-violet-700 p-8 rounded-[2.5rem] text-white shadow-2xl shadow-indigo-200 relative overflow-hidden group h-full flex flex-col justify-center">
<div class="relative z-10"> <i data-lucide="wallet"
<p class="text-xs font-bold opacity-80 mb-2">当前可用余额</p> class="absolute -right-6 -bottom-6 w-32 h-32 opacity-10 rotate-12 group-hover:scale-110 transition-transform duration-700"></i>
<h4 class="text-5xl font-black tracking-tighter mb-4"><span id="modalPointsDisplay">0</span> <div class="relative z-10 space-y-6">
<span class="text-lg opacity-60">Pts</span> <p class="text-[10px] font-black text-indigo-100 uppercase tracking-widest opacity-80">
</h4> 当前账户可用余额</p>
<div class="flex items-baseline gap-2">
<span id="modalPointsDisplay" class="text-6xl font-black">0</span>
<span class="text-xl font-bold opacity-70">Pts</span>
</div>
<a href="/buy" <a href="/buy"
class="inline-flex items-center gap-2 bg-white/20 hover:bg-white/30 px-5 py-2.5 rounded-xl text-xs font-bold backdrop-blur-md transition-all"> class="w-fit flex items-center gap-2 px-6 py-3 bg-white/20 hover:bg-white/30 backdrop-blur-md rounded-2xl text-[11px] font-black transition-all border border-white/20">
<i data-lucide="plus-circle" class="w-4 h-4"></i> 立即充值 <i data-lucide="external-link" class="w-4 h-4"></i>
前往充值页面
</a> </a>
</div> </div>
<i data-lucide="wallet" class="absolute -right-6 -bottom-6 w-32 h-32 opacity-10 rotate-12"></i>
</div> </div>
</div> </div>
</div> </div>
<!-- 使用明细 --> <!-- Consumption Table -->
<div class="space-y-6"> <div class="space-y-6 pt-10 border-t border-slate-100">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-sm font-black text-slate-900 uppercase tracking-widest">积分消耗明细</h3> <h3
class="text-sm font-black text-slate-800 tracking-widest uppercase italic border-l-4 border-indigo-600 pl-4">
积分消耗明细记录</h3>
<button onclick="loadPointDetails()" <button onclick="loadPointDetails()"
class="text-indigo-500 hover:text-indigo-600 p-2 transition-colors"> class="p-2 text-slate-400 hover:text-indigo-600 transition-colors">
<i data-lucide="rotate-cw" class="w-4 h-4"></i> <i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button> </button>
</div> </div>
<div class="bg-white border border-slate-100 rounded-[2rem] overflow-hidden shadow-sm"> <div class="bg-white border border-slate-100 rounded-[2.5rem] overflow-hidden shadow-sm">
<table class="w-full text-left"> <table class="w-full text-left">
<thead> <thead>
<tr class="bg-slate-50 font-black text-[10px] text-slate-400 uppercase tracking-widest"> <tr class="bg-slate-50/50 font-black text-[10px] text-slate-400 uppercase tracking-widest">
<th class="px-8 py-4">动作描述</th> <th class="px-8 py-5">交易描述 / 来源</th>
<th class="px-8 py-4">计算核心</th> <th class="px-8 py-5">类型 / 模型</th>
<th class="px-8 py-4">积分变动</th> <th class="px-8 py-5">积分变动</th>
<th class="px-8 py-4 text-right">发生时间</th> <th class="px-8 py-5 text-right">时间</th>
</tr> </tr>
</thead> </thead>
<tbody id="pointDetailsBody" class="text-xs font-bold text-slate-600"> <tbody id="pointDetailsBody" class="text-xs font-bold text-slate-600 divide-y divide-slate-50">
<!-- 动态数据 --> <!-- JS Dynamic -->
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="pointDetailsPagination" class="flex justify-center gap-2"></div>
</div> </div>
</div>
</div>
</div>
<!-- 修改密码弹窗 -->
<div id="pwdModal" </div>
<!-- 修改密码弹窗 -->
<div id="pwdModal"
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300"> class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div <div
class="bg-white w-full max-w-sm rounded-[2.5rem] shadow-2xl p-10 space-y-8 transform scale-95 transition-transform duration-300"> class="bg-white w-full max-w-sm rounded-[2.5rem] shadow-2xl p-10 space-y-8 transform scale-95 transition-transform duration-300">
@ -390,10 +404,10 @@
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- 拍摄角度设置器弹窗 --> <!-- 拍摄角度设置器弹窗 -->
<div id="visualizerModal" <div id="visualizerModal"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-300"> class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div <div
class="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl overflow-hidden transform scale-95 transition-transform duration-300 flex flex-col h-[85vh]"> class="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl overflow-hidden transform scale-95 transition-transform duration-300 flex flex-col h-[85vh]">
@ -416,16 +430,17 @@
<iframe id="visualizerFrame" src="/visualizer" class="w-full h-full border-none"></iframe> <iframe id="visualizerFrame" src="/visualizer" class="w-full h-full border-none"></iframe>
</div> </div>
</div> </div>
</div> </div>
<!-- 保存提示词弹窗 --> <!-- 保存提示词弹窗 -->
<div id="savePromptModal" <div id="savePromptModal"
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300"> class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div <div
class="bg-white w-full max-w-sm rounded-[2rem] shadow-2xl p-8 space-y-6 transform scale-95 transition-transform duration-300"> class="bg-white w-full max-w-sm rounded-[2rem] shadow-2xl p-8 space-y-6 transform scale-95 transition-transform duration-300">
<h2 class="text-xl font-black text-slate-900">收藏提示词</h2> <h2 class="text-xl font-black text-slate-900">收藏提示词</h2>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">给这个灵感起个名字</label> <label
class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">给这个灵感起个名字</label>
<input type="text" id="promptTitleInput" placeholder="例如:赛博朋克风格" <input type="text" id="promptTitleInput" placeholder="例如:赛博朋克风格"
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold"> class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div> </div>
@ -436,11 +451,11 @@
class="flex-1 btn-primary px-6 py-3 rounded-2xl font-bold shadow-lg shadow-indigo-100">保存</button> class="flex-1 btn-primary px-6 py-3 rounded-2xl font-bold shadow-lg shadow-indigo-100">保存</button>
</div> </div>
</div> </div>
</div> </div>
<!-- 删除确认弹窗 --> <!-- 删除确认弹窗 -->
<div id="deleteConfirmModal" <div id="deleteConfirmModal"
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300"> class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div <div
class="bg-white w-full max-w-sm rounded-[2rem] shadow-2xl p-8 space-y-6 transform scale-95 transition-transform duration-300"> class="bg-white w-full max-w-sm rounded-[2rem] shadow-2xl p-8 space-y-6 transform scale-95 transition-transform duration-300">
@ -460,9 +475,9 @@
class="flex-1 bg-rose-500 text-white px-6 py-3 rounded-2xl font-bold shadow-lg shadow-rose-200 hover:bg-rose-600 transition-all">确认删除</button> class="flex-1 bg-rose-500 text-white px-6 py-3 rounded-2xl font-bold shadow-lg shadow-rose-200 hover:bg-rose-600 transition-all">确认删除</button>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% endblock %} {% endblock %}

View File

@ -41,9 +41,12 @@
<i data-lucide="image" <i data-lucide="image"
class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i> class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
</div> </div>
<div class="w-32 h-14 bg-slate-50 rounded-2xl overflow-hidden cursor-pointer border border-slate-200" <div class="w-32 h-14 bg-slate-100 rounded-2xl overflow-hidden cursor-pointer border border-slate-200 relative"
title="点击刷新验证码"> title="点击刷新验证码">
<img id="captchaImg" src="" class="w-full h-full object-cover"> <span
class="absolute inset-0 flex items-center justify-center text-[10px] text-slate-400 font-bold z-0">输入手机号显示</span>
<img id="captchaImg" src="" class="w-full h-full object-cover relative z-10" alt="验证码"
onerror="this.style.display='none'" onload="this.style.display='block'">
</div> </div>
</div> </div>
@ -53,6 +56,13 @@
<i data-lucide="lock" <i data-lucide="lock"
class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i> class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
</div> </div>
<div id="inviteGroup" class="hidden relative group">
<input id="authInvite" type="text" placeholder="邀请码 (选填)"
class="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 pl-12 text-sm outline-none focus:border-indigo-500 transition-all uppercase">
<i data-lucide="gift"
class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
</div>
</div> </div>
<button id="authSubmitBtn" <button id="authSubmitBtn"

574
templates/points.html Normal file
View File

@ -0,0 +1,574 @@
{% extends "base.html" %}
{% block title %}积分与邀请管理 - AI 视界{% endblock %}
{% block content %}
<div class="w-full h-full overflow-hidden flex flex-col p-6 lg:p-10 bg-slate-50/50">
<div class="max-w-7xl w-full mx-auto flex flex-col h-full space-y-6">
<!-- Header -->
<div class="flex items-center justify-between shrink-0">
<div class="flex items-center gap-4">
<div
class="w-12 h-12 bg-indigo-600 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-indigo-200">
<i data-lucide="gift" class="w-7 h-7"></i>
</div>
<div>
<h1 class="text-3xl font-black text-slate-900 tracking-tight">积分与邀请管理</h1>
<p class="text-slate-400 text-sm font-medium italic">Points & Growth Ecosystem</p>
</div>
</div>
<!-- Tabs Switcher -->
<div
class="bg-white/80 backdrop-blur-md p-1.5 rounded-2xl flex font-black text-[12px] uppercase tracking-wider shadow-sm border border-slate-100/50">
<button onclick="switchTab('grant')" id="tab-grant"
class="px-6 py-2.5 rounded-xl transition-all duration-300">发放积分</button>
<button onclick="switchTab('history')" id="tab-history"
class="px-6 py-2.5 rounded-xl transition-all duration-300">发放记录</button>
<button onclick="switchTab('invite')" id="tab-invite"
class="px-6 py-2.5 rounded-xl transition-all duration-300">邀请统计</button>
</div>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 shrink-0">
<div class="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-xl shadow-slate-200/40">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center">
<i data-lucide="users" class="w-6 h-6"></i>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">总邀请用户</p>
<h3 id="stat-total-invited" class="text-2xl font-black text-slate-900">0</h3>
</div>
</div>
</div>
<div class="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-xl shadow-slate-200/40">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-amber-50 text-amber-600 rounded-2xl flex items-center justify-center">
<i data-lucide="zap" class="w-6 h-6"></i>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">累计奖励积分</p>
<h3 id="stat-total-rewards" class="text-2xl font-black text-slate-900">0</h3>
</div>
</div>
</div>
<div class="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-xl shadow-slate-200/40">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center">
<i data-lucide="send" class="w-6 h-6"></i>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">手动发放总计</p>
<h3 id="stat-total-grants" class="text-2xl font-black text-slate-900">0</h3>
</div>
</div>
</div>
<div class="bg-indigo-600 p-6 rounded-[2rem] shadow-xl shadow-indigo-200/50 text-white">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center">
<i data-lucide="crown" class="w-6 h-6"></i>
</div>
<div>
<p class="text-[10px] font-black text-indigo-200 uppercase tracking-widest">邀请达人榜首</p>
<h3 id="stat-top-inviter" class="text-lg font-black truncate w-32">加载中...</h3>
</div>
</div>
</div>
</div>
<!-- Main Content (Grant Tab) -->
<div id="view-grant"
class="flex-1 flex flex-col min-h-0 space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<!-- Action Bar -->
<div
class="flex items-center justify-between gap-4 bg-white/50 backdrop-blur-md p-4 rounded-3xl border border-white/50 shadow-sm">
<div class="relative w-96">
<i data-lucide="search" class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"></i>
<input type="text" id="userSearchInput" onkeyup="if(event.key === 'Enter') loadUserList()"
placeholder="搜索手机号或用户ID..."
class="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-2xl text-sm font-bold outline-none focus:border-indigo-500 transition-all">
</div>
<div class="flex items-center gap-3">
<button onclick="openGrantModal('selected')"
class="px-5 py-2.5 bg-indigo-600 text-white rounded-2xl text-xs font-black hover:bg-indigo-700 transition-all shadow-lg shadow-indigo-100 flex items-center gap-2">
<i data-lucide="check-square" class="w-4 h-4"></i> 发放选中用户
</button>
<button onclick="openGrantModal('all')"
class="px-5 py-2.5 bg-slate-900 text-white rounded-2xl text-xs font-black hover:bg-slate-800 transition-all shadow-lg shadow-slate-200 flex items-center gap-2">
<i data-lucide="globe" class="w-4 h-4"></i> 全员发放 (Global)
</button>
</div>
</div>
<!-- User Data Table -->
<div
class="bg-white rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100 flex-1 flex flex-col overflow-hidden">
<div class="flex-1 overflow-auto custom-scrollbar">
<table class="w-full text-left border-collapse">
<thead class="sticky top-0 bg-white z-10">
<tr>
<th class="px-8 py-5 border-b border-slate-50">
<input type="checkbox" id="selectAllUsers" onchange="toggleSelectAll(this)"
class="w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 transition-all">
</th>
<th
class="px-4 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
用户 ID</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
手机号</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
当前积分</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
用户角色</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50 text-right">
注册时间</th>
</tr>
</thead>
<tbody id="userTableBody" class="divide-y divide-slate-50">
<!-- Dynamic Content -->
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="p-6 border-t border-slate-50 flex items-center justify-between bg-slate-50/30">
<span class="text-xs font-bold text-slate-400" id="userPageInfo">加载中...</span>
<div class="flex items-center gap-2">
<button onclick="changeUserPage(-1)"
class="p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 disabled:opacity-50 transition-all shadow-sm">
<i data-lucide="chevron-left" class="w-4 h-4 text-slate-600"></i>
</button>
<button onclick="changeUserPage(1)"
class="p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 disabled:opacity-50 transition-all shadow-sm">
<i data-lucide="chevron-right" class="w-4 h-4 text-slate-600"></i>
</button>
</div>
</div>
</div>
</div>
<!-- History Tab (Hidden by Default) -->
<div id="view-history"
class="hidden flex-1 flex flex-col min-h-0 animate-in fade-in slide-in-from-bottom-4 duration-500">
<!-- Same History Table Structure as before, but full width -->
<div
class="bg-white rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100 flex-1 flex flex-col overflow-hidden">
<div class="p-6 border-b border-slate-50 bg-slate-50/30 flex justify-between items-center">
<div class="relative w-96">
<i data-lucide="search"
class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"></i>
<input type="text" id="historySearch" onkeyup="if(event.key === 'Enter') loadGrantHistory()"
placeholder="搜索手机号或操作备注..."
class="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold outline-none focus:border-indigo-500 transition-all">
</div>
</div>
<div class="flex-1 overflow-auto custom-scrollbar">
<table class="w-full text-left border-collapse">
<thead class="sticky top-0 bg-white z-10">
<tr>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
接收用户</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
积分数</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
发放原因</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
操作人</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50 text-right">
时间</th>
</tr>
</thead>
<tbody id="grantHistoryTable" class="divide-y divide-slate-50">
<!-- Dynamic Content -->
</tbody>
</table>
</div>
<div class="p-6 border-t border-slate-50 flex items-center justify-between bg-slate-50/30">
<span class="text-xs font-bold text-slate-400" id="grantPageInfo">共 0 条记录</span>
<div class="flex items-center gap-2">
<button onclick="changeGrantPage(-1)"
class="p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 disabled:opacity-50 transition-all shadow-sm"><i
data-lucide="chevron-left" class="w-4 h-4"></i></button>
<button onclick="changeGrantPage(1)"
class="p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 disabled:opacity-50 transition-all shadow-sm"><i
data-lucide="chevron-right" class="w-4 h-4"></i></button>
</div>
</div>
</div>
</div>
<!-- Invite Tab (Hidden by Default) -->
<div id="view-invite"
class="hidden flex-1 flex flex-col min-h-0 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div
class="bg-white rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100 flex-1 flex flex-col overflow-hidden">
<div class="p-6 border-b border-slate-50 bg-slate-50/30 flex justify-between items-center">
<h3 class="font-black text-slate-800 flex items-center gap-2">
<i data-lucide="list-tree" class="w-5 h-5 text-indigo-500"></i>
邀请奖励明细
</h3>
<div class="relative w-72">
<i data-lucide="search"
class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"></i>
<input type="text" id="inviteSearch" onkeyup="if(event.key === 'Enter') loadInviteRewards()"
placeholder="搜索邀请双方..."
class="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold outline-none focus:border-indigo-500 transition-all">
</div>
</div>
<div class="flex-1 overflow-auto custom-scrollbar">
<table class="w-full text-left border-collapse">
<thead class="sticky top-0 bg-white z-10">
<tr>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
邀请人</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
被邀请人</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
奖励积分 (10%)</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50">
充值轮次</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-50 text-right">
结算时间</th>
</tr>
</thead>
<tbody id="inviteRewardsTable" class="divide-y divide-slate-50">
<!-- Dynamic Content -->
</tbody>
</table>
</div>
<div class="p-6 border-t border-slate-50 flex items-center justify-between bg-slate-50/30">
<span class="text-xs font-bold text-slate-400" id="invitePageInfo">共 0 条数据</span>
<div class="flex items-center gap-2">
<button onclick="changeInvitePage(-1)"
class="p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 disabled:opacity-50 transition-all shadow-sm"><i
data-lucide="chevron-left" class="w-4 h-4"></i></button>
<button onclick="changeInvitePage(1)"
class="p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 disabled:opacity-50 transition-all shadow-sm"><i
data-lucide="chevron-right" class="w-4 h-4"></i></button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Grant Modal -->
<div id="grantModal"
class="hidden fixed inset-0 z-50 flex items-center justify-center p-6 backdrop-blur-md bg-slate-900/20 animate-in fade-in duration-300">
<div
class="bg-white w-full max-w-md rounded-[3rem] shadow-2xl border border-slate-100 p-10 space-y-8 animate-in zoom-in duration-300">
<div class="text-center space-y-2">
<div
class="w-20 h-20 bg-indigo-50 text-indigo-600 rounded-[2.5rem] flex items-center justify-center mx-auto mb-4">
<i data-lucide="plus-circle" id="modalIcon" class="w-10 h-10"></i>
</div>
<h2 id="modalTitle" class="text-2xl font-black text-slate-900 uppercase">确定发放积分</h2>
<p id="modalSub" class="text-slate-400 text-sm font-medium">请确认积分数量与发放理由</p>
</div>
<div class="space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">发放额度
(Points)</label>
<div class="relative">
<i data-lucide="zap" class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-amber-500"></i>
<input type="number" id="modalPoints" placeholder="请输入正整数"
class="w-full pl-12 pr-4 py-4 bg-slate-50 border border-slate-100 rounded-2xl text-lg font-black outline-none focus:border-indigo-500 transition-all">
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">发放原因 / 备注</label>
<textarea id="modalReason" rows="3" placeholder="例如:运营活动奖励..."
class="w-full p-4 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-medium outline-none focus:border-indigo-500 transition-all resize-none"></textarea>
</div>
</div>
<div class="flex items-center gap-4">
<button onclick="closeGrantModal()"
class="flex-1 py-4 bg-slate-100 text-slate-600 rounded-2xl font-black text-sm hover:bg-slate-200 transition-all">取消</button>
<button id="btnModalConfirm" onclick="confirmGrant()"
class="flex-1 py-4 bg-indigo-600 text-white rounded-2xl font-black text-sm hover:bg-indigo-700 transition-all shadow-xl shadow-indigo-100">确认发放</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// --- State Management ---
let currentTab = 'grant';
let userPage = 1;
let grantPage = 1;
let invitePage = 1;
let currentGrantTarget = 'selected'; // 'selected' or 'all'
// --- Tab Logic ---
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('[id^="tab-"]').forEach(btn => {
if (btn.id === `tab-${tab}`) {
btn.classList.add('bg-white', 'text-slate-900', 'shadow-sm', 'scale-105');
btn.classList.remove('text-slate-400');
} else {
btn.classList.remove('bg-white', 'text-slate-900', 'shadow-sm', 'scale-105');
btn.classList.add('text-slate-400');
}
});
document.getElementById('view-grant').classList.toggle('hidden', tab !== 'grant');
document.getElementById('view-history').classList.toggle('hidden', tab !== 'history');
document.getElementById('view-invite').classList.toggle('hidden', tab !== 'invite');
if (tab === 'grant') loadUserList();
if (tab === 'history') loadGrantHistory();
if (tab === 'invite') loadInviteRewards();
}
// --- Overall Stats ---
async function loadStats() {
try {
const r = await fetch('/api/admin/invite/stats');
const d = await r.json();
document.getElementById('stat-total-invited').innerText = d.total_invited;
document.getElementById('stat-total-rewards').innerText = d.total_rewards;
document.getElementById('stat-total-grants').innerText = d.total_grants;
if (d.top_inviters && d.top_inviters.length > 0) {
document.getElementById('stat-top-inviter').innerText = d.top_inviters[0].phone;
} else {
document.getElementById('stat-top-inviter').innerText = '尚无排名';
}
} catch (e) {
console.error('加载统计失败', e);
}
}
// --- User Management (Grant Tab) ---
async function loadUserList() {
const q = document.getElementById('userSearchInput').value;
const r = await fetch(`/api/admin/users?page=${userPage}&q=${q}&per_page=15`);
const d = await r.json();
const tbody = document.getElementById('userTableBody');
document.getElementById('userPageInfo').innerText = `第 ${d.current_page} / ${d.pages} 页 (共 ${d.total} 人)`;
tbody.innerHTML = d.users.map(u => `
<tr class="hover:bg-slate-50/80 transition-all duration-300">
<td class="px-8 py-5">
<input type="checkbox" name="userSelect" value="${u.id}" class="user-checkbox w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 transition-all">
</td>
<td class="px-4 py-5 font-mono text-xs text-slate-400">#${u.id}</td>
<td class="px-8 py-5 font-black text-slate-800">${u.phone}</td>
<td class="px-8 py-5">
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-black bg-indigo-50 text-indigo-600">
<i data-lucide="zap" class="w-3 h-3 text-amber-500"></i>
${u.points}
</span>
</td>
<td class="px-8 py-5">
<span class="text-xs font-bold text-slate-500">${u.role || '普通用户'}</span>
</td>
<td class="px-8 py-5 text-right text-[10px] font-black text-slate-300 uppercase italic">
${u.created_at}
</td>
</tr>
`).join('');
lucide.createIcons();
document.getElementById('selectAllUsers').checked = false;
}
function toggleSelectAll(master) {
const checkboxes = document.getElementsByName('userSelect');
for (let cb of checkboxes) {
cb.checked = master.checked;
}
}
function changeUserPage(delta) {
userPage += delta;
if (userPage < 1) userPage = 1;
loadUserList();
}
// --- Modal Logic ---
function openGrantModal(target) {
currentGrantTarget = target;
const modal = document.getElementById('grantModal');
const title = document.getElementById('modalTitle');
const sub = document.getElementById('modalSub');
if (target === 'all') {
title.innerText = "全员普惠发放";
sub.innerText = "注意:此动作将影响系统内所有注册用户";
} else {
const selectedCount = Array.from(document.getElementsByName('userSelect')).filter(c => c.checked).length;
if (selectedCount === 0) return showToast('请先在列表中勾选目标用户', 'warning');
title.innerText = `批量发放 (${selectedCount} 人)`;
sub.innerText = "仅对选中的用户进行积分补偿或奖励";
}
modal.classList.remove('hidden');
}
function closeGrantModal() {
document.getElementById('grantModal').classList.add('hidden');
document.getElementById('modalPoints').value = '';
document.getElementById('modalReason').value = '';
}
async function confirmGrant() {
const points = document.getElementById('modalPoints').value;
const reason = document.getElementById('modalReason').value;
if (!points || points <= 0) return showToast('请输入有效的积分数', 'warning');
let url = '';
let body = { points, reason };
if (currentGrantTarget === 'all') {
url = '/api/admin/points/global_grant';
} else {
url = '/api/admin/points/batch_grant_ids';
const selectedIds = Array.from(document.getElementsByName('userSelect'))
.filter(c => c.checked)
.map(c => parseInt(c.value));
body.user_ids = selectedIds;
}
const btn = document.getElementById('btnModalConfirm');
btn.disabled = true;
btn.innerText = '执行中...';
try {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const d = await r.json();
if (d.message) {
showToast(`✅ ${d.message}`, 'success');
closeGrantModal();
loadUserList();
loadStats();
} else {
showToast(d.error, 'error');
}
} catch (e) {
showToast('请求异常,发送失败', 'error');
} finally {
btn.disabled = false;
btn.innerText = '确认发放';
}
}
// --- Grant History ---
async function loadGrantHistory() {
const q = document.getElementById('historySearch').value;
const r = await fetch(`/api/admin/points/grants?page=${grantPage}&q=${q}`);
const d = await r.json();
const tbody = document.getElementById('grantHistoryTable');
document.getElementById('grantPageInfo').innerText = `第 ${d.current_page} / ${d.pages} 页 (共 ${d.total} 条记录)`;
tbody.innerHTML = d.grants.map(g => `
<tr class="hover:bg-slate-50 transition-colors border-b border-slate-50 last:border-0 text-sm">
<td class="px-8 py-5">
<div class="flex flex-col">
<span class="font-black text-slate-800 text-sm">${g.user_phone}</span>
<span class="text-[9px] text-slate-400 font-mono italic">UID: ${g.user_id}</span>
</div>
</td>
<td class="px-8 py-5">
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-black bg-emerald-50 text-emerald-600">
+${g.points}
</span>
</td>
<td class="px-8 py-5 font-bold text-slate-500">${g.reason || '-'}</td>
<td class="px-8 py-5">
<span class="text-xs font-black text-slate-700 underline decoration-indigo-200">${g.admin_phone}</span>
</td>
<td class="px-8 py-5 text-right text-[10px] font-black text-slate-300 uppercase">
${g.created_at}
</td>
</tr>
`).join('');
lucide.createIcons();
}
function changeGrantPage(delta) {
grantPage += delta;
if (grantPage < 1) grantPage = 1;
loadGrantHistory();
}
// --- Invite Rewards ---
async function loadInviteRewards() {
const q = document.getElementById('inviteSearch').value;
const r = await fetch(`/api/admin/invite/rewards?page=${invitePage}&q=${q}`);
const d = await r.json();
const tbody = document.getElementById('inviteRewardsTable');
document.getElementById('invitePageInfo').innerText = `第 ${d.current_page} / ${d.pages} 页 (共 ${d.total} 条数据)`;
tbody.innerHTML = d.rewards.map(r => `
<tr class="hover:bg-slate-50 transition-colors border-b border-slate-50 last:border-0 text-sm">
<td class="px-8 py-5">
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-indigo-100 text-indigo-600 rounded-lg flex items-center justify-center font-black text-[9px]"></div>
<span class="font-black text-slate-800">${r.inviter_phone}</span>
</div>
</td>
<td class="px-8 py-5">
<div class="flex flex-col">
<span class="font-bold text-slate-700">${r.invitee_phone}</span>
<span class="text-[9px] text-slate-400">UID: ${r.invitee_id}</span>
</div>
</td>
<td class="px-8 py-5">
<span class="text-indigo-600 font-black">+${r.reward_points} 积分</span>
</td>
<td class="px-8 py-5">
<span class="px-2 py-0.5 bg-slate-100 text-slate-500 text-[10px] font-black rounded uppercase">充值 #${r.recharge_count}</span>
</td>
<td class="px-8 py-5 text-right text-[10px] font-black text-slate-300 uppercase">
${r.created_at}
</td>
</tr>
`).join('');
lucide.createIcons();
}
function changeInvitePage(delta) {
invitePage += delta;
if (invitePage < 1) invitePage = 1;
loadInviteRewards();
}
// --- Init ---
document.addEventListener('DOMContentLoaded', () => {
switchTab('grant');
loadStats();
});
</script>
{% endblock %}