This commit is contained in:
公司git 2026-02-02 12:54:46 +08:00
commit 1b7fcd603c
11 changed files with 1920 additions and 580 deletions

View File

@ -1,7 +1,7 @@
from flask import Blueprint, request, jsonify
from datetime import timedelta
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 services.logger import system_logger
@ -91,7 +91,9 @@ def get_users():
"phone": u.phone,
"role": u.role.name if u.role else "未分配",
"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],
"total": pagination.total,
"pages": pagination.pages,
@ -300,3 +302,300 @@ def get_orders():
"paid_at": o.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') if o.paid_at else None
} 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
from sqlalchemy.orm import aliased
Inviter = aliased(User)
Invitee = aliased(User)
top_inviters = db.session.query(
Inviter.id,
Inviter.phone,
db.func.count(Invitee.id).label('invite_count')
).join(Invitee, Invitee.invited_by == Inviter.id)\
.group_by(Inviter.id, Inviter.phone)\
.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')
@auth_bp.route('/admin/points')
@admin_required
def admin_points_page():
"""积分发放管理页面"""
return render_template('points.html')
@auth_bp.route('/buy')
def buy_page():
"""购买积分页面"""
@ -88,6 +94,7 @@ def buy_page():
import datetime as dt_module
return render_template('buy.html',
user=user,
personal_orders=personal_orders,
admin_orders=admin_orders,
is_admin=is_admin,
@ -95,6 +102,23 @@ def buy_page():
success=success,
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')
def get_captcha():
"""获取图形验证码并存入 Redis"""
@ -177,6 +201,7 @@ def register():
phone = data.get('phone')
code = data.get('code')
password = data.get('password')
invite_code = data.get('invite_code', '').strip().upper() # 获取邀请码
import re
if not phone or not re.match(r'^1[3-9]\d{9}$', phone):
@ -192,16 +217,37 @@ def register():
system_logger.warning(f"注册失败: 手机号已存在", phone=phone)
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
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)
db.session.add(user)
db.session.commit()
system_logger.info(f"用户注册成功", phone=phone, user_id=user.id)
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)
return jsonify({"message": "注册成功"})
@auth_bp.route('/api/auth/login', methods=['POST'])
@ -314,7 +360,216 @@ def me():
"full_phone": phone, # 某些场景可能需要完整号
"api_key": user.api_key,
"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'])
@ -361,6 +616,7 @@ def get_user_menu(user):
{"name": "AI 视频创作", "icon": "video", "url": "/video", "perm": None},
{"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "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": "book-open", "url": "/dicts", "perm": "manage_dicts"},
{"name": "系统通知管理", "icon": "megaphone", "url": "/notifications", "perm": "manage_notifications"},

View File

@ -1,6 +1,6 @@
from flask import Blueprint, request, redirect, url_for, session, jsonify, render_template
from extensions import db, redis_client
from models import Order, User, to_bj_time, get_bj_now
from models import Order, User, InviteReward, to_bj_time, get_bj_now
from services.alipay_service import AlipayService
from services.logger import system_logger
import uuid
@ -16,6 +16,57 @@ POINTS_PACKAGES = {
'5000': {'points': 5000, 'amount': 500.00},
}
def _process_success_order(order, trade_no):
"""内部处理订单成功逻辑 (需在锁内调用,不包含 commit)"""
if order.status == 'PAID':
return False
order.status = 'PAID'
order.trade_no = trade_no
order.paid_at = get_bj_now()
# 增加用户积分
user = db.session.get(User, order.user_id)
if user:
user.points += order.points
system_logger.info(f"订单支付成功", order_id=order.out_trade_no, points=order.points, user_id=user.id)
# ========== 邀请奖励逻辑 ==========
if user.invited_by:
# 统计该被邀请人之前已经完成多少次充值(不含本次)
paid_count = Order.query.filter(
Order.user_id == user.id,
Order.status == 'PAID',
Order.id != order.id
).count()
# 只有前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
)
return True
@payment_bp.route('/create', methods=['POST'])
def create_payment():
if 'user_id' not in session:
@ -88,17 +139,8 @@ def payment_return():
order = Order.query.filter_by(out_trade_no=out_trade_no).with_for_update().first()
# 如果订单存在且状态为PENDING则更新为PAID
if order and order.status == 'PENDING':
order.status = 'PAID'
order.trade_no = trade_no
order.paid_at = get_bj_now()
# 增加用户积分
user = db.session.get(User, order.user_id)
if user:
user.points += order.points
system_logger.info(f"同步回调-订单支付成功", order_id=out_trade_no, points=order.points, user_id=user.id)
if order:
_process_success_order(order, trade_no)
db.session.commit()
elif order:
# 订单已经是完成状态,不做处理
@ -216,36 +258,28 @@ def api_sync_order():
order_locked = Order.query.filter_by(out_trade_no=out_trade_no).with_for_update().first()
# 二次校验状态,防止异步回调/定时任务已经处理
if order_locked and order_locked.status == 'PENDING':
order_locked.status = 'PAID'
order_locked.trade_no = alipay_result.get('trade_no')
if not order_locked.paid_at:
order_locked.paid_at = get_bj_now()
# 二次校验状态,防止异步回调/定时任务已经处理
if order_locked:
processed = _process_success_order(order_locked, alipay_result.get('trade_no'))
if processed:
db.session.commit()
# 增加用户积分
user = db.session.get(User, order_locked.user_id)
if user:
user.points += order_locked.points
system_logger.info(f"主动查询-订单支付成功", order_id=out_trade_no, points=order_locked.points, user_id=user.id)
db.session.commit()
return jsonify({
'code': 200,
'msg': '订单已支付,积分已增加',
'status': 'PAID',
'points': order_locked.points,
'paid_at': order_locked.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S')
})
elif order_locked and order_locked.status == 'PAID':
# 订单已经被处理,直接返回
return jsonify({
'code': 200,
'msg': '订单已支付',
'status': 'PAID',
'points': order_locked.points,
'paid_at': order_locked.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') if order_locked.paid_at else None
})
return jsonify({
'code': 200,
'msg': '订单已支付,积分已增加',
'status': 'PAID',
'points': order_locked.points,
'paid_at': order_locked.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S')
})
else:
# 订单已经是完成状态,不做处理
return jsonify({
'code': 200,
'msg': '订单已支付',
'status': 'PAID',
'points': order_locked.points,
'paid_at': order_locked.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') if order_locked.paid_at else None
})
except Exception as e:
if "LockError" in str(e) or "BlockingIOError" in str(e):
# 如果获取锁失败,可能是因为正在处理中,返回成功状态(前端会重试或刷新)
@ -301,20 +335,10 @@ def payment_notify():
lock_key = f"lock:order:{out_trade_no}"
with redis_client.lock(lock_key, timeout=10, blocking_timeout=3):
order = Order.query.filter_by(out_trade_no=out_trade_no).with_for_update().first()
if order and order.status == 'PENDING':
order.status = 'PAID'
order.trade_no = trade_no
order.paid_at = get_bj_now()
user = db.session.get(User, order.user_id)
if user:
user.points += order.points
system_logger.info(f"订单支付成功", order_id=out_trade_no, points=order.points, user_id=user.id)
if order:
_process_success_order(order, trade_no)
db.session.commit()
return "success"
elif order:
return "success"
else:
return "fail"
except Exception as e:
@ -326,6 +350,13 @@ def payment_notify():
# 如果返回fail支付宝会重试。
return "fail"
raise e
order = Order.query.filter_by(out_trade_no=out_trade_no).first()
if order:
_process_success_order(order, trade_no)
db.session.commit()
return "success"
else:
return "fail"
else:
return "fail"
@ -334,6 +365,7 @@ def payment_notify():
db.session.rollback()
return "fail"
@payment_bp.route('/api/query/<out_trade_no>', methods=['GET'])
def api_query_order(out_trade_no):
"""简单查询接口 - 获取订单当前状态而不自动更新"""

