diff --git a/data/web.db b/data/web.db index f29ea73..e4be754 100644 Binary files a/data/web.db and b/data/web.db differ diff --git a/web/backend/__pycache__/main.cpython-312.pyc b/web/backend/__pycache__/main.cpython-312.pyc index b630853..483a9b2 100644 Binary files a/web/backend/__pycache__/main.cpython-312.pyc and b/web/backend/__pycache__/main.cpython-312.pyc differ diff --git a/web/backend/main.py b/web/backend/main.py index c1deca0..c8fe990 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from .database import init_db -from .routers import auth, users, accounts, login, proxy +from .routers import auth, users, accounts, login, proxy, cookies @asynccontextmanager @@ -36,6 +36,7 @@ app.include_router(users.router) app.include_router(accounts.router) app.include_router(login.router) app.include_router(proxy.router) +app.include_router(cookies.router) @app.get("/api/health") diff --git a/web/backend/routers/__pycache__/login.cpython-312.pyc b/web/backend/routers/__pycache__/login.cpython-312.pyc index 4f77e01..d66f986 100644 Binary files a/web/backend/routers/__pycache__/login.cpython-312.pyc and b/web/backend/routers/__pycache__/login.cpython-312.pyc differ diff --git a/web/backend/routers/cookies.py b/web/backend/routers/cookies.py new file mode 100644 index 0000000..dbeb7d6 --- /dev/null +++ b/web/backend/routers/cookies.py @@ -0,0 +1,95 @@ +"""Cookie 管理路由""" + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +import io +import csv + +from ..database import get_db +from ..models import User, LoginTask, Account +from ..deps import get_current_user, require_permission +from ..permissions import has_permission + +router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"]) + + +@router.get("") +def list_cookies( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """查看登录成功的 Cookie 列表。""" + query = db.query(LoginTask).filter(LoginTask.status == "success") + + # 客服只能看自己账号的 + if not has_permission(current.role, "login:view_all"): + query = query.join(Account, LoginTask.account_id == Account.id).filter( + Account.assigned_to == current.id + ) + + tasks = query.order_by(LoginTask.finished_at.desc()).all() + result = [] + for t in tasks: + acc = db.query(Account).filter(Account.id == t.account_id).first() + item = { + "id": t.id, + "batch_id": t.batch_id, + "account_id": t.account_id, + "account_username": acc.username if acc else "", + "created_at": t.finished_at.isoformat() if t.finished_at else None, + } + # 只有有 cookie:view 权限才返回 cookie 内容 + if has_permission(current.role, "cookie:view"): + cookie = t.cookie or "" + item["cookie"] = cookie + item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie + else: + item["cookie"] = "" + item["cookie_preview"] = "***" + result.append(item) + return result + + +@router.get("/export") +def export_cookies( + db: Session = Depends(get_db), + current: User = Depends(require_permission("cookie:export")), +): + """导出 Cookie 为 CSV。""" + tasks = db.query(LoginTask).filter( + LoginTask.status == "success" + ).order_by(LoginTask.finished_at.desc()).all() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["账号", "Cookie", "时间"]) + + for t in tasks: + acc = db.query(Account).filter(Account.id == t.account_id).first() + username = acc.username if acc else "" + writer.writerow([username, t.cookie or "", t.finished_at.isoformat() if t.finished_at else ""]) + + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=cookies.csv"}, + ) + + +@router.delete("/{task_id}") +def delete_cookie( + task_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("cookie:export")), +): + """删除一条 Cookie 记录。""" + task = db.query(LoginTask).filter(LoginTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="记录不存在") + task.cookie = "" + task.status = "failed" + task.message = "Cookie已清除" + db.commit() + return {"message": "已删除", "success": True} diff --git a/web/backend/routers/login.py b/web/backend/routers/login.py index 096829b..3087a4e 100644 --- a/web/backend/routers/login.py +++ b/web/backend/routers/login.py @@ -15,12 +15,12 @@ from ..services.login_service import LoginBatchRunner router = APIRouter(prefix="/api/login", tags=["登录任务"]) -# 运行中的批次: batch_id -> {runner, log_queue, loop} +# 运行中的批次: batch_id -> {log_queue, loop, runner} _active_batches: dict[str, dict] = {} @router.post("/batch") -def create_batch( +async def create_batch( req: LoginBatchRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("login:batch")), @@ -46,6 +46,10 @@ def create_batch( if not valid_ids: raise HTTPException(status_code=403, detail="没有可登录的账号") + # 在主事件循环中创建 log_queue,传给后台线程 + log_queue = asyncio.Queue() + loop = asyncio.get_running_loop() + # 创建执行器(用独立的 DB 会话,因为在线程中运行) thread_db = SessionLocal() runner = LoginBatchRunner( @@ -55,10 +59,19 @@ def create_batch( creator_role=current.role, max_geetest_retries=req.max_geetest_retries, proxy_config=proxy, + log_queue=log_queue, + loop=loop, ) batch_id = runner.batch_id + # 先注册到全局,再启动线程,确保 WebSocket 连接时能找到 + _active_batches[batch_id] = { + "log_queue": log_queue, + "loop": loop, + "runner": runner, + } + # 启动线程 thread = threading.Thread(target=runner.run, daemon=True) thread.start() @@ -114,21 +127,24 @@ async def ws_login_logs(websocket: WebSocket, batch_id: str): """WebSocket 推送登录实时日志。""" await websocket.accept() - log_queue = asyncio.Queue() - loop = asyncio.get_running_loop() + # 从已注册的批次中获取 log_queue(由 create_batch 创建) + batch = _active_batches.get(batch_id) + if not batch: + await websocket.send_json({"level": "error", "message": "批次不存在或已结束"}) + await websocket.close() + return - # 查找已运行的批次,或等待新批次 - # 简化:直接把 log_queue 注册到全局,前端创建批次后连 ws - _active_batches[batch_id] = { - "log_queue": log_queue, - "loop": loop, - } + log_queue: asyncio.Queue = batch["log_queue"] try: while True: try: msg = await asyncio.wait_for(log_queue.get(), timeout=30) await websocket.send_json(msg) + # 收到 result 表示任务结束 + if msg.get("level") == "result": + await asyncio.sleep(0.1) + break except asyncio.TimeoutError: await websocket.send_json({"level": "heartbeat", "message": ""}) except WebSocketDisconnect: diff --git a/web/backend/services/__pycache__/login_service.cpython-312.pyc b/web/backend/services/__pycache__/login_service.cpython-312.pyc index 4432412..0833bb0 100644 Binary files a/web/backend/services/__pycache__/login_service.cpython-312.pyc and b/web/backend/services/__pycache__/login_service.cpython-312.pyc differ diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index 469ceec..ff599f0 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -175,3 +175,4 @@ class LoginBatchRunner: self.db.commit() self._push_log("info", f"批量登录任务 {batch_id} 完成") + self._push_log("result", "") diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index b4058b0..7c59e83 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -9,6 +9,7 @@ import AccountsPage from './pages/AccountsPage'; import LoginTasksPage from './pages/LoginTasksPage'; import ProxyPage from './pages/ProxyPage'; import UsersPage from './pages/UsersPage'; +import CookiePage from './pages/CookiePage'; import { getToken } from './store/auth'; function App() { @@ -29,6 +30,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index 3c2de94..bf3d354 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -52,6 +52,12 @@ export const loginApi = { stop: (batch_id: string) => api.post(`/login/stop/${batch_id}`), }; +export const cookieApi = { + list: () => api.get('/cookies'), + exportCsv: () => api.get('/cookies/export', { responseType: 'blob' }), + delete: (id: number) => api.delete(`/cookies/${id}`), +}; + export const proxyApi = { get: () => api.get('/proxy'), update: (data: any) => api.put('/proxy', data), diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index 005cef2..82b9fd8 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -1,14 +1,15 @@ import { useEffect, useState } from 'react'; -import { Layout, Menu, Dropdown, Avatar, Space, Typography } from 'antd'; +import { Layout, Menu, Avatar, Space, Typography, Button } from 'antd'; import { DashboardOutlined, UserOutlined, LogoutOutlined, - CloudServerOutlined, TeamOutlined, ApiOutlined, + CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined, + MenuFoldOutlined, MenuUnfoldOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { getUser, clearAuth, hasPerm, type AuthUser } from '../store/auth'; import { authApi } from '../api/modules'; -const { Header, Sider, Content } = Layout; +const { Sider, Content } = Layout; const { Text } = Typography; const ROLE_LABELS: Record = { @@ -44,6 +45,11 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { menuItems.push({ key: '/login-tasks', label: '登录任务', icon: }); } + // Cookie 管理 + if (hasPerm(user, 'cookie:view')) { + menuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: }); + } + // 代理配置 if (hasPerm(user, 'proxy:manage')) { menuItems.push({ key: '/proxy', label: '代理配置', icon: }); @@ -59,30 +65,30 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { await authApi.logout(); } catch {} clearAuth(); - onLogout?.(); // 触发 App 重渲染 + onLogout?.(); navigate('/login', { replace: true }); }; - const userMenu = { - items: [ - { - key: 'logout', - label: '退出登录', - icon: , - onClick: handleLogout, - }, - ], - }; - return ( - - + +
- {collapsed ? '鱼' : '斗鱼登录后台'} + + {collapsed ? '鱼' : '斗鱼登录后台'} + +
void }) { selectedKeys={[location.pathname]} items={menuItems} onClick={({ key }) => navigate(key)} + style={{ flex: 1, overflow: 'auto', marginTop: 8 }} /> +
+ + } size="small" /> + {!collapsed && ( + <> + {user.username} + + ({ROLE_LABELS[user.role] || user.role}) + + + )} + + {!collapsed && ( + + )} +
-
- - - } /> - {user.username} - ({ROLE_LABELS[user.role] || user.role}) - - -
- +
diff --git a/web/frontend/src/pages/CookiePage.tsx b/web/frontend/src/pages/CookiePage.tsx new file mode 100644 index 0000000..465ad8b --- /dev/null +++ b/web/frontend/src/pages/CookiePage.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'react'; +import { Table, Button, Card, Row, Col, Statistic, message, Tag, Popconfirm } from 'antd'; +import { DownloadOutlined, DeleteOutlined } from '@ant-design/icons'; +import { cookieApi } from '../api/modules'; +import { getUser, hasPerm } from '../store/auth'; + +export default function CookiePage() { + const [cookies, setCookies] = useState([]); + const [loading, setLoading] = useState(false); + const user = getUser(); + + const canView = hasPerm(user, 'cookie:view'); + const canExport = hasPerm(user, 'cookie:export'); + + const loadCookies = async () => { + setLoading(true); + try { + const data = await cookieApi.list(); + setCookies(data); + } catch (e: any) { + message.error(e.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadCookies(); + }, []); + + const handleExport = async () => { + try { + const res = await cookieApi.exportCsv(); + const url = URL.createObjectURL(new Blob([res.data])); + const a = document.createElement('a'); + a.href = url; + a.download = 'cookies.csv'; + a.click(); + URL.revokeObjectURL(url); + message.success('已导出'); + } catch (e: any) { + message.error(e.message); + } + }; + + const handleDelete = async (id: number) => { + try { + await cookieApi.delete(id); + message.success('已删除'); + loadCookies(); + } catch (e: any) { + message.error(e.message); + } + }; + + const columns: any[] = [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '账号', dataIndex: 'account_username' }, + { + title: 'Cookie', + dataIndex: 'cookie_preview', + ellipsis: true, + render: (val: string) => { + if (!canView) return ***; + return {val}; + }, + }, + { title: '时间', dataIndex: 'created_at', width: 180 }, + ]; + + if (canExport) { + columns.push({ + title: '操作', + width: 80, + render: (_: any, record: any) => ( + handleDelete(record.id)}> + + + ), + }); + } + + return ( +
+
+

Cookie 管理

+ {canExport && ( + + )} +
+ + + + + + + + ); +} diff --git a/web/frontend/src/pages/LoginTasksPage.tsx b/web/frontend/src/pages/LoginTasksPage.tsx index 6459757..de7578a 100644 --- a/web/frontend/src/pages/LoginTasksPage.tsx +++ b/web/frontend/src/pages/LoginTasksPage.tsx @@ -29,6 +29,7 @@ export default function LoginTasksPage() { const [loading, setLoading] = useState(false); const [batchId, setBatchId] = useState(null); const [logs, setLogs] = useState<{ level: string; message: string }[]>([]); + const [wsConnected, setWsConnected] = useState(false); const wsRef = useRef(null); const user = getUser(); @@ -75,13 +76,19 @@ export default function LoginTasksPage() { const wsUrl = `ws://${window.location.hostname}:8000/api/login/ws/login/${result.batch_id}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; + setWsConnected(true); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.level === 'heartbeat') return; + if (msg.level === 'result') return; setLogs((prev) => [...prev, msg]); }; ws.onclose = () => { wsRef.current = null; + setWsConnected(false); + }; + ws.onerror = () => { + setWsConnected(false); }; } catch (e: any) { message.error(e.message); @@ -183,7 +190,7 @@ export default function LoginTasksPage() { bodyStyle={{ maxHeight: 500, overflow: 'auto', fontFamily: 'monospace', fontSize: 12 }} > {logs.length === 0 ? ( - + ) : ( logs.map((log, i) => (
-
+
+

代理配置

@@ -122,6 +122,7 @@ export default function ProxyPage() { form={form} layout="vertical" disabled={!configLoaded} + size="small" initialValues={{ enabled: false, whitelist_enabled: false, @@ -131,37 +132,38 @@ export default function ProxyPage() { whitelist_uid: '', whitelist_ukey: '', }} + style={{ flexShrink: 0 }} > - +
- - + + - + - + - + - + - - + + - + - + - + @@ -170,8 +172,8 @@ export default function ProxyPage() { {logs.length === 0 ? ( 点击"测试代理"或"测试白名单"查看日志