feat: 添加租户积分校验设置页面和优化 Android 上传服务

- 新增 `TenantMeetingPointsSettings` 页面,用于管理租户积分校验设置
- 优化 `AndroidChunkUploadServiceImpl`,新增 `normalizeChunkSourceFileName` 方法以处理文件名规范化
dev_na
chenhao 2026-06-11 17:22:57 +08:00
parent 6e4d10427a
commit 146b31b809
2 changed files with 358 additions and 3 deletions

View File

@ -213,7 +213,7 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
}
private String buildStoredChunkFileName(Integer chunkIndex, String originalFileName) {
String normalizedSourceName = originalFileName == null ? "" : Paths.get(originalFileName.trim()).getFileName().toString();
String normalizedSourceName = normalizeChunkSourceFileName(originalFileName);
int extensionIndex = normalizedSourceName.lastIndexOf('.');
String extension = extensionIndex >= 0 ? normalizedSourceName.substring(extensionIndex) : "";
String safeExtension = extension.isBlank() ? ".bin" : extension;
@ -222,7 +222,7 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
private String buildMergedOriginalFilename(AndroidChunkUploadSessionState state, Path mergedFile) {
if (state.getFileName() != null && !state.getFileName().isBlank()) {
String normalizedSourceName = Paths.get(state.getFileName().trim()).getFileName().toString();
String normalizedSourceName = normalizeChunkSourceFileName(state.getFileName());
int extensionIndex = normalizedSourceName.lastIndexOf('.');
if (extensionIndex >= 0) {
return "merged" + normalizedSourceName.substring(extensionIndex);
@ -236,11 +236,22 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
if (chunkPath == null || chunkPath.getFileName() == null) {
return ".bin";
}
String fileName = chunkPath.getFileName().toString();
String fileName = normalizeChunkSourceFileName(chunkPath.getFileName().toString());
int extensionIndex = fileName.lastIndexOf('.');
return extensionIndex >= 0 ? fileName.substring(extensionIndex) : ".bin";
}
private String normalizeChunkSourceFileName(String fileName) {
if (fileName == null) {
return "";
}
String normalized = Paths.get(fileName.trim()).getFileName().toString();
if (normalized.endsWith(".pending")) {
return normalized.substring(0, normalized.length() - ".pending".length());
}
return normalized;
}
private void writeConcatListFile(Path concatList, List<Path> chunkPaths) throws IOException {
List<String> lines = new ArrayList<>(chunkPaths.size());
for (Path chunkPath : chunkPaths) {

View File

@ -0,0 +1,344 @@
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { getCurrentUser } from "@/api";
import AppPagination from "@/components/shared/AppPagination";
import ListTable from "@/components/shared/ListTable/ListTable";
import PageContainer from "@/components/shared/PageContainer";
import {
getCurrentTenantMeetingPointsSetting,
pageTenantMeetingPointsSettings,
type TenantMeetingPointsSettingVO,
updateTenantMeetingPointsBalanceCheck,
} from "@/api/business/meetingPoints";
import type { UserProfile } from "@/types";
import { Button, Card, Input, message, Modal, Select, Space, Statistic, Tag, Typography } from "antd";
import { useEffect, useState } from "react";
const { Text } = Typography;
function formatDateTime(value?: string) {
return value ? value.replace("T", " ").substring(0, 19) : "-";
}
function renderStatusTag(enabled?: boolean) {
return enabled === false
? <Tag color="volcano"></Tag>
: <Tag color="green"></Tag>;
}
export default function TenantMeetingPointsSettings() {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(false);
const [switchingTenantId, setSwitchingTenantId] = useState<number | null>(null);
const [records, setRecords] = useState<TenantMeetingPointsSettingVO[]>([]);
const [total, setTotal] = useState(0);
const [currentTenantSetting, setCurrentTenantSetting] = useState<TenantMeetingPointsSettingVO | null>(null);
const [params, setParams] = useState({
current: 1,
size: 20,
tenantName: "",
tenantCode: "",
balanceCheckEnabled: "",
});
const isPlatformAdmin = Boolean(profile?.isPlatformAdmin);
const isTenantAdmin = Boolean(profile?.isTenantAdmin);
const loadPlatformPage = async (nextParams = params) => {
setLoading(true);
try {
const result = await pageTenantMeetingPointsSettings({
current: nextParams.current,
size: nextParams.size,
tenantName: nextParams.tenantName || undefined,
tenantCode: nextParams.tenantCode || undefined,
balanceCheckEnabled: nextParams.balanceCheckEnabled === "" ? undefined : nextParams.balanceCheckEnabled === "true",
});
setRecords(result.records || []);
setTotal(result.total || 0);
} finally {
setLoading(false);
}
};
const loadCurrentTenant = async () => {
setLoading(true);
try {
const data = await getCurrentTenantMeetingPointsSetting();
setCurrentTenantSetting(data);
} finally {
setLoading(false);
}
};
const loadData = async () => {
const nextProfile = await getCurrentUser();
setProfile(nextProfile);
if (nextProfile.isPlatformAdmin) {
await loadPlatformPage();
return;
}
if (nextProfile.isTenantAdmin) {
await loadCurrentTenant();
return;
}
setCurrentTenantSetting(null);
setRecords([]);
setTotal(0);
};
useEffect(() => {
void loadData();
}, []);
const handleRefresh = async () => {
if (isPlatformAdmin) {
await loadPlatformPage();
} else if (isTenantAdmin) {
await loadCurrentTenant();
}
message.success("已刷新租户积分校验配置");
};
const confirmSwitch = (record: TenantMeetingPointsSettingVO, nextEnabled: boolean) => {
Modal.confirm({
title: nextEnabled ? "开启余额校验" : "关闭余额校验",
content: nextEnabled
? "开启后将按当前账面余额重新执行后续会议提交校验。"
: "关闭后将进入无限余额模式,只记录消耗和流水,不扣减账面余额。",
okText: "确认",
cancelText: "取消",
onOk: async () => {
setSwitchingTenantId(record.tenantId);
try {
await updateTenantMeetingPointsBalanceCheck(record.tenantId, { balanceCheckEnabled: nextEnabled });
message.success("余额校验开关已更新");
if (isPlatformAdmin) {
await loadPlatformPage();
} else {
await loadCurrentTenant();
}
} finally {
setSwitchingTenantId(null);
}
},
});
};
const columns = [
{
title: "租户名称",
dataIndex: "tenantName",
key: "tenantName",
width: 180,
render: (value: string) => <Text strong>{value || "-"}</Text>,
},
{
title: "租户编码",
dataIndex: "tenantCode",
key: "tenantCode",
width: 160,
render: (value: string) => <Text>{value || "-"}</Text>,
},
{
title: "余额校验状态",
dataIndex: "balanceCheckEnabled",
key: "balanceCheckEnabled",
width: 160,
render: (value: boolean) => renderStatusTag(value),
},
{
title: "公共账户余额",
dataIndex: "publicBalance",
key: "publicBalance",
width: 140,
render: (value: number) => value ?? 0,
},
{
title: "公共账户累计消耗",
dataIndex: "publicTotalPointsUsed",
key: "publicTotalPointsUsed",
width: 160,
render: (value: number) => value ?? 0,
},
{
title: "最近切换时间",
dataIndex: "lastSwitchAt",
key: "lastSwitchAt",
width: 180,
render: (value: string) => formatDateTime(value),
},
{
title: "最近切换人",
dataIndex: "lastSwitchByName",
key: "lastSwitchByName",
width: 160,
render: (value: string) => value || "-",
},
{
title: "操作",
key: "action",
width: 140,
fixed: "right" as const,
render: (_: unknown, record: TenantMeetingPointsSettingVO) => (
<Button
type="link"
loading={switchingTenantId === record.tenantId}
onClick={() => confirmSwitch(record, !record.balanceCheckEnabled)}
>
{record.balanceCheckEnabled ? "切换为无限余额" : "开启余额校验"}
</Button>
),
},
];
const renderTenantAdminCard = () => {
if (!currentTenantSetting) {
return null;
}
return (
<Card>
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
<Space direction="vertical" size={4}>
<Text strong style={{ fontSize: 18 }}>{currentTenantSetting.tenantName || "当前租户"}</Text>
<Text type="secondary">{currentTenantSetting.tenantCode || "-"}</Text>
</Space>
{renderStatusTag(currentTenantSetting.balanceCheckEnabled)}
</div>
<Card size="small" style={{ background: currentTenantSetting.balanceCheckEnabled ? "#f6ffed" : "#fff7e6" }}>
<Space direction="vertical" size={4}>
<Text strong>
{currentTenantSetting.balanceCheckEnabled ? "当前为校验余额模式" : "当前为无限余额模式"}
</Text>
<Text type="secondary">
{currentTenantSetting.balanceCheckEnabled
? "后续会议提交将按当前账面余额执行拦截与扣减。"
: "后续会议只记录消耗与流水,不扣减账面余额,积分分配也会被禁用。"}
</Text>
</Space>
</Card>
<Space size={40} wrap>
<Statistic title="当前可用额度" value={currentTenantSetting.balanceCheckEnabled ? currentTenantSetting.publicBalance ?? 0 : "无限"} />
<Statistic title="公共账户余额" value={currentTenantSetting.publicBalance ?? 0} />
<Statistic title="公共账户累计消耗" value={currentTenantSetting.publicTotalPointsUsed ?? 0} />
</Space>
<Space direction="vertical" size={4}>
<Text type="secondary">{formatDateTime(currentTenantSetting.lastSwitchAt)}</Text>
<Text type="secondary">{currentTenantSetting.lastSwitchByName || "-"}</Text>
</Space>
<Space>
<Button
type="primary"
loading={switchingTenantId === currentTenantSetting.tenantId}
onClick={() => confirmSwitch(currentTenantSetting, !currentTenantSetting.balanceCheckEnabled)}
>
{currentTenantSetting.balanceCheckEnabled ? "切换为无限余额" : "开启余额校验"}
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void handleRefresh()}>
</Button>
</Space>
</Space>
</Card>
);
};
return (
<PageContainer
title="租户积分校验"
subtitle="按租户控制会议积分是否校验余额,关闭后按无限余额模式记录消耗"
headerExtra={
<Button icon={<ReloadOutlined />} onClick={() => void handleRefresh()}>
</Button>
}
toolbar={isPlatformAdmin ? (
<Space wrap size="middle">
<Input
placeholder="按租户名称搜索"
value={params.tenantName}
onChange={(event) => setParams((prev) => ({ ...prev, tenantName: event.target.value }))}
style={{ width: 220 }}
prefix={<SearchOutlined className="text-gray-400" />}
allowClear
/>
<Input
placeholder="按租户编码搜索"
value={params.tenantCode}
onChange={(event) => setParams((prev) => ({ ...prev, tenantCode: event.target.value }))}
style={{ width: 180 }}
allowClear
/>
<Select
style={{ width: 180 }}
value={params.balanceCheckEnabled}
onChange={(value) => setParams((prev) => ({ ...prev, balanceCheckEnabled: value }))}
options={[
{ label: "全部状态", value: "" },
{ label: "校验余额模式", value: "true" },
{ label: "无限余额模式", value: "false" },
]}
/>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={() => {
const nextParams = { ...params, current: 1 };
setParams(nextParams);
void loadPlatformPage(nextParams);
}}
>
</Button>
<Button
onClick={() => {
const nextParams = { current: 1, size: 20, tenantName: "", tenantCode: "", balanceCheckEnabled: "" };
setParams(nextParams);
void loadPlatformPage(nextParams);
}}
>
</Button>
</Space>
) : undefined}
>
{isPlatformAdmin ? (
<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"></Text>
</div>
</div>
<div className="app-page__table-wrap" style={{ overflow: "auto", padding: "0 24px" }}>
<ListTable<TenantMeetingPointsSettingVO>
rowKey="tenantId"
columns={columns}
dataSource={records}
loading={loading}
totalCount={total}
scroll={{ x: 1200, y: 500 }}
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 loadPlatformPage(nextParams);
}}
/>
</div>
</Card>
) : renderTenantAdminCard()}
</PageContainer>
);
}