```
feat(admin): 添加积分发放管理和邀请奖励功能 - 新增积分发放相关模型 PointsGrant 和 InviteReward - 实现管理员积分发放接口(单个、批量、全员发放) - 添加积分发放记录查询和统计功能 - 集成邀请奖励机制,在用户充值时自动发放邀请奖励 - 在用户注册流程中集成邀请码功能 - 扩展用户信息返回积分和创建时间字段 - 添加前端邀请码处理和邀请统计功能 ```
This commit is contained in:
parent
2ef673d0d6
commit
0da71bc439
@ -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,297 @@ 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)
|
||||
top_inviters = db.session.query(
|
||||
User.id,
|
||||
User.phone,
|
||||
db.func.count(User.id).label('invite_count')
|
||||
).join(User, User.invited_by == User.id, isouter=True).filter(
|
||||
User.invited_by.isnot(None)
|
||||
).group_by(User.invited_by).order_by(
|
||||
db.desc('invite_count')
|
||||
).limit(10).all()
|
||||
|
||||
return jsonify({
|
||||
"total_invited": total_invited,
|
||||
"total_rewards": int(total_rewards),
|
||||
"total_grants": int(total_grants),
|
||||
"top_inviters": [{
|
||||
"user_id": t[0],
|
||||
"phone": t[1],
|
||||
"invite_count": t[2]
|
||||
} for t in top_inviters] if top_inviters else []
|
||||
})
|
||||
|
||||
@ -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,15 +217,36 @@ 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()
|
||||
|
||||
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": "注册成功"})
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -161,6 +161,43 @@ def payment_notify():
|
||||
user.points += order.points
|
||||
system_logger.info(f"订单支付成功", order_id=out_trade_no, points=order.points, user_id=user.id)
|
||||
|
||||
# ========== 邀请奖励逻辑 ==========
|
||||
if user.invited_by:
|
||||
from models import InviteReward
|
||||
# 统计该被邀请人之前已经完成多少次充值(不含本次)
|
||||
paid_count = Order.query.filter(
|
||||
Order.user_id == user.id,
|
||||
Order.status == 'PAID',
|
||||
Order.id != order.id # 排除本次订单
|
||||
).count()
|
||||
|
||||
# 只有前3次充值才有奖励(paid_count 是之前的次数,0/1/2 表示这是第1/2/3次)
|
||||
if paid_count < 3:
|
||||
inviter = db.session.get(User, user.invited_by)
|
||||
if inviter:
|
||||
# 计算10%奖励积分(四舍五入)
|
||||
reward_points = round(order.points * 0.1)
|
||||
if reward_points > 0:
|
||||
inviter.points += reward_points
|
||||
|
||||
# 记录邀请奖励
|
||||
invite_reward = InviteReward(
|
||||
inviter_id=inviter.id,
|
||||
invitee_id=user.id,
|
||||
order_id=order.id,
|
||||
reward_points=reward_points,
|
||||
recharge_count=paid_count + 1 # 第几次充值
|
||||
)
|
||||
db.session.add(invite_reward)
|
||||
system_logger.info(
|
||||
f"邀请奖励发放成功",
|
||||
inviter_id=inviter.id,
|
||||
invitee_id=user.id,
|
||||
reward_points=reward_points,
|
||||
recharge_count=paid_count + 1
|
||||
)
|
||||
# ========== 邀请奖励逻辑结束 ==========
|
||||
|
||||
db.session.commit()
|
||||
return "success"
|
||||
elif order:
|
||||
@ -174,3 +211,4 @@ def payment_notify():
|
||||
system_logger.error(f"处理异步通知异常: {str(e)}")
|
||||
db.session.rollback()
|
||||
return "fail"
|
||||
|
||||
|
||||
42
models.py
42
models.py
@ -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))
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -755,7 +755,7 @@ window.addEventListener('message', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- 积分与钱包中心逻辑 ---
|
||||
// --- 积分与钱包中心逻辑 (Modern Modal Version) ---
|
||||
let pointsChart = null;
|
||||
|
||||
async function openPointsModal() {
|
||||
@ -767,11 +767,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) {
|
||||
@ -781,26 +782,23 @@ 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');
|
||||
try {
|
||||
const r = await fetch('/api/auth/point_stats');
|
||||
const d = await r.json();
|
||||
|
||||
const canvas = document.getElementById('pointsChart');
|
||||
const canvas = document.getElementById('modalTrendChart');
|
||||
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: {
|
||||
@ -808,11 +806,10 @@ async function loadPointStats() {
|
||||
datasets: [
|
||||
{
|
||||
label: '消耗积分',
|
||||
data: d.deductions,
|
||||
data: d.consumption,
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.05)',
|
||||
borderWidth: 3,
|
||||
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
@ -820,9 +817,9 @@ async function loadPointStats() {
|
||||
},
|
||||
{
|
||||
label: '充值积分',
|
||||
data: d.incomes,
|
||||
data: d.recharge,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.05)',
|
||||
borderWidth: 3,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
@ -835,52 +832,130 @@ async function loadPointStats() {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: '#1e293b',
|
||||
padding: 12,
|
||||
cornerRadius: 12,
|
||||
titleFont: { weight: 'bold' },
|
||||
bodyFont: { weight: '900' }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(241, 245, 249, 1)' },
|
||||
ticks: { font: { weight: 'bold' } }
|
||||
grid: { color: '#f1f5f9' },
|
||||
ticks: { font: { weight: 'bold', size: 10 }, color: '#94a3b8' }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { font: { weight: 'bold' } }
|
||||
ticks: { font: { weight: 'bold', size: 10 }, color: '#94a3b8' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('加载统计图失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPointDetails() {
|
||||
async function loadPointDetails(page = 1) {
|
||||
const body = document.getElementById('pointDetailsBody');
|
||||
const 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>';
|
||||
|
||||
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_history?page=${page}&per_page=5`);
|
||||
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>';
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 %}
|
||||
<!-- Top Header -->
|
||||
<div class="flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-5">
|
||||
<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>
|
||||
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>
|
||||
<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 '' }} 已完成,积分已到账。
|
||||
<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>
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
<!-- 套餐 2 -->
|
||||
<div class="bg-white rounded-[2.5rem] border border-slate-100 p-8 shadow-xl relative">
|
||||
<div
|
||||
class="absolute -top-3 right-6 bg-indigo-600 text-white text-[8px] font-black px-3 py-1 rounded-full shadow-lg uppercase">
|
||||
推荐</div>
|
||||
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4">普通创作</div>
|
||||
<div class="flex items-baseline gap-1 mb-6">
|
||||
<span class="text-4xl font-black text-slate-900">200</span>
|
||||
<span class="text-sm text-slate-400 font-bold">积分</span>
|
||||
</div>
|
||||
<div class="space-y-4 mb-8">
|
||||
<div class="flex items-center gap-2 text-xs font-bold text-slate-600">
|
||||
<i data-lucide="check-circle-2" class="w-4 h-4 text-emerald-500"></i>
|
||||
<span>满足日常生成需求</span>
|
||||
</div>
|
||||
</div>
|
||||
<form action="/payment/create" method="POST">
|
||||
<input type="hidden" name="package_id" value="200">
|
||||
<button type="submit"
|
||||
class="w-full py-4 rounded-2xl bg-indigo-600 text-white font-black text-sm">
|
||||
¥ 20.00 购买
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 套餐 3 -->
|
||||
<div class="bg-indigo-900 rounded-[2.5rem] border border-indigo-800 p-8 shadow-2xl">
|
||||
<div class="text-[10px] font-black text-indigo-300 uppercase tracking-widest mb-4">量大管饱</div>
|
||||
<div class="flex items-baseline gap-1 mb-6">
|
||||
<span class="text-4xl font-black text-white">1000</span>
|
||||
<span class="text-sm text-indigo-300 font-bold">积分</span>
|
||||
</div>
|
||||
<div class="space-y-4 mb-8">
|
||||
<div class="flex items-center gap-2 text-xs font-bold text-indigo-100">
|
||||
<i data-lucide="check-circle-2" class="w-4 h-4 text-amber-400"></i>
|
||||
<span>创作自由,无需等待</span>
|
||||
</div>
|
||||
</div>
|
||||
<form action="/payment/create" method="POST">
|
||||
<input type="hidden" name="package_id" value="1000">
|
||||
<button type="submit"
|
||||
class="w-full py-4 rounded-2xl bg-white text-indigo-900 font-black text-sm">
|
||||
¥ 100.00 购买
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 套餐 4 -->
|
||||
<div class="bg-amber-500 rounded-[2.5rem] border border-amber-400 p-8 shadow-2xl">
|
||||
<div class="text-[10px] font-black text-amber-900 uppercase tracking-widest mb-4">豪掷千金</div>
|
||||
<div class="flex items-baseline gap-1 mb-6">
|
||||
<span class="text-4xl font-black text-slate-900">5000</span>
|
||||
<span class="text-sm text-amber-900 font-bold">积分</span>
|
||||
</div>
|
||||
<div class="space-y-4 mb-8">
|
||||
<div class="flex items-center gap-2 text-xs font-bold text-amber-900">
|
||||
<i data-lucide="check-circle-2" class="w-4 h-4 text-white"></i>
|
||||
<span>至尊权限,顶级服务</span>
|
||||
</div>
|
||||
</div>
|
||||
<form action="/payment/create" method="POST">
|
||||
<input type="hidden" name="package_id" value="5000">
|
||||
<button type="submit"
|
||||
class="w-full py-4 rounded-2xl bg-slate-900 text-white font-black text-sm">
|
||||
¥ 500.00 购买
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支付提示 -->
|
||||
<div class="bg-slate-100/50 rounded-3xl p-6 text-center">
|
||||
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
<i data-lucide="shield-check" class="w-3 h-3 inline-block mr-1"></i>
|
||||
支付安全保障 · 充值实时到账
|
||||
</p>
|
||||
</div>
|
||||
<!-- 充值记录区域 -->
|
||||
<div class="space-y-8 pt-8 border-t border-slate-200">
|
||||
<!-- 标签页切换 -->
|
||||
<div class="flex items-center gap-4 bg-slate-100 p-1.5 rounded-2xl w-fit">
|
||||
<button onclick="switchTab('personal')" id="tabBtn-personal"
|
||||
class="px-6 py-2.5 rounded-xl text-sm font-black transition-all bg-white text-slate-900 shadow-sm">
|
||||
我的充值
|
||||
</button>
|
||||
<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">
|
||||
<!-- 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>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Package 50 -->
|
||||
<div onclick="submitRecharge('50')"
|
||||
class="bg-white p-6 rounded-[2.5rem] border border-slate-100 shadow-xl shadow-slate-200/30 hover:border-indigo-400 cursor-pointer transition-all group relative overflow-hidden">
|
||||
<div class="flex justify-between items-start mb-4 relative z-10">
|
||||
<div
|
||||
class="w-10 h-10 bg-slate-50 rounded-xl flex items-center justify-center text-slate-400 group-hover:text-indigo-600 transition-colors">
|
||||
<i data-lucide="zap" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-lg font-black text-slate-900">¥5.00</span>
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<h5 class="text-xl font-black text-slate-800">50 <small
|
||||
class="text-[10px] opacity-40">Pts</small></h5>
|
||||
<p class="text-[9px] font-bold text-slate-400 mt-1">个人尝鲜包</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Package 200 (Hot) -->
|
||||
<div onclick="submitRecharge('200')"
|
||||
class="bg-white p-6 rounded-[2.5rem] border-2 border-indigo-500 shadow-xl shadow-indigo-100/40 hover:scale-105 cursor-pointer transition-all relative ring-4 ring-indigo-50/50">
|
||||
<div
|
||||
class="absolute -top-3 -right-3 px-3 py-1 bg-indigo-600 text-white rounded-full text-[9px] font-black shadow-lg z-20">
|
||||
HOT</div>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="w-10 h-10 bg-indigo-50 rounded-xl flex items-center justify-center text-indigo-600">
|
||||
<i data-lucide="zap" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-lg font-black text-slate-900">¥20.00</span>
|
||||
</div>
|
||||
<h5 class="text-xl font-black text-slate-800">200 <small class="text-[10px] opacity-40">Pts</small>
|
||||
</h5>
|
||||
<p class="text-[9px] font-bold text-indigo-500 mt-1 uppercase">全站最受欢迎</p>
|
||||
</div>
|
||||
|
||||
<!-- Package 1000 -->
|
||||
<div onclick="submitRecharge('1000')"
|
||||
class="bg-white p-6 rounded-[2.5rem] border border-slate-100 shadow-xl shadow-slate-200/30 hover:border-indigo-400 cursor-pointer transition-all group">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div
|
||||
class="w-10 h-10 bg-slate-50 rounded-xl flex items-center justify-center text-slate-400 group-hover:text-indigo-600 transition-colors">
|
||||
<i data-lucide="zap" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-lg font-black text-slate-900">¥100.00</span>
|
||||
</div>
|
||||
<h5 class="text-xl font-black text-slate-800">1000 <small class="text-[10px] opacity-40">Pts</small>
|
||||
</h5>
|
||||
<p class="text-[9px] font-bold text-slate-400 mt-1">工作室/创作包</p>
|
||||
</div>
|
||||
|
||||
<!-- Package 5000 -->
|
||||
<div onclick="submitRecharge('5000')"
|
||||
class="bg-slate-900 p-6 rounded-[2.5rem] border border-slate-800 shadow-2xl hover:bg-slate-800 cursor-pointer transition-all group text-white">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center text-indigo-400">
|
||||
<i data-lucide="crown" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-lg font-black">¥500.00</span>
|
||||
</div>
|
||||
<h5 class="text-xl font-black">5000 <small class="text-[10px] opacity-40">Pts</small></h5>
|
||||
<p class="text-[9px] font-bold text-indigo-300 mt-1">旗舰版海量包</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="rechargeForm" action="/payment/create" method="POST" class="hidden">
|
||||
<input type="hidden" name="package_id" id="packageIdInput">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 2. Invitation Section -->
|
||||
<div id="invite-section"
|
||||
class="bg-white rounded-[3rem] shadow-xl shadow-slate-200/50 border border-slate-100 overflow-hidden relative">
|
||||
<div class="absolute top-0 right-0 p-6 opacity-5">
|
||||
<i data-lucide="gift" class="w-64 h-64"></i>
|
||||
</div>
|
||||
|
||||
<div class="p-10 relative z-10 flex flex-col md:flex-row items-center gap-12">
|
||||
<div class="flex-1 space-y-6">
|
||||
<div>
|
||||
<h4 class="text-xl font-black text-slate-900 tracking-tight flex items-center gap-3">
|
||||
<span class="text-indigo-600">邀请好友</span> 赚取佣金
|
||||
</h4>
|
||||
<p class="text-sm font-bold text-slate-400 mt-2 leading-relaxed">
|
||||
每成功邀请一位好友注册,您将获得其前 3 次充值金额的 <span class="text-indigo-600 text-base">10%</span>
|
||||
等值积分奖励。实时到账,无上限。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-slate-50 border border-slate-100 p-6 rounded-[2rem] flex flex-col sm:flex-row items-center gap-6">
|
||||
<div class="flex-1 w-full space-y-1">
|
||||
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">我的专属邀请码</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="myInviteCode"
|
||||
class="text-3xl font-black text-indigo-900 tracking-widest cursor-pointer"
|
||||
onclick="copyInviteLink()" title="点击复制链接">...</span>
|
||||
<button onclick="copyInviteLink()"
|
||||
class="p-2 bg-white border border-slate-200 rounded-xl hover:text-indigo-600 hover:border-indigo-200 transition-all">
|
||||
<i data-lucide="link" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-10 w-px bg-slate-200 hidden sm:block"></div>
|
||||
<div class="flex gap-8">
|
||||
<div class="text-center">
|
||||
<p class="text-[9px] font-black text-slate-400">已邀请</p>
|
||||
<p id="myInvitedCount" class="text-xl font-black text-slate-800">0</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-[9px] font-black text-slate-400">累计获取</p>
|
||||
<p id="myTotalRewards" class="text-xl font-black text-amber-500">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Illustration or Right Side -->
|
||||
<div class="hidden md:flex items-center justify-center">
|
||||
<div
|
||||
class="w-20 h-20 bg-indigo-50 rounded-full flex items-center justify-center text-indigo-500 animate-bounce">
|
||||
<i data-lucide="gift" class="w-8 h-8"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Recharge History (Order List) -->
|
||||
<div class="space-y-6 pt-6">
|
||||
<h3 class="text-sm font-black text-slate-900 tracking-widest uppercase italic px-2">最近充值记录</h3>
|
||||
|
||||
<div class="bg-white rounded-[2.5rem] shadow-sm border border-slate-100 overflow-hidden">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<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 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-100">
|
||||
<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/50 transition-colors">
|
||||
<tr class="hover:bg-slate-50/30 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-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 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 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="px-2 py-1 bg-emerald-50 text-emerald-600 text-[10px] font-black rounded-lg border border-emerald-100">已完成</span>
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-50 text-emerald-600 text-[10px] font-black">
|
||||
<span class="w-1.5 h-1.5 bg-emerald-500 rounded-full"></span> 已完成
|
||||
</span>
|
||||
{% elif order.status == 'PENDING' %}
|
||||
<span
|
||||
class="px-2 py-1 bg-amber-50 text-amber-600 text-[10px] font-black rounded-lg border border-amber-100">待支付</span>
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-amber-50 text-amber-600 text-[10px] font-black">
|
||||
<span class="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse"></span> 待支付
|
||||
</span>
|
||||
{% else %}
|
||||
<span
|
||||
class="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-black rounded-lg border border-slate-100">已取消</span>
|
||||
<span class="text-slate-400 text-[10px]">已取消</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 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">
|
||||
<p class="text-slate-300 font-bold text-sm">暂无记录</p>
|
||||
<td colspan="4" class="px-8 py-16 text-center text-slate-300 italic">
|
||||
暂无充值记录
|
||||
</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>
|
||||
</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>
|
||||
<div class="p-6 bg-slate-50/50 border-t border-slate-100 text-center">
|
||||
<a href="/admin/orders"
|
||||
class="text-[10px] font-black text-indigo-600 hover:text-indigo-700 uppercase tracking-widest flex items-center justify-center gap-1">
|
||||
进入后台管理全员订单 <i data-lucide="chevron-right" class="w-3 h-3"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% 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";
|
||||
// --- Recharge Logic ---
|
||||
function submitRecharge(pkgId) {
|
||||
document.getElementById('packageIdInput').value = pkgId;
|
||||
document.getElementById('rechargeForm').submit();
|
||||
}
|
||||
lucide.createIcons();
|
||||
|
||||
// --- Invite Logic ---
|
||||
async function loadInviteStats() {
|
||||
try {
|
||||
const r = await fetch('/api/auth/invite_stats');
|
||||
const d = await r.json();
|
||||
if (d.invite_code) {
|
||||
document.getElementById('myInviteCode').innerText = d.invite_code;
|
||||
document.getElementById('myInvitedCount').innerText = d.invited_count;
|
||||
document.getElementById('myTotalRewards').innerText = d.total_rewards;
|
||||
}
|
||||
} catch (e) { console.error('加载邀请统计失败', e); }
|
||||
}
|
||||
|
||||
function copyInviteLink() {
|
||||
const code = document.getElementById('myInviteCode').innerText;
|
||||
if (code === '...') return;
|
||||
|
||||
const link = `${window.location.origin}/login?invite_code=${code}`;
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
showToast('专属邀请链接已复制', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadInviteStats();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -275,98 +275,112 @@
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码弹窗 -->
|
||||
<div id="pwdModal"
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 修改密码弹窗 -->
|
||||
<div id="pwdModal"
|
||||
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
|
||||
<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">
|
||||
@ -390,10 +404,10 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拍摄角度设置器弹窗 -->
|
||||
<div id="visualizerModal"
|
||||
<!-- 拍摄角度设置器弹窗 -->
|
||||
<div id="visualizerModal"
|
||||
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
|
||||
<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]">
|
||||
@ -416,16 +430,17 @@
|
||||
<iframe id="visualizerFrame" src="/visualizer" class="w-full h-full border-none"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存提示词弹窗 -->
|
||||
<div id="savePromptModal"
|
||||
<!-- 保存提示词弹窗 -->
|
||||
<div id="savePromptModal"
|
||||
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
|
||||
<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>
|
||||
<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>
|
||||
@ -436,11 +451,11 @@
|
||||
class="flex-1 btn-primary px-6 py-3 rounded-2xl font-bold shadow-lg shadow-indigo-100">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 删除确认弹窗 -->
|
||||
<div id="deleteConfirmModal"
|
||||
<!-- 删除确认弹窗 -->
|
||||
<div id="deleteConfirmModal"
|
||||
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
|
||||
<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">
|
||||
@ -460,9 +475,9 @@
|
||||
class="flex-1 bg-rose-500 text-white px-6 py-3 rounded-2xl font-bold shadow-lg shadow-rose-200 hover:bg-rose-600 transition-all">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% endblock %}
|
||||
@ -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
574
templates/points.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user