feat(weibo): 增加强制分配权限与账号详情

This commit is contained in:
yml
2026-11-13 13:36:41 +08:00
parent 9cf4f65a3d
commit 4e47b5b299
10 changed files with 995 additions and 34 deletions
+40 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react';
import {
Table, Button, Modal, Form, Input, Select, Tag, Popconfirm, Space,
Checkbox, Divider, Alert,
Checkbox, Divider, Alert, Switch, Typography,
} from 'antd';
import type { TableProps } from 'antd';
import { message } from '../utils/antdMessage';
@@ -22,6 +22,9 @@ const ROLE_COLORS: Record<string, string> = {
support: 'green',
};
const { Text } = Typography;
const WEIBO_FORCE_ASSIGN_PERMISSION = 'weibo:assign:force';
// 权限分组定义
const PERMISSION_GROUPS = [
{ label: '用户管理', prefix: 'user:' },
@@ -225,6 +228,19 @@ export default function UsersPage() {
setSelectedPerms(rolePerms);
};
const handleWeiboForceAssignToggle = (checked: boolean) => {
setUseCustom(true);
setSelectedPerms((current) => {
const next = new Set(current);
if (checked) {
next.add(WEIBO_FORCE_ASSIGN_PERMISSION);
} else {
next.delete(WEIBO_FORCE_ASSIGN_PERMISSION);
}
return Array.from(next);
});
};
const columns: TableProps<UserInfo>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username', width: 128, ellipsis: true },
@@ -443,6 +459,29 @@ export default function UsersPage() {
</Button>
)}
</div>
<Divider style={{ margin: '8px 0 12px' }}>微博高级分配</Divider>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
marginBottom: 16,
}}
>
<Switch
checked={selectedPerms.includes(WEIBO_FORCE_ASSIGN_PERMISSION)}
onChange={handleWeiboForceAssignToggle}
/>
<div>
<Text strong>全部账号与强制分配</Text>
<div>
<Text type="secondary">
打开后可查看“全部账号”页签,并把勾选账号或指定标签的账号强制转给客服。
该权限仅超级管理员默认拥有,运营默认关闭。
</Text>
</div>
</div>
</div>
<Checkbox.Group
value={selectedPerms}
onChange={(vals) => setSelectedPerms(vals as string[])}
+460 -4
View File
@@ -33,6 +33,7 @@ import {
CopyOutlined,
DownloadOutlined,
EditOutlined,
EyeOutlined,
ImportOutlined,
QrcodeOutlined,
ReloadOutlined,
@@ -287,6 +288,21 @@ export default function WeiboAccountsPage({
(entry: BatchRechargeEntry) => Promise<void>
>(async () => {});
const [detailOpen, setDetailOpen] = useState(false);
const [detailAccount, setDetailAccount] = useState<WeiboAccountItem | null>(
null,
);
const [detailTab, setDetailTab] = useState('basic');
const [detailLoading, setDetailLoading] = useState(false);
const [detailExchangeLoading, setDetailExchangeLoading] = useState(false);
const [detailExchangeError, setDetailExchangeError] = useState('');
const [detailExchangeResult, setDetailExchangeResult] =
useState<WeiboExchangeRecordsResult | null>(null);
const [detailPointLoading, setDetailPointLoading] = useState(false);
const [detailPointError, setDetailPointError] = useState('');
const [detailPointResult, setDetailPointResult] =
useState<WeiboPointHistoryResult | null>(null);
const loadAccounts = useCallback(async () => {
setLoading(true);
try {
@@ -940,6 +956,71 @@ export default function WeiboAccountsPage({
}
};
const loadDetailExchangeRecords = async (
account: WeiboAccountItem,
page = 1,
) => {
setDetailExchangeLoading(true);
setDetailExchangeError('');
try {
setDetailExchangeResult(
await weiboApi.exchangeRecords(account.id, page, 20),
);
} catch (error) {
setDetailExchangeError(getErrorMessage(error));
} finally {
setDetailExchangeLoading(false);
}
};
const loadDetailPointHistory = async (
account: WeiboAccountItem,
page = 1,
) => {
setDetailPointLoading(true);
setDetailPointError('');
try {
setDetailPointResult(await weiboApi.pointHistory(account.id, page, 20));
} catch (error) {
setDetailPointError(getErrorMessage(error));
} finally {
setDetailPointLoading(false);
}
};
const openAccountDetail = async (account: WeiboAccountItem) => {
setDetailAccount(account);
setDetailOpen(true);
setDetailTab('basic');
setDetailExchangeResult(null);
setDetailExchangeError('');
setDetailPointResult(null);
setDetailPointError('');
setDetailLoading(true);
try {
const updated = await weiboApi.getAccount(account.id);
setDetailAccount(updated);
setItems((current) =>
current.map((item) => (item.id === updated.id ? updated : item)),
);
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setDetailLoading(false);
}
};
const switchDetailTab = (key: string) => {
setDetailTab(key);
if (!detailAccount || !detailAccount.is_enabled) return;
if (key === 'exchange' && !detailExchangeResult) {
void loadDetailExchangeRecords(detailAccount);
}
if (key === 'points' && !detailPointResult) {
void loadDetailPointHistory(detailAccount);
}
};
const openFeatures = (account: WeiboAccountItem) => {
if (!account.is_enabled) {
message.warning('账号已停用,请先启用');
@@ -1439,7 +1520,7 @@ export default function WeiboAccountsPage({
{
title: '操作',
key: 'actions',
width: 540,
width: 610,
fixed: 'right',
align: 'center',
onHeaderCell: centerHeader,
@@ -1476,6 +1557,15 @@ export default function WeiboAccountsPage({
绑定码
</Button>
</Tooltip>
{!isWorkbench && (
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => void openAccountDetail(account)}
>
详情
</Button>
)}
<Button
size="small"
icon={<AppstoreOutlined />}
@@ -1672,7 +1762,12 @@ export default function WeiboAccountsPage({
align: 'right',
render: (value: number | null) => value ?? '-',
},
{ title: '兑换时间', dataIndex: 'exchange_time', width: 170 },
{
title: '兑换时间',
dataIndex: 'exchange_time',
width: 170,
render: (value: string) => value || '-',
},
{
title: '状态',
dataIndex: 'reward_status',
@@ -1696,8 +1791,18 @@ export default function WeiboAccountsPage({
</Text>
),
},
{ title: '到账时间', dataIndex: 'create_time', width: 170 },
{ title: '过期时间', dataIndex: 'expire_time', width: 170 },
{
title: '到账时间',
dataIndex: 'create_time',
width: 170,
render: (value: string) => value || '-',
},
{
title: '过期时间',
dataIndex: 'expire_time',
width: 170,
render: (value: string) => value || '-',
},
{ title: '订单号', dataIndex: 'source_id' },
];
const activeQrScheme = qrResult?.schemes.find(
@@ -1730,6 +1835,21 @@ export default function WeiboAccountsPage({
const batchRechargeCreditedCount = batchRechargeEntries.filter(
(entry) => entry.creditPhase === 'credited',
).length;
const detailPointIncome =
detailPointResult?.items.reduce(
(total, item) => total + Math.max(0, item.point_num),
0,
) ?? 0;
const detailPointSpent =
detailPointResult?.items.reduce(
(total, item) => total + Math.max(0, -item.point_num),
0,
) ?? 0;
const detailExchangeCost =
detailExchangeResult?.items.reduce(
(total, item) => total + Math.max(0, item.cost_weidou ?? 0),
0,
) ?? 0;
return (
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
@@ -3025,6 +3145,342 @@ export default function WeiboAccountsPage({
</Space>
</Modal>
<Drawer
title={
detailAccount
? `账号详情 · ${detailAccount.nickname || detailAccount.uid}`
: '账号详情'
}
open={detailOpen}
onClose={() => setDetailOpen(false)}
size={920}
destroyOnHidden
>
{detailAccount && (
<Spin spinning={detailLoading}>
<Tabs
activeKey={detailTab}
onChange={switchDetailTab}
items={[
{
key: 'basic',
label: '账号资料',
children: (
<Space
orientation="vertical"
size={16}
style={{ width: '100%' }}
>
{!detailAccount.is_enabled && (
<Alert
type="warning"
showIcon
title="账号已停用"
description={
detailAccount.disabled_reason ||
'停用期间仍可查看本地账号资料,但不能实时查询微豆明细和兑换记录。'
}
/>
)}
<Descriptions
bordered
size="small"
column={2}
items={[
{
key: 'uid',
label: '微博 UID',
children: (
<Text copyable>{detailAccount.uid}</Text>
),
},
{
key: 'nickname',
label: '微博昵称',
children: detailAccount.nickname || '-',
},
{
key: 'created',
label: '导入时间',
children: formatTime(detailAccount.created_at),
},
{
key: 'updated',
label: '最近更新',
children: formatTime(detailAccount.updated_at),
},
{
key: 'assigned',
label: '归属客服',
children: detailAccount.assigned_username ? (
<Tag color="cyan">
{detailAccount.assigned_username}
</Tag>
) : (
<Text type="secondary">未分配</Text>
),
},
{
key: 'tag',
label: '标签',
children: detailAccount.tag ? (
<Tag color="blue">{detailAccount.tag}</Tag>
) : (
'-'
),
},
{
key: 'enabled',
label: '启用状态',
children: (
<Tag
color={
detailAccount.is_enabled
? 'success'
: 'default'
}
>
{detailAccount.is_enabled
? '已启用'
: '已停用'}
</Tag>
),
},
{
key: 'balance',
label: '微豆余额',
children:
detailAccount.weidou_balance?.toLocaleString() ??
'-',
},
{
key: 'ck',
label: 'CK 状态',
children: (
<Space orientation="vertical" size={2}>
<Tag
color={
CK_STATUS[detailAccount.ck_check_status]
?.color || 'default'
}
>
{CK_STATUS[detailAccount.ck_check_status]
?.label || '未检测'}
</Tag>
{detailAccount.ck_check_message && (
<Text type="secondary">
{detailAccount.ck_check_message}
</Text>
)}
</Space>
),
},
{
key: 'checked',
label: '最近检查',
children: formatTime(
detailAccount.ck_checked_at,
),
},
{
key: 'cookie',
label: 'CK 更新时间',
children: formatTime(
detailAccount.cookie_updated_at,
),
},
{
key: 'bind',
label: '角色绑定',
children: bindStatus(detailAccount),
},
{
key: 'role',
label: '游戏角色',
children: detailAccount.game_role_name || '-',
},
{
key: 'remark',
label: '备注',
span: 2,
children: detailAccount.remark || '-',
},
]}
/>
</Space>
),
},
{
key: 'points',
label: '微豆收支',
children: (
<Space
orientation="vertical"
size={12}
style={{ width: '100%' }}
>
<Alert
type="info"
showIcon
title="微豆收支来自微博实时数据"
description="展示充值、活动发放、过期和消费等变动;下方的收入、消耗统计只计算当前页。"
/>
{!detailAccount.is_enabled ? (
<Alert
type="warning"
showIcon
title="账号已停用,无法查询实时微豆明细"
/>
) : (
<>
<Space wrap>
<Tag color="blue">
当前余额{' '}
{detailAccount.weidou_balance?.toLocaleString() ??
'-'}
</Tag>
<Tag color="success">
本页收入 +{detailPointIncome.toLocaleString()}
</Tag>
<Tag color="error">
本页消耗 -{detailPointSpent.toLocaleString()}
</Tag>
</Space>
{detailPointError && (
<Alert
type="error"
showIcon
title="微豆明细查询失败"
description={detailPointError}
action={
<Button
size="small"
onClick={() =>
void loadDetailPointHistory(
detailAccount,
detailPointResult?.page || 1,
)
}
>
重试
</Button>
}
/>
)}
<Table
rowKey={(record) => record.id || record.source_id}
size="small"
loading={detailPointLoading}
columns={pointColumns}
dataSource={detailPointResult?.items || []}
locale={{ emptyText: '暂无微豆收支明细' }}
pagination={{
current: detailPointResult?.page || 1,
pageSize: detailPointResult?.page_size || 20,
total: detailPointResult?.total || 0,
showSizeChanger: false,
showTotal: (total) => `共 ${total} 条`,
onChange: (page) =>
void loadDetailPointHistory(
detailAccount,
page,
),
}}
scroll={{ x: 900 }}
/>
</>
)}
</Space>
),
},
{
key: 'exchange',
label: '兑换记录',
children: (
<Space
orientation="vertical"
size={12}
style={{ width: '100%' }}
>
<Alert
type="info"
showIcon
title="兑换记录来自微博实时数据"
description="展示商品、绑定角色、微豆消耗、兑换时间和发放状态;本页消耗只计算当前页。"
/>
{!detailAccount.is_enabled ? (
<Alert
type="warning"
showIcon
title="账号已停用,无法查询实时兑换记录"
/>
) : (
<>
<Space wrap>
<Tag color="blue">
共 {detailExchangeResult?.total ?? 0} 条
</Tag>
<Tag color="error">
本页消耗 {detailExchangeCost.toLocaleString()}{' '}
微豆
</Tag>
</Space>
{detailExchangeError && (
<Alert
type="error"
showIcon
title="兑换记录查询失败"
description={detailExchangeError}
action={
<Button
size="small"
onClick={() =>
void loadDetailExchangeRecords(
detailAccount,
detailExchangeResult?.page || 1,
)
}
>
重试
</Button>
}
/>
)}
<Table
rowKey={(record, index) =>
`${record.goods_name}-${record.exchange_time}-${index}`
}
size="small"
loading={detailExchangeLoading}
columns={exchangeColumns}
dataSource={detailExchangeResult?.items || []}
locale={{ emptyText: '暂无兑换记录' }}
pagination={{
current: detailExchangeResult?.page || 1,
pageSize:
detailExchangeResult?.page_size || 20,
total: detailExchangeResult?.total || 0,
showSizeChanger: false,
showTotal: (total) => `共 ${total} 条`,
onChange: (page) =>
void loadDetailExchangeRecords(
detailAccount,
page,
),
}}
scroll={{ x: 760 }}
/>
</>
)}
</Space>
),
},
]}
/>
</Spin>
)}
</Drawer>
<Drawer
title={
featureAccount
+173 -5
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import {
App,
Badge,
Button,
Card,
@@ -34,6 +35,7 @@ import {
type SupportUserItem,
type WeiboAccountItem,
} from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { formatTime } from '../utils/time';
@@ -62,6 +64,9 @@ function renderBindStatus(account: WeiboAccountItem) {
export default function WeiboAssignmentsPage() {
const { token } = theme.useToken();
const { modal } = App.useApp();
const { can } = usePermissions();
const canForceAssign = can('weibo:assign:force');
const [summary, setSummary] = useState<AssignmentsSummary | null>(null);
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
@@ -75,7 +80,10 @@ export default function WeiboAssignmentsPage() {
const [searchText, setSearchText] = useState('');
const [filterTag, setFilterTag] = useState<string | undefined>();
const [enabledFilter, setEnabledFilter] = useState<string>('');
const [activeTab, setActiveTab] = useState<'unassigned' | 'assigned'>('unassigned');
const [assignFilter, setAssignFilter] = useState<string>('');
const [activeTab, setActiveTab] = useState<
'unassigned' | 'assigned' | 'all'
>('unassigned');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY_PAGE_SIZE);
@@ -119,7 +127,12 @@ export default function WeiboAssignmentsPage() {
: enabledFilter === 'disabled'
? false
: undefined,
assign_status: activeTab === 'unassigned' ? 'unassigned' : undefined,
assign_status:
activeTab === 'unassigned'
? 'unassigned'
: activeTab === 'all' && assignFilter
? assignFilter
: undefined,
assigned_user_id:
activeTab === 'assigned' ? selectedUser?.id : undefined,
});
@@ -133,6 +146,7 @@ export default function WeiboAssignmentsPage() {
},
[
activeTab,
assignFilter,
currentPage,
enabledFilter,
filterTag,
@@ -176,7 +190,7 @@ export default function WeiboAssignmentsPage() {
const handleSelectUser = (user: SupportUserItem) => {
setSelectedUser(user);
setActiveTab('unassigned');
setActiveTab((current) => (current === 'all' ? 'all' : 'unassigned'));
setSelectedRowKeys([]);
setCurrentPage(1);
localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id));
@@ -211,6 +225,88 @@ export default function WeiboAssignmentsPage() {
}
};
const handleForceAssignSelected = async () => {
if (!selectedUser || selectedRowKeys.length === 0) {
message.warning('请先选择客服和微博账号');
return;
}
setAssigning(true);
try {
const result = await weiboApi.forceAssign(
selectedUser.id,
selectedRowKeys.length,
{ accountIds: selectedRowKeys },
);
message.success(
`已强制将 ${result.updated} 个账号分配给 ${result.assigned_username}`,
);
setSelectedRowKeys([]);
await refreshData();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setAssigning(false);
}
};
const handleForceAssignByTag = async () => {
if (!selectedUser) {
message.warning('请先选择客服');
return;
}
const tag = filterTag?.trim();
if (!tag) {
message.warning('请先选择要强制分配的统一标签');
return;
}
setAssigning(true);
try {
const preview = await weiboApi.listAccountIds({ tag });
const count = preview.account_ids.length;
if (count === 0) {
message.info(`标签「${tag}」下没有账号`);
return;
}
modal.confirm({
title: `强制分配标签「${tag}」下的 ${count} 个账号?`,
content: (
<Space orientation="vertical" size={4}>
<Text>目标客服:{selectedUser.username}</Text>
<Text type="warning">
已分配给其他客服的账号也会转走,原客服的普通工作台映射会同步清理。
</Text>
</Space>
),
okText: '确认强制分配',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
setAssigning(true);
try {
const result = await weiboApi.forceAssign(selectedUser.id, count, {
tag,
});
message.success(
`已强制将标签「${tag}」下的 ${result.updated} 个账号分配给 ${result.assigned_username}`,
);
setSelectedRowKeys([]);
await refreshData();
} catch (error) {
message.error(getErrorMessage(error));
throw error;
} finally {
setAssigning(false);
}
},
});
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setAssigning(false);
}
};
const handleAssignAllFiltered = async () => {
if (!selectedUser) {
message.warning('请先选择客服');
@@ -302,6 +398,22 @@ export default function WeiboAssignmentsPage() {
render: (tag: string) =>
tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
},
...(activeTab === 'all'
? [
{
title: '归属客服',
dataIndex: 'assigned_username',
width: 110,
align: 'center' as const,
render: (username: string) =>
username ? (
<Tag color="cyan">{username}</Tag>
) : (
<Text type="secondary">未分配</Text>
),
},
]
: []),
{
title: 'CK 状态',
dataIndex: 'ck_check_status',
@@ -524,13 +636,29 @@ export default function WeiboAssignmentsPage() {
{ value: 'disabled', label: '已停用' },
]}
/>
{activeTab === 'all' && (
<Select
value={assignFilter}
onChange={(value) => {
setAssignFilter(value);
setCurrentPage(1);
}}
style={{ width: 120 }}
size="small"
options={[
{ value: '', label: '全部归属' },
{ value: 'unassigned', label: '未分配' },
{ value: 'assigned', label: '已分配' },
]}
/>
)}
</div>
<Tabs
size="small"
activeKey={activeTab}
onChange={(key) => {
setActiveTab(key as 'unassigned' | 'assigned');
setActiveTab(key as 'unassigned' | 'assigned' | 'all');
setSelectedRowKeys([]);
setCurrentPage(1);
}}
@@ -544,6 +672,9 @@ export default function WeiboAssignmentsPage() {
: '已分配账号',
disabled: !selectedUser,
},
...(canForceAssign
? [{ key: 'all', label: `全部账号 (${accountCount})` }]
: []),
]}
/>
@@ -593,6 +724,43 @@ export default function WeiboAssignmentsPage() {
取消分配选中 ({selectedRowKeys.length})
</Button>
)}
{activeTab === 'all' && canForceAssign && selectedUser && (
<>
<Popconfirm
title={`强制把选中的 ${selectedRowKeys.length} 个账号分配给 ${selectedUser.username}?`}
description="已分配给其他客服的账号也会转走,原客服的普通工作台映射会同步清理。"
okText="确认强制分配"
cancelText="取消"
okButtonProps={{ danger: true }}
disabled={selectedRowKeys.length === 0}
onConfirm={handleForceAssignSelected}
>
<Button
danger
size="small"
icon={<SwapOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
>
强制分配选中 ({selectedRowKeys.length}) 给 {selectedUser.username}
</Button>
</Popconfirm>
<Button
danger
type="primary"
ghost
size="small"
icon={<SwapOutlined />}
loading={assigning}
disabled={!filterTag}
onClick={() => void handleForceAssignByTag()}
>
{filterTag
? `强制分配标签「${filterTag}」全部`
: '选择标签后按标签强制分配'}
</Button>
</>
)}
</div>
<Table<WeiboAccountItem>
@@ -615,7 +783,7 @@ export default function WeiboAssignmentsPage() {
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
}}
scroll={{ x: 940, y: 'calc(100vh - 500px)' }}
scroll={{ x: activeTab === 'all' ? 1050 : 940, y: 'calc(100vh - 500px)' }}
/>
</Card>
</Col>