feat(weibo): 增加账号分配与客服数据隔离

This commit is contained in:
yml2213
2026-11-08 18:30:51 +08:00
parent c8b62e5267
commit 6b2285913f
16 changed files with 1320 additions and 55 deletions
+2
View File
@@ -32,6 +32,7 @@ const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage'));
const HuyaCollabPage = lazy(() => import('./pages/HuyaCollabPage'));
const HuyaBrowserExchangePage = lazy(() => import('./pages/HuyaBrowserExchangePage'));
const WeiboAccountsPage = lazy(() => import('./pages/WeiboAccountsPage'));
const WeiboAssignmentsPage = lazy(() => import('./pages/WeiboAssignmentsPage'));
const WeiboCollabPage = lazy(() => import('./pages/WeiboCollabPage'));
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
@@ -108,6 +109,7 @@ function AppContent() {
<Route path="huya/collab" element={lazyRoute(<HuyaCollabPage />)} />
<Route path="huya/browser-exchange" element={lazyRoute(<HuyaBrowserExchangePage />)} />
<Route path="weibo/accounts" element={lazyRoute(<WeiboAccountsPage mode="management" />)} />
<Route path="weibo/assignments" element={lazyRoute(<WeiboAssignmentsPage />)} />
<Route path="weibo/workbench" element={lazyRoute(<WeiboAccountsPage mode="workbench" />)} />
<Route path="weibo/collab" element={lazyRoute(<WeiboCollabPage />)} />
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
+2
View File
@@ -754,6 +754,8 @@ export interface WeiboAccountItem {
nickname: string;
tag: string;
remark: string;
assigned_to: number | null;
assigned_username: string;
is_enabled: boolean;
disabled_at: string | null;
disabled_reason: string;
+22
View File
@@ -1,5 +1,6 @@
import api from './client';
import type {
AssignmentsSummary,
MessageResponse,
PaginatedResponse,
WeiboAccountItem,
@@ -22,6 +23,8 @@ export const weiboApi = {
tag?: string;
bind_status?: string;
enabled?: boolean;
assign_status?: string;
assigned_user_id?: number;
page: number;
page_size: number;
}) =>
@@ -29,6 +32,18 @@ export const weiboApi = {
'/weibo/accounts',
{ params },
),
listAccountIds: (params: {
search?: string;
tag?: string;
bind_status?: string;
enabled?: boolean;
assign_status?: string;
assigned_user_id?: number;
}) =>
api.get<{ account_ids: number[]; total: number }, { account_ids: number[]; total: number }>(
'/weibo/accounts/ids',
{ params },
),
getAccount: (accountId: number) =>
api.get<WeiboAccountItem, WeiboAccountItem>(`/weibo/accounts/${accountId}`),
importCookies: (text: string, tag = '', updateExisting = false) =>
@@ -69,6 +84,13 @@ export const weiboApi = {
'/weibo/accounts/batch-tag',
{ ids, tag },
),
batchAssign: (accountIds: number[], assignedTo: number | null) =>
api.post<MessageResponse, MessageResponse>('/weibo/accounts/batch-assign', {
account_ids: accountIds,
assigned_to: assignedTo,
}),
assignmentsSummary: () =>
api.get<AssignmentsSummary, AssignmentsSummary>('/weibo/assignments/summary'),
accountTags: () =>
api.get<{ tags: string[] }, { tags: string[] }>('/weibo/accounts/tags'),
workbenchTags: () =>
+3
View File
@@ -128,6 +128,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
if (can('weibo:view')) {
weiboItems.push({ key: '/weibo/accounts', label: '账号管理', icon: <UserOutlined /> });
}
if (can('weibo:assign')) {
weiboItems.push({ key: '/weibo/assignments', label: '分配管理', icon: <SwapOutlined /> });
}
if (can('weibo:workbench')) {
weiboItems.push({ key: '/weibo/workbench', label: '微博工作台', icon: <DesktopOutlined /> });
}
+42 -8
View File
@@ -132,6 +132,7 @@ export default function WeiboAccountsPage({
const canRecharge = can('weibo:recharge');
const canExchange = can('weibo:exchange');
const canDelete = can('weibo:delete');
const canAssign = can('weibo:assign');
const [items, setItems] = useState<WeiboAccountItem[]>([]);
const [total, setTotal] = useState(0);
@@ -860,6 +861,23 @@ export default function WeiboAccountsPage({
<Text type="secondary">-</Text>
),
},
...(canAssign
? [
{
title: '归属客服',
dataIndex: 'assigned_username',
width: 120,
align: 'center' as const,
onHeaderCell: centerHeader,
render: (value: string) =>
value ? (
<Tag color="cyan">{value}</Tag>
) : (
<Text type="secondary">未分配</Text>
),
},
]
: []),
{
title: 'CK',
key: 'ck',
@@ -1237,7 +1255,9 @@ export default function WeiboAccountsPage({
<Text type="secondary">
{isWorkbench
? '当前账号独立使用的微博账号、角色绑定、充值与兑换'
: '全局账号池、账号生命周期、批量操作、微豆资料和角色绑定'}
: canAssign
? '全局账号池、归属分配、账号生命周期、批量操作和角色绑定'
: '仅显示分配给你的微博账号,可进行绑定、充值与兑换'}
</Text>
</div>
<Space wrap>
@@ -1484,16 +1504,30 @@ export default function WeiboAccountsPage({
>
{featureAccount ? (
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
<div>
<Text strong style={{ fontSize: 16 }}>
{featureAccount.nickname || featureAccount.uid}
</Text>
<Space
align="start"
style={{ width: '100%', justifyContent: 'space-between' }}
>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {featureAccount.uid}
<Text strong style={{ fontSize: 16 }}>
{featureAccount.nickname || featureAccount.uid}
</Text>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {featureAccount.uid}
</Text>
</div>
</div>
</div>
<Button
size="small"
icon={<ReloadOutlined />}
loading={refreshingId === featureAccount.id}
disabled={!featureAccount.is_enabled}
onClick={() => void handleRefresh(featureAccount)}
>
刷新
</Button>
</Space>
<div
style={{
width: '100%',
@@ -0,0 +1,625 @@
import { useCallback, useEffect, useState } from 'react';
import {
Badge,
Button,
Card,
Col,
Input,
Popconfirm,
Row,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Tooltip,
Typography,
theme,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined,
ClearOutlined,
ReloadOutlined,
SwapOutlined,
TeamOutlined,
UserOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import {
weiboApi,
type AssignmentsSummary,
type PaginatedResponse,
type SupportUserItem,
type WeiboAccountItem,
} from '../api/modules';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { formatTime } from '../utils/time';
const { Text } = Typography;
const STORAGE_KEY_SELECTED_USER = 'weibo_assignments_selected_user_id';
const STORAGE_KEY_PAGE_SIZE = 'weibo_assignment_page_size';
const CK_STATUS: Record<string, { label: string; color: string }> = {
valid: { label: '有效', color: 'success' },
invalid: { label: '失效', color: 'error' },
error: { label: '查询失败', color: 'warning' },
};
function renderBindStatus(account: WeiboAccountItem) {
if (account.is_bound === true && account.can_rebind === true) {
return <Tag color="processing">可换绑</Tag>;
}
if (account.is_bound === true) {
const days = account.rebind_remain_days ?? 0;
return <Tag color="success">{days > 0 ? `${days} 天后可换绑` : '已绑定'}</Tag>;
}
if (account.is_bound === false) return <Tag color="warning">未绑定</Tag>;
return <Tag>未查询</Tag>;
}
export default function WeiboAssignmentsPage() {
const { token } = theme.useToken();
const [summary, setSummary] = useState<AssignmentsSummary | null>(null);
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
const [accounts, setAccounts] = useState<WeiboAccountItem[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [tableLoading, setTableLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [assigning, setAssigning] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [knownTags, setKnownTags] = useState<string[]>([]);
const [searchText, setSearchText] = useState('');
const [filterTag, setFilterTag] = useState<string | undefined>();
const [enabledFilter, setEnabledFilter] = useState<string>('');
const [activeTab, setActiveTab] = useState<'unassigned' | 'assigned'>('unassigned');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY_PAGE_SIZE);
return saved ? Number(saved) || 15 : 15;
});
const loadSummary = useCallback(async (silent = false) => {
try {
const data = await weiboApi.assignmentsSummary();
setSummary(data);
setSupportUsers(data.support_users);
return data.support_users;
} catch (error) {
if (!silent) message.error(getErrorMessage(error));
return [];
}
}, []);
const loadTags = useCallback(async () => {
try {
const result = await weiboApi.accountTags();
setKnownTags(result.tags);
} catch {
// 标签字典失败不影响分配列表。
}
}, []);
const loadAccounts = useCallback(
async (silent = false) => {
if (activeTab === 'assigned' && !selectedUser) return;
if (!silent) setTableLoading(true);
try {
const data: PaginatedResponse<WeiboAccountItem> = await weiboApi.listAccounts({
page: currentPage,
page_size: pageSize,
search: searchText.trim() || undefined,
tag: filterTag,
enabled:
enabledFilter === 'enabled'
? true
: enabledFilter === 'disabled'
? false
: undefined,
assign_status: activeTab === 'unassigned' ? 'unassigned' : undefined,
assigned_user_id:
activeTab === 'assigned' ? selectedUser?.id : undefined,
});
setAccounts(data.items);
setTotalCount(data.total);
} catch (error) {
if (!silent) message.error(getErrorMessage(error));
} finally {
if (!silent) setTableLoading(false);
}
},
[
activeTab,
currentPage,
enabledFilter,
filterTag,
pageSize,
searchText,
selectedUser,
],
);
useEffect(() => {
const initialize = async () => {
void loadTags();
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))
: undefined;
setSelectedUser(savedUser || users[0]);
}
};
void initialize();
}, [loadSummary, loadTags]);
useEffect(() => {
void loadAccounts();
}, [loadAccounts]);
const refreshData = async (silent = false) => {
await Promise.all([loadSummary(silent), loadAccounts(silent)]);
};
const handleRefresh = async () => {
setRefreshing(true);
try {
await refreshData();
} finally {
setRefreshing(false);
}
};
const handleSelectUser = (user: SupportUserItem) => {
setSelectedUser(user);
setActiveTab('unassigned');
setSelectedRowKeys([]);
setCurrentPage(1);
localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id));
};
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
localStorage.setItem(STORAGE_KEY_PAGE_SIZE, String(size));
setCurrentPage(1);
}
};
const handleBatchAssign = async () => {
if (!selectedUser || selectedRowKeys.length === 0) {
message.warning('请先选择客服和微博账号');
return;
}
setAssigning(true);
try {
const result = await weiboApi.batchAssign(selectedRowKeys, selectedUser.id);
message.success(
result.message || `已将 ${selectedRowKeys.length} 个账号分配给 ${selectedUser.username}`,
);
setSelectedRowKeys([]);
await refreshData();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setAssigning(false);
}
};
const handleAssignAllFiltered = async () => {
if (!selectedUser) {
message.warning('请先选择客服');
return;
}
setAssigning(true);
try {
const result = await weiboApi.listAccountIds({
search: searchText.trim() || undefined,
tag: filterTag,
enabled:
enabledFilter === 'enabled'
? true
: enabledFilter === 'disabled'
? false
: undefined,
assign_status: 'unassigned',
});
if (result.account_ids.length === 0) {
message.info('当前筛选下没有未分配账号');
return;
}
await weiboApi.batchAssign(result.account_ids, selectedUser.id);
message.success(
`已将 ${result.account_ids.length} 个账号分配给 ${selectedUser.username}`,
);
setSelectedRowKeys([]);
await refreshData();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setAssigning(false);
}
};
const handleBatchUnassign = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择微博账号');
return;
}
setAssigning(true);
try {
await weiboApi.batchAssign(selectedRowKeys, null);
message.success(`已取消 ${selectedRowKeys.length} 个账号的分配`);
setSelectedRowKeys([]);
await refreshData();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setAssigning(false);
}
};
const selectedUserCount =
supportUsers.find((user) => user.id === selectedUser?.id)?.assigned_count ??
selectedUser?.assigned_count ??
0;
const unassignedCount = summary?.unassigned_count ?? 0;
const assignedCount = summary?.assigned_count ?? 0;
const accountCount = summary?.total_count ?? assignedCount + unassignedCount;
const columns: TableProps<WeiboAccountItem>['columns'] = [
{
title: '序号',
key: 'index',
width: 58,
align: 'center',
render: (_, __, index) => (currentPage - 1) * pageSize + index + 1,
},
{
title: '微博账号',
key: 'account',
width: 190,
align: 'center',
render: (_, account) => (
<Space orientation="vertical" size={0}>
<Text strong>{account.nickname || account.uid}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {account.uid}
</Text>
</Space>
),
},
{
title: '标签',
dataIndex: 'tag',
width: 100,
align: 'center',
render: (tag: string) =>
tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
},
{
title: 'CK 状态',
dataIndex: 'ck_check_status',
width: 100,
align: 'center',
render: (status: string) => {
const state = CK_STATUS[status] || { label: '未检测', color: 'default' };
return <Tag color={state.color}>{state.label}</Tag>;
},
},
{
title: '角色绑定',
key: 'bind',
width: 120,
align: 'center',
render: (_, account) => renderBindStatus(account),
},
{
title: '微豆',
dataIndex: 'weidou_balance',
width: 90,
align: 'center',
render: (value: number | null) =>
value == null ? <Text type="secondary">-</Text> : value.toLocaleString(),
},
{
title: '生命周期',
dataIndex: 'is_enabled',
width: 90,
align: 'center',
render: (enabled: boolean) => (
<Tag color={enabled ? 'success' : 'default'}>
{enabled ? '已启用' : '已停用'}
</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 155,
align: 'center',
render: (value: string | null) =>
value ? formatTime(value) : <Text type="secondary">-</Text>,
},
];
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ margin: 0 }}>微博分配管理</h2>
<Text type="secondary">管理员和运营查看全部账号;客服只能看到分配给自己的账号。</Text>
</div>
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={handleRefresh}>
刷新
</Button>
</div>
<Row gutter={12} style={{ flexShrink: 0 }}>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic title="微博账号" value={accountCount} prefix={<TeamOutlined />} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic
title="已分配"
value={assignedCount}
styles={{ content: { color: token.colorSuccess } }}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic
title="未分配"
value={unassignedCount}
styles={{ content: { color: token.colorError } }}
prefix={<SwapOutlined />}
/>
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic
title="客服人数"
value={supportUsers.length}
prefix={<UsergroupAddOutlined />}
/>
</Card>
</Col>
</Row>
<Row gutter={12} style={{ flex: 1, minHeight: 0 }}>
<Col xs={24} lg={8} style={{ height: '100%', display: 'flex', 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,
}}
>
<Space>
<UserOutlined style={{ color: token.colorPrimary }} />
<Text strong>{user.username}</Text>
</Space>
<Tooltip title={`已分配 ${user.assigned_count} 个账号`}>
<Badge
count={user.assigned_count}
overflowCount={999999}
showZero
style={{ backgroundColor: token.colorPrimary }}
/>
</Tooltip>
</div>
);
})}
{supportUsers.length === 0 && (
<Text
type="secondary"
style={{ textAlign: 'center', padding: 20, display: 'block' }}
>
暂无客服用户
</Text>
)}
</div>
</Card>
</Col>
<Col xs={24} lg={16} style={{ height: '100%', display: 'flex', minHeight: 0 }}>
<Card
size="small"
title={
selectedUser ? (
<Space wrap>
<span>已选中客服:</span>
<Tag color="blue" style={{ fontSize: 14, padding: '2px 10px' }}>
{selectedUser.username}(已分配 {selectedUserCount} 个)
</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',
flexWrap: 'wrap',
gap: 8,
alignItems: 'center',
flexShrink: 0,
}}
>
<Input.Search
allowClear
placeholder="搜索 UID、昵称、角色或备注"
value={searchText}
onChange={(event) => {
setSearchText(event.target.value);
setCurrentPage(1);
}}
style={{ width: 230 }}
size="small"
/>
<Select
allowClear
placeholder="按标签筛选"
value={filterTag}
onChange={(value) => {
setFilterTag(value);
setCurrentPage(1);
}}
style={{ width: 140 }}
size="small"
options={knownTags.map((tag) => ({ value: tag, label: tag }))}
/>
<Select
value={enabledFilter}
onChange={(value) => {
setEnabledFilter(value);
setCurrentPage(1);
}}
style={{ width: 120 }}
size="small"
options={[
{ value: '', label: '全部状态' },
{ value: 'enabled', label: '已启用' },
{ value: 'disabled', label: '已停用' },
]}
/>
</div>
<Tabs
size="small"
activeKey={activeTab}
onChange={(key) => {
setActiveTab(key as 'unassigned' | 'assigned');
setSelectedRowKeys([]);
setCurrentPage(1);
}}
style={{ padding: '0 16px', flexShrink: 0, marginBottom: 0 }}
items={[
{ key: 'unassigned', label: `未分配 (${unassignedCount})` },
{
key: 'assigned',
label: selectedUser
? `已分配给 ${selectedUser.username} (${selectedUserCount})`
: '已分配账号',
disabled: !selectedUser,
},
]}
/>
<div
style={{
padding: '0 16px 8px',
display: 'flex',
gap: 8,
flexWrap: 'wrap',
flexShrink: 0,
}}
>
{activeTab === 'unassigned' && selectedUser && (
<>
<Button
type="primary"
size="small"
icon={<SwapOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchAssign}
>
分配选中 ({selectedRowKeys.length}) 给 {selectedUser.username}
</Button>
<Popconfirm
title={`把当前筛选下 ${totalCount} 个未分配账号全部分给 ${selectedUser.username}?`}
okText="确认分配"
cancelText="取消"
disabled={totalCount === 0}
onConfirm={handleAssignAllFiltered}
>
<Button size="small" loading={assigning} disabled={totalCount === 0}>
分配当前筛选全部
</Button>
</Popconfirm>
</>
)}
{activeTab === 'assigned' && selectedUser && (
<Button
danger
size="small"
icon={<ClearOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchUnassign}
>
取消分配选中 ({selectedRowKeys.length})
</Button>
)}
</div>
<Table<WeiboAccountItem>
columns={columns}
dataSource={accounts}
rowKey="id"
size="small"
loading={tableLoading}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
size: 'small',
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
onChange: handlePageChange,
}}
rowSelection={{
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
}}
scroll={{ x: 940, y: 'calc(100vh - 500px)' }}
/>
</Card>
</Col>
</Row>
</div>
);
}