View File

@ -58,6 +58,10 @@ class User(db.Model):
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
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
def created_at_bj(self):
return to_bj_time(self.created_at)
@ -203,3 +207,41 @@ class SavedPrompt(db.Model):
return to_bj_time(self.created_at)
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))

36
scripts/reset_user.py Normal file
View File

@ -0,0 +1,36 @@
from app import create_app, db
from models import User, Order, InviteReward
import sys
def reset_user(phone):
app = create_app()
with app.app_context():
user = User.query.filter_by(phone=phone).first()
if not user:
print(f"❌ 找不到用户: {phone}")
return
print(f"🔧 正在重置用户 {phone} (ID: {user.id})...")
# 1. 删除该用户的订单
orders = Order.query.filter_by(user_id=user.id).all()
order_count = len(orders)
for order in orders:
# 删除与该订单关联的邀请奖励(如果存在)
InviteReward.query.filter_by(order_id=order.id).delete()
db.session.delete(order)
# 2. 重置用户积分(可选,方便测试)
# user.points = 2 # 重置为默认赠送积分
# 3. 删除该用户作为邀请人获得的奖励记录 (如果需要彻底重置)
# InviteReward.query.filter_by(inviter_id=user.id).delete()
db.session.commit()
print(f"✅ 已删除 {order_count} 条订单记录,用户重置完成。可以重新测试邀请奖励。")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("使用方法: python scripts/reset_user.py <手机号>")
else:
reset_user(sys.argv[1])

