feat: refine attendance and finance workflows

This commit is contained in:
2026-07-18 20:46:36 +08:00
parent 7439663302
commit 1201837865
22 changed files with 1460 additions and 554 deletions

View File

@@ -369,6 +369,36 @@
font-size: 20px;
}
.lesson-summary-strip {
grid-template-columns: minmax(210px, 1fr) repeat(2, minmax(120px, 0.7fr));
}
.lesson-summary-strip .attendance-rate,
.lesson-summary-strip .attendance-summary-cell {
justify-content: center;
}
.lesson-summary-strip .attendance-rate {
text-align: left;
}
.attendance-correction-segment.ant-segmented {
padding: 2px;
border: 1px solid var(--student-line, #e1e8e5);
background: #f8faf9;
}
.attendance-correction-segment .ant-segmented-item {
min-width: 52px;
}
.attendance-correction-segment .ant-segmented-item-label {
min-height: 28px;
line-height: 28px;
font-size: 12px;
}
.attendance-summary-icon {
display: grid !important;
flex: 0 0 auto;
@@ -774,9 +804,10 @@
.student-filter-panel {
display: grid;
grid-template-columns: minmax(230px, 1.4fr) repeat(4, minmax(120px, 1fr)) auto;
gap: 12px;
grid-template-columns: 176px 220px 142px 142px 142px auto;
gap: 10px;
align-items: end;
justify-content: start;
min-width: 0;
margin-bottom: 16px;
padding: 16px;
@@ -803,13 +834,54 @@
.student-filter-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
flex-wrap: nowrap;
gap: 8px;
padding-top: 2px;
}
.student-filter-actions .ant-btn {
min-width: 88px;
}
.attendance-period-editor {
display: grid;
gap: 12px;
}
.attendance-period-row {
display: grid;
grid-template-columns: 1.05fr 1fr 132px 132px 92px auto;
gap: 10px;
align-items: start;
padding: 12px;
border: 1px solid var(--student-line);
border-radius: 12px;
background: #fff;
}
.attendance-period-row .ant-form-item {
margin-bottom: 0;
}
.attendance-period-row__delete {
align-self: end;
min-width: 72px;
}
.attendance-period-add {
height: 44px;
border-radius: 12px;
}
.attendance-period-modal .ant-modal-body {
padding-top: 12px;
}
.student-class-overview {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.1fr);
grid-template-columns: minmax(360px, 0.95fr) minmax(360px, 1.05fr);
gap: 16px;
min-width: 0;
margin-bottom: 16px;
@@ -819,9 +891,10 @@
.student-metric-strip,
.student-workspace,
.student-record-card {
border: 1px solid var(--student-line);
border-radius: 10px;
border: 1px solid rgb(21 122 101 / 12%);
border-radius: 18px;
background: var(--student-surface);
box-shadow: 0 14px 36px rgb(24 44 38 / 7%);
}
.student-class-overview > *,
@@ -831,26 +904,66 @@
}
.student-class-identity {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 156px;
padding: 18px;
min-height: 176px;
overflow: hidden;
padding: 20px;
background:
radial-gradient(circle at 100% 0%, rgb(21 122 101 / 10%), transparent 34%),
linear-gradient(135deg, #ffffff 0%, #f8fcfa 100%);
}
.student-class-identity::after {
content: '';
position: absolute;
right: -36px;
bottom: -44px;
width: 118px;
height: 118px;
border: 18px solid rgb(21 122 101 / 7%);
border-radius: 999px;
pointer-events: none;
}
.student-class-heading {
position: relative;
z-index: 1;
}
.student-overview-kicker {
display: inline-flex;
align-items: center;
min-height: 24px;
margin-bottom: 8px;
padding: 0 9px;
border-radius: 999px;
background: rgb(21 122 101 / 10%);
color: var(--student-primary);
font-size: 12px;
font-weight: 700;
}
.student-class-identity h2 {
margin: 0 0 5px;
font-size: 22px;
margin: 0 0 6px;
color: #111c18;
font-size: 24px;
line-height: 1.2;
}
.student-class-identity span {
.student-class-identity p {
margin: 0;
color: var(--student-muted);
font-size: 12px;
font-size: 13px;
}
.student-teacher-list {
display: flex;
flex-wrap: wrap;
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-top: 20px;
}
@@ -858,78 +971,118 @@
.student-teacher-item {
display: flex;
align-items: center;
gap: 9px;
min-width: 150px;
padding: 9px;
border-radius: 8px;
background: var(--student-soft);
gap: 10px;
min-width: 0;
padding: 10px;
border: 1px solid rgb(21 122 101 / 10%);
border-radius: 12px;
background: rgb(255 255 255 / 76%);
}
.student-teacher-item.is-muted {
opacity: 0.72;
}
.student-teacher-item .ant-avatar {
flex: 0 0 auto;
color: var(--student-primary);
background: var(--student-primary-soft);
}
.student-teacher-item > div {
min-width: 0;
}
.student-teacher-item strong,
.student-teacher-item span {
display: block;
}
.student-teacher-item span {
color: var(--student-muted);
font-size: 12px;
}
.student-teacher-item strong {
min-width: 0;
margin-top: 2px;
overflow-wrap: anywhere;
color: #15231f;
font-size: 13px;
line-height: 1.35;
}
.student-metric-strip {
display: grid;
grid-template-columns: repeat(6, minmax(70px, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
min-width: 0;
overflow: hidden;
padding: 10px;
}
.student-metric-card {
appearance: none;
display: grid;
align-content: center;
gap: 6px;
min-height: 156px;
padding: 16px 12px;
border: 0;
border-right: 1px solid var(--student-line);
background: #fff;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
min-height: 76px;
padding: 13px 14px;
border: 1px solid transparent;
border-radius: 14px;
background: #f9fbfa;
color: inherit;
text-align: left;
cursor: pointer;
}
.student-metric-card:last-child {
border-right: 0;
transition:
background 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.student-metric-card:hover,
.student-metric-card.active {
background: var(--student-primary-soft);
transform: translateY(-1px);
border-color: rgb(21 122 101 / 24%);
background: #ffffff;
box-shadow: 0 10px 22px rgb(24 44 38 / 8%);
}
.student-metric-card:focus-visible {
outline: 3px solid rgb(21 122 101 / 22%);
outline-offset: 2px;
}
.student-metric-icon {
display: inline-grid;
width: max-content;
min-width: 34px;
height: 26px;
flex: 0 0 auto;
width: 40px;
height: 40px;
place-items: center;
padding: 0 7px;
border-radius: 999px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
font-weight: 800;
}
.student-metric-copy {
display: grid;
gap: 4px;
min-width: 0;
}
.student-metric-card strong {
font-size: 24px;
overflow: hidden;
color: #111c18;
font-size: clamp(19px, 3vw, 24px);
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.student-metric-card small {
color: var(--student-muted);
font-size: 12px;
}
.student-workspace {
@@ -959,13 +1112,20 @@
.student-workspace-tools {
display: flex;
align-items: center;
flex-wrap: wrap;
flex: 0 0 auto;
flex-wrap: nowrap;
gap: 8px;
min-width: 0;
white-space: nowrap;
}
.student-workspace-tools .ant-input-search {
width: min(240px, 100%);
flex: 0 0 240px;
width: 240px;
}
.student-workspace-tools .ant-btn {
flex: 0 0 auto;
}
.student-legend {
@@ -1014,7 +1174,7 @@
.student-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(184px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(216px, 1fr));
gap: 10px;
}
@@ -1055,7 +1215,12 @@
}
.student-attendance-head small {
flex: 0 0 auto;
max-width: 112px;
overflow: hidden;
color: var(--student-quiet);
text-overflow: ellipsis;
white-space: nowrap;
}
.student-status-blocks {
@@ -1189,10 +1354,11 @@
}
.student-filter-field--date {
grid-column: span 2;
grid-column: auto;
}
.student-filter-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
@@ -1203,11 +1369,20 @@
}
.student-metric-strip {
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.lesson-summary-strip {
grid-template-columns: 1fr;
}
.lesson-summary-strip .attendance-rate,
.lesson-summary-strip .attendance-summary-cell {
justify-content: flex-start;
}
.student-attendance-center {
margin: -12px;
padding: 0 12px 20px;
@@ -1233,6 +1408,10 @@
}
.student-filter-panel,
.attendance-period-row {
grid-template-columns: 1fr;
}
.student-class-overview {
grid-template-columns: 1fr;
}
@@ -1242,12 +1421,21 @@
}
.student-filter-actions > *,
.student-center-actions .ant-btn,
.student-workspace-tools,
.student-workspace-tools .ant-input-search {
.student-center-actions .ant-btn {
width: 100%;
}
.student-workspace-tools {
align-self: stretch;
width: 100%;
}
.student-workspace-tools .ant-input-search {
flex: 1 1 auto;
min-width: 0;
width: auto;
}
.student-filter-actions > * {
flex: 1;
}
@@ -1257,8 +1445,7 @@
}
.student-metric-card {
min-height: 112px;
border-bottom: 1px solid var(--student-line);
min-height: 72px;
}
.student-workspace {
@@ -1269,6 +1456,10 @@
grid-template-columns: 1fr;
}
.student-teacher-list {
grid-template-columns: 1fr;
}
.student-detail-profile {
grid-template-columns: auto minmax(0, 1fr);
}
@@ -1282,3 +1473,19 @@
grid-template-columns: 56px minmax(0, 1fr);
}
}
@media (max-width: 430px) {
.student-metric-strip {
grid-template-columns: 1fr;
}
.student-attendance-head {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.student-attendance-head small {
max-width: 100%;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -591,10 +591,10 @@ const ClassroomRentalsPage: React.FC = () => {
/>
</Form.Item>
<Form.Item name="dailyRate" label="日租金(可选)">
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="totalAmount" label="合同总额(可选)">
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />

View File

@@ -604,7 +604,7 @@ const DepositsPage: React.FC = () => {
/>
</Form.Item>
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
@@ -752,7 +752,7 @@ const DepositsPage: React.FC = () => {
>
<Form form={installmentForm} layout="vertical">
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="dueDate" label="到期日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择到期日期" format="YYYY-MM-DD" />

View File

@@ -684,7 +684,7 @@ const ExpensesPage: React.FC = () => {
<Select options={typeOptions} />
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}>
<RangePicker
@@ -769,7 +769,7 @@ const ExpensesPage: React.FC = () => {
<Select options={personalTypeOptions} />
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="expenseDate" label="费用日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择日期" format="YYYY-MM-DD" />

View File

@@ -74,6 +74,11 @@ interface StudentUpdateImportResult {
skipped?: number;
}
interface StudentFilterLookups {
classes: Array<{ id: number; name: string; code?: string }>;
teachers: Array<{ id: number; name: string; username: string }>;
}
const StudentsPage: React.FC = () => {
const { modal } = App.useApp();
const [data, setData] = useState<any[]>([]);
@@ -84,6 +89,10 @@ const StudentsPage: React.FC = () => {
const [searchName, setSearchName] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterOrganizationId, setFilterOrganizationId] = useState<number | undefined>(undefined);
const [filterClassId, setFilterClassId] = useState<number | undefined>(undefined);
const [filterTeacherId, setFilterTeacherId] = useState<number | undefined>(undefined);
const [classOptions, setClassOptions] = useState<StudentFilterLookups['classes']>([]);
const [teacherOptions, setTeacherOptions] = useState<StudentFilterLookups['teachers']>([]);
const [showArchived, setShowArchived] = useState(false);
const [archivedCount, setArchivedCount] = useState(0);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
@@ -149,6 +158,8 @@ const StudentsPage: React.FC = () => {
};
if (filterStatus) params.status = filterStatus;
if (filterOrganizationId) params.organizationId = filterOrganizationId;
if (filterClassId) params.classId = filterClassId;
if (filterTeacherId) params.teacherId = filterTeacherId;
const res = (await api.get('/students', { params })) as Array<Record<string, unknown>>;
const list = res as Array<Record<string, unknown>>;
const archived = list.filter((r) => r.status === 'archived');
@@ -159,7 +170,7 @@ const StudentsPage: React.FC = () => {
message.error(err?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, [searchName, showArchived, filterStatus, filterOrganizationId]);
}, [searchName, showArchived, filterStatus, filterOrganizationId, filterClassId, filterTeacherId]);
useEffect(() => {
fetchData();
@@ -172,6 +183,13 @@ const StudentsPage: React.FC = () => {
setOrganizations(res as Array<{ id: number; name: string }>);
})
.catch(() => {});
api
.get<StudentFilterLookups>('/students/filter-lookups')
.then((res) => {
setClassOptions(res.classes || []);
setTeacherOptions(res.teachers || []);
})
.catch(() => {});
}, []);
const handleSave = async () => {
const values = await form.validateFields();
@@ -329,8 +347,15 @@ const StudentsPage: React.FC = () => {
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const params = showArchived ? '?includeArchived=true' : '';
fetch(`${baseURL}/students/export${params}`, { headers: { Authorization: `Bearer ${token}` } })
const params = new URLSearchParams();
if (searchName) params.set('name', searchName);
if (filterStatus) params.set('status', filterStatus);
if (filterOrganizationId) params.set('organizationId', String(filterOrganizationId));
if (showArchived) params.set('includeArchived', 'true');
if (filterClassId) params.set('classId', String(filterClassId));
if (filterTeacherId) params.set('teacherId', String(filterTeacherId));
const query = params.toString() ? `?${params.toString()}` : '';
fetch(`${baseURL}/students/export${query}`, { headers: { Authorization: `Bearer ${token}` } })
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
@@ -567,6 +592,36 @@ const StudentsPage: React.FC = () => {
</Select.Option>
))}
</Select>
<Select
placeholder="所属班级"
allowClear
showSearch
optionFilterProp="label"
style={{ width: 160 }}
value={filterClassId}
onChange={(v) => {
setFilterClassId(v);
}}
options={classOptions.map((item) => ({
value: item.id,
label: item.code ? `${item.name}${item.code}` : item.name,
}))}
/>
<Select
placeholder="所属老师"
allowClear
showSearch
optionFilterProp="label"
style={{ width: 160 }}
value={filterTeacherId}
onChange={(v) => {
setFilterTeacherId(v);
}}
options={teacherOptions.map((item) => ({
value: item.id,
label: item.name === item.username ? item.name : `${item.name}${item.username}`,
}))}
/>
<Button
type={showArchived ? 'primary' : 'default'}
onClick={() => setShowArchived(!showArchived)}

View File

@@ -7,6 +7,7 @@ import {
InputNumber,
Modal,
Radio,
Select,
Space,
Switch,
Table,
@@ -25,6 +26,8 @@ interface WalletRow {
studentNo?: string;
balance: number;
outstandingAmount: number;
roomType?: string;
roomNumber?: string;
}
const transactionNames: Record<string, string> = {
@@ -39,6 +42,8 @@ const WalletsPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [debtOnly, setDebtOnly] = useState(false);
const [roomType, setRoomType] = useState<string | undefined>();
const [roomTypes, setRoomTypes] = useState<string[]>([]);
const [selected, setSelected] = useState<WalletRow | null>(null);
const [transactions, setTransactions] = useState<any[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -52,7 +57,7 @@ const WalletsPage: React.FC = () => {
setLoading(true);
try {
const data = await api.get('/wallets', {
params: { keyword: keyword || undefined, debtOnly },
params: { keyword: keyword || undefined, debtOnly, roomType },
});
setRows(data as WalletRow[]);
} catch (error: any) {
@@ -60,12 +65,27 @@ const WalletsPage: React.FC = () => {
} finally {
setLoading(false);
}
}, [keyword, debtOnly]);
}, [keyword, debtOnly, roomType]);
useEffect(() => {
void fetchRows();
}, [fetchRows]);
useEffect(() => {
const fetchRoomTypes = async () => {
try {
setRoomTypes((await api.get('/wallets/room-types')) as string[]);
} catch (error: any) {
message.error(error?.message || '加载房型失败');
}
};
void fetchRoomTypes();
}, []);
useEffect(() => {
setSelectedRowKeys([]);
}, [keyword, debtOnly, roomType]);
const openChange = (row: WalletRow) => {
setSelected(row);
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
@@ -154,6 +174,9 @@ const WalletsPage: React.FC = () => {
<>
<strong>{row.studentName}</strong>
<div style={{ color: '#999' }}>{row.studentNo || `#${row.studentId}`}</div>
<div style={{ color: '#999' }}>
{row.roomType ? `${row.roomType}${row.roomNumber ? ` · ${row.roomNumber}` : ''}` : '未入住'}
</div>
</>
),
},
@@ -218,6 +241,14 @@ const WalletsPage: React.FC = () => {
onSearch={setKeyword}
onChange={(event) => !event.target.value && setKeyword('')}
/>
<Select
allowClear
placeholder="按房型筛选"
style={{ width: 180 }}
value={roomType}
onChange={setRoomType}
options={roomTypes.map((type) => ({ label: type, value: type }))}
/>
<span></span>
<Switch checked={debtOnly} onChange={setDebtOnly} />
</Space>
@@ -285,7 +316,7 @@ const WalletsPage: React.FC = () => {
<Form form={batchForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
<strong>{selectedRowKeys.length}</strong>{' '}
</div>
<Form.Item name="type" label="操作类型" rules={[{ required: true }]}>
<Radio.Group