refactor: 更新异常处理和前端页面布局

- 在 `AndroidAuthServiceImpl` 中将 `RuntimeException` 替换为 `BusinessException`,并使用 `ErrorCodeEnum.UNAUTHORIZED`
- 优化前端 `MeetingPointsManagement` 页面布局,移除不必要的组件和样式
- 添加个人账户分页功能和新的统计卡片样式
- 调整积分管理页面的表格和标签样式,提升用户体验
dev_na
chenhao 2026-06-11 14:38:07 +08:00
parent 2e05a25e63
commit e330edc965
2 changed files with 330 additions and 310 deletions

View File

@ -9,6 +9,7 @@ import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidDeviceBindingService;
import com.imeeting.service.biz.LicenseService;
import com.unisbase.common.exception.BusinessException;
import com.unisbase.common.exception.ErrorCodeEnum;
import com.unisbase.dto.InternalAuthCheckResponse;
import com.unisbase.security.LoginUser;
import com.unisbase.service.TokenValidationService;
@ -205,14 +206,14 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
private InternalAuthCheckResponse validateToken(String token) {
String resolvedToken = normalizeToken(token);
if (!StringUtils.hasText(resolvedToken)) {
throw new RuntimeException("Missing Android access token");
throw new BusinessException(ErrorCodeEnum.UNAUTHORIZED.getCode(),"Missing Android access token");
}
InternalAuthCheckResponse authResult = tokenValidationService.validateAccessToken(resolvedToken);
if (authResult == null || !authResult.isValid()) {
throw new RuntimeException(authResult == null || !StringUtils.hasText(authResult.getMessage()) ? "Android access token is invalid" : authResult.getMessage());
throw new BusinessException(ErrorCodeEnum.UNAUTHORIZED.getCode(),authResult == null || !StringUtils.hasText(authResult.getMessage()) ? "Android access token is invalid" : authResult.getMessage());
}
if (authResult.getUserId() == null || authResult.getTenantId() == null) {
throw new RuntimeException("Android access token is missing user or tenant context");
throw new BusinessException(ErrorCodeEnum.UNAUTHORIZED.getCode(),"Android access token is missing user or tenant context");
}
return authResult;
}

View File

