完善虎牙账号管理并规范Cookie

This commit is contained in:
yml2213
2026-07-05 13:13:38 +08:00
parent 8f48b96fdc
commit 984096f758
15 changed files with 1320 additions and 59 deletions
+4
View File
@@ -16,6 +16,8 @@ const ProxyPage = lazy(() => import('./pages/ProxyPage'));
const UsersPage = lazy(() => import('./pages/UsersPage'));
const CookiePage = lazy(() => import('./pages/CookiePage'));
const HuyaAccountsPage = lazy(() => import('./pages/HuyaAccountsPage'));
const HuyaAssignmentsPage = lazy(() => import('./pages/HuyaAssignmentsPage'));
const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
function RouteFallback() {
@@ -64,6 +66,8 @@ function AppContent() {
<Route path="login-tasks" element={lazyRoute(<LoginTasksPage />)} />
<Route path="cookies" element={lazyRoute(<CookiePage />)} />
<Route path="huya/accounts" element={lazyRoute(<HuyaAccountsPage />)} />
<Route path="huya/assignments" element={lazyRoute(<HuyaAssignmentsPage />)} />
<Route path="huya/cookies" element={lazyRoute(<HuyaCookiePage />)} />
<Route path="huya/tasks" element={lazyRoute(<HuyaTasksPage />)} />
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
<Route path="users" element={lazyRoute(<UsersPage />)} />
+19 -1
View File
@@ -2,6 +2,7 @@ import api from './client';
import type {
HuyaAccountItem,
HuyaConfig,
HuyaCookieItem,
HuyaCookieImportResult,
HuyaGoodsItem,
HuyaPasswordLoginRequest,
@@ -10,21 +11,38 @@ import type {
HuyaTaskBatchRequest,
HuyaTaskBatchResult,
HuyaTaskItem,
MessageCountResponse,
MessageDeletedResponse,
MessageResponse,
} from './types';
export const huyaApi = {
taskTypes: () => api.get<Record<string, string>, Record<string, string>>('/huya/task-types'),
listAccounts: (params?: { tag?: string }) =>
listAccounts: (params?: { assigned_only?: boolean; tag?: string; has_cookie?: boolean }) =>
api.get<HuyaAccountItem[], HuyaAccountItem[]>('/huya/accounts', { params }),
importCookies: (text: string, tag: string = '') =>
api.post<HuyaCookieImportResult, HuyaCookieImportResult>('/huya/accounts/import-cookies', { text, tag }),
passwordLogin: (data: HuyaPasswordLoginRequest) =>
api.post<HuyaPasswordLoginResult, HuyaPasswordLoginResult>('/huya/accounts/password-login', data),
assign: (id: number, assigned_to: number | null) =>
api.put<MessageResponse, MessageResponse>(`/huya/accounts/${id}/assign`, { assigned_to }),
batchAssign: (account_ids: number[], assigned_to: number | null) =>
api.post<MessageCountResponse, MessageCountResponse>('/huya/accounts/batch-assign', { account_ids, assigned_to }),
assignmentsSummary: () =>
api.get<{ support_users: { id: number; username: string; assigned_count: number }[]; unassigned_count: number }, { support_users: { id: number; username: string; assigned_count: number }[]; unassigned_count: number }>('/huya/accounts/assignments/summary'),
setTag: (id: number, tag: string) =>
api.put<MessageResponse, MessageResponse>(`/huya/accounts/${id}/tag`, { tag }),
batchTag: (account_ids: number[], tag: string) =>
api.put<MessageCountResponse, MessageCountResponse>('/huya/accounts/batch-tag', { account_ids, tag }),
listTags: () => api.get<string[], string[]>('/huya/accounts/tags/list'),
deleteAccount: (id: number) => api.delete<MessageResponse, MessageResponse>(`/huya/accounts/${id}`),
deleteAccounts: (accountIds: number[]) =>
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/huya/accounts/batch', { params: { account_ids: accountIds.join(',') } }),
listCookies: () => api.get<HuyaCookieItem[], HuyaCookieItem[]>('/huya/cookies'),
exportCookies: (format?: string) => api.get<Blob, Blob>('/huya/cookies/export', { responseType: 'blob', params: format ? { format } : {} }),
deleteCookie: (id: number) => api.delete<MessageResponse, MessageResponse>(`/huya/cookies/${id}`),
deleteCookies: (accountIds: number[]) =>
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/huya/cookies/batch', { params: { account_ids: accountIds.join(',') } }),
getConfig: () => api.get<HuyaConfig, HuyaConfig>('/huya/config'),
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
+13
View File
@@ -148,6 +148,19 @@ export interface HuyaPasswordLoginResult extends MessageResponse {
sdid: string;
}
export interface HuyaCookieItem {
id: number;
account_id: number;
account_username: string;
uid: string;
yyuid: string;
assigned_to: number | null;
assigned_username: string | null;
created_at: string | null;
cookie: string;
cookie_preview: string;
}
export interface HuyaConfig {
room_pid: string;
sid: string;
+7 -1
View File
@@ -68,9 +68,15 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
}
// 虎牙
if (can('huya:account')) {
if (canAny(['huya:account', 'huya:view_all', 'huya:view_assigned'])) {
huyaItems.push({ key: '/huya/accounts', label: '账号管理', icon: <GiftOutlined /> });
}
if (canAny(['huya:account', 'huya:assign'])) {
huyaItems.push({ key: '/huya/assignments', label: '分配管理', icon: <SwapOutlined /> });
}
if (canAny(['huya:account', 'huya:cookie:view', 'huya:cookie:export'])) {
huyaItems.push({ key: '/huya/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
}
if (can('huya:task')) {
huyaItems.push({ key: '/huya/tasks', label: '任务操作台', icon: <ShoppingCartOutlined /> });
}
+1 -1
View File
@@ -80,7 +80,7 @@ export default function DashboardPage() {
const canViewDouyuAccounts = canAny(['account:view_all', 'account:view_assigned']);
const canViewDouyuTasks = canAny(['login:batch', 'login:view_all', 'login:view_assigned']);
const canViewCookies = can('cookie:view');
const canViewHuyaAccounts = can('huya:account');
const canViewHuyaAccounts = canAny(['huya:account', 'huya:view_all', 'huya:view_assigned']);
const canViewHuyaTasks = can('huya:task');
useEffect(() => {
+208 -22
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button, Card, Col, Form, Input, message, Modal, Popconfirm, Row, Space, Statistic, Table, Tag, Typography,
Button, Card, Col, Form, Input, message, Modal, Popconfirm, Row, Select, Space, Statistic, Table, Tag, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import { DeleteOutlined, ImportOutlined, LoginOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem } from '../api/modules';
import { DeleteOutlined, FilterOutlined, ImportOutlined, LoginOutlined, ReloadOutlined, SearchOutlined, TagOutlined } from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem, type SupportUserItem } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
@@ -37,6 +37,8 @@ interface PasswordLoginFormValues {
export default function HuyaAccountsPage() {
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [users, setUsers] = useState<SupportUserItem[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importText, setImportText] = useState('');
@@ -45,7 +47,10 @@ export default function HuyaAccountsPage() {
const [passwordLoginOpen, setPasswordLoginOpen] = useState(false);
const [passwordLogging, setPasswordLogging] = useState(false);
const [searchText, setSearchText] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchTagInput, setBatchTagInput] = useState('');
const [batchTagVisible, setBatchTagVisible] = useState(false);
const [pageSize, setPageSize] = useState(() => {
const v = localStorage.getItem('huya_account_page_size');
return v ? Number(v) || 20 : 20;
@@ -55,26 +60,56 @@ export default function HuyaAccountsPage() {
const { can } = usePermissions();
const canManage = can('huya:account');
const canImport = can('huya:import') || canManage;
const canAssign = can('huya:assign') || canManage;
const canDelete = can('huya:delete') || canManage;
const canViewCookie = can('huya:cookie:view') || can('huya:cookie:export') || canManage;
const loadAccounts = useCallback(async () => {
setLoading(true);
try {
const data = await huyaApi.listAccounts();
const params: { tag?: string } = {};
if (tagFilter) params.tag = tagFilter;
const data = await huyaApi.listAccounts(params);
setAccounts(data);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
}, [tagFilter]);
const loadUsers = useCallback(async () => {
try {
const data = await huyaApi.assignmentsSummary();
setUsers(data.support_users);
} catch {
// 忽略客服列表加载失败,账号列表仍可继续使用。
}
}, []);
const loadTags = useCallback(async () => {
try {
const data = await huyaApi.listTags();
setTags(data);
} catch {
// 忽略标签加载失败,页面会退化为无标签筛选。
}
}, []);
useEffect(() => {
loadAccounts();
}, [loadAccounts]);
if (canAssign) loadUsers();
loadTags();
}, [loadAccounts, canAssign, loadUsers, loadTags]);
const tags = useMemo(() => {
return [...new Set(accounts.map((item) => item.tag.trim()).filter(Boolean))].sort();
}, [accounts]);
const tagColorMap = useMemo(() => {
const map: Record<string, string> = {};
tags.forEach((tag, index) => {
map[tag] = ['blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano'][index % 8];
});
return map;
}, [tags]);
const filteredAccounts = useMemo(() => {
const s = searchText.trim().toLowerCase();
@@ -103,6 +138,7 @@ export default function HuyaAccountsPage() {
setImportText('');
setImportTag('');
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
@@ -134,6 +170,7 @@ export default function HuyaAccountsPage() {
setPasswordLoginOpen(false);
passwordLoginForm.resetFields();
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
@@ -147,6 +184,7 @@ export default function HuyaAccountsPage() {
message.success('已删除');
setSelectedRowKeys((prev) => prev.filter((key) => key !== id));
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
@@ -162,6 +200,46 @@ export default function HuyaAccountsPage() {
message.success(result.message);
setSelectedRowKeys([]);
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleAssign = async (accountId: number, assignedTo: number | null) => {
try {
await huyaApi.assign(accountId, assignedTo);
message.success('已分配');
loadAccounts();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleSetTag = async (accountId: number, tag: string) => {
try {
await huyaApi.setTag(accountId, tag);
message.success('标签已更新');
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleBatchTag = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择虎牙账号');
return;
}
try {
await huyaApi.batchTag(selectedRowKeys.map((key) => Number(key)), batchTagInput);
message.success(`已为 ${selectedRowKeys.length} 个虎牙账号设置标签`);
setBatchTagVisible(false);
setBatchTagInput('');
setSelectedRowKeys([]);
loadAccounts();
loadTags();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
@@ -169,6 +247,7 @@ export default function HuyaAccountsPage() {
const boundCount = accounts.filter((item) => item.game_name || item.game_channel || item.game_phone).length;
const pointCount = accounts.filter((item) => item.points !== null && item.points !== undefined).length;
const assignedCount = accounts.filter((item) => item.assigned_to).length;
const columns: TableProps<HuyaAccountItem>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
@@ -188,7 +267,43 @@ export default function HuyaAccountsPage() {
title: '标签',
dataIndex: 'tag',
width: 110,
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
render: (tag: string, record) => {
if (!tag) {
if (canImport) {
return (
<Input
size="small"
placeholder="输入标签"
style={{ width: 90 }}
onPressEnter={(e) => {
const value = (e.target as HTMLInputElement).value.trim();
if (value) handleSetTag(record.id, value);
}}
onBlur={(e) => {
const value = e.target.value.trim();
if (value) handleSetTag(record.id, value);
}}
/>
);
}
return <Text type="secondary">-</Text>;
}
if (canImport) {
return (
<Tag
color={tagColorMap[tag]}
closable
onClose={(e) => {
e.preventDefault();
handleSetTag(record.id, '');
}}
>
{tag}
</Tag>
);
}
return <Tag color={tagColorMap[tag]}>{tag}</Tag>;
},
},
{
title: '积分',
@@ -224,10 +339,30 @@ export default function HuyaAccountsPage() {
ellipsis: true,
render: (value: string) => (
<Text code style={{ fontSize: 12 }}>
{value || '-'}
{canViewCookie ? (value || '-') : '***'}
</Text>
),
},
{
title: '分配给',
dataIndex: 'assigned_username',
width: 150,
render: (_: unknown, record) => {
if (canAssign) {
return (
<Select
style={{ width: 130 }}
allowClear
placeholder="未分配"
value={record.assigned_to}
onChange={(value) => handleAssign(record.id, value ?? null)}
options={users.map((user) => ({ value: user.id, label: user.username }))}
/>
);
}
return record.assigned_username || <Text type="secondary"></Text>;
},
},
{
title: '状态',
dataIndex: 'status',
@@ -249,9 +384,11 @@ export default function HuyaAccountsPage() {
fixed: 'right',
align: 'center',
render: (_: unknown, record) => (
<Popconfirm title="确认删除这条虎牙 CK" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
canDelete ? (
<Popconfirm title="确认删除这条虎牙账号?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
) : null
),
},
];
@@ -259,24 +396,45 @@ export default function HuyaAccountsPage() {
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}> CK </h2>
<h2 style={{ margin: 0 }}></h2>
<Space wrap>
<Select
allowClear
placeholder="按标签筛选"
style={{ width: 150 }}
value={tagFilter || undefined}
onChange={(value) => setTagFilter(value || '')}
options={tags.map((tag) => ({ value: tag, label: tag }))}
prefix={<FilterOutlined />}
/>
<Button icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
</Button>
{selectedRowKeys.length > 0 && (
{canImport && (
<Button
disabled={selectedRowKeys.length === 0}
icon={<TagOutlined />}
onClick={() => {
setBatchTagInput('');
setBatchTagVisible(true);
}}
>
</Button>
)}
{canDelete && selectedRowKeys.length > 0 && (
<Popconfirm title={`确认删除选中的 ${selectedRowKeys.length} 条虎牙 CK`} onConfirm={handleDeleteSelected}>
<Button danger icon={<DeleteOutlined />}>
({selectedRowKeys.length})
</Button>
</Popconfirm>
)}
{canManage && (
{canImport && (
<Button icon={<LoginOutlined />} onClick={openPasswordLogin}>
</Button>
)}
{canManage && (
{canImport && (
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
CK
</Button>
@@ -286,14 +444,23 @@ export default function HuyaAccountsPage() {
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Card size="small"><Statistic title="CK 总数" value={accounts.length} /></Card>
<Card size="small"><Statistic title="账号总数" value={accounts.length} /></Card>
</Col>
<Col xs={24} sm={8}>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="标签数" value={tags.length} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已查积分" value={pointCount} /></Card>
</Col>
<Col xs={24} sm={8}>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已绑定信息" value={boundCount} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="已分配" value={assignedCount} /></Card>
</Col>
<Col xs={24} sm={4}>
<Card size="small"><Statistic title="未分配" value={accounts.length - assignedCount} /></Card>
</Col>
</Row>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
@@ -313,10 +480,10 @@ export default function HuyaAccountsPage() {
</div>
<Table
rowSelection={{
rowSelection={(canImport || canDelete || canAssign) ? {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
} : undefined}
columns={columns}
dataSource={filteredAccounts}
rowKey="id"
@@ -366,6 +533,25 @@ export default function HuyaAccountsPage() {
</Space>
</Modal>
<Modal
title="批量设置标签"
open={batchTagVisible}
onCancel={() => setBatchTagVisible(false)}
onOk={handleBatchTag}
okText="确定"
width={400}
>
<p> {selectedRowKeys.length} </p>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder="输入或选择标签"
value={batchTagInput ? [batchTagInput] : []}
onChange={(values) => setBatchTagInput(values.length > 0 ? values[values.length - 1] : '')}
options={tags.map((tag) => ({ value: tag, label: tag }))}
/>
</Modal>
<Modal
title="虎牙密码登录"
open={passwordLoginOpen}
@@ -0,0 +1,382 @@
import { useEffect, useMemo, useState } from 'react';
import {
Badge, Button, Card, Col, Input, message, Row, Select, Space, Statistic, Table, Tabs, Tag, theme, Typography,
} from 'antd';
import {
CheckCircleOutlined, ClearOutlined, SwapOutlined, TeamOutlined, UsergroupAddOutlined, UserOutlined,
} from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem, type SupportUserItem } from '../api/modules';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
const STORAGE_KEY_SELECTED_USER = 'huya_assignments_selected_user_id';
function accountName(account: HuyaAccountItem) {
return account.nickname || account.username || account.uid || `#${account.id}`;
}
export default function HuyaAssignmentsPage() {
const { token } = theme.useToken();
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [searchText, setSearchText] = useState('');
const [filterTag, setFilterTag] = useState<string | undefined>(undefined);
const [activeTab, setActiveTab] = useState('unassigned');
const [assigning, setAssigning] = useState(false);
const [pageSize, setPageSize] = useState(() => {
const value = localStorage.getItem('huya_assignment_page_size');
return value ? Number(value) || 15 : 15;
});
const [currentPage, setCurrentPage] = useState(1);
const loadSummary = async () => {
try {
const data = await huyaApi.assignmentsSummary();
setSupportUsers(data.support_users);
return data.support_users;
} catch (e: unknown) {
message.error(getErrorMessage(e));
return [];
}
};
const loadAccounts = async () => {
try {
const data = await huyaApi.listAccounts({ has_cookie: true });
setAccounts(data);
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
useEffect(() => {
const init = async () => {
void loadAccounts();
const users = await loadSummary();
if (users.length > 0) {
const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER);
const savedUser = savedId ? users.find((user) => user.id === Number(savedId)) : null;
setSelectedUser(savedUser || users[0]);
}
};
void init();
}, []);
const allTags = useMemo(() => {
return [...new Set(accounts.map((account) => (account.tag || '').trim()).filter(Boolean))].sort();
}, [accounts]);
const unassignedAccounts = useMemo(() => accounts.filter((account) => !account.assigned_to), [accounts]);
const assignedAccounts = useMemo(() => {
if (!selectedUser) return [];
return accounts.filter((account) => account.assigned_to === selectedUser.id);
}, [accounts, selectedUser]);
const displayAccounts = useMemo(() => {
let list = activeTab === 'unassigned' ? unassignedAccounts : assignedAccounts;
if (searchText) {
const search = searchText.toLowerCase();
list = list.filter((account) => (
accountName(account).toLowerCase().includes(search) ||
account.uid.toLowerCase().includes(search) ||
account.yyuid.toLowerCase().includes(search) ||
account.game_phone.toLowerCase().includes(search)
));
}
if (filterTag) {
list = list.filter((account) => (account.tag || '').trim() === filterTag);
}
return list;
}, [activeTab, assignedAccounts, filterTag, searchText, unassignedAccounts]);
const handleSelectUser = (user: SupportUserItem) => {
setSelectedUser(user);
setSelectedRowKeys([]);
setActiveTab('unassigned');
setSearchText('');
setFilterTag(undefined);
localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id));
};
const refreshAfterAssign = async () => {
await loadAccounts();
const users = await loadSummary();
const refreshed = users.find((user) => user.id === selectedUser?.id);
if (refreshed) setSelectedUser(refreshed);
};
const handleBatchAssign = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择虎牙账号');
return;
}
if (!selectedUser) {
message.warning('请先选择客服');
return;
}
setAssigning(true);
try {
await huyaApi.batchAssign(selectedRowKeys, selectedUser.id);
message.success(`已将 ${selectedRowKeys.length} 个虎牙账号分配给 ${selectedUser.username}`);
setSelectedRowKeys([]);
await refreshAfterAssign();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAssigning(false);
}
};
const handleBatchUnassign = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择虎牙账号');
return;
}
setAssigning(true);
try {
await huyaApi.batchAssign(selectedRowKeys, null);
message.success(`已取消 ${selectedRowKeys.length} 个虎牙账号的分配`);
setSelectedRowKeys([]);
await refreshAfterAssign();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAssigning(false);
}
};
const handleAssignAllUnassigned = async () => {
if (!selectedUser) {
message.warning('请先选择客服');
return;
}
const ids = displayAccounts.map((account) => account.id);
if (ids.length === 0) {
message.info('没有未分配的虎牙账号');
return;
}
setAssigning(true);
try {
await huyaApi.batchAssign(ids, selectedUser.id);
message.success(`已将 ${ids.length} 个虎牙账号分配给 ${selectedUser.username}`);
await refreshAfterAssign();
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setAssigning(false);
}
};
const assignedCount = accounts.filter((account) => !!account.assigned_to).length;
const unassignedCount = accounts.length - assignedCount;
const columns = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{
title: '虎牙账号',
render: (_: unknown, record: HuyaAccountItem) => (
<Space direction="vertical" size={0}>
<Text strong>{accountName(record)}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>UID {record.uid || record.yyuid || '-'}</Text>
</Space>
),
},
{
title: '标签',
dataIndex: 'tag',
width: 100,
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
},
{
title: '手机号',
dataIndex: 'game_phone',
width: 130,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
];
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<h2 style={{ margin: 0, flexShrink: 0 }}></h2>
<Row gutter={12} style={{ flexShrink: 0 }}>
<Col span={6}>
<Card size="small"><Statistic title="虎牙账号" value={accounts.length} prefix={<TeamOutlined />} /></Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="已分配" value={assignedCount} styles={{ content: { color: token.colorSuccess } }} prefix={<CheckCircleOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="未分配" value={unassignedCount} styles={{ content: { color: token.colorError } }} prefix={<SwapOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card size="small"><Statistic title="客服人数" value={supportUsers.length} prefix={<UsergroupAddOutlined />} /></Card>
</Col>
</Row>
<Row gutter={12} style={{ flex: 1, minHeight: 0 }}>
<Col span={8} style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Card
title="客服列表"
size="small"
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
styles={{ body: { flex: 1, overflow: 'auto', padding: 8 } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{supportUsers.map((user) => {
const isSelected = selectedUser?.id === user.id;
return (
<div
key={user.id}
onClick={() => handleSelectUser(user)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
borderRadius: 6,
cursor: 'pointer',
border: `1px solid ${isSelected ? token.colorPrimary : token.colorBorderSecondary}`,
background: isSelected ? token.colorPrimaryBg : token.colorBgContainer,
transition: 'all 0.2s',
}}
>
<Space>
<UserOutlined style={{ color: token.colorPrimary }} />
<Text strong>{user.username}</Text>
</Space>
<Badge count={user.assigned_count} showZero style={{ backgroundColor: token.colorPrimary }} />
</div>
);
})}
{supportUsers.length === 0 && (
<Text type="secondary" style={{ textAlign: 'center', padding: 20, display: 'block' }}>
</Text>
)}
</div>
</Card>
</Col>
<Col span={16} style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<Card
size="small"
title={selectedUser ? (
<Space>
<span></span>
<Tag color="blue" style={{ fontSize: 14, padding: '2px 10px' }}>
{selectedUser.username} {selectedUser.assigned_count}
</Tag>
</Space>
) : <Text type="secondary"></Text>}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, padding: 0 } }}
>
<div style={{ padding: '8px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
<Input.Search
placeholder="搜索昵称、UID、手机号"
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 220 }}
size="small"
/>
<Select
allowClear
placeholder="按标签筛选"
value={filterTag}
onChange={(value) => setFilterTag(value || undefined)}
style={{ width: 150 }}
size="small"
options={allTags.map((tag) => ({ value: tag, label: tag }))}
/>
</div>
<Tabs
size="small"
activeKey={activeTab}
onChange={(key) => { setActiveTab(key); setSelectedRowKeys([]); }}
style={{ padding: '0 16px', flexShrink: 0, marginBottom: 0 }}
items={[
{ key: 'unassigned', label: `未分配 (${unassignedAccounts.length})` },
{
key: 'assigned',
label: selectedUser ? `已分配给 ${selectedUser.username} (${assignedAccounts.length})` : '已分配账号',
disabled: !selectedUser,
},
]}
/>
<div style={{ padding: '0 16px 8px', flexShrink: 0 }}>
<Space>
{activeTab === 'unassigned' && selectedUser && (
<>
<Button
type="primary"
size="small"
icon={<SwapOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchAssign}
>
({selectedRowKeys.length}) {selectedUser.username}
</Button>
<Button size="small" loading={assigning} onClick={handleAssignAllUnassigned}>
{selectedUser.username}
</Button>
</>
)}
{activeTab === 'assigned' && selectedUser && (
<Button
danger
size="small"
icon={<ClearOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchUnassign}
>
({selectedRowKeys.length})
</Button>
)}
</Space>
</div>
<Table
columns={columns}
dataSource={displayAccounts}
rowKey="id"
size="small"
pagination={{
current: currentPage,
pageSize,
size: 'small',
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, size) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
localStorage.setItem('huya_assignment_page_size', String(size));
setCurrentPage(1);
}
},
}}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as number[]),
}}
scroll={{ y: 'calc(100vh - 500px)' }}
/>
</Card>
</Col>
</Row>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button, Card, Col, Dropdown, Input, message, Popconfirm, Row, Space, Statistic, Table, Tag, theme, Typography,
} from 'antd';
import { CopyOutlined, DeleteOutlined, DownloadOutlined, SearchOutlined } from '@ant-design/icons';
import { huyaApi, type HuyaCookieItem } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise((resolve, reject) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
try {
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
if (ok) resolve();
else reject(new Error('execCommand copy failed'));
} catch (e) {
document.body.removeChild(textarea);
reject(e);
}
});
}
export default function HuyaCookiePage() {
const { token } = theme.useToken();
const [cookies, setCookies] = useState<HuyaCookieItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [searchText, setSearchText] = useState('');
const [pageSize, setPageSize] = useState(() => {
const value = localStorage.getItem('huya_cookie_page_size');
return value ? Number(value) || 20 : 20;
});
const [currentPage, setCurrentPage] = useState(1);
const { can } = usePermissions();
const canManage = can('huya:account');
const canView = can('huya:cookie:view') || can('huya:cookie:export') || canManage;
const canExport = can('huya:cookie:export') || canManage;
const loadCookies = useCallback(async () => {
setLoading(true);
try {
const data = await huyaApi.listCookies();
setCookies(data);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadCookies();
}, [loadCookies]);
const handleExport = async (format: string = 'csv') => {
try {
const blob = await huyaApi.exportCookies(format);
const url = URL.createObjectURL(blob instanceof Blob ? blob : new Blob([blob]));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = format === 'custom' ? 'huya_cookies_custom.txt' : 'huya_cookies.csv';
anchor.click();
URL.revokeObjectURL(url);
message.success('已导出');
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleCopyCookie = (record: HuyaCookieItem) => {
if (!record.cookie) {
message.warning('Cookie 为空');
return;
}
const text = `${record.account_username}----${record.cookie}`;
copyToClipboard(text).then(() => {
message.success(`已复制 ${record.account_username} 的 Cookie`);
}).catch(() => {
message.error('复制失败');
});
};
const handleCopySelected = () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择 Cookie');
return;
}
const selected = cookies.filter((item) => selectedRowKeys.includes(item.id));
const text = selected.map((item) => `${item.account_username}----${item.cookie || ''}`).join('\r\n');
copyToClipboard(text).then(() => {
message.success(`已复制 ${selected.length} 条 Cookie`);
}).catch(() => {
message.error('复制失败');
});
};
const handleDelete = async (id: number) => {
try {
await huyaApi.deleteCookie(id);
message.success('已清除');
setSelectedRowKeys((prev) => prev.filter((key) => key !== id));
loadCookies();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleDeleteSelected = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择要清除的 Cookie');
return;
}
try {
const result = await huyaApi.deleteCookies(selectedRowKeys.map((key) => Number(key)));
message.success(result.message);
setSelectedRowKeys([]);
loadCookies();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const filteredCookies = cookies.filter((item) => {
if (!searchText) return true;
const search = searchText.toLowerCase();
return (
item.account_username.toLowerCase().includes(search) ||
item.uid.toLowerCase().includes(search) ||
item.yyuid.toLowerCase().includes(search) ||
(item.assigned_username || '').toLowerCase().includes(search)
);
});
const columns = [
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' as const },
{
title: '虎牙账号',
dataIndex: 'account_username',
width: 160,
ellipsis: true,
},
{
title: 'UID',
dataIndex: 'uid',
width: 130,
ellipsis: true,
},
{
title: '分配',
dataIndex: 'assigned_username',
width: 110,
align: 'center' as const,
render: (name: string | null) =>
name ? <Tag color="green">{name}</Tag> : <Text type="secondary" style={{ fontSize: 12 }}></Text>,
},
{
title: 'Cookie',
dataIndex: 'cookie_preview',
ellipsis: true,
render: (value: string) => {
if (!canView) return <Tag>***</Tag>;
return <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{value}</span>;
},
},
{
title: '更新时间',
dataIndex: 'created_at',
width: 180,
align: 'center' as const,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
{
title: '操作',
width: 130,
align: 'center' as const,
fixed: 'right' as const,
render: (_: unknown, record: HuyaCookieItem) => (
<Space size={4}>
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopyCookie(record)}>
</Button>
{canExport && (
<Popconfirm title="确认清除这条 Cookie" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
const assignedCount = cookies.filter((item) => item.assigned_to).length;
const unassignedCount = cookies.length - assignedCount;
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}> Cookie </h2>
<Space>
<Button icon={<CopyOutlined />} onClick={handleCopySelected} disabled={selectedRowKeys.length === 0}>
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button>
{canExport && (
<Popconfirm
title={`确认清除选中的 ${selectedRowKeys.length} 条虎牙 Cookie`}
onConfirm={handleDeleteSelected}
disabled={selectedRowKeys.length === 0}
>
<Button danger icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0}>
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button>
</Popconfirm>
)}
{canExport && (
<Dropdown
menu={{
items: [
{ key: 'csv', label: 'CSV(账号, UID, Cookie, 时间)', onClick: () => handleExport('csv') },
{ key: 'custom', label: '自定义(账号----ck', onClick: () => handleExport('custom') },
],
}}
>
<Button type="primary" icon={<DownloadOutlined />}>
</Button>
</Dropdown>
)}
</Space>
</div>
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card size="small"><Statistic title="Cookie 总数" value={cookies.length} /></Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="已分配" value={assignedCount} styles={{ content: { color: token.colorSuccess } }} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="未分配" value={unassignedCount} styles={{ content: { color: token.colorError } }} />
</Card>
</Col>
</Row>
<div style={{ marginBottom: 12 }}>
<Input.Search
placeholder="搜索账号、UID 或分配客服"
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
size="small"
prefix={<SearchOutlined />}
/>
</div>
<Table
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
columns={columns}
dataSource={filteredCookies}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: currentPage,
pageSize,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, size) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
localStorage.setItem('huya_cookie_page_size', String(size));
setCurrentPage(1);
}
},
}}
scroll={{ x: 960 }}
/>
</div>
);
}