View File

@ -16,6 +16,7 @@ const updateUI = () => {
const forgotBtn = getEl('forgotPwdBtn');
const smsGroup = getEl('smsGroup');
const captchaGroup = getEl('captchaGroup');
const inviteGroup = getEl('inviteGroup');
if (!title || !sub || !submitBtnSpan || !switchBtn || !forgotBtn || !smsGroup || !captchaGroup) return;
@ -27,6 +28,7 @@ const updateUI = () => {
forgotBtn.classList.add('hidden');
smsGroup.classList.remove('hidden');
captchaGroup.classList.remove('hidden'); // 注册模式默认显示图形验证码防止刷短信
if (inviteGroup) inviteGroup.classList.remove('hidden');
} else if (authMode === 2) { // Reset Password
title.innerText = "重置密码";
sub.innerText = "验证短信以设置新密码";
@ -35,6 +37,7 @@ const updateUI = () => {
forgotBtn.classList.add('hidden');
smsGroup.classList.remove('hidden');
captchaGroup.classList.remove('hidden'); // 重置模式默认显示
if (inviteGroup) inviteGroup.classList.add('hidden');
} else { // Login
title.innerText = "欢迎回来";
sub.innerText = "请登录以开启 AI 创作之旅";
@ -43,13 +46,23 @@ const updateUI = () => {
forgotBtn.classList.remove('hidden');
smsGroup.classList.add('hidden');
captchaGroup.classList.add('hidden'); // 登录默认隐藏,除非高频失败
if (inviteGroup) inviteGroup.classList.add('hidden');
}
};
const refreshCaptcha = () => {
const phone = getEl('authPhone')?.value;
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()}`;
};
@ -67,6 +80,7 @@ const handleAuth = async () => {
if (authMode === 1) {
url = '/api/auth/register';
body.code = getEl('authCode')?.value;
body.invite_code = getEl('authInvite')?.value; // 获取邀请码
} else if (authMode === 0) {
url = '/api/auth/login';
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 () => {
const phone = getEl('authPhone')?.value;
const captcha = getEl('authCaptcha')?.value;
@ -199,4 +222,20 @@ document.addEventListener('DOMContentLoaded', () => {
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

@ -806,7 +806,7 @@ window.addEventListener('message', (e) => {
}
});
// --- 积分与钱包中心逻辑 ---
// --- 积分与钱包中心逻辑 (Modern Modal Version) ---
let pointsChart = null;
async function openPointsModal() {
@ -818,11 +818,12 @@ async function openPointsModal() {
modal.querySelector('div').classList.remove('scale-95');
}, 10);
// 加载数据
// 加载核心数据
loadPointStats();
loadPointDetails();
loadPointDetails(1);
loadInviteStatsModal();
// 更新当前余额
// 实时更新余额显示
const r = await fetch('/api/auth/me');
const d = await r.json();
if (d.logged_in) {
@ -832,106 +833,180 @@ async function openPointsModal() {
function closePointsModal() {
const modal = document.getElementById('pointsModal');
if (!modal) return;
modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => modal.classList.add('hidden'), 300);
}
async function loadPointStats() {
const r = await fetch('/api/stats/points?days=7');
const d = await r.json();
const canvas = document.getElementById('pointsChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (pointsChart) pointsChart.destroy();
if (typeof Chart === 'undefined') {
console.error('Chart.js not loaded');
return;
}
pointsChart = new Chart(ctx, {
type: 'line',
data: {
labels: d.labels,
datasets: [
{
label: '消耗积分',
data: d.deductions,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#6366f1'
},
{
label: '充值积分',
data: d.incomes,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#10b981'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(241, 245, 249, 1)' },
ticks: { font: { weight: 'bold' } }
},
x: {
grid: { display: false },
ticks: { font: { weight: 'bold' } }
}
}
}
});
}
async function loadPointDetails() {
const body = document.getElementById('pointDetailsBody');
if (!body) return;
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-400">正在加载明细...</td></tr>';
try {
const r = await fetch('/api/stats/details?page=1');
const r = await fetch('/api/auth/point_stats');
const d = await r.json();
if (d.items.length === 0) {
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-400">暂无积分变动记录</td></tr>';
const canvas = document.getElementById('modalTrendChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (pointsChart) pointsChart.destroy();
pointsChart = new Chart(ctx, {
type: 'line',
data: {
labels: d.labels,
datasets: [
{
label: '消耗积分',
data: d.consumption,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.05)',
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#6366f1'
},
{
label: '充值积分',
data: d.recharge,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.05)',
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#10b981'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#1e293b',
padding: 12,
cornerRadius: 12,
titleFont: { weight: 'bold' },
bodyFont: { weight: '900' }
}
},
scales: {
y: {
beginAtZero: true,
grid: { color: '#f1f5f9' },
ticks: { font: { weight: 'bold', size: 10 }, color: '#94a3b8' }
},
x: {
grid: { display: false },
ticks: { font: { weight: 'bold', size: 10 }, color: '#94a3b8' }
}
}
}
});
} catch (e) {
console.error('加载统计图失败:', e);
}
}
async function loadPointDetails(page = 1) {
const body = document.getElementById('pointDetailsBody');
const pagContainer = document.getElementById('pointDetailsPagination');
if (!body) return;
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-400">正在同步账户流水...</td></tr>';
try {
const r = await fetch(`/api/auth/point_history?page=${page}&per_page=5`);
const d = await r.json();
if (d.details.length === 0) {
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-300 italic">暂无资金变动记录</td></tr>';
if (pagContainer) pagContainer.innerHTML = '';
return;
}
body.innerHTML = d.items.map(item => `
<tr class="hover:bg-slate-50 transition-colors border-b border-slate-50">
body.innerHTML = d.details.map(item => {
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">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full ${item.type === 'deduction' ? 'bg-indigo-500' : 'bg-emerald-500'}"></div>
<span class="truncate max-w-[200px]">${item.desc}</span>
<div class="flex items-center gap-3 text-slate-700">
<div class="w-2 h-2 rounded-full ${iconColor}"></div>
<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>
</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 font-black ${item.type === 'deduction' ? 'text-rose-500' : 'text-emerald-500'}">${item.change}</td>
<td class="px-8 py-5 text-right font-mono text-slate-400">${item.time}</td>
<td class="px-8 py-5">
<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">
${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>
`).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) {
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" %}
{% block title %}购买积分 - AI 视界{% endblock %}
{% block title %}充值与邀请 - AI 视界{% endblock %}
{% block content %}
<div class="w-full h-full overflow-y-auto p-8 lg:p-12 custom-scrollbar">
<div class="max-w-4xl mx-auto space-y-12">
<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>
<div class="w-full h-full overflow-y-auto p-4 lg:p-10 custom-scrollbar bg-slate-50/20">
<div class="max-w-6xl w-full mx-auto flex flex-col space-y-12 pb-20">
{% if success %}
<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">
<div
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>
<h3 class="text-emerald-900 font-black">支付成功!</h3>
<p class="text-emerald-600 text-xs font-bold">您的订单 #{{ order.out_trade_no if order else '' }} 已完成,积分已到账。
</p>
</div>
</div>
{% endif %}
<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>
<!-- Top Header -->
<div class="flex items-center justify-between shrink-0">
<div class="flex items-center gap-5">
<div
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">
<i data-lucide="crown" class="w-8 h-8"></i>
</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>
<h1 class="text-3xl font-black text-slate-900 tracking-tight">充值中心</h1>
<p class="text-slate-400 text-xs font-black uppercase tracking-widest opacity-60">Recharge & Invite
</p>
</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>
<div class="flex gap-2">
{% if is_admin %}
<button onclick="switchTab('admin')" id="tabBtn-admin"
class="px-6 py-2.5 rounded-xl text-sm font-black transition-all text-slate-500 hover:text-slate-700">
全员记录 (管理员)
</button>
<a href="/admin/orders"
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>
管理后台
</a>
{% 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 id="tab-personal"
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="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 personal_orders %}
{% for order in personal_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.out_trade_no }}</span>
<span class="text-[10px] text-slate-400 font-mono">Ali: {{ order.trade_no or '-'
}}</span>
</div>
</td>
<td class="px-8 py-5">
<div class="flex flex-col">
<div class="flex items-center gap-1">
<i data-lucide="zap" class="w-3 h-3 text-amber-500"></i>
<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>
</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>
{% elif order.status == 'PENDING' %}
<span
class="px-2 py-1 bg-amber-50 text-amber-600 text-[10px] font-black rounded-lg border border-amber-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">已取消</span>
{% endif %}
</td>
<td class="px-8 py-5">
<span class="text-[10px] font-bold text-slate-400">
{% if order.paid_at_bj %}
{{ order.paid_at_bj.strftime('%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</span>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="px-8 py-16 text-center">
<p class="text-slate-300 font-bold text-sm">暂无记录</p>
</td>
</tr>
{% endif %}
</tbody>
</table>
</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>
<!-- 1. Recharge Packages Section -->
<div class="space-y-6">
<div class="flex items-center justify-between px-2">
<h3 class="text-sm font-black text-slate-900 tracking-widest uppercase italic">选择充值套餐</h3>
<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>
{% if is_admin %}
<!-- 管理员全员记录列表 -->
<div id="tab-admin"
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">
<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 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>
<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>
<!-- 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>
<tr class="bg-slate-50/50 text-[10px] font-black text-slate-400 uppercase tracking-widest">
<th class="px-8 py-5">订单信息</th>
<th class="px-8 py-5">积分/金额</th>
<th class="px-8 py-5">状态</th>
<th class="px-8 py-5 text-right">时间</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 text-sm font-bold text-slate-600">
{% if personal_orders %}
{% for order in personal_orders %}
<tr class="hover:bg-slate-50/30 transition-colors">
<td class="px-8 py-5">
<div class="flex flex-col">
<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>
</div>
</td>
<td class="px-8 py-5">
<div class="flex items-center gap-2">
<span class="text-indigo-600">+{{ order.points }} Pts</span>
<span class="text-[10px] text-slate-400">(¥{{ order.amount }})</span>
</div>
</td>
<td class="px-8 py-5">
{% if order.status == 'PAID' %}
<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' %}
<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 %}
<span class="text-slate-400 text-[10px]">已取消</span>
{% endif %}
</td>
<td class="px-8 py-5 text-right text-slate-400 text-xs">
{{ order.paid_at_bj.strftime('%Y-%m-%d %H:%M') if order.paid_at_bj else
order.created_at_bj.strftime('%Y-%m-%d %H:%M') }}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="px-8 py-16 text-center text-slate-300 italic">
暂无充值记录
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% if personal_orders|length >= 10 %}
<div class="text-center pt-2">
<a href="/recharge_history" class="text-xs font-bold text-indigo-500 hover:text-indigo-600">查看更多记录
-></a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function switchTab(tab) {
// 切换内容显隐
document.getElementById('tab-personal').classList.toggle('hidden', tab !== 'personal');
const adminTab = document.getElementById('tab-admin');
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();
// --- Recharge Logic ---
function submitRecharge(pkgId) {
document.getElementById('packageIdInput').value = pkgId;
document.getElementById('rechargeForm').submit();
}
// --- 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>
{% endblock %}

View File

@ -275,194 +275,209 @@
</div>
</main>
<!-- 积分与钱包中心弹窗 -->
<!-- 财富与积分中心仪表盘弹窗 (Modern Dashboard Version) -->
<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">
<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]">
<div
class="px-10 py-8 border-b border-slate-100 flex items-center justify-between flex-shrink-0 bg-slate-50/50">
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]">
<!-- 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="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>
<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
Statistics</p>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-0.5 opacity-60">Wallet &
Usage Statistics</p>
</div>
</div>
<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>
</button>
</div>
<div class="flex-1 overflow-y-auto p-10 space-y-10 custom-scrollbar">
<!-- 统计概览卡片 -->
<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">
<div class="flex items-center justify-between mb-6">
<h3 class="text-sm font-black text-slate-900 uppercase tracking-widest">消费趋势 (近7日)</h3>
<div class="flex items-center gap-4 text-[10px] font-bold">
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-10 space-y-12 custom-scrollbar bg-slate-50/30">
<!-- Top Dashboard: Trend Chart vs Balance Card -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-10">
<!-- Trend Chart Section -->
<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
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
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 class="h-64">
<canvas id="pointsChart"></canvas>
<div class="h-64 w-full">
<canvas id="modalTrendChart"></canvas>
</div>
</div>
<div class="flex flex-col gap-6">
<!-- Balance Action Card -->
<div class="space-y-6">
<div
class="bg-indigo-600 rounded-[2rem] p-8 text-white relative overflow-hidden shadow-2xl shadow-indigo-200">
<div class="relative z-10">
<p class="text-xs font-bold opacity-80 mb-2">当前可用余额</p>
<h4 class="text-5xl font-black tracking-tighter mb-4"><span id="modalPointsDisplay">0</span>
<span class="text-lg opacity-60">Pts</span>
</h4>
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">
<i data-lucide="wallet"
class="absolute -right-6 -bottom-6 w-32 h-32 opacity-10 rotate-12 group-hover:scale-110 transition-transform duration-700"></i>
<div class="relative z-10 space-y-6">
<p class="text-[10px] font-black text-indigo-100 uppercase tracking-widest opacity-80">
当前账户可用余额</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"
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">
<i data-lucide="plus-circle" class="w-4 h-4"></i> 立即充值
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="external-link" class="w-4 h-4"></i>
前往充值页面
</a>
</div>
<i data-lucide="wallet" class="absolute -right-6 -bottom-6 w-32 h-32 opacity-10 rotate-12"></i>
</div>
</div>
</div>
<!-- 使用明细 -->
<div class="space-y-6">
<!-- Consumption Table -->
<div class="space-y-6 pt-10 border-t border-slate-100">
<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()"
class="text-indigo-500 hover:text-indigo-600 p-2 transition-colors">
<i data-lucide="rotate-cw" class="w-4 h-4"></i>
class="p-2 text-slate-400 hover:text-indigo-600 transition-colors">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
</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">
<thead>
<tr class="bg-slate-50 font-black text-[10px] text-slate-400 uppercase tracking-widest">
<th class="px-8 py-4">动作描述</th>
<th class="px-8 py-4">计算核心</th>
<th class="px-8 py-4">积分变动</th>
<th class="px-8 py-4 text-right">发生时间</th>
<tr class="bg-slate-50/50 font-black text-[10px] text-slate-400 uppercase tracking-widest">
<th class="px-8 py-5">交易描述 / 来源</th>
<th class="px-8 py-5">类型 / 模型</th>
<th class="px-8 py-5">积分变动</th>
<th class="px-8 py-5 text-right">时间</th>
</tr>
</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>
</table>
</div>
<div id="pointDetailsPagination" class="flex justify-center gap-2"></div>
</div>
</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">
<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">
<h2 class="text-2xl font-black text-slate-900">修改登录密码</h2>
<form id="pwdForm" class="space-y-5">
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">原密码</label>
<input type="password" id="oldPwd" required
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 class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">新密码</label>
<input type="password" id="newPwd" required
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 class="flex gap-4 pt-4">
<button type="button" onclick="closePwdModal()"
class="flex-1 px-8 py-4 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button type="submit"
class="flex-1 btn-primary px-8 py-4 rounded-2xl font-bold shadow-lg shadow-indigo-100">确认修改</button>
</div>
</form>
</div>
</div>
</div>
</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">
<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">
<h2 class="text-2xl font-black text-slate-900">修改登录密码</h2>
<form id="pwdForm" class="space-y-5">
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">原密码</label>
<input type="password" id="oldPwd" required
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 class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">新密码</label>
<input type="password" id="newPwd" required
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 class="flex gap-4 pt-4">
<button type="button" onclick="closePwdModal()"
class="flex-1 px-8 py-4 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button type="submit"
class="flex-1 btn-primary px-8 py-4 rounded-2xl font-bold shadow-lg shadow-indigo-100">确认修改</button>
</div>
</form>
</div>
</div>
<!-- 拍摄角度设置器弹窗 -->
<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">
<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]">
<div class="p-6 border-b border-slate-100 flex items-center justify-between flex-shrink-0">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center">
<i data-lucide="video" class="w-6 h-6"></i>
<!-- 拍摄角度设置器弹窗 -->
<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">
<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]">
<div class="p-6 border-b border-slate-100 flex items-center justify-between flex-shrink-0">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center">
<i data-lucide="video" class="w-6 h-6"></i>
</div>
<div>
<h3 class="text-lg font-black text-slate-900">拍摄角度设置器</h3>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest">相对于参考图设置相机移动路径</p>
</div>
</div>
<button id="closeVisualizerBtn"
class="w-10 h-10 rounded-xl hover:bg-slate-100 flex items-center justify-center text-slate-400 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div>
<h3 class="text-lg font-black text-slate-900">拍摄角度设置器</h3>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest">相对于参考图设置相机移动路径</p>
<div class="flex-1 overflow-hidden relative">
<iframe id="visualizerFrame" src="/visualizer" class="w-full h-full border-none"></iframe>
</div>
</div>
<button id="closeVisualizerBtn"
class="w-10 h-10 rounded-xl hover:bg-slate-100 flex items-center justify-center text-slate-400 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="flex-1 overflow-hidden relative">
<iframe id="visualizerFrame" src="/visualizer" class="w-full h-full border-none"></iframe>
</div>
</div>
</div>
<!-- 保存提示词弹窗 -->
<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">
<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">
<h2 class="text-xl font-black text-slate-900">收藏提示词</h2>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">给这个灵感起个名字</label>
<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">
</div>
<div class="flex gap-4 pt-2">
<button onclick="closeSavePromptModal()"
class="flex-1 px-6 py-3 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button onclick="confirmSavePrompt()"
class="flex-1 btn-primary px-6 py-3 rounded-2xl font-bold shadow-lg shadow-indigo-100">保存</button>
</div>
</div>
</div>
<!-- 删除确认弹窗 -->
<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">
<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">
<div class="flex flex-col items-center text-center space-y-4">
<div class="w-16 h-16 bg-rose-50 text-rose-500 rounded-2xl flex items-center justify-center mb-2">
<i data-lucide="alert-triangle" class="w-8 h-8"></i>
<!-- 保存提示词弹窗 -->
<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">
<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">
<h2 class="text-xl font-black text-slate-900">收藏提示词</h2>
<div class="space-y-2">
<label
class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">给这个灵感起个名字</label>
<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">
</div>
<div class="flex gap-4 pt-2">
<button onclick="closeSavePromptModal()"
class="flex-1 px-6 py-3 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button onclick="confirmSavePrompt()"
class="flex-1 btn-primary px-6 py-3 rounded-2xl font-bold shadow-lg shadow-indigo-100">保存</button>
</div>
</div>
<h2 class="text-xl font-black text-slate-900">确认删除?</h2>
<p class="text-sm text-slate-500 font-bold leading-relaxed px-4">
您确定要删除这个收藏的提示词吗?<br>此操作无法撤销。
</p>
</div>
<div class="flex gap-4 pt-2">
<button onclick="closeDeleteConfirmModal()"
class="flex-1 px-6 py-3 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button id="confirmDeleteBtn"
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>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% endblock %}
<!-- 删除确认弹窗 -->
<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">
<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">
<div class="flex flex-col items-center text-center space-y-4">
<div class="w-16 h-16 bg-rose-50 text-rose-500 rounded-2xl flex items-center justify-center mb-2">
<i data-lucide="alert-triangle" class="w-8 h-8"></i>
</div>
<h2 class="text-xl font-black text-slate-900">确认删除?</h2>
<p class="text-sm text-slate-500 font-bold leading-relaxed px-4">
您确定要删除这个收藏的提示词吗?<br>此操作无法撤销。
</p>
</div>
<div class="flex gap-4 pt-2">
<button onclick="closeDeleteConfirmModal()"
class="flex-1 px-6 py-3 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button id="confirmDeleteBtn"
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>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% endblock %}

View File

@ -41,9 +41,12 @@
<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>
</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="点击刷新验证码">
<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>
@ -53,6 +56,13 @@
<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>
</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>
<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 %}