@ -1,11 +1,10 @@
import { EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
import { EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { listUsers } from "@/api";
import {
Button,
Card,
Col,
Descriptions,
Empty,
Form,
Input,
InputNumber,
@ -16,6 +15,7 @@ import {
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
} from "antd";
@ -36,7 +36,7 @@ import {
} from "@/api/business/meetingPoints";
import type { SysUser } from "@/types";
const { Text, Title } = Typography;
const { Text } = Typography;
const POINTS_TYPE_OPTIONS = [
{ label: "全部类型", value: "" },
@ -93,6 +93,7 @@ function buildSummaryCards(overview: MeetingPointsOverviewVO | null) {
if (!overview) {
return [];
}
const isAdmin = Boolean(overview.admin);
const isPublicOnly = overview.accountMode === ACCOUNT_MODE_PUBLIC;
const isPersonalOnly = overview.accountMode === ACCOUNT_MODE_PERSONAL;
@ -113,14 +114,14 @@ function buildSummaryCards(overview: MeetingPointsOverviewVO | null) {
key: "public-balance",
title: "公共账户余额",
value: overview.publicBalance ?? 0,
accent: "linear-gradient(135deg, rgba(18, 87, 241, 0.14), rgba(18, 87, 241, 0.02))",
accent: "rgba(24, 144, 255, 0.08)",
note: "用于统一分配和公共扣费",
},
{
key: "public-used",
title: "公共账户累计消耗积分",
title: "公共账户累计消耗",
value: overview.publicTotalPointsUsed ?? 0,
accent: "linear-gradient(135deg, rgba(11, 132, 98, 0.14), rgba(11, 132, 98, 0.02))",
accent: "rgba(82, 196, 26, 0.08)",
note: "公共账户历史累计扣减",
},
);
@ -132,14 +133,14 @@ function buildSummaryCards(overview: MeetingPointsOverviewVO | null) {
key: "personal-balance",
title: isAdmin ? "个人账户余额汇总" : "个人账户余额",
value: overview.personalBalance ?? 0,
accent: "linear-gradient(135deg, rgba(148, 77, 255, 0.14), rgba(148, 77, 255, 0.02))",
accent: "rgba(114, 46, 209, 0.08)",
note: isAdmin ? "管理员视角下的个人账户汇总" : "当前账号可用积分",
},
{
key: "personal-used",
title: isAdmin ? "个人账户累计消耗积分汇总" : "个人账户累计消耗积分",
title: isAdmin ? "个人账户累计消耗汇总" : "个人账户累计消耗",
value: overview.personalTotalPointsUsed ?? 0,
accent: "linear-gradient(135deg, rgba(237, 108, 2, 0.14), rgba(237, 108, 2, 0.02))",
accent: "rgba(250, 140, 22, 0.08)",
note: isAdmin ? "管理员视角下的个人账户消耗汇总" : "当前账号历史累计扣减",
},
);
@ -149,7 +150,7 @@ function buildSummaryCards(overview: MeetingPointsOverviewVO | null) {
key: "charge-count",
title: "累计消耗次数",
value: overview.totalChargeCount ?? 0,
accent: "linear-gradient(135deg, rgba(48, 48, 48, 0.12), rgba(48, 48, 48, 0.02))",
accent: "rgba(38, 38, 38, 0.06)",
note: "已发生扣费的总结记录数",
});
@ -168,6 +169,11 @@ export default function MeetingPointsManagement() {
const [transferOpen, setTransferOpen] = useState(false);
const [detail, setDetail] = useState<MeetingPointsLedgerDetailVO | null>(null);
const [users, setUsers] = useState<SysUser[]>([]);
const [contentTab, setContentTab] = useState("ledger");
const [personalAccountPagination, setPersonalAccountPagination] = useState({
current: 1,
pageSize: 10,
});
const [params, setParams] = useState({
current: 1,
size: 20,
@ -180,8 +186,46 @@ export default function MeetingPointsManagement() {
const isPublicOnly = overview?.accountMode === ACCOUNT_MODE_PUBLIC;
const isPersonalOnly = overview?.accountMode === ACCOUNT_MODE_PERSONAL;
const showTransferButton = isAdmin && !isPublicOnly;
const showPersonalGallery = isAdmin && !isPublicOnly;
const summaryCards = buildSummaryCards(overview);
const showPersonalAccountSection = Boolean(overview) && !isPublicOnly;
const summaryCards = useMemo(() => buildSummaryCards(overview), [overview]);
const personalAccountRows = useMemo<MeetingPointsPersonalAccountVO[]>(() => {
if (!overview || isPublicOnly) {
return [];
}
if (isAdmin) {
return overview.personalAccounts || [];
}
return [
{
userId: -1,
displayName: "当前账号",
currentBalance: overview.personalBalance ?? 0,
totalPointsUsed: overview.personalTotalPointsUsed ?? 0,
},
];
}, [overview, isAdmin, isPublicOnly]);
const pagedPersonalAccounts = useMemo(() => {
const start = (personalAccountPagination.current - 1) * personalAccountPagination.pageSize;
const end = start + personalAccountPagination.pageSize;
return personalAccountRows.slice(start, end);
}, [personalAccountPagination, personalAccountRows]);
useEffect(() => {
setPersonalAccountPagination((prev) => {
const maxPage = Math.max(1, Math.ceil(personalAccountRows.length / prev.pageSize));
return prev.current > maxPage ? { ...prev, current: maxPage } : prev;
});
}, [personalAccountRows.length]);
useEffect(() => {
if (!isPersonalOnly && contentTab !== "ledger") {
setContentTab("ledger");
}
}, [contentTab, isPersonalOnly]);
const loadOverview = async () => {
const data = await getMeetingPointsOverview();
@ -267,118 +311,205 @@ export default function MeetingPointsManagement() {
}
};
const columns = useMemo(
() => [
{
title: "用户",
dataIndex: "ownerUserName",
key: "ownerUserName",
width: 140,
render: (value: string) => <Text strong>{value || "-"}</Text>,
},
{
title: "扣费账户",
dataIndex: "chargeAccountType",
key: "chargeAccountType",
width: 120,
render: (value: string) => <Tag>{getAccountTypeLabel(value)}</Tag>,
},
{
title: "消耗类型",
dataIndex: "pointsType",
key: "pointsType",
width: 100,
render: (value: string) => <Tag color={getPointsTypeColor(value)}>{getPointsTypeLabel(value)}</Tag>,
},
{
title: "消耗积分",
dataIndex: "consumedPoints",
key: "consumedPoints",
width: 110,
render: (value: number) => <Text>{value ?? 0}</Text>,
},
{
title: "会议标题",
dataIndex: "meetingTitle",
key: "meetingTitle",
ellipsis: true,
render: (value: string) => <Text>{value || "-"}</Text>,
},
{
title: "触发类型",
dataIndex: "chargeTriggerType",
key: "chargeTriggerType",
width: 130,
render: (value: string) => <Tag>{getChargeTriggerLabel(value)}</Tag>,
},
{
title: "消耗时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
render: (value: string) => <Text>{formatDateTime(value)}</Text>,
},
{
title: "操作",
key: "action",
width: 88,
fixed: "right" as const,
render: (_: unknown, record: MeetingPointsLedgerListItemVO) => (
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => void handleOpenDetail(record.id)}>
</Button>
),
},
],
[],
const ledgerColumns = [
{
title: "用户",
dataIndex: "ownerUserName",
key: "ownerUserName",
width: 140,
render: (value: string) => <Text strong>{value || "-"}</Text>,
},
{
title: "扣费账户",
dataIndex: "chargeAccountType",
key: "chargeAccountType",
width: 120,
render: (value: string) => <Tag>{getAccountTypeLabel(value)}</Tag>,
},
{
title: "消耗类型",
dataIndex: "pointsType",
key: "pointsType",
width: 100,
render: (value: string) => <Tag color={getPointsTypeColor(value)}>{getPointsTypeLabel(value)}</Tag>,
},
{
title: "消耗积分",
dataIndex: "consumedPoints",
key: "consumedPoints",
width: 110,
render: (value: number) => <Text>{value ?? 0}</Text>,
},
{
title: "会议标题",
dataIndex: "meetingTitle",
key: "meetingTitle",
ellipsis: true,
render: (value: string) => <Text>{value || "-"}</Text>,
},
{
title: "触发类型",
dataIndex: "chargeTriggerType",
key: "chargeTriggerType",
width: 130,
render: (value: string) => <Tag>{getChargeTriggerLabel(value)}</Tag>,
},
{
title: "消耗时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
render: (value: string) => <Text>{formatDateTime(value)}</Text>,
},
{
title: "操作",
key: "action",
width: 88,
fixed: "right" as const,
render: (_: unknown, record: MeetingPointsLedgerListItemVO) => (
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => void handleOpenDetail(record.id)}>
</Button>
),
},
];
const personalAccountColumns = [
{
title: "序号",
key: "index",
width: 80,
render: (_: unknown, __: MeetingPointsPersonalAccountVO, index: number) =>
(personalAccountPagination.current - 1) * personalAccountPagination.pageSize + index + 1,
},
{
title: "账户",
key: "displayName",
width: 260,
render: (_: unknown, record: MeetingPointsPersonalAccountVO) => (
<Space direction="vertical" size={2}>
<Text strong>{record.displayName || record.username || `用户 #${record.userId}`}</Text>
<Text type="secondary">{record.username ? `@${record.username}` : `ID ${record.userId}`}</Text>
</Space>
),
},
{
title: "当前余额",
dataIndex: "currentBalance",
key: "currentBalance",
width: 140,
render: (value: number) => <Text strong>{value ?? 0}</Text>,
},
{
title: "累计消耗",
dataIndex: "totalPointsUsed",
key: "totalPointsUsed",
width: 140,
render: (value: number) => <Text>{value ?? 0}</Text>,
},
{
title: "账户类型",
key: "accountType",
width: 120,
render: () => <Tag color="purple"></Tag>,
},
];
const chargeItemColumns = [
{
title: "阶段",
dataIndex: "chargeStage",
key: "chargeStage",
render: (value: string) => <Tag color={getPointsTypeColor(value)}>{getPointsTypeLabel(value)}</Tag>,
},
{
title: "扣费账户",
dataIndex: "accountType",
key: "accountType",
render: (value: string) => getAccountTypeLabel(value),
},
{
title: "账户用户ID",
dataIndex: "accountUserId",
key: "accountUserId",
},
{
title: "扣费积分",
dataIndex: "chargedPoints",
key: "chargedPoints",
},
{
title: "扣费前余额",
dataIndex: "balanceBefore",
key: "balanceBefore",
},
{
title: "扣费后余额",
dataIndex: "balanceAfter",
key: "balanceAfter",
},
];
const ledgerTableContent = (
<div style={{ display: "flex", flexDirection: "column" , height:"400px"}}>
<div className="app-page__table-wrap" style={{ overflow: "auto", padding: "0 24px" }}>
<ListTable<MeetingPointsLedgerListItemVO>
rowKey="id"
columns={ledgerColumns}
dataSource={records}
loading={loading}
totalCount={total}
scroll={{ x: 1200,y: 400 }}
pagination={false}
/>
</div>
<div style={{ padding: "16px 24px" }}>
<AppPagination
current={params.current}
pageSize={params.size}
total={total}
onChange={(page, pageSize) => {
const nextParams = { ...params, current: page, size: pageSize };
setParams(nextParams);
void loadPage(nextParams);
}}
/>
</div>
</div>
);
const chargeItemColumns = useMemo(
() => [
{
title: "阶段",
dataIndex: "chargeStage",
key: "chargeStage",
render: (value: string) => <Tag color={getPointsTypeColor(value)}>{getPointsTypeLabel(value)}</Tag>,
},
{
title: "扣费账户",
dataIndex: "accountType",
key: "accountType",
render: (value: string) => getAccountTypeLabel(value),
},
{
title: "账户用户ID",
dataIndex: "accountUserId",
key: "accountUserId",
},
{
title: "扣费积分",
dataIndex: "chargedPoints",
key: "chargedPoints",
},
{
title: "扣费前余额",
dataIndex: "balanceBefore",
key: "balanceBefore",
},
{
title: "扣费后余额",
dataIndex: "balanceAfter",
key: "balanceAfter",
},
],
[],
const personalAccountTableContent = (
<div style={{ display: "flex", flexDirection: "column",height:'400px' }}>
<div className="app-page__table-wrap" style={{ overflow: "auto", padding: "0 24px" }}>
<ListTable<MeetingPointsPersonalAccountVO>
rowKey="userId"
columns={personalAccountColumns}
dataSource={pagedPersonalAccounts}
totalCount={personalAccountRows.length}
scroll={{ x: 1200,y: 400 }}
pagination={false}
/>
</div>
<div style={{ padding: "16px 24px" }}>
<AppPagination
current={personalAccountPagination.current}
pageSize={personalAccountPagination.pageSize}
total={personalAccountRows.length}
onChange={(page, pageSize) => {
setPersonalAccountPagination({ current: page, pageSize });
}}
/>
</div>
</div>
);
return (
<PageContainer
title="积分管理"
subtitle="根据当前扣费模式查看公共账户、个人账户和会议积分消耗轨迹"
style={{ height: "auto" }}
headerExtra={
<Space>
<Tag color="processing">{getAccountModeLabel(overview?.accountMode)}</Tag>
<Tag color="blue">{getChargePriorityLabel(overview?.chargePriority)}</Tag>
{showTransferButton ? (
<Button icon={<PlusOutlined />} onClick={() => void handleOpenTransfer()}>
@ -412,204 +543,92 @@ export default function MeetingPointsManagement() {
</Space>
}
>
<Card
bordered={false}
style={{
marginBottom: 16,
borderRadius: 24,
background:
"linear-gradient(180deg, rgba(248,250,252,0.96) 0%, rgba(255,255,255,0.98) 100%)",
boxShadow: "0 18px 40px rgba(15, 23, 42, 0.06)",
}}
>
<Space direction="vertical" size={20} style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, flexWrap: "wrap" }}>
<div>
<Text type="secondary" style={{ letterSpacing: 1.4 }}>
POINTS OPERATIONS BOARD
</Text>
<Title level={4} style={{ margin: "8px 0 0" }}>
{isPublicOnly
? "当前为公共账户结算视图"
: isPersonalOnly
? "当前为个人账户结算视图"
: "当前为公共与个人混合结算视图"}
</Title>
</div>
<div style={{ textAlign: "right" }}>
<Text type="secondary"></Text>
<div style={{ marginTop: 6 }}>
<Tag color={isAdmin ? "gold" : "default"}>{isAdmin ? "管理员视角" : "当前用户视角"}</Tag>
</div>
</div>
</div>
<Row gutter={[16, 16]}>
{summaryCards.map((item) => (
<Col xs={24} md={12} xl={24 / Math.min(summaryCards.length, 5)} key={item.key}>
<Card
size="small"
style={{
borderRadius: 20,
border: "1px solid rgba(15, 23, 42, 0.06)",
background: item.accent,
minHeight: 142,
}}
styles={{ body: { padding: 18 } }}
>
<Space direction="vertical" size={10} style={{ width: "100%" }}>
<Text type="secondary">{item.title}</Text>
<Statistic value={item.value} valueStyle={{ fontSize: 30, fontWeight: 700, color: "#111827" }} />
<Text type="secondary" style={{ fontSize: 12 }}>
{item.note}
</Text>
</Space>
</Card>
</Col>
))}
</Row>
</Space>
</Card>
{showPersonalGallery ? (
<Card
bordered={false}
<div style={{ marginBottom: 20, padding: "0 4px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 16,
flexWrap: "wrap",
marginBottom: 16,
borderRadius: 24,
background: "linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(245,247,250,0.96) 100%)",
boxShadow: "0 16px 34px rgba(15, 23, 42, 0.05)",
}}
>
<Space direction="vertical" size={18} style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 16, flexWrap: "wrap" }}>
<div>
<Title level={5} style={{ margin: 0 }}>
</Title>
<Text type="secondary">
</Text>
</div>
<Tag color="purple">{overview?.personalAccounts?.length ?? 0} </Tag>
</div>
{overview?.personalAccounts?.length ? (
<Row gutter={[16, 16]}>
{overview.personalAccounts.map((account: MeetingPointsPersonalAccountVO, index) => (
<Col xs={24} sm={12} xl={8} xxl={6} key={account.userId}>
<Card
size="small"
style={{
borderRadius: 20,
border: "1px solid rgba(124, 58, 237, 0.08)",
background:
index < 3
? "linear-gradient(145deg, rgba(255,255,255,1) 0%, rgba(245,241,255,0.98) 100%)"
: "linear-gradient(145deg, rgba(255,255,255,1) 0%, rgba(249,250,251,0.98) 100%)",
minHeight: 158,
}}
styles={{ body: { padding: 18 } }}
>
<Space direction="vertical" size={14} style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
<Space size={12} align="start">
<div
style={{
width: 38,
height: 38,
borderRadius: 14,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(124, 58, 237, 0.10)",
color: "#7c3aed",
flexShrink: 0,
}}
>
<UserOutlined />
</div>
<div>
<Text strong style={{ display: "block" }}>
{account.displayName || account.username || `用户 #${account.userId}`}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{account.username ? `@${account.username}` : `ID ${account.userId}`}
</Text>
</div>
</Space>
{index < 3 ? <Tag color="gold">TOP {index + 1}</Tag> : null}
</div>
<div>
<Text type="secondary"></Text>
<div style={{ fontSize: 30, fontWeight: 700, lineHeight: 1.2, color: "#111827" }}>
{account.currentBalance ?? 0}
</div>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 10,
padding: 12,
borderRadius: 16,
background: "rgba(15, 23, 42, 0.03)",
}}
>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<div style={{ fontWeight: 600 }}>{account.totalPointsUsed ?? 0}</div>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<div style={{ fontWeight: 600 }}></div>
</div>
</div>
</Space>
</Card>
</Col>
))}
</Row>
) : (
<Empty description="当前没有可展示的个人账户数据" />
)}
<Text strong style={{ fontSize: 16 }}>
</Text>
<Space wrap>
<Tag color="processing" bordered={false}>
{getAccountModeLabel(overview?.accountMode)}
</Tag>
<Tag color="blue" bordered={false}>
{getChargePriorityLabel(overview?.chargePriority)}
</Tag>
<Tag color={isAdmin ? "gold" : "default"} bordered={false}>
{isAdmin ? "管理员视角" : "当前用户视角"}
</Tag>
</Space>
</Card>
) : null}
<Card
className="app-page__content-card"
style={{ flex: 1, minHeight: 0 }}
styles={{ body: { padding: 0, display: "flex", flexDirection: "column", minHeight: 0 } }}
>
<div className="app-page__table-wrap" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "0 24px" }}>
<ListTable
rowKey="id"
columns={columns as never}
dataSource={records}
loading={loading}
totalCount={total}
scroll={{ y: "calc(100vh - 510px)", x: 1200 }}
pagination={false}
/>
</div>
<AppPagination
current={params.current}
pageSize={params.size}
total={total}
onChange={(page, pageSize) => {
const nextParams = { ...params, current: page, size: pageSize };
setParams(nextParams);
void loadPage(nextParams);
}}
/>
</Card>
<Row gutter={[40, 16]}>
{summaryCards.map((item) => (
<Col key={item.key}>
<Statistic
title={<span style={{ color: "rgba(0,0,0,0.45)", fontSize: 12 }}>{item.title}</span>}
value={item.value}
valueStyle={{ fontSize: 24, fontWeight: 700 }}
/>
<div style={{ fontSize: 11, color: "rgba(0,0,0,0.45)", marginTop: 2 }}>{item.note}</div>
</Col>
))}
</Row>
</div>
{isPersonalOnly ? (
<Card className="app-page__content-card" styles={{ body: { padding: 0 } }}>
<Tabs
activeKey={contentTab}
onChange={setContentTab}
items={[
{
key: "ledger",
label: "积分明细",
children: ledgerTableContent,
},
{
key: "personal-account",
label: "个人账户",
children: personalAccountTableContent,
},
]}
/>
</Card>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Card className="app-page__content-card" styles={{ body: { padding: 0 } }}>
{ledgerTableContent}
</Card>
{showPersonalAccountSection ? (
<Card className="app-page__content-card" styles={{ body: { padding: 0 } }}>
<div style={{ padding: "20px 24px 8px" }}>
<Text strong style={{ fontSize: 16 }}>
</Text>
<div style={{ marginTop: 4 }}>
<Text type="secondary">
{isAdmin ? "按当前余额展示全部个人账户。" : "展示当前账号的个人积分账户。"}
</Text>
</div>
</div>
{personalAccountTableContent}
</Card>
) : null}
</div>
)}
<Modal
title="积分流水详情"
@ -655,7 +674,7 @@ export default function MeetingPointsManagement() {
rowKey="id"
size="small"
pagination={false}
columns={chargeItemColumns as never}
columns={chargeItemColumns}
dataSource={detail.chargeItems || []}
/>
</Card>