feat: add scoped cookie operations for support

This commit is contained in:
yml2213
2026-08-13 16:17:06 +08:00
parent cf3fe68de3
commit 47ded2bc3e
15 changed files with 806 additions and 14 deletions
+2
View File
@@ -17,6 +17,7 @@ const LoginTasksPage = lazy(() => import('./pages/LoginTasksPage'));
const ProxyPage = lazy(() => import('./pages/ProxyPage'));
const UsersPage = lazy(() => import('./pages/UsersPage'));
const CookiePage = lazy(() => import('./pages/CookiePage'));
const CookieOperationsPage = lazy(() => import('./pages/CookieOperationsPage'));
const DouyuTasksPage = lazy(() => import('./pages/DouyuTasksPage'));
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
@@ -79,6 +80,7 @@ function AppContent() {
<Route path="assignments" element={lazyRoute(<AssignmentsPage />)} />
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
<Route path="cookie-operations" element={lazyRoute(<CookieOperationsPage />)} />
<Route path="douyu/tasks" element={<Navigate to="/douyu/elite" replace />} />
<Route path="douyu/elite" element={lazyRoute(<DouyuTasksPage key="elite" handbook="elite" />)} />
<Route path="douyu/esports" element={lazyRoute(<DouyuTasksPage key="esports" handbook="esports" />)} />
+7 -1
View File
@@ -1,11 +1,13 @@
import api from './client';
import type { BasicSummary, CookieCheckResult, CookieDuplicateResponse, CookieItem, LoginTaskItem, PageParams, PaginatedResponse, MessageDeletedResponse, MessageResponse } from './types';
import type { BasicSummary, CookieCheckResult, CookieDuplicateResponse, CookieItem, CookieOperationCheckResult, CookieOperationItem, LoginTaskItem, PageParams, PaginatedResponse, MessageDeletedResponse, MessageResponse } from './types';
export const cookieApi = {
list: () => api.get<CookieItem[], CookieItem[]>('/cookies'),
listPaged: (params: PageParams & { include_cookie?: boolean; account_names?: string }) =>
api.get<PaginatedResponse<CookieItem>, PaginatedResponse<CookieItem>>('/cookies', { params }),
summary: () => api.get<BasicSummary, BasicSummary>('/cookies/summary'),
listOperations: (params: PageParams) =>
api.get<PaginatedResponse<CookieOperationItem>, PaginatedResponse<CookieOperationItem>>('/cookies/operations', { params }),
duplicates: () => api.get<CookieDuplicateResponse, CookieDuplicateResponse>('/cookies/duplicates'),
get: (id: number) => api.get<CookieItem, CookieItem>(`/cookies/${id}`),
exportCsv: (format?: string, accountNames?: string) => api.get<Blob, Blob>('/cookies/export', {
@@ -17,8 +19,12 @@ export const cookieApi = {
}),
check: (ids: number[]) =>
api.post<{ results: CookieCheckResult[] }, { results: CookieCheckResult[] }>('/cookies/check', null, { params: { ids: ids.join(',') } }),
checkOperations: (ids: number[]) =>
api.post<{ results: CookieOperationCheckResult[] }, { results: CookieOperationCheckResult[] }>('/cookies/operations/check', null, { params: { ids: ids.join(',') } }),
relogin: (ids: number[]) =>
api.post<{ batch_id: string; count: number; skipped: number; success: boolean }, { batch_id: string; count: number; skipped: number; success: boolean }>('/cookies/relogin', { ids }),
reloginOperations: (ids: number[]) =>
api.post<{ batch_id: string; count: number; skipped: number; success: boolean }, { batch_id: string; count: number; skipped: number; success: boolean }>('/cookies/operations/relogin', { ids }),
loginTasks: (batchId: string) =>
api.get<LoginTaskItem[], LoginTaskItem[]>('/login/tasks', { params: { batch_id: batchId } }),
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/cookies/${id}`),
+14
View File
@@ -253,6 +253,20 @@ export interface CookieItem {
ck_checked_at: string | null;
}
export interface CookieOperationItem {
id: number;
account_username: string;
ck_check_status: string;
ck_checked_at: string | null;
created_at: string | null;
}
export interface CookieOperationCheckResult {
id: number;
valid: boolean;
checked_at: string;
}
export interface CookieCheckResult {
id: number;
valid: boolean;
+3
View File
@@ -71,6 +71,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
if (can('cookie:view')) {
douyuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
}
if (can('cookie:operate')) {
douyuItems.push({ key: '/cookie-operations', label: 'CK 检测与重登', icon: <SafetyCertificateOutlined /> });
}
if (can('douyu:task')) {
douyuItems.push({ key: '/douyu/elite', label: '精英宝典', icon: <BookOutlined /> });
douyuItems.push({ key: '/douyu/esports', label: '电竞手册', icon: <TrophyOutlined /> });
@@ -0,0 +1,207 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Input, Popconfirm, Space, Table, Tag, Typography } from 'antd';
import { message } from '../utils/antdMessage';
import { LoadingOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { cookieApi, type CookieOperationCheckResult, type CookieOperationItem } from '../api/modules';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
export default function CookieOperationsPage() {
const [items, setItems] = useState<CookieOperationItem[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [checkingIds, setCheckingIds] = useState<Set<number>>(new Set());
const [reloginIds, setReloginIds] = useState<Set<number>>(new Set());
const [checkResults, setCheckResults] = useState<Map<number, CookieOperationCheckResult>>(new Map());
const [total, setTotal] = useState(0);
const [pageSize, setPageSize] = useState(() => {
const value = localStorage.getItem('cookie_operations_page_size');
return value ? Number(value) || 20 : 20;
});
const [currentPage, setCurrentPage] = useState(1);
const loadItems = useCallback(async () => {
setLoading(true);
try {
const data = await cookieApi.listOperations({
page: currentPage,
page_size: pageSize,
search: search.trim() || undefined,
});
setItems(data.items);
setTotal(data.total);
setCheckResults((previous) => {
const next = new Map(previous);
for (const item of data.items) {
if (!item.ck_check_status) continue;
next.set(item.id, {
id: item.id,
valid: item.ck_check_status === 'valid',
checked_at: item.ck_checked_at ?? '',
});
}
return next;
});
} catch (error: unknown) {
message.error(getErrorMessage(error));
} finally {
setLoading(false);
}
}, [currentPage, pageSize, search]);
useEffect(() => {
void loadItems();
}, [loadItems]);
const handleCheck = async (ids: number[]) => {
if (ids.length === 0) return;
setCheckingIds((previous) => new Set([...previous, ...ids]));
try {
const response = await cookieApi.checkOperations(ids);
setCheckResults((previous) => {
const next = new Map(previous);
response.results.forEach((item) => next.set(item.id, item));
return next;
});
message.success(`检测完成:${response.results.filter((item) => item.valid).length}/${response.results.length} 条有效`);
void loadItems();
} catch (error: unknown) {
message.error(getErrorMessage(error));
} finally {
setCheckingIds((previous) => {
const next = new Set(previous);
ids.forEach((id) => next.delete(id));
return next;
});
}
};
const handleRelogin = async (ids: number[]) => {
if (ids.length === 0) return;
setReloginIds((previous) => new Set([...previous, ...ids]));
try {
const response = await cookieApi.reloginOperations(ids);
const skipped = response.skipped ? `${response.skipped} 条因缺少登录凭据跳过` : '';
message.success(`已开始重登 ${response.count} 个账号${skipped}`);
setSelectedRowKeys([]);
window.setTimeout(() => void loadItems(), 1000);
} catch (error: unknown) {
message.error(getErrorMessage(error));
} finally {
setReloginIds((previous) => {
const next = new Set(previous);
ids.forEach((id) => next.delete(id));
return next;
});
}
};
const resultFor = (item: CookieOperationItem) => checkResults.get(item.id);
const selectedIds = selectedRowKeys.map((key) => Number(key));
const columns = [
{ title: '账号', dataIndex: 'account_username', ellipsis: true },
{
title: '有效性',
width: 110,
render: (_: unknown, item: CookieOperationItem) => {
if (checkingIds.has(item.id)) return <Tag color="processing" icon={<LoadingOutlined />}></Tag>;
const result = resultFor(item);
if (!result) return <Text type="secondary"></Text>;
return <Tag color={result.valid ? 'success' : 'error'}>{result.valid ? '有效' : '无效'}</Tag>;
},
},
{
title: '检测时间',
width: 180,
render: (_: unknown, item: CookieOperationItem) => {
const checkedAt = resultFor(item)?.checked_at || item.ck_checked_at;
return checkedAt ? formatTime(checkedAt) : <Text type="secondary">-</Text>;
},
},
{
title: '操作',
width: 190,
render: (_: unknown, item: CookieOperationItem) => (
<Space size={4}>
<Button
size="small"
loading={checkingIds.has(item.id)}
disabled={checkingIds.has(item.id)}
onClick={() => void handleCheck([item.id])}
>
</Button>
<Popconfirm title="将使用已保存的账号信息重新登录并替换 CK,确认继续?" onConfirm={() => void handleRelogin([item.id])}>
<Button
size="small"
icon={<ReloadOutlined />}
loading={reloginIds.has(item.id)}
disabled={reloginIds.has(item.id)}
>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}>CK </h2>
<Space wrap>
<Button loading={checkingIds.size > 0} disabled={selectedIds.length === 0} onClick={() => void handleCheck(selectedIds)}>
{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
<Popconfirm
title={`将重新登录选中的 ${selectedIds.length} 个账号并替换 CK,确认继续?`}
disabled={selectedIds.length === 0}
onConfirm={() => void handleRelogin(selectedIds)}
>
<Button icon={<ReloadOutlined />} disabled={selectedIds.length === 0}>
{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button>
</Popconfirm>
</Space>
</div>
<Input.Search
allowClear
prefix={<SearchOutlined />}
placeholder="搜索账号"
style={{ width: 280, marginBottom: 12 }}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setCurrentPage(1);
setSelectedRowKeys([]);
}}
/>
<Table
rowKey="id"
size="small"
loading={loading}
columns={columns}
dataSource={items}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys, preserveSelectedRowKeys: true }}
pagination={{
current: currentPage,
pageSize,
total,
showSizeChanger: true,
showTotal: (count) => `${count}`,
onChange: (page, size) => {
setCurrentPage(size === pageSize ? page : 1);
if (size !== pageSize) {
setPageSize(size);
localStorage.setItem('cookie_operations_page_size', String(size));
}
},
}}
/>
</div>
);
}