feat(task1): restructure directories for turborepo monorepo

- Move backend/ to apps/server/ via git mv
- Move frontend/ to apps/admin/ via git mv
- Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
2026-07-02 15:05:12 +08:00
parent 4704adcba1
commit 46a817503e
137 changed files with 52 additions and 0 deletions

View File

@@ -0,0 +1,306 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, DatePicker, Space, message, Tag, Descriptions, Popconfirm, Input, Select, Tooltip } from 'antd';
import { FileTextOutlined, DeleteOutlined, DownloadOutlined, FilePdfOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const { RangePicker } = DatePicker;
const statusMap: Record<string, { text: string; color: string }> = {
draft: { text: '草稿', color: 'default' },
confirmed: { text: '已确认', color: 'blue' },
paid: { text: '已支付', color: 'green' },
};
const typeMap: Record<string, string> = { water: '水费', electricity: '电费', cleaning: '保洁费', damage: '损坏赔偿', penalty: '罚款', other: '其他' };
const BillsPage: React.FC = () => {
const [bills, setBills] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [generateModal, setGenerateModal] = useState(false);
const [detailModal, setDetailModal] = useState<any>(null);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [generateForm] = Form.useForm();
const fetchData = async () => {
setLoading(true);
try {
const res: any = await api.get('/bills');
setBills(res);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const filteredBills = useMemo(() => {
return bills.filter((b: any) => {
if (searchText) {
const s = searchText.toLowerCase();
const matchName = b.student?.name?.toLowerCase().includes(s);
const matchPeriod = `${b.periodStart} ~ ${b.periodEnd}`.includes(s);
if (!matchName && !matchPeriod) return false;
}
if (filterStatus && b.status !== filterStatus) return false;
return true;
});
}, [bills, searchText, filterStatus]);
const handleGenerate = async () => {
const values = await generateForm.validateFields();
try {
const res: any = await api.post('/bills/generate', {
periodStart: values.period[0].format('YYYY-MM-DD'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
});
message.success(res.message || '生成成功');
setGenerateModal(false);
generateForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '生成失败'); }
};
const showDetail = async (id: number) => {
try {
const res = await api.get(`/bills/${id}`);
setDetailModal(res);
} catch (e) { console.error(e); }
};
const updateStatus = async (id: number, status: string) => {
try {
await api.put(`/bills/${id}/status`, { status });
message.success('状态更新成功');
fetchData();
if (detailModal?.id === id) {
setDetailModal({ ...detailModal, status });
}
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const batchUpdateStatus = async (status: string) => {
if (selectedRows.length === 0) return message.warning('请先选择账单');
try {
await api.put('/bills/batch/status', { ids: selectedRows, status });
message.success(`已批量更新 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/bills/${id}`);
message.success('账单已删除');
fetchData();
} catch (e: any) { message.error(e?.message || '删除失败'); }
};
const batchDelete = async () => {
if (selectedRows.length === 0) return message.warning('请先选择账单');
try {
await api.post('/bills/batch/delete', { ids: selectedRows });
message.success(`已删除 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const handleExportExcel = () => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const url = `${baseURL}/bills/export/excel`;
const a = document.createElement('a');
// 使用 fetch 来携带 token
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
const blobUrl = URL.createObjectURL(blob);
a.href = blobUrl;
a.download = `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
a.click();
URL.revokeObjectURL(blobUrl);
message.success('Excel 导出成功');
})
.catch(() => message.error('导出失败'));
};
const handleExportPdf = (billId: number) => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/bills/export/pdf/${billId}`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `账单_${billId}.pdf`;
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
};
const columns = [
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
{ title: '账单周期', render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` },
{ title: '分摊费用', dataIndex: 'sharedAmount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '个人费用', dataIndex: 'personalAmount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '总计', dataIndex: 'totalAmount', render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong> },
{
title: '可用押金',
dataIndex: 'availableDeposit',
render: (v: number) => v > 0
? <span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
: <span style={{ color: '#999' }}>-</span>,
},
{
title: '抵扣后应付',
dataIndex: 'amountAfterDeposit',
render: (v: number, r: any) => {
const has = Number(r.availableDeposit || 0) > 0;
if (!has) return <span style={{ color: '#999' }}>-</span>;
const after = Number(v ?? r.totalAmount).toFixed(2);
const applied = Number(r.depositApplied || 0).toFixed(2);
return (
<Tooltip title={`已抵扣押金 ¥${applied}`}>
<strong style={{ color: '#fa541c' }}>¥{after}</strong>
</Tooltip>
);
},
},
{
title: '状态', dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text}</Tag>,
},
{ title: '生成时间', dataIndex: 'generatedAt', render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm') },
{
title: '操作', width: 320,
render: (_: any, record: any) => (
<Space>
<PermissionButton permission="bill:view" size="small" type="link" onClick={() => showDetail(record.id)}></PermissionButton>
{record.status === 'draft' && <PermissionButton permission="bill:confirm" size="small" onClick={() => updateStatus(record.id, 'confirmed')}></PermissionButton>}
{record.status === 'confirmed' && <PermissionButton permission="bill:confirm" size="small" type="primary" onClick={() => updateStatus(record.id, 'paid')}></PermissionButton>}
<PermissionButton permission="bill:export-pdf" size="small" icon={<FilePdfOutlined />} onClick={() => handleExportPdf(record.id)}>PDF</PermissionButton>
<PermissionButton permission="bill:delete">
<Popconfirm title="确定删除此账单?" onConfirm={() => handleDelete(record.id)} okText="删除" cancelText="取消">
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</PermissionButton>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名或账单周期"
allowClear
style={{ width: 220 }}
onSearch={v => setSearchText(v)}
onChange={e => { if (!e.target.value) setSearchText(''); }}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={v => setFilterStatus(v)}
options={[
{ value: 'draft', label: '草稿' },
{ value: 'confirmed', label: '已确认' },
{ value: 'paid', label: '已支付' },
]}
/>
<PermissionButton permission="bill:confirm" onClick={() => batchUpdateStatus('confirmed')} disabled={selectedRows.length === 0}></PermissionButton>
<PermissionButton permission="bill:confirm" type="primary" onClick={() => batchUpdateStatus('paid')} disabled={selectedRows.length === 0}></PermissionButton>
<PermissionButton permission="bill:delete">
<Popconfirm title={`确定删除选中的 ${selectedRows.length} 条账单?`} onConfirm={batchDelete} okText="删除" cancelText="取消" disabled={selectedRows.length === 0}>
<Button danger disabled={selectedRows.length === 0} icon={<DeleteOutlined />}></Button>
</Popconfirm>
</PermissionButton>
</Space>
<Space>
<PermissionButton permission="bill:generate" type="primary" icon={<FileTextOutlined />} onClick={() => { generateForm.resetFields(); setGenerateModal(true); }}>
</PermissionButton>
<PermissionButton permission="bill:export-excel" icon={<DownloadOutlined />} onClick={handleExportExcel}>Excel</PermissionButton>
</Space>
</div>
<Table
columns={columns}
dataSource={filteredBills}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={{
selectedRowKeys: selectedRows,
onChange: (keys) => setSelectedRows(keys as number[]),
}}
/>
<Modal title="生成账单" open={generateModal} onOk={handleGenerate} onCancel={() => setGenerateModal(false)} okText="生成">
<Form form={generateForm} layout="vertical">
<Form.Item name="period" label="账单周期" rules={[{ required: true, message: '请选择账单周期' }]} extra="选择费用对应的时间段,系统将自动计算每个学生的分摊费用">
<RangePicker style={{ width: '100%' }} placeholder={['开始日期', '结束日期']} format="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
<Modal
title="账单详情"
open={!!detailModal}
onCancel={() => setDetailModal(null)}
footer={null}
width={800}
>
{detailModal && (
<>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
<Descriptions.Item label="状态"><Tag color={statusMap[detailModal.status]?.color}>{statusMap[detailModal.status]?.text}</Tag></Descriptions.Item>
<Descriptions.Item label="账单周期">{detailModal.periodStart} ~ {detailModal.periodEnd}</Descriptions.Item>
<Descriptions.Item label="生成时间">{dayjs(detailModal.generatedAt).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
<Descriptions.Item label="分摊费用">¥{Number(detailModal.sharedAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="个人费用">¥{Number(detailModal.personalAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="合计" span={2}><strong style={{ fontSize: 18, color: '#007AFF' }}>¥{Number(detailModal.totalAmount).toFixed(2)}</strong></Descriptions.Item>
</Descriptions>
{Number(detailModal.availableDeposit || 0) > 0 && (
<div style={{ marginBottom: 16, padding: 12, background: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: 8 }}>
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}></div>
<Space size={24} wrap>
<span><strong style={{ color: '#52c41a' }}>¥{Number(detailModal.availableDeposit).toFixed(2)}</strong></span>
<span><strong style={{ color: '#fa8c16' }}>-¥{Number(detailModal.depositApplied || 0).toFixed(2)}</strong></span>
<span><strong style={{ color: '#fa541c', fontSize: 16 }}>¥{Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).toFixed(2)}</strong></span>
</Space>
</div>
)}
<h4></h4>
<Table
dataSource={detailModal.items || []}
rowKey="id"
pagination={false}
size="small"
columns={[
{ title: '类型', dataIndex: 'expenseType', render: (v: string) => typeMap[v] || v },
{ title: '说明', dataIndex: 'description' },
{ title: '计费天数', dataIndex: 'days', render: (v: number) => v > 0 ? `${v}` : '-' },
{ title: '宿舍总人天', dataIndex: 'totalRoomDays', render: (v: number) => v > 0 ? `${v}` : '-' },
{ title: '宿舍总费用', dataIndex: 'roomTotalAmount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '应分摊', dataIndex: 'studentAmount', render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong> },
]}
/>
</>
)}
</Modal>
</div>
);
};
export default BillsPage;