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

1532 lines
49 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Alert,
AutoComplete,
Button,
Checkbox,
Col,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Tooltip,
Typography,
theme,
} from 'antd';
import type { TableProps } from 'antd';
import {
CopyOutlined,
DeleteOutlined,
EditOutlined,
FileExcelOutlined,
InboxOutlined,
PlusOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
SearchOutlined,
ShopOutlined,
SyncOutlined,
UndoOutlined,
} from '@ant-design/icons';
import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader';
import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker';
import {
weiboCollabApi,
type WeiboCollabAccountLookup,
type WeiboCollabAccountOption,
type WeiboCollabBalanceCheckResult,
type WeiboCollabExchangeCheckResult,
type WeiboCollabGoods,
type WeiboCollabMetric,
type WeiboCollabOrder,
type WeiboCollabOrderCreate,
type WeiboCollabOrderUpdate,
type WeiboCollabShop,
type WeiboCollabStatus,
type WeiboCollabSummary,
type WeiboCollabTimeline,
} from '../api/modules';
import { useCollabRealtime } from '../hooks/useCollabRealtime';
import { usePermissions } from '../hooks/usePermissions';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { copyTextToClipboard } from '../utils/clipboard';
import { formatTime } from '../utils/time';
const { Text, Title } = Typography;
const { TextArea } = Input;
const STATUS_META: Record<WeiboCollabStatus, { label: string; color: string }> = {
incomplete: { label: '待补资料', color: 'default' },
to_recharge: { label: '待充值', color: 'blue' },
recharged: { label: '已充值', color: 'cyan' },
exchange_pending: { label: '兑换待确认', color: 'purple' },
done: { label: '兑换成功', color: 'success' },
weidou_error: { label: '微豆异常', color: 'orange' },
failed: { label: '兑换失败', color: 'error' },
void: { label: '已作废', color: 'default' },
};
const ACCOUNT_CK_LABELS: Record<string, string> = {
valid: '有效',
invalid: '失效',
error: '查询失败',
};
const STATUS_ORDER: WeiboCollabStatus[] = [
'incomplete',
'to_recharge',
'recharged',
'exchange_pending',
'done',
'weidou_error',
'failed',
'void',
];
const MANUAL_STATUS_OPTIONS = STATUS_ORDER.filter(
(item) => item !== 'recharged' && item !== 'weidou_error',
).map((value) => ({ value, label: STATUS_META[value].label }));
const VERDICT_META: Record<string, { label: string; color: string }> = {
success: { label: '唯一命中', color: 'success' },
failed: { label: '未命中', color: 'error' },
unknown: { label: '待确认', color: 'warning' },
'': { label: '未判定', color: 'default' },
};
function balanceMeta(reason: string) {
if (reason === 'ok') return { label: '余额正常', color: 'success' };
if (reason === 'shortage') return { label: '微豆不足', color: 'error' };
if (reason === 'excess') return { label: '超出阈值', color: 'warning' };
return { label: '未核账', color: 'default' };
}
interface OrderFormValues {
order_no: string;
shop?: string;
owner_id?: number | null;
account_id?: number | null;
goods_name?: string;
goods_image?: string;
goods_id?: number | null;
required_weidou?: number | null;
home_images?: string[];
remark?: string;
draft?: boolean;
}
export default function WeiboCollabPage() {
const { token } = theme.useToken();
const { can } = usePermissions();
const location = useLocation();
const navigate = useNavigate();
const canEdit = can('weibo:collab:edit');
const canManage = can('weibo:collab:manage');
const [orders, setOrders] = useState<WeiboCollabOrder[]>([]);
const [summary, setSummary] = useState<WeiboCollabSummary | null>(null);
const [owners, setOwners] = useState<Array<{ id: number; username: string }>>([]);
const [shops, setShops] = useState<WeiboCollabShop[]>([]);
const [goodsCatalog, setGoodsCatalog] = useState<WeiboCollabGoods[]>([]);
const [accounts, setAccounts] = useState<WeiboCollabAccountOption[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('');
const [shop, setShop] = useState('');
const [ownerId, setOwnerId] = useState<number | null>(null);
const [goodsName, setGoodsName] = useState('');
const [includeVoid, setIncludeVoid] = useState(false);
const [archiveScope, setArchiveScope] = useState<'active' | 'archived' | 'all'>('active');
const [threshold, setThreshold] = useState(100);
const [form] = Form.useForm<OrderFormValues>();
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<WeiboCollabOrder | null>(null);
const [saving, setSaving] = useState(false);
const [lookup, setLookup] = useState<WeiboCollabAccountLookup | null>(null);
const [lookupLoading, setLookupLoading] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<WeiboCollabOrder | null>(null);
const [detailMetrics, setDetailMetrics] = useState<WeiboCollabMetric[]>([]);
const [detailTimeline, setDetailTimeline] = useState<WeiboCollabTimeline[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const transferHandledRef = useRef<number | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
const [statusTarget, setStatusTarget] = useState<WeiboCollabStatus>('to_recharge');
const [statusReason, setStatusReason] = useState('');
const [statusRollback, setStatusRollback] = useState(false);
const [statusIds, setStatusIds] = useState<number[]>([]);
const [statusSaving, setStatusSaving] = useState(false);
const [shopOpen, setShopOpen] = useState(false);
const [goodsOpen, setGoodsOpen] = useState(false);
const [shopForm] = Form.useForm<{ name: string; remark?: string }>();
const [goodsForm] = Form.useForm<{
name: string;
price: number;
goods_id?: number | null;
goods_image?: string;
remark?: string;
}>();
const loadBase = useCallback(async () => {
try {
const [summaryData, ownerRows, shopRows, goodsRows, accountData] =
await Promise.all([
weiboCollabApi.summary({ include_void: includeVoid, archive_scope: archiveScope }),
weiboCollabApi.owners(),
weiboCollabApi.listShops(),
weiboCollabApi.listGoods(),
weiboCollabApi.accounts(),
]);
setSummary(summaryData);
setOwners(ownerRows);
setShops(shopRows);
setGoodsCatalog(goodsRows);
setAccounts(accountData);
} catch (error) {
message.error(getErrorMessage(error));
}
}, [archiveScope, includeVoid]);
const loadOrders = useCallback(async () => {
setLoading(true);
try {
const data = await weiboCollabApi.listOrders({
page,
page_size: pageSize,
archive_scope: archiveScope,
status: status || undefined,
shop: shop || undefined,
owner_id: ownerId ?? undefined,
goods_name: goodsName || undefined,
keyword: keyword.trim() || undefined,
include_void: includeVoid,
});
setOrders(data.items);
setTotal(data.total);
setSelectedIds((ids) => ids.filter((id) => data.items.some((row) => row.id === id)));
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setLoading(false);
}
}, [
archiveScope,
goodsName,
includeVoid,
keyword,
ownerId,
page,
pageSize,
shop,
status,
]);
const refreshAll = useCallback(() => {
void loadBase();
void loadOrders();
}, [loadBase, loadOrders]);
useEffect(() => {
void loadBase();
}, [loadBase]);
useEffect(() => {
void loadOrders();
}, [loadOrders]);
useCollabRealtime({
enabled: true,
path: '/api/weibo/external-orders/ws',
onInvalidate: () => refreshAll(),
});
const accountOptions = accounts.map((item) => ({
value: item.id,
label: `${item.nickname || item.uid} · UID ${item.uid}${item.game_role_name ? ` · ${item.game_role_name}` : ''}`,
title: item.nickname || `UID ${item.uid}`,
}));
const liveGoodsOptions = (lookup?.goods || []).map((item) => ({
value: item.goods_name,
label: `${item.goods_name} · ${item.price} 微豆${item.is_sold_out ? ' · 售罄' : ''}`,
goods: {
name: item.goods_name,
price: item.price,
goods_id: item.id,
goods_image: item.goods_image,
},
}));
const catalogGoodsOptions = goodsCatalog
.filter((item) => item.is_active)
.map((item) => ({
value: item.name,
label: `${item.name} · ${item.price} 微豆(自定义)`,
goods: {
name: item.name,
price: item.price,
goods_id: item.goods_id,
goods_image: item.goods_image,
},
}));
const goodsOptions: Array<{
value: string;
label: string;
goods: {
name: string;
price: number;
goods_id: number | null;
goods_image: string;
};
}> = [...catalogGoodsOptions];
for (const option of liveGoodsOptions) {
if (!goodsOptions.some((item) => item.value === option.value)) goodsOptions.push(option);
}
const lookupAccount = useCallback(async (accountId?: number | null) => {
setLookup(null);
if (!accountId) return;
setLookupLoading(true);
try {
const result = await weiboCollabApi.lookupAccount(accountId, true);
setLookup(result);
if (!result.ok) message.warning(result.message || '账号当前不可用');
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setLookupLoading(false);
}
}, []);
const openCreate = () => {
setEditing(null);
setLookup(null);
form.resetFields();
form.setFieldsValue({
owner_id: owners[0]?.id,
required_weidou: 0,
home_images: [],
draft: false,
});
setEditOpen(true);
};
const transferAccountId = (
location.state as { transferAccountId?: number } | null
)?.transferAccountId;
useEffect(() => {
if (!transferAccountId || transferHandledRef.current === transferAccountId) {
return;
}
transferHandledRef.current = transferAccountId;
setEditing(null);
setLookup(null);
form.resetFields();
form.setFieldsValue({
owner_id: owners[0]?.id,
account_id: transferAccountId,
required_weidou: 0,
home_images: [],
draft: false,
});
setEditOpen(true);
void lookupAccount(transferAccountId);
navigate(location.pathname, { replace: true, state: null });
}, [
form,
location.pathname,
lookupAccount,
navigate,
owners,
transferAccountId,
]);
const openEdit = (order: WeiboCollabOrder) => {
setEditing(order);
form.setFieldsValue({
order_no: order.order_no,
shop: order.shop,
owner_id: order.owner_id,
account_id: order.account_id,
goods_id: order.goods_id,
goods_name: order.goods_name,
goods_image: order.goods_image,
required_weidou: order.required_weidou,
home_images: order.home_images,
remark: order.remark,
draft: false,
});
void lookupAccount(order.account_id);
setEditOpen(true);
};
const handleGoodsChange = (value: string) => {
const selected = goodsOptions.find((item) => item.value === value);
if (!selected) return;
form.setFieldsValue({
goods_name: selected.goods.name,
required_weidou: selected.goods.price,
goods_id: selected.goods.goods_id,
goods_image: selected.goods.goods_image,
});
};
const saveOrder = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
const update: WeiboCollabOrderUpdate = {
order_no: values.order_no,
shop: values.shop || '',
owner_id: values.owner_id ?? null,
goods_id: values.goods_id ?? null,
goods_name: values.goods_name || '',
goods_image: values.goods_image || '',
required_weidou: values.required_weidou ?? null,
home_images: values.home_images || [],
remark: values.remark || '',
expected_version: editing.version,
};
await weiboCollabApi.updateOrder(editing.id, update);
if (editing.account_id !== values.account_id) {
if (values.account_id) {
await weiboCollabApi.setAccount(editing.id, values.account_id);
} else {
message.warning('订单暂不支持取消关联账号');
}
}
} else {
const create: WeiboCollabOrderCreate = {
order_no: values.order_no,
shop: values.shop || '',
owner_id: values.owner_id ?? null,
account_id: values.account_id ?? null,
goods_id: values.goods_id ?? null,
goods_name: values.goods_name || '',
goods_image: values.goods_image || '',
required_weidou: values.required_weidou ?? null,
home_images: values.home_images || [],
remark: values.remark || '',
draft: Boolean(values.draft),
require_verified: !values.draft,
};
await weiboCollabApi.createOrder(create);
}
setEditOpen(false);
message.success(editing ? '订单已更新' : values.draft ? '草稿已创建' : '协作单已创建');
refreshAll();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setSaving(false);
}
};
const openDetail = async (order: WeiboCollabOrder) => {
setDetail(order);
setDetailOpen(true);
setDetailLoading(true);
try {
const [fresh, metrics, timeline] = await Promise.all([
weiboCollabApi.getOrder(order.id),
weiboCollabApi.metrics(order.id),
weiboCollabApi.timeline(order.id),
]);
setDetail(fresh);
setDetailMetrics(metrics);
setDetailTimeline(timeline);
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setDetailLoading(false);
}
};
const verifyOrders = async (ids: number[]) => {
if (!ids.length) return;
try {
const result = await weiboCollabApi.verifyBatch(ids);
if (result.success) message.success(`已校验 ${ids.length} 单`);
else message.warning(`校验完成,${result.results.filter((item) => !item.success).length} 单失败`);
refreshAll();
} catch (error) {
message.error(getErrorMessage(error));
}
};
const runBalanceCheck = async (ids: number[]) => {
if (!ids.length) return;
try {
const result: WeiboCollabBalanceCheckResult = await weiboCollabApi.checkBalance(
ids,
threshold,
);
const failed = result.failures.length;
message[failed ? 'warning' : 'success'](
`余额核账完成:更新 ${result.updated} 单${failed ? `,失败 ${failed} 单` : ''}`,
);
refreshAll();
} catch (error) {
message.error(getErrorMessage(error));
}
};
const runExchangeCheck = async (ids: number[]) => {
if (!ids.length) return;
try {
const result: WeiboCollabExchangeCheckResult =
await weiboCollabApi.checkExchangeRecords(ids);
message.success(
`兑换记录查询完成:命中 ${result.success},待确认 ${result.unknown},失败 ${result.failed}`,
);
refreshAll();
} catch (error) {
message.error(getErrorMessage(error));
}
};
const openStatus = (ids: number[], initial?: WeiboCollabStatus) => {
if (!ids.length) return;
setStatusIds(ids);
setStatusTarget(initial || 'to_recharge');
setStatusReason('');
setStatusRollback(false);
setStatusOpen(true);
};
const saveStatus = async () => {
setStatusSaving(true);
try {
if (statusIds.length === 1) {
const row = orders.find((item) => item.id === statusIds[0]);
await weiboCollabApi.updateStatus(statusIds[0], {
status: statusTarget,
reason: statusReason,
rollback: statusRollback,
expected_version: row?.version,
});
} else {
await weiboCollabApi.batchStatus({
ids: statusIds,
status: statusTarget,
reason: statusReason,
rollback: statusRollback,
});
}
setStatusOpen(false);
message.success(`已更新为「${STATUS_META[statusTarget].label}」`);
refreshAll();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setStatusSaving(false);
}
};
const archiveOrders = async (ids: number[]) => {
if (!ids.length) return;
try {
const result = await weiboCollabApi.archive(ids);
message[result.archived ? 'success' : 'warning'](
`已归档 ${result.archived} 单${result.skipped ? `,跳过 ${result.skipped} 单` : ''}`,
);
setSelectedIds([]);
refreshAll();
} catch (error) {
message.error(getErrorMessage(error));
}
};
const unarchiveOrders = async (ids: number[]) => {
if (!ids.length) return;
try {
const result = await weiboCollabApi.unarchive(ids);
message.success(`已取消归档 ${result.unarchived} 单`);
setSelectedIds([]);
refreshAll();
} catch (error) {
message.error(getErrorMessage(error));
}
};
const deleteOrder = async (id: number) => {
try {
await weiboCollabApi.deleteOrder(id);
message.success('协作单已删除');
refreshAll();
} catch (error) {
message.error(getErrorMessage(error));
}
};
const createShop = async () => {
const values = await shopForm.validateFields();
try {
await weiboCollabApi.createShop(values);
shopForm.resetFields();
await loadBase();
message.success('店铺已新增');
} catch (error) {
message.error(getErrorMessage(error));
}
};
const createGoods = async () => {
const values = await goodsForm.validateFields();
try {
await weiboCollabApi.createGoods(values);
goodsForm.resetFields();
await loadBase();
message.success('商品已新增');
} catch (error) {
message.error(getErrorMessage(error));
}
};
const statusCards = useMemo(
() => [
{ key: '', label: '全部', value: summary?.total || 0 },
...STATUS_ORDER.map((item) => ({
key: item,
label: STATUS_META[item].label,
value: summary?.by_status[item] || 0,
})),
],
[summary],
);
const copySelectedAccountValues = async (kind: 'username' | 'uid') => {
const selectedOrders = orders.filter((row) => selectedIds.includes(row.id));
const values = Array.from(
new Set(
selectedOrders
.map((row) =>
kind === 'uid'
? row.account_uid
: row.account_nickname || row.account_uid,
)
.map((value) => String(value || '').trim())
.filter(Boolean),
),
);
if (values.length === 0) {
message.warning(
kind === 'uid' ? '所选订单没有微博 UID' : '所选订单没有微博用户名',
);
return;
}
try {
await copyTextToClipboard(values.join('\n'));
message.success(
`已复制 ${values.length} 个${
kind === 'uid' ? ' UID' : '用户名'
},可直接粘贴到工作台导入搜索框`,
);
} catch (error) {
message.error(getErrorMessage(error));
}
};
const columns: TableProps<WeiboCollabOrder>['columns'] = [
{
title: '序号',
key: 'index',
width: 72,
align: 'center',
fixed: 'left',
render: (_, __, index) => (page - 1) * pageSize + index + 1,
},
{
title: '订单编号',
dataIndex: 'order_no',
width: 170,
fixed: 'left',
render: (value: string, row) => (
<Space orientation="vertical" size={0}>
<Text strong copyable={{ text: value }}>
{value}
</Text>
{row.is_archived && <Tag>已归档</Tag>}
</Space>
),
},
{
title: '店铺 / 负责人',
width: 150,
render: (_, row) => (
<Space orientation="vertical" size={0}>
<Text>{row.shop || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{row.owner_name || '-'}
</Text>
</Space>
),
},
{
title: '微博账号 / 角色',
width: 210,
render: (_, row) => (
<Space orientation="vertical" size={0}>
<Text
copyable={
row.account_nickname || row.account_uid
? {
text: row.account_nickname || row.account_uid || '',
tooltips: ['复制用户名', '已复制用户名'],
}
: false
}
>
{row.account_nickname || row.account_uid || '未关联'}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{row.account_uid ? (
<Text
type="secondary"
style={{ fontSize: 12 }}
copyable={{
text: row.account_uid,
tooltips: ['复制 UID', '已复制 UID'],
}}
>
UID {row.account_uid}
</Text>
) : (
'UID -'
)}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{row.game_role_name || '未读到角色'}
</Text>
<Space size={4}>
{row.account_is_bound === false && <Tag color="warning">未绑定</Tag>}
{!row.account_enabled && row.account_exists && <Tag>账号停用</Tag>}
{row.account_cluster && <Tag color="blue">同账号 {row.account_cluster.total} 单</Tag>}
</Space>
</Space>
),
},
{
title: '商品 / 微豆',
width: 190,
render: (_, row) => (
<Space orientation="vertical" size={0}>
<Text>{row.goods_name || '未选商品'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
需要 {row.required_weidou?.toLocaleString() ?? '-'} 微豆
</Text>
<Tag color={VERDICT_META[row.exchange_verdict]?.color}>
{VERDICT_META[row.exchange_verdict]?.label}
</Tag>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
width: 130,
render: (value: WeiboCollabStatus, row) => (
<Space size={4} wrap>
<Tag color={STATUS_META[value]?.color}>{row.status_label || value}</Tag>
{row.exchange_verdict === 'unknown' && <Tag color="warning">待确认</Tag>}
</Space>
),
},
{
title: '最近核账',
width: 200,
render: (_, row) => {
const meta = balanceMeta(row.weidou_check_reason);
return (
<Tooltip title={row.weidou_check_message}>
<Space orientation="vertical" size={0}>
<Text>
余额 {row.weidou_balance?.toLocaleString() ?? '-'} / 合计{' '}
{row.weidou_required_total?.toLocaleString() ?? '-'}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
结余 {row.weidou_surplus?.toLocaleString() ?? '-'}
</Text>
<Tag color={meta.color}>{meta.label}</Tag>
</Space>
</Tooltip>
);
},
},
{
title: '最近改动',
width: 150,
render: (_, row) => (
<Space orientation="vertical" size={0}>
<Text>{row.updated_by_name || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatTime(row.updated_at)}
</Text>
</Space>
),
},
{
title: '操作',
width: 300,
fixed: 'right',
render: (_, row) => (
<Space size={4} wrap>
<Button size="small" onClick={() => void openDetail(row)}>
详情
</Button>
{canEdit && !row.is_archived && (
<>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}>
编辑
</Button>
<Button
size="small"
icon={<SafetyCertificateOutlined />}
onClick={() => void verifyOrders([row.id])}
>
校验
</Button>
<Button size="small" onClick={() => openStatus([row.id])}>
状态
</Button>
</>
)}
{canManage && (
<Popconfirm
title="删除这条协作单?"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => void deleteOrder(row.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
return (
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
flexWrap: 'wrap',
}}
>
<div>
<Title level={4} style={{ margin: 0 }}>
微博外部单协作
</Title>
<Text type="secondary">外部 CK 账号核账、兑换确认与协作台账</Text>
</div>
<Space wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="单号、UID、昵称、角色、商品、备注"
value={keyword}
onChange={(event) => {
setKeyword(event.target.value);
setPage(1);
}}
style={{ width: 260 }}
/>
<Button icon={<ReloadOutlined />} onClick={refreshAll}>
刷新
</Button>
</Space>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(118px, 1fr))',
gap: 8,
}}
>
{statusCards.map((item) => (
<button
key={item.key || 'all'}
type="button"
onClick={() => {
setStatus(item.key);
setPage(1);
}}
style={{
border: `1px solid ${status === item.key ? token.colorPrimary : token.colorBorderSecondary}`,
background: status === item.key ? token.colorPrimaryBg : token.colorBgContainer,
borderRadius: 6,
padding: '10px 12px',
textAlign: 'left',
cursor: 'pointer',
}}
>
<Statistic title={item.label} value={item.value} valueStyle={{ fontSize: 20 }} />
</button>
))}
</div>
<div
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 6,
padding: 12,
}}
>
<Space wrap>
<Select
value={shop}
onChange={(value) => {
setShop(value);
setPage(1);
}}
allowClear
placeholder="店铺"
style={{ width: 140 }}
options={shops
.filter((item) => item.is_active)
.map((item) => ({ value: item.name, label: item.name }))}
/>
<Select
value={ownerId ?? undefined}
onChange={(value) => {
setOwnerId(value ?? null);
setPage(1);
}}
allowClear
placeholder="负责人"
style={{ width: 130 }}
options={owners.map((item) => ({ value: item.id, label: item.username }))}
/>
<Select
value={goodsName || undefined}
onChange={(value) => {
setGoodsName(value || '');
setPage(1);
}}
allowClear
placeholder="商品"
style={{ width: 180 }}
options={goodsCatalog.map((item) => ({ value: item.name, label: item.name }))}
/>
<Select
value={status}
onChange={(value) => {
setStatus(value);
setPage(1);
}}
style={{ width: 130 }}
options={[
{ value: '', label: '全部状态' },
...STATUS_ORDER.map((item) => ({ value: item, label: STATUS_META[item].label })),
]}
/>
<Select
value={archiveScope}
onChange={(value) => {
setArchiveScope(value);
setPage(1);
}}
style={{ width: 120 }}
options={[
{ value: 'active', label: '活跃台账' },
{ value: 'archived', label: '已归档' },
{ value: 'all', label: '全部' },
]}
/>
<Checkbox
checked={includeVoid}
onChange={(event) => {
setIncludeVoid(event.target.checked);
setPage(1);
}}
>
显示作废
</Checkbox>
<InputNumber
min={0}
max={1_000_000}
value={threshold}
onChange={(value) => setThreshold(Number(value) || 0)}
addonBefore="核账阈值"
style={{ width: 150 }}
/>
<Button icon={<ShopOutlined />} onClick={() => setShopOpen(true)}>
店铺管理
</Button>
<Button icon={<FileExcelOutlined />} onClick={() => setGoodsOpen(true)}>
商品管理
</Button>
{canEdit && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建协作单
</Button>
)}
</Space>
</div>
<Space wrap>
<Text type="secondary">已选 {selectedIds.length} 单</Text>
{selectedIds.length > 0 && (
<>
<Button
icon={<CopyOutlined />}
onClick={() => void copySelectedAccountValues('username')}
>
复制所选用户名
</Button>
<Button
icon={<CopyOutlined />}
onClick={() => void copySelectedAccountValues('uid')}
>
复制所选 UID
</Button>
</>
)}
{canEdit && selectedIds.length > 0 && archiveScope !== 'archived' && (
<>
<Button
icon={<SafetyCertificateOutlined />}
onClick={() => void verifyOrders(selectedIds)}
>
批量校验
</Button>
<Button icon={<SyncOutlined />} onClick={() => void runBalanceCheck(selectedIds)}>
充值核账
</Button>
<Button icon={<SyncOutlined />} onClick={() => void runExchangeCheck(selectedIds)}>
查兑换记录
</Button>
<Button onClick={() => openStatus(selectedIds)}>批量改状态</Button>
<Button icon={<InboxOutlined />} onClick={() => void archiveOrders(selectedIds)}>
归档
</Button>
</>
)}
{canEdit && selectedIds.length > 0 && archiveScope === 'archived' && (
<Button icon={<UndoOutlined />} onClick={() => void unarchiveOrders(selectedIds)}>
取消归档
</Button>
)}
</Space>
{archiveScope === 'archived' && (
<Alert
type="info"
showIcon
message="正在查看已归档订单"
description="归档订单只读,取消归档后可继续操作。"
/>
)}
<Table<WeiboCollabOrder>
rowKey="id"
loading={loading}
columns={columns}
dataSource={orders}
scroll={{ x: 1500 }}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map(Number)),
}}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (value) => `共 ${value} 单`,
onChange: (nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
},
}}
/>
<Modal
title={editing ? `编辑协作单 · ${editing.order_no}` : '新建微博外部单'}
open={editOpen}
width={920}
confirmLoading={saving}
okText={editing ? '保存' : form.getFieldValue('draft') ? '保存草稿' : '创建并校验'}
cancelText="取消"
onOk={() => void saveOrder()}
onCancel={() => setEditOpen(false)}
styles={{ body: { maxHeight: '78vh', overflowY: 'auto', paddingRight: 12 } }}
destroyOnHidden
>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
name="order_no"
label="订单编号"
rules={[{ required: true, message: '请填写订单编号' }]}
>
<Input maxLength={64} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="owner_id" label="负责人">
<Select
allowClear
options={owners.map((item) => ({ value: item.id, label: item.username }))}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="shop" label="店铺">
<HuyaCollabShopPicker
shops={shops}
allowClear={false}
allowCustom
size="large"
/>
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="account_id" label="微博账号">
<Select
allowClear
showSearch
optionFilterProp="label"
optionLabelProp="title"
options={accountOptions}
loading={lookupLoading}
placeholder="搜索并选择微博账号"
onChange={(value) => void lookupAccount(value)}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="goods_name" label="商品">
<AutoComplete
options={goodsOptions}
onChange={handleGoodsChange}
placeholder={
lookup
? '选择当前账号商城商品'
: '选择自定义商品,或先选账号加载实时商品'
}
filterOption
/>
</Form.Item>
</Col>
<Col xs={24} md={4}>
<Form.Item
name="required_weidou"
label="所需微豆"
rules={[{ required: true, message: '请填写所需微豆' }]}
>
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="goods_id" hidden>
<InputNumber />
</Form.Item>
<Form.Item name="goods_image" hidden>
<Input />
</Form.Item>
{lookup && (
<>
<Alert
type={lookup.ok ? 'success' : 'warning'}
showIcon
style={{ marginBottom: 12 }}
message={
lookup.ok
? `${lookup.nickname || lookup.uid} · 当前 ${lookup.weidou_balance?.toLocaleString() ?? '-'} 微豆`
: lookup.message || '账号不可用'
}
/>
{lookup.ok && (
<Descriptions
size="small"
column={3}
style={{ marginBottom: 16 }}
items={[
{ key: 'uid', label: 'UID', children: lookup.uid || '-' },
{
key: 'role',
label: '绑定角色',
children: lookup.game_role_name || '未读取到角色',
},
{
key: 'bind',
label: '绑定状态',
children:
lookup.is_bound === true
? '已绑定'
: lookup.is_bound === false
? '未绑定'
: '未知',
},
{
key: 'ck',
label: 'CK 状态',
children:
ACCOUNT_CK_LABELS[lookup.ck_check_status] ||
lookup.ck_check_status ||
'未检测',
},
{
key: 'orders',
label: '同账号订单',
span: 2,
children:
lookup.orders_using_account.length > 0
? lookup.orders_using_account
.map(
(item) =>
`${item.order_no}(${item.status_label || item.status})`,
)
.join('、')
: '暂无',
},
]}
/>
)}
</>
)}
<Row gutter={16}>
<Col xs={24} md={14}>
<Form.Item name="home_images" label="主页截图">
<HuyaCollabHomeImageUploader optional />
</Form.Item>
</Col>
<Col xs={24} md={10}>
<Form.Item name="remark" label="备注">
<TextArea rows={6} maxLength={512} showCount />
</Form.Item>
</Col>
</Row>
{!editing && (
<Form.Item name="draft" valuePropName="checked">
<Checkbox>先保存草稿,不校验账号</Checkbox>
</Form.Item>
)}
</Form>
</Modal>
<Modal
title={`修改状态${statusIds.length > 1 ? `(${statusIds.length} 单)` : ''}`}
open={statusOpen}
confirmLoading={statusSaving}
okText="确认修改"
cancelText="取消"
onOk={() => void saveStatus()}
onCancel={() => setStatusOpen(false)}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Select
value={statusTarget}
onChange={setStatusTarget}
options={MANUAL_STATUS_OPTIONS}
style={{ width: '100%' }}
/>
<TextArea
rows={3}
value={statusReason}
onChange={(event) => setStatusReason(event.target.value)}
placeholder="原因或备注,作废时必填"
maxLength={256}
/>
<Checkbox checked={statusRollback} onChange={(e) => setStatusRollback(e.target.checked)}>
这是显式回退
</Checkbox>
</Space>
</Modal>
<Drawer
title={detail ? `协作单详情 · ${detail.order_no}` : '协作单详情'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
size={880}
loading={detailLoading}
>
{detail && (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions
size="small"
column={2}
items={[
{ key: 'shop', label: '店铺 / 负责人', children: `${detail.shop || '-'} / ${detail.owner_name || '-'}` },
{
key: 'account',
label: '微博账号',
children: (
<Space size={4} wrap>
<Text
copyable={
detail.account_nickname || detail.account_uid
? {
text:
detail.account_nickname ||
detail.account_uid ||
'',
tooltips: ['复制用户名', '已复制用户名'],
}
: false
}
>
{detail.account_nickname || '-'}
</Text>
{detail.account_uid && (
<Text
type="secondary"
copyable={{
text: detail.account_uid,
tooltips: ['复制 UID', '已复制 UID'],
}}
>
UID {detail.account_uid}
</Text>
)}
</Space>
),
},
{ key: 'role', label: '绑定角色', children: detail.game_role_name || '-' },
{ key: 'goods', label: '商品 / 所需微豆', children: `${detail.goods_name || '-'} / ${detail.required_weidou ?? '-'}` },
{ key: 'status', label: '业务状态', children: <Tag color={STATUS_META[detail.status]?.color}>{detail.status_label}</Tag> },
{ key: 'balance', label: '最近核账', children: detail.weidou_check_message || '-' },
{ key: 'exchange', label: '兑换确认', children: detail.exchange_message || '-' },
{ key: 'updated', label: '最后修改', children: `${detail.updated_by_name || '-'} · ${formatTime(detail.updated_at)}` },
]}
/>
{detail.home_images.length > 0 && (
<Space wrap>
{detail.home_images.map((key) => (
<img
key={key}
src={`/api/files/object?key=${encodeURIComponent(key)}&variant=thumb`}
alt="主页截图"
style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
)}
<Space wrap>
{canEdit && !detail.is_archived && (
<>
<Button icon={<SafetyCertificateOutlined />} onClick={() => void verifyOrders([detail.id])}>
校验账号
</Button>
<Button icon={<SyncOutlined />} onClick={() => void runBalanceCheck([detail.id])}>
充值核账
</Button>
<Button icon={<SyncOutlined />} onClick={() => void runExchangeCheck([detail.id])}>
查兑换记录
</Button>
<Button onClick={() => openStatus([detail.id], 'exchange_pending')}>
标记兑换发起
</Button>
</>
)}
</Space>
<Title level={5}>指标快照</Title>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detailMetrics}
columns={[
{ title: '时间', dataIndex: 'created_at', width: 170, render: formatTime },
{ title: '来源', dataIndex: 'source', width: 130 },
{
title: '核账',
width: 220,
render: (_, row) =>
`余额 ${row.weidou_balance ?? '-'} / 合计 ${row.weidou_required_total ?? '-'} / 结余 ${row.weidou_surplus ?? '-'}`,
},
{
title: '兑换',
width: 220,
render: (_, row) =>
`${row.verdict_label || '未判定'} · ${row.records_matched}/${row.records_total}`,
},
{ title: '结论', dataIndex: 'message' },
]}
/>
<Title level={5}>操作时间线</Title>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detailTimeline}
columns={[
{ title: '时间', dataIndex: 'created_at', width: 170, render: formatTime },
{ title: '操作人', dataIndex: 'username', width: 120 },
{ title: '动作', dataIndex: 'action', width: 190 },
{ title: '详情', dataIndex: 'detail' },
]}
/>
</Space>
)}
</Drawer>
<Modal
title="店铺管理"
open={shopOpen}
footer={null}
width={680}
onCancel={() => setShopOpen(false)}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Form form={shopForm} layout="inline" onFinish={() => void createShop()}>
<Form.Item name="name" rules={[{ required: true, message: '店铺名' }]}>
<Input placeholder="店铺名" />
</Form.Item>
<Form.Item name="remark">
<Input placeholder="备注" />
</Form.Item>
<Button type="primary" htmlType="submit">
新增
</Button>
</Form>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={shops}
columns={[
{ title: '店铺', dataIndex: 'name' },
{
title: '状态',
dataIndex: 'is_active',
width: 90,
render: (value: boolean, row) => (
<Button
size="small"
onClick={async () => {
await weiboCollabApi.updateShop(row.id, { is_active: !value });
await loadBase();
}}
>
{value ? '停用' : '启用'}
</Button>
),
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Popconfirm
title="删除店铺?"
onConfirm={async () => {
try {
await weiboCollabApi.deleteShop(row.id);
await loadBase();
} catch (error) {
message.error(getErrorMessage(error));
}
}}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
]}
/>
</Space>
</Modal>
<Modal
title="商品字典管理"
open={goodsOpen}
footer={null}
width={820}
onCancel={() => setGoodsOpen(false)}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Form form={goodsForm} layout="inline" onFinish={() => void createGoods()}>
<Form.Item name="name" rules={[{ required: true, message: '商品名' }]}>
<Input placeholder="商品名" />
</Form.Item>
<Form.Item name="price" rules={[{ required: true, message: '微豆' }]}>
<InputNumber min={0} placeholder="微豆" />
</Form.Item>
<Form.Item name="goods_id">
<InputNumber min={1} placeholder="商城 ID(可选)" />
</Form.Item>
<Button type="primary" htmlType="submit">
新增
</Button>
</Form>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={goodsCatalog}
columns={[
{ title: '商品', dataIndex: 'name' },
{ title: '微豆', dataIndex: 'price', width: 100 },
{ title: '商城 ID', dataIndex: 'goods_id', width: 100 },
{
title: '状态',
dataIndex: 'is_active',
width: 90,
render: (value: boolean, row) => (
<Button
size="small"
onClick={async () => {
await weiboCollabApi.updateGoods(row.id, { is_active: !value });
await loadBase();
}}
>
{value ? '停用' : '启用'}
</Button>
),
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Popconfirm
title="删除商品字典?"
onConfirm={async () => {
try {
await weiboCollabApi.deleteGoods(row.id);
await loadBase();
} catch (error) {
message.error(getErrorMessage(error));
}
}}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
]}
/>
</Space>
</Modal>
</Space>
);
}