diff --git a/apps/admin/src/pages/Wallets/index.tsx b/apps/admin/src/pages/Wallets/index.tsx index 46f926f..9ac926f 100644 --- a/apps/admin/src/pages/Wallets/index.tsx +++ b/apps/admin/src/pages/Wallets/index.tsx @@ -27,7 +27,10 @@ const WalletsPage: React.FC = () => { const [transactions, setTransactions] = useState([]); const [drawerOpen, setDrawerOpen] = useState(false); const [form] = Form.useForm(); + const [batchForm] = Form.useForm(); const [saving, setSaving] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [batchModalOpen, setBatchModalOpen] = useState(false); const fetchRows = useCallback(async () => { setLoading(true); @@ -46,6 +49,11 @@ const WalletsPage: React.FC = () => { form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' }); }; + const openBatchChange = () => { + batchForm.setFieldsValue({ type: 'recharge', amount: undefined, description: '' }); + setBatchModalOpen(true); + }; + const submitChange = async () => { if (!selected) return; const values = await form.validateFields(); @@ -60,6 +68,30 @@ const WalletsPage: React.FC = () => { finally { setSaving(false); } }; + const submitBatchChange = async () => { + const values = await batchForm.validateFields(); + setSaving(true); + try { + const result: any = await api.post('/wallets/batch-change-balance', { + studentIds: selectedRowKeys, + ...values, + }); + const paid = (result.results || []).reduce((sum: number, item: any) => { + return sum + (item.payments || []).reduce((paymentSum: number, bill: any) => paymentSum + Number(bill.paidAmount || 0), 0); + }, 0); + message.success( + paid > 0 + ? `已批量更新 ${selectedRowKeys.length} 名学生余额,并自动补扣历史账单` + : `已批量更新 ${selectedRowKeys.length} 名学生余额`, + ); + setBatchModalOpen(false); + setSelectedRowKeys([]); + batchForm.resetFields(); + await fetchRows(); + } catch (error: any) { message.error(error?.message || '批量余额操作失败'); } + finally { setSaving(false); } + }; + const showTransactions = async (row: WalletRow) => { setSelected(row); setDrawerOpen(true); try { setTransactions(await api.get('/wallets/transactions', { params: { studentId: row.studentId } }) as any[]); } @@ -76,9 +108,19 @@ const WalletsPage: React.FC = () => { return
!event.target.value && setKeyword('')} />仅看欠费 - + + } disabled={selectedRowKeys.length === 0} onClick={openBatchChange}>批量充值/调账 + +
- `共 ${total} 人` }} /> +
`共 ${total} 人` }} + /> setSelected(null)} onOk={submitChange} confirmLoading={saving} okText="确认">
@@ -86,6 +128,27 @@ const WalletsPage: React.FC = () => {
+ setBatchModalOpen(false)} + onOk={submitBatchChange} + confirmLoading={saving} + okText="确认批量修改" + > +
+
+ 已选择 {selectedRowKeys.length} 名学生,将按相同金额批量修改水电费余额。 +
+ + + + + + + + +
{ setDrawerOpen(false); setSelected(null); }}>
dayjs(value).format('YYYY-MM-DD HH:mm') }, diff --git a/apps/server/src/wallets/dto/wallet.dto.ts b/apps/server/src/wallets/dto/wallet.dto.ts index a74b176..f15557c 100644 --- a/apps/server/src/wallets/dto/wallet.dto.ts +++ b/apps/server/src/wallets/dto/wallet.dto.ts @@ -1,4 +1,5 @@ -import { IsIn, IsInt, IsNumber, IsOptional, IsString, MaxLength, NotEquals } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, MaxLength, NotEquals } from 'class-validator'; export class ChangeWalletBalanceDto { @IsInt() @@ -16,3 +17,24 @@ export class ChangeWalletBalanceDto { @MaxLength(300) description?: string; } + + +export class BatchChangeWalletBalanceDto { + @IsArray() + @ArrayNotEmpty() + @IsInt({ each: true }) + @Type(() => Number) + studentIds: number[]; + + @IsNumber({ maxDecimalPlaces: 2 }) + @NotEquals(0) + amount: number; + + @IsIn(['recharge', 'adjustment']) + type: 'recharge' | 'adjustment'; + + @IsOptional() + @IsString() + @MaxLength(300) + description?: string; +} diff --git a/apps/server/src/wallets/wallets.controller.ts b/apps/server/src/wallets/wallets.controller.ts index 6707c52..5e36b24 100644 --- a/apps/server/src/wallets/wallets.controller.ts +++ b/apps/server/src/wallets/wallets.controller.ts @@ -3,7 +3,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; -import { ChangeWalletBalanceDto } from './dto/wallet.dto'; +import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto'; import { WalletsService } from './wallets.service'; @UseGuards(JwtAuthGuard) @@ -41,4 +41,24 @@ export class WalletsController { }); return result; } + + + @Post('batch-change-balance') + @RequirePermission('wallet:edit') + async batchChangeBalance(@Body() dto: BatchChangeWalletBalanceDto, @Request() req: any) { + const result = await this.service.batchChangeBalance(dto, req.user?.id); + const { ipAddress, userAgent } = extractRequestInfo(req); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生余额', + action: dto.type === 'recharge' ? '批量余额充值' : '批量余额调账', + targetId: undefined, + targetType: 'student_wallet', + detail: `学生${result.count}人,金额 ¥${dto.amount}${dto.description ? `,${dto.description}` : ''}`, + ipAddress, + userAgent, + }); + return result; + } } diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts index f9cd709..489c230 100644 --- a/apps/server/src/wallets/wallets.service.ts +++ b/apps/server/src/wallets/wallets.service.ts @@ -6,7 +6,7 @@ import { Student } from '../entities/student.entity'; import { StudentWallet } from '../entities/student-wallet.entity'; import { WalletTransaction } from '../entities/wallet-transaction.entity'; import { In } from 'typeorm'; -import { ChangeWalletBalanceDto } from './dto/wallet.dto'; +import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto'; const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); @@ -92,6 +92,20 @@ export class WalletsService { }); } + async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) { + const uniqueStudentIds = Array.from(new Set(dto.studentIds)); + const results: Awaited>[] = []; + for (const studentId of uniqueStudentIds) { + results.push(await this.changeBalance({ + studentId, + amount: dto.amount, + type: dto.type, + description: dto.description, + }, recordedBy)); + } + return { count: uniqueStudentIds.length, results }; + } + async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) { if (bill.status === 'cancelled') return bill;