Files
live-hub-py/web/frontend/src/pages/CookiePage.tsx
T

316 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } from 'react';
import { Table, Button, Card, Row, Col, Statistic, message, Tag, Popconfirm, Space, Typography, Input, theme, Dropdown } from 'antd';
import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons';
import { cookieApi, type CookieItem } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
/** 复制文本到剪贴板,兼容非安全上下文(http://非localhost */
function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text);
}
// fallback:利用 textarea + execCommand,兼容 http 远程访问
return new Promise((resolve, reject) => {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try {
const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (ok) {
resolve();
} else {
reject(new Error('execCommand copy failed'));
}
} catch (e) {
document.body.removeChild(ta);
reject(e);
}
});
}
export default function CookiePage() {
const { token } = theme.useToken();
const [cookies, setCookies] = useState<CookieItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [searchText, setSearchText] = useState('');
const [pageSize, setPageSize] = useState(() => {
const v = localStorage.getItem('cookie_page_size');
return v ? Number(v) || 20 : 20;
});
const [currentPage, setCurrentPage] = useState(1);
const { can } = usePermissions();
const canView = can('cookie:view');
const canExport = can('cookie:export');
const loadCookies = useCallback(async () => {
setLoading(true);
try {
const data = await cookieApi.list();
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 cookieApi.exportCsv(format);
const url = URL.createObjectURL(blob instanceof Blob ? blob : new Blob([blob]));
const a = document.createElement('a');
a.href = url;
a.download = format === 'custom' ? 'cookies_custom.txt' : 'cookies.csv';
a.click();
URL.revokeObjectURL(url);
message.success('已导出');
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleCopyCookie = (cookie: string, username: string) => {
if (!cookie) {
message.warning('Cookie 为空');
return;
}
copyToClipboard(cookie).then(() => {
message.success(`已复制 ${username} 的 Cookie`);
}).catch(() => {
message.error('复制失败');
});
};
const handleCopySelected = () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择 Cookie');
return;
}
const selected = cookies.filter((c) => selectedRowKeys.includes(c.id));
const text = selected
.map((c) => `${c.account_username}: ${c.cookie || '(空)'}`)
.join('\n');
copyToClipboard(text).then(() => {
message.success(`已复制 ${selected.length} 条 Cookie`);
}).catch(() => {
message.error('复制失败');
});
};
const handleDelete = async (id: number) => {
try {
await cookieApi.delete(id);
message.success('已删除');
setSelectedRowKeys((prev) => prev.filter((k) => k !== id));
loadCookies();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
const handleDeleteSelected = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择要删除的 Cookie');
return;
}
try {
const ids = selectedRowKeys.map((k) => Number(k));
const res = await cookieApi.deleteBatch(ids);
message.success(res.message);
setSelectedRowKeys([]);
loadCookies();
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
// 筛选
const filteredCookies = cookies.filter((c) => {
if (!searchText) return true;
const s = searchText.toLowerCase();
return (
c.account_username?.toLowerCase().includes(s) ||
c.assigned_username?.toLowerCase().includes(s)
);
});
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60, align: 'center' as const },
{
title: '账号',
dataIndex: 'account_username',
width: 140,
ellipsis: true,
},
{
title: '分配',
dataIndex: 'assigned_username',
width: 100,
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: (val: string) => {
if (!canView) return <Tag>***</Tag>;
return <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{val}</span>;
},
},
{
title: '时间',
dataIndex: 'created_at',
width: 180,
align: 'center' as const,
render: (val: string) => formatTime(val),
},
{
title: '操作',
width: 130,
align: 'center' as const,
fixed: 'right' as const,
render: (_: unknown, record: CookieItem) => (
<Space size={4}>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyCookie(record.cookie, record.account_username)}
>
复制
</Button>
{canExport && (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
const assignedCount = cookies.filter((c) => c.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(账号, 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="搜索账号或分配客服"
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 260 }}
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: (t) => `共 ${t} 条`,
onChange: (page, size) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
localStorage.setItem('cookie_page_size', String(size));
setCurrentPage(1); // 切换 pageSize 时重置到第1页
}
},
}}
scroll={{ x: 900 }}
/>
</div>
);
}