注册码 + 管理员系统:

User 模型新增 is_admin 字段
新增 InviteCode 模型(邀请码表)
注册接口必须提供有效邀请码,使用后自动标记
管理员接口:查看所有用户、启用/禁用用户、生成/删除邀请码
前端新增管理面板页面 /admin,导航栏对管理员显示入口
注册页面新增邀请码输入框
选择性超话签到:

新增 GET /api/v1/accounts/{id}/topics 接口获取超话列表
POST /signin 接口支持 {"topic_indices": [0,1,3]} 选择性签到
新增超话选择页面 /accounts/{id}/topics,支持全选/手动勾选
账号详情页新增"选择超话签到"按钮
This commit is contained in:
2026-03-17 17:05:28 +08:00
parent 2fb27aa714
commit e514a11e62
26 changed files with 649 additions and 18 deletions

View File

@@ -8,7 +8,7 @@ from datetime import datetime
from typing import Dict, List from typing import Dict, List
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status, Body
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -249,6 +249,25 @@ async def verify_account(
) )
@router.get("/{account_id}/topics")
async def list_topics(
account_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取账号关注的超话列表,供用户勾选签到。"""
account = await _get_owned_account(account_id, user, db)
key = _encryption_key()
try:
cookie_str = decrypt_cookie(account.encrypted_cookies, account.iv, key)
except Exception:
return error_response("Cookie 解密失败", "COOKIE_ERROR", status_code=400)
topics = await _get_super_topics(cookie_str, account.weibo_user_id)
return success_response({"topics": topics, "total": len(topics)})
# ---- MANUAL SIGNIN ---- # ---- MANUAL SIGNIN ----
async def _get_super_topics(cookie_str: str, weibo_uid: str = "") -> List[dict]: async def _get_super_topics(cookie_str: str, weibo_uid: str = "") -> List[dict]:
@@ -384,10 +403,12 @@ async def manual_signin(
account_id: str, account_id: str,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
body: dict = Body(default=None),
): ):
""" """
Manually trigger sign-in for all followed super topics. Manually trigger sign-in for selected (or all) super topics.
Verifies cookie first, fetches topic list, signs each one, writes logs. Body (optional): {"topic_indices": [0, 1, 3]} — indices of topics to sign.
If omitted or empty, signs all topics.
""" """
account = await _get_owned_account(account_id, user, db) account = await _get_owned_account(account_id, user, db)
key = _encryption_key() key = _encryption_key()
@@ -421,6 +442,18 @@ async def manual_signin(
"No super topics found for this account", "No super topics found for this account",
) )
# Filter topics if specific indices provided
selected_indices = None
if body and isinstance(body, dict):
selected_indices = body.get("topic_indices")
if selected_indices and isinstance(selected_indices, list):
topics = [topics[i] for i in selected_indices if 0 <= i < len(topics)]
if not topics:
return success_response(
{"signed": 0, "already_signed": 0, "failed": 0, "topics": []},
"No valid topics selected",
)
# Sign each topic # Sign each topic
results = [] results = []
signed = already = failed = 0 signed = already = failed = 0

View File

@@ -7,12 +7,14 @@ from fastapi import FastAPI, Depends, HTTPException, status, Security
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, func as sa_func
import uvicorn import uvicorn
import os import os
import logging import logging
import secrets
from datetime import datetime
from shared.models import get_db, User from shared.models import get_db, User, InviteCode
from shared.config import shared_settings from shared.config import shared_settings
from auth_service.app.models.database import create_tables from auth_service.app.models.database import create_tables
from auth_service.app.schemas.user import ( from auth_service.app.schemas.user import (
@@ -113,11 +115,26 @@ async def health_check():
@app.post("/auth/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED) @app.post("/auth/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db)): async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
""" """
Register a new user account and return tokens Register a new user account and return tokens.
Requires a valid invite code.
""" """
auth_service = AuthService(db) auth_service = AuthService(db)
# Validate invite code
result = await db.execute(
select(InviteCode).where(
InviteCode.code == user_data.invite_code,
InviteCode.is_used == False,
)
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="邀请码无效或已被使用",
)
# Check if user already exists - optimized with single query # Check if user already exists
email_user, username_user = await auth_service.check_user_exists(user_data.email, user_data.username) email_user, username_user = await auth_service.check_user_exists(user_data.email, user_data.username)
if email_user: if email_user:
@@ -135,6 +152,12 @@ async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db
# Create new user # Create new user
try: try:
user = await auth_service.create_user(user_data) user = await auth_service.create_user(user_data)
# Mark invite code as used
invite.is_used = True
invite.used_by = str(user.id)
invite.used_at = datetime.utcnow()
await db.commit()
# Create tokens for auto-login # Create tokens for auto-login
access_token = create_access_token(data={"sub": str(user.id), "username": user.username}) access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
@@ -335,3 +358,128 @@ async def wx_login(body: WxLoginRequest, db: AsyncSession = Depends(get_db)):
expires_in=3600, expires_in=3600,
user=UserResponse.from_orm(user), user=UserResponse.from_orm(user),
) )
# ===================== Admin Endpoints =====================
async def require_admin(
credentials: HTTPAuthorizationCredentials = Security(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""Dependency: require admin user."""
payload = decode_access_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
user_id = payload.get("sub")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found")
if not user.is_admin:
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
@app.get("/admin/users")
async def admin_list_users(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""管理员查看所有用户"""
result = await db.execute(select(User).order_by(User.created_at.desc()))
users = result.scalars().all()
return {
"success": True,
"data": [
{
"id": str(u.id),
"username": u.username,
"email": u.email,
"is_active": u.is_active,
"is_admin": u.is_admin,
"created_at": str(u.created_at) if u.created_at else None,
}
for u in users
],
}
@app.put("/admin/users/{user_id}/toggle")
async def admin_toggle_user(
user_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""管理员启用/禁用用户"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if str(user.id) == str(admin.id):
raise HTTPException(status_code=400, detail="不能禁用自己")
user.is_active = not user.is_active
await db.commit()
return {"success": True, "is_active": user.is_active}
@app.post("/admin/invite-codes")
async def admin_create_invite_code(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""管理员生成邀请码"""
code = secrets.token_urlsafe(8)[:12].upper()
invite = InviteCode(code=code, created_by=str(admin.id))
db.add(invite)
await db.commit()
await db.refresh(invite)
return {
"success": True,
"data": {
"id": str(invite.id),
"code": invite.code,
"created_at": str(invite.created_at),
},
}
@app.get("/admin/invite-codes")
async def admin_list_invite_codes(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""管理员查看所有邀请码"""
result = await db.execute(select(InviteCode).order_by(InviteCode.created_at.desc()))
codes = result.scalars().all()
return {
"success": True,
"data": [
{
"id": str(c.id),
"code": c.code,
"is_used": c.is_used,
"used_by": c.used_by,
"created_at": str(c.created_at) if c.created_at else None,
"used_at": str(c.used_at) if c.used_at else None,
}
for c in codes
],
}
@app.delete("/admin/invite-codes/{code_id}")
async def admin_delete_invite_code(
code_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""管理员删除未使用的邀请码"""
result = await db.execute(select(InviteCode).where(InviteCode.id == code_id))
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(status_code=404, detail="邀请码不存在")
if invite.is_used:
raise HTTPException(status_code=400, detail="已使用的邀请码不能删除")
await db.delete(invite)
await db.commit()
return {"success": True}

View File

@@ -16,6 +16,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
"""Schema for user registration request""" """Schema for user registration request"""
password: str = Field(..., min_length=8, description="Password (min 8 characters)") password: str = Field(..., min_length=8, description="Password (min 8 characters)")
invite_code: str = Field(..., min_length=1, description="注册邀请码")
class UserLogin(BaseModel): class UserLogin(BaseModel):
"""Schema for user login request""" """Schema for user login request"""
@@ -35,6 +36,7 @@ class UserResponse(BaseModel):
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
created_at: datetime created_at: datetime
is_active: bool is_active: bool
is_admin: bool = False
wx_openid: Optional[str] = None wx_openid: Optional[str] = None
wx_nickname: Optional[str] = None wx_nickname: Optional[str] = None
wx_avatar: Optional[str] = None wx_avatar: Optional[str] = None

View File

@@ -1,7 +1,7 @@
"""Shared ORM models for Weibo-HotSign.""" """Shared ORM models for Weibo-HotSign."""
from .base import Base, get_db, engine, AsyncSessionLocal from .base import Base, get_db, engine, AsyncSessionLocal
from .user import User from .user import User, InviteCode
from .account import Account from .account import Account
from .task import Task from .task import Task
from .signin_log import SigninLog from .signin_log import SigninLog
@@ -12,6 +12,7 @@ __all__ = [
"engine", "engine",
"AsyncSessionLocal", "AsyncSessionLocal",
"User", "User",
"InviteCode",
"Account", "Account",
"Task", "Task",
"SigninLog", "SigninLog",

View File

@@ -19,6 +19,7 @@ class User(Base):
wx_openid = Column(String(64), unique=True, nullable=True, index=True) wx_openid = Column(String(64), unique=True, nullable=True, index=True)
wx_nickname = Column(String(100), nullable=True) wx_nickname = Column(String(100), nullable=True)
wx_avatar = Column(String(500), nullable=True) wx_avatar = Column(String(500), nullable=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
@@ -26,3 +27,15 @@ class User(Base):
def __repr__(self): def __repr__(self):
return f"<User(id={self.id}, username='{self.username}')>" return f"<User(id={self.id}, username='{self.username}')>"
class InviteCode(Base):
__tablename__ = "invite_codes"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
code = Column(String(32), unique=True, nullable=False, index=True)
created_by = Column(String(36), nullable=False)
used_by = Column(String(36), nullable=True)
is_used = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())
used_at = Column(DateTime, nullable=True)

Binary file not shown.

View File

@@ -44,8 +44,8 @@ def create_database():
hashed_password = hash_password(test_password) hashed_password = hash_password(test_password)
cursor.execute(""" cursor.execute("""
INSERT INTO users (id, username, email, hashed_password, is_active) INSERT INTO users (id, username, email, hashed_password, is_admin, is_active)
VALUES (?, ?, ?, ?, 1) VALUES (?, ?, ?, ?, 1, 1)
""", (test_user_id, test_username, test_email, hashed_password)) """, (test_user_id, test_username, test_email, hashed_password))
conn.commit() conn.commit()

View File

@@ -112,15 +112,20 @@ def register():
email = request.form.get('email') email = request.form.get('email')
password = request.form.get('password') password = request.form.get('password')
confirm_password = request.form.get('confirm_password') confirm_password = request.form.get('confirm_password')
invite_code = request.form.get('invite_code', '').strip()
if password != confirm_password: if password != confirm_password:
flash('两次输入的密码不一致', 'danger') flash('两次输入的密码不一致', 'danger')
return redirect(url_for('register')) return redirect(url_for('register'))
if not invite_code:
flash('请输入邀请码', 'danger')
return redirect(url_for('register'))
try: try:
response = requests.post( response = requests.post(
f'{AUTH_BASE_URL}/auth/register', f'{AUTH_BASE_URL}/auth/register',
json={'username': username, 'email': email, 'password': password}, json={'username': username, 'email': email, 'password': password, 'invite_code': invite_code},
timeout=10 timeout=10
) )
@@ -694,7 +699,7 @@ def verify_account(account_id):
def manual_signin(account_id): def manual_signin(account_id):
"""手动触发签到""" """手动触发签到"""
try: try:
response = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin') response = api_request('POST', f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin', json={})
data = response.json() data = response.json()
if data.get('success'): if data.get('success'):
result = data.get('data', {}) result = data.get('data', {})
@@ -910,6 +915,144 @@ def not_found(error):
def server_error(error): def server_error(error):
return render_template('500.html'), 500 return render_template('500.html'), 500
# ===================== Admin Routes =====================
def admin_required(f):
"""管理员权限装饰器"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
flash('请先登录', 'warning')
return redirect(url_for('login'))
if not session.get('user', {}).get('is_admin'):
flash('需要管理员权限', 'danger')
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated_function
@app.route('/admin')
@admin_required
def admin_panel():
"""管理员面板"""
# 获取用户列表
try:
resp = api_request('GET', f'{AUTH_BASE_URL}/admin/users')
users = resp.json().get('data', []) if resp.status_code == 200 else []
except Exception:
users = []
# 获取邀请码列表
try:
resp = api_request('GET', f'{AUTH_BASE_URL}/admin/invite-codes')
codes = resp.json().get('data', []) if resp.status_code == 200 else []
except Exception:
codes = []
return render_template('admin.html', users=users, invite_codes=codes, user=session.get('user'))
@app.route('/admin/invite-codes/create', methods=['POST'])
@admin_required
def create_invite_code():
"""生成邀请码"""
try:
resp = api_request('POST', f'{AUTH_BASE_URL}/admin/invite-codes')
data = resp.json()
if resp.status_code == 200 and data.get('success'):
code = data['data']['code']
flash(f'邀请码已生成: {code}', 'success')
else:
flash('生成邀请码失败', 'danger')
except Exception as e:
flash(f'连接错误: {str(e)}', 'danger')
return redirect(url_for('admin_panel'))
@app.route('/admin/invite-codes/<code_id>/delete', methods=['POST'])
@admin_required
def delete_invite_code(code_id):
"""删除邀请码"""
try:
resp = api_request('DELETE', f'{AUTH_BASE_URL}/admin/invite-codes/{code_id}')
data = resp.json()
if resp.status_code == 200 and data.get('success'):
flash('邀请码已删除', 'success')
else:
flash(data.get('detail', '删除失败'), 'danger')
except Exception as e:
flash(f'连接错误: {str(e)}', 'danger')
return redirect(url_for('admin_panel'))
@app.route('/admin/users/<user_id>/toggle', methods=['POST'])
@admin_required
def toggle_user(user_id):
"""启用/禁用用户"""
try:
resp = api_request('PUT', f'{AUTH_BASE_URL}/admin/users/{user_id}/toggle')
data = resp.json()
if resp.status_code == 200 and data.get('success'):
status_text = '已启用' if data.get('is_active') else '已禁用'
flash(f'用户{status_text}', 'success')
else:
flash(data.get('detail', '操作失败'), 'danger')
except Exception as e:
flash(f'连接错误: {str(e)}', 'danger')
return redirect(url_for('admin_panel'))
# ===================== Topic Selection Signin =====================
@app.route('/accounts/<account_id>/topics')
@login_required
def account_topics(account_id):
"""获取超话列表页面,供用户勾选签到"""
try:
resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}/topics')
data = resp.json()
topics = data.get('data', {}).get('topics', []) if data.get('success') else []
# 获取账号信息
acc_resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}')
acc_data = acc_resp.json()
account = acc_data.get('data') if acc_data.get('success') else None
if not account:
flash('账号不存在', 'danger')
return redirect(url_for('dashboard'))
return render_template('topics.html', account=account, topics=topics, user=session.get('user'))
except Exception as e:
flash(f'获取超话列表失败: {str(e)}', 'danger')
return redirect(url_for('account_detail', account_id=account_id))
@app.route('/accounts/<account_id>/signin-selected', methods=['POST'])
@login_required
def signin_selected(account_id):
"""签到选中的超话"""
try:
indices = request.json.get('topic_indices', [])
resp = api_request(
'POST',
f'{API_BASE_URL}/api/v1/accounts/{account_id}/signin',
json={'topic_indices': indices},
)
data = resp.json()
if data.get('success'):
result = data.get('data', {})
return jsonify({
'success': True,
'data': result,
'message': f"签到完成: {result.get('signed', 0)} 成功, {result.get('already_signed', 0)} 已签, {result.get('failed', 0)} 失败",
})
else:
return jsonify({'success': False, 'message': data.get('message', '签到失败')}), 400
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
if __name__ == '__main__': if __name__ == '__main__':
debug_mode = os.getenv('FLASK_DEBUG', 'True').lower() in ('true', '1', 'yes') debug_mode = os.getenv('FLASK_DEBUG', 'True').lower() in ('true', '1', 'yes')
# use_reloader=False 避免 Windows 终端 QuickEdit 模式导致进程挂起 # use_reloader=False 避免 Windows 终端 QuickEdit 模式导致进程挂起

View File

@@ -86,8 +86,9 @@
<button type="submit" class="action-btn action-btn-secondary">🔍 验证 Cookie</button> <button type="submit" class="action-btn action-btn-secondary">🔍 验证 Cookie</button>
</form> </form>
<form method="POST" action="{{ url_for('manual_signin', account_id=account.id) }}" onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').textContent='⏳ 签到中...';"> <form method="POST" action="{{ url_for('manual_signin', account_id=account.id) }}" onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').textContent='⏳ 签到中...';">
<button type="submit" class="action-btn action-btn-primary">🚀 立即签到</button> <button type="submit" class="action-btn action-btn-primary">🚀 全部签到</button>
</form> </form>
<a href="{{ url_for('account_topics', account_id=account.id) }}" class="action-btn action-btn-secondary">🎯 选择超话签到</a>
<a href="{{ url_for('add_task', account_id=account.id) }}" class="action-btn action-btn-secondary">⏰ 添加定时任务</a> <a href="{{ url_for('add_task', account_id=account.id) }}" class="action-btn action-btn-secondary">⏰ 添加定时任务</a>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}管理面板 - 微博超话签到{% endblock %}
{% block extra_css %}
<style>
.admin-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px; }
@media (max-width: 768px) { .admin-grid { grid-template-columns: 1fr; } }
.admin-title {
font-size: 28px; font-weight: 700; margin-bottom: 24px;
background: linear-gradient(135deg, #6366f1, #a855f7);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.stat-row { display: flex; gap: 16px; margin-bottom: 24px; }
.stat-card {
flex: 1; background: rgba(255,255,255,0.9); border-radius: 16px;
padding: 20px; text-align: center; border: 1px solid rgba(0,0,0,0.05);
}
.stat-num { font-size: 32px; font-weight: 700; color: #6366f1; }
.stat-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
.user-row, .code-row {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px;
}
.user-row:last-child, .code-row:last-child { border-bottom: none; }
.code-text {
font-family: 'SF Mono', Monaco, monospace; background: #eef2ff; color: #6366f1;
padding: 4px 12px; border-radius: 8px; font-size: 14px; font-weight: 600;
letter-spacing: 1px;
}
.code-used { background: #f1f5f9; color: #94a3b8; text-decoration: line-through; }
</style>
{% endblock %}
{% block content %}
<div style="max-width: 960px; margin: 0 auto;">
<h1 class="admin-title">🛡️ 管理面板</h1>
<div class="stat-row">
<div class="stat-card">
<div class="stat-num">{{ users|length }}</div>
<div class="stat-label">总用户数</div>
</div>
<div class="stat-card">
<div class="stat-num">{{ users|selectattr('is_active')|list|length }}</div>
<div class="stat-label">活跃用户</div>
</div>
<div class="stat-card">
<div class="stat-num">{{ invite_codes|rejectattr('is_used')|list|length }}</div>
<div class="stat-label">可用邀请码</div>
</div>
</div>
<div class="admin-grid">
<div class="card">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
🎟️ 邀请码管理
<form method="POST" action="{{ url_for('create_invite_code') }}" style="display:inline;">
<button type="submit" class="btn btn-primary" style="padding:6px 16px; font-size:13px;">+ 生成邀请码</button>
</form>
</div>
{% if invite_codes %}
{% for code in invite_codes %}
<div class="code-row">
<div>
<span class="code-text {{ 'code-used' if code.is_used }}">{{ code.code }}</span>
{% if code.is_used %}
<span style="color:#94a3b8; font-size:12px; margin-left:8px;">已使用</span>
{% else %}
<span style="color:#10b981; font-size:12px; margin-left:8px;">可用</span>
{% endif %}
</div>
<div>
{% if not code.is_used %}
<form method="POST" action="{{ url_for('delete_invite_code', code_id=code.id) }}" style="display:inline;" onsubmit="return confirm('确定删除?');">
<button type="submit" class="btn btn-danger" style="padding:4px 12px; font-size:12px;">删除</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p style="color:#94a3b8; text-align:center; padding:24px; font-size:14px;">暂无邀请码,点击上方按钮生成</p>
{% endif %}
</div>
<div class="card">
<div class="card-header">👥 用户管理</div>
{% for u in users %}
<div class="user-row">
<div>
<span style="font-weight:600; color:#1e293b;">{{ u.username }}</span>
{% if u.is_admin %}<span class="badge badge-info" style="margin-left:6px;">管理员</span>{% endif %}
<div style="font-size:12px; color:#94a3b8;">{{ u.email or '-' }}</div>
</div>
<div style="display:flex; align-items:center; gap:8px;">
{% if u.is_active %}
<span class="badge badge-success">正常</span>
{% else %}
<span class="badge badge-danger">已禁用</span>
{% endif %}
{% if not u.is_admin %}
<form method="POST" action="{{ url_for('toggle_user', user_id=u.id) }}" style="display:inline;">
<button type="submit" class="btn {{ 'btn-danger' if u.is_active else 'btn-primary' }}" style="padding:4px 12px; font-size:12px;">
{{ '禁用' if u.is_active else '启用' }}
</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -223,6 +223,9 @@
<a href="{{ url_for('dashboard') }}" class="navbar-brand">🔥 微博超话签到</a> <a href="{{ url_for('dashboard') }}" class="navbar-brand">🔥 微博超话签到</a>
<div class="navbar-menu"> <div class="navbar-menu">
<a href="{{ url_for('dashboard') }}">控制台</a> <a href="{{ url_for('dashboard') }}">控制台</a>
{% if session.get('user', {}).get('is_admin') %}
<a href="{{ url_for('admin_panel') }}">🛡️ 管理</a>
{% endif %}
<div class="navbar-user"> <div class="navbar-user">
<span>👤 {{ session.get('user').get('username') }}</span> <span>👤 {{ session.get('user').get('username') }}</span>
<a href="{{ url_for('logout') }}" class="btn-logout">退出</a> <a href="{{ url_for('logout') }}" class="btn-logout">退出</a>

View File

@@ -55,6 +55,10 @@
<label for="confirm_password">确认密码</label> <label for="confirm_password">确认密码</label>
<input type="password" id="confirm_password" name="confirm_password" required placeholder="再次输入密码"> <input type="password" id="confirm_password" name="confirm_password" required placeholder="再次输入密码">
</div> </div>
<div class="form-group">
<label for="invite_code">邀请码</label>
<input type="text" id="invite_code" name="invite_code" required placeholder="请输入邀请码">
</div>
<button type="submit" class="btn btn-primary" style="width:100%; padding:14px; font-size:16px; border-radius:16px;">注册</button> <button type="submit" class="btn btn-primary" style="width:100%; padding:14px; font-size:16px; border-radius:16px;">注册</button>
</form> </form>
<div class="auth-link">已有账号?<a href="{{ url_for('login') }}">登录</a></div> <div class="auth-link">已有账号?<a href="{{ url_for('login') }}">登录</a></div>

View File

@@ -0,0 +1,139 @@
{% extends "base.html" %}
{% block title %}超话签到 - {{ account.remark or account.weibo_user_id }}{% endblock %}
{% block extra_css %}
<style>
.topics-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.topics-header h1 { font-size: 24px; font-weight: 700; color: #1e293b; }
.topic-list { display: flex; flex-direction: column; gap: 0; }
.topic-item {
display: flex; align-items: center; gap: 14px; padding: 14px 16px;
border-bottom: 1px solid #f1f5f9; cursor: pointer; transition: background 0.15s;
}
.topic-item:hover { background: #f8fafc; }
.topic-item:last-child { border-bottom: none; }
.topic-cb { width: 20px; height: 20px; accent-color: #6366f1; cursor: pointer; }
.topic-name { font-weight: 500; color: #1e293b; font-size: 15px; }
.topic-id { font-size: 12px; color: #94a3b8; font-family: monospace; }
.select-bar {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 0; border-bottom: 1px solid #e2e8f0; margin-bottom: 8px;
}
.select-bar label { font-weight: 600; color: #475569; font-size: 14px; cursor: pointer; }
.signin-btn {
padding: 14px 32px; border-radius: 16px; border: none; font-size: 16px;
font-weight: 600; cursor: pointer; color: white;
background: linear-gradient(135deg, #6366f1, #818cf8);
box-shadow: 0 2px 12px rgba(99,102,241,0.3); transition: all 0.2s;
}
.signin-btn:hover { box-shadow: 0 4px 20px rgba(99,102,241,0.4); transform: translateY(-1px); }
.signin-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
.result-box {
margin-top: 20px; padding: 16px; border-radius: 14px; display: none;
font-size: 14px; font-weight: 500;
}
.result-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
.result-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
</style>
{% endblock %}
{% block content %}
<div style="max-width: 720px; margin: 0 auto;">
<div class="topics-header">
<div>
<h1>🔥 选择签到超话</h1>
<div style="color:#94a3b8; font-size:14px; margin-top:4px;">
{{ account.remark or account.weibo_user_id }} · 共 {{ topics|length }} 个超话
</div>
</div>
<a href="{{ url_for('account_detail', account_id=account.id) }}" class="btn btn-secondary">← 返回</a>
</div>
<div class="card">
{% if topics %}
<div class="select-bar">
<label><input type="checkbox" id="selectAll" class="topic-cb" checked onchange="toggleAll()"> 全选 (<span id="selectedCount">{{ topics|length }}</span>/{{ topics|length }})</label>
<button class="signin-btn" id="signinBtn" onclick="doSignin()">🚀 签到选中超话</button>
</div>
<div class="topic-list" id="topicList">
{% for topic in topics %}
<label class="topic-item">
<input type="checkbox" class="topic-cb topic-check" data-index="{{ loop.index0 }}" checked onchange="updateCount()">
<div>
<div class="topic-name">{{ topic.title }}</div>
<div class="topic-id">{{ topic.containerid }}</div>
</div>
</label>
{% endfor %}
</div>
{% else %}
<p style="color:#94a3b8; text-align:center; padding:40px; font-size:15px;">
未找到关注的超话,请确认 Cookie 有效且已关注超话
</p>
{% endif %}
</div>
<div id="resultBox" class="result-box"></div>
</div>
<script>
function toggleAll() {
const checked = document.getElementById('selectAll').checked;
document.querySelectorAll('.topic-check').forEach(cb => cb.checked = checked);
updateCount();
}
function updateCount() {
const total = document.querySelectorAll('.topic-check').length;
const checked = document.querySelectorAll('.topic-check:checked').length;
document.getElementById('selectedCount').textContent = checked;
document.getElementById('selectAll').checked = (checked === total);
}
async function doSignin() {
const btn = document.getElementById('signinBtn');
const resultBox = document.getElementById('resultBox');
const indices = [];
document.querySelectorAll('.topic-check:checked').forEach(cb => {
indices.push(parseInt(cb.dataset.index));
});
if (indices.length === 0) {
resultBox.className = 'result-box result-error';
resultBox.style.display = 'block';
resultBox.textContent = '请至少选择一个超话';
return;
}
btn.disabled = true;
btn.textContent = '⏳ 签到中...';
resultBox.style.display = 'none';
try {
const resp = await fetch('{{ url_for("signin_selected", account_id=account.id) }}', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({topic_indices: indices}),
});
const data = await resp.json();
resultBox.style.display = 'block';
if (data.success) {
resultBox.className = 'result-box result-success';
resultBox.textContent = data.message;
} else {
resultBox.className = 'result-box result-error';
resultBox.textContent = data.message || '签到失败';
}
} catch (e) {
resultBox.className = 'result-box result-error';
resultBox.style.display = 'block';
resultBox.textContent = '请求失败: ' + e.message;
} finally {
btn.disabled = false;
btn.textContent = '🚀 签到选中超话';
}
}
</script>
{% endblock %}

View File

@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS users (
wx_openid TEXT UNIQUE, wx_openid TEXT UNIQUE,
wx_nickname TEXT, wx_nickname TEXT,
wx_avatar TEXT, wx_avatar TEXT,
is_admin INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active INTEGER DEFAULT 1 is_active INTEGER DEFAULT 1
); );
@@ -61,3 +62,17 @@ CREATE INDEX IF NOT EXISTS idx_tasks_is_enabled ON tasks(is_enabled);
CREATE INDEX IF NOT EXISTS idx_signin_logs_account_id ON signin_logs(account_id); CREATE INDEX IF NOT EXISTS idx_signin_logs_account_id ON signin_logs(account_id);
CREATE INDEX IF NOT EXISTS idx_signin_logs_signed_at ON signin_logs(signed_at); CREATE INDEX IF NOT EXISTS idx_signin_logs_signed_at ON signin_logs(signed_at);
CREATE INDEX IF NOT EXISTS idx_signin_logs_status ON signin_logs(status); CREATE INDEX IF NOT EXISTS idx_signin_logs_status ON signin_logs(status);
-- Invite codes table
CREATE TABLE IF NOT EXISTS invite_codes (
id TEXT PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
created_by TEXT NOT NULL,
used_by TEXT,
is_used INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
used_at TIMESTAMP NULL
);
CREATE INDEX IF NOT EXISTS idx_invite_codes_code ON invite_codes(code);
CREATE INDEX IF NOT EXISTS idx_invite_codes_is_used ON invite_codes(is_used);

View File

@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS users (
wx_openid VARCHAR(64) UNIQUE, wx_openid VARCHAR(64) UNIQUE,
wx_nickname VARCHAR(100), wx_nickname VARCHAR(100),
wx_avatar VARCHAR(500), wx_avatar VARCHAR(500),
is_admin BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
INDEX idx_users_email (email), INDEX idx_users_email (email),
@@ -60,3 +61,16 @@ CREATE TABLE IF NOT EXISTS signin_logs (
INDEX idx_signin_logs_status (status), INDEX idx_signin_logs_status (status),
FOREIGN KEY (account_id) REFERENCES accounts(id) FOREIGN KEY (account_id) REFERENCES accounts(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Invite codes table
CREATE TABLE IF NOT EXISTS invite_codes (
id CHAR(36) PRIMARY KEY,
code VARCHAR(32) UNIQUE NOT NULL,
created_by CHAR(36) NOT NULL,
used_by CHAR(36),
is_used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
used_at TIMESTAMP NULL,
INDEX idx_invite_codes_code (code),
INDEX idx_invite_codes_is_used (is_used)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -93,15 +93,15 @@ USER_COUNT=$($ROOT_CMD -N -e "SELECT COUNT(*) FROM ${DB_NAME}.users" 2>/dev/null
if [ "$USER_COUNT" = "0" ]; then if [ "$USER_COUNT" = "0" ]; then
# 检查 bcrypt 是否可用 # 检查 bcrypt 是否可用
if python3 -c "import bcrypt" 2>/dev/null; then if python3 -c "import bcrypt" 2>/dev/null; then
HASHED_PW=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'Admin123!', bcrypt.gensalt(12)).decode())") HASHED_PW=$(python3 -c "import bcrypt; print(bcrypt.hashpw(b'Admin123', bcrypt.gensalt(12)).decode())")
USER_ID=$(python3 -c "import uuid; print(str(uuid.uuid4()))") USER_ID=$(python3 -c "import uuid; print(str(uuid.uuid4()))")
$ROOT_CMD ${DB_NAME} -e " $ROOT_CMD ${DB_NAME} -e "
INSERT INTO users (id, username, email, hashed_password, is_active) INSERT INTO users (id, username, email, hashed_password, is_admin, is_active)
VALUES ('${USER_ID}', 'admin', 'admin@example.com', '${HASHED_PW}', 1); VALUES ('${USER_ID}', 'admin', 'admin@example.com', '${HASHED_PW}', 1, 1);
" "
info "测试用户已创建: admin / Admin123!" info "管理员用户已创建: admin@example.com / Admin123 (管理员)"
else else
warn "bcrypt 未安装,跳过测试用户创建(运行 setup_linux.sh 安装依赖后可手动创建)" warn "bcrypt 未安装,跳过管理员创建(运行 setup_linux.sh 安装依赖后可手动创建)"
fi fi
else else
info "已有 ${USER_COUNT} 个用户,跳过" info "已有 ${USER_COUNT} 个用户,跳过"

Binary file not shown.