feat: 义工时长登记页新增批量勾选设置时长功能
Deploy to Server / deploy (push) Successful in 1m37s

- Hours.tsx 参与者列表首列新增复选框:表头全选/半选态(indeterminate)、每行切换选中;选中行整行高亮 primary-50/70
- 工具栏按钮拆分:「全表填时长」原逻辑保留(prompt 全量填);新增「批量设置(选中N)」按钮,勾选 1-N 人后可用
- 批量设置弹窗(max-w-lg):统一小时数必填;备注可勾选同时统一设置后启用;确认后仅覆盖选中人员,顶部显示绿色提示提醒请点击保存登记写入
- 切换活动或重新加载时清空选择集合;批量设置值仅更新前端内存,实际写库仍走原 save→hoursApi.batch
- Icons.tsx 新增 IconBatchSet 图标(勾选列表+批量)
- build 通过
This commit is contained in:
Trae Assistant
2026-10-05 09:22:28 +08:00
parent 30021d7b46
commit 5ffcb533ff
2 changed files with 107 additions and 4 deletions
+6
View File
@@ -122,3 +122,9 @@ export const IconFileImage: React.FC<IconProps> = ({ size, ...rest }) => (
<circle cx="9.5" cy="11.5" r="1.2" /> <circle cx="9.5" cy="11.5" r="1.2" />
</svg> </svg>
); );
export const IconBatchSet: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<path d="M3 6h13v4H3zM20 8h-3M3 14h13v4H3zM20 16h-3" />
</svg>
);
+101 -4
View File
@@ -2,10 +2,11 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import Button from '../../components/Button'; import Button from '../../components/Button';
import Select from '../../components/Select'; import Select from '../../components/Select';
import Input from '../../components/Input'; import Input from '../../components/Input';
import Modal from '../../components/Modal';
import { activitiesApi, hoursApi, errMsg } from '../../api'; import { activitiesApi, hoursApi, errMsg } from '../../api';
import { exportToExcel, exportTableToImage, formatDateRange, utc8DateStamp } from '../../utils/export'; import { exportToExcel, exportTableToImage, formatDateRange, utc8DateStamp } from '../../utils/export';
import type { ActivityItem, HoursRow, Participant, HoursActivityResult } from '../../types'; import type { ActivityItem, HoursRow, Participant, HoursActivityResult } from '../../types';
import { IconClock, IconFileExcel, IconFileImage } from '../../components/Icons'; import { IconClock, IconFileExcel, IconFileImage, IconBatchSet } from '../../components/Icons';
export default function Hours() { export default function Hours() {
const [activities, setActivities] = useState<ActivityItem[]>([]); const [activities, setActivities] = useState<ActivityItem[]>([]);
@@ -18,6 +19,12 @@ export default function Hours() {
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const tableRef = useRef<HTMLDivElement>(null); const tableRef = useRef<HTMLDivElement>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchModalOpen, setBatchModalOpen] = useState(false);
const [batchHours, setBatchHours] = useState<string>('2');
const [batchRemark, setBatchRemark] = useState<string>('');
const [batchApplyRemark, setBatchApplyRemark] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
activitiesApi.list().then(setActivities).catch(e => alert(errMsg(e))); activitiesApi.list().then(setActivities).catch(e => alert(errMsg(e)));
}, []); }, []);
@@ -27,6 +34,7 @@ export default function Hours() {
setMsg(''); setMsg('');
setParticipants([]); setParticipants([]);
setResult(null); setResult(null);
setSelectedIds(new Set());
try { try {
const [p, r] = await Promise.all([activitiesApi.participants(id), hoursApi.byActivity(id)]); const [p, r] = await Promise.all([activitiesApi.participants(id), hoursApi.byActivity(id)]);
setActivityInfo(p.activity); setActivityInfo(p.activity);
@@ -44,6 +52,47 @@ export default function Hours() {
} }
}; };
const selectedCount = selectedIds.size;
const isAllSelected = participants.length > 0 && selectedCount === participants.length;
const isIndeterminate = selectedCount > 0 && selectedCount < participants.length;
const toggleAll = () => {
if (isAllSelected) setSelectedIds(new Set());
else setSelectedIds(new Set(participants.map(p => p.id)));
};
const toggleOne = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const openBatchModal = () => {
if (!selectedId) return;
if (selectedCount === 0) { alert('请先勾选要批量设置时长的部员'); return; }
setBatchHours('2');
setBatchRemark('');
setBatchApplyRemark(false);
setBatchModalOpen(true);
};
const confirmBatchSet = () => {
const h = Number(batchHours);
if (isNaN(h) || h < 0) { alert('请填写有效的时长(小时数)'); return; }
setParticipants(list => list.map(p => {
if (!selectedIds.has(p.id)) return p;
const out: Participant = { ...p, hours: h };
if (batchApplyRemark) out.remark = batchRemark;
return out;
}));
setBatchModalOpen(false);
setMsg(`✓ 已为 ${selectedCount} 名部员设置时长 ${h.toFixed(2)} 小时,请点击「保存登记」写入`);
};
useEffect(() => { useEffect(() => {
if (selectedId) loadParticipants(Number(selectedId)); if (selectedId) loadParticipants(Number(selectedId));
else { setParticipants([]); setActivityInfo(null); setResult(null); } else { setParticipants([]); setActivityInfo(null); setResult(null); }
@@ -127,7 +176,8 @@ export default function Hours() {
</Select> </Select>
</div> </div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<Button variant="secondary" onClick={() => { const v = prompt('统一设置时长(小时)', '2'); if (v !== null) fillAll(v); }} disabled={!selectedId}>批量填时长</Button> <Button variant="secondary" onClick={() => { const v = prompt('统一设置时长(小时)', '2'); if (v !== null) fillAll(v); }} disabled={!selectedId}>全表填时长</Button>
<Button variant="secondary" onClick={openBatchModal} disabled={!selectedId}><span className="inline-flex items-center gap-2"><IconBatchSet size={18} />批量设置({selectedCount > 0 ? `选中${selectedCount}` : '勾选后可用'})</span></Button>
<Button variant="secondary" onClick={doExcelExport} disabled={!selectedId}><span className="inline-flex items-center gap-2"><IconFileExcel size={18} />导出 Excel</span></Button> <Button variant="secondary" onClick={doExcelExport} disabled={!selectedId}><span className="inline-flex items-center gap-2"><IconFileExcel size={18} />导出 Excel</span></Button>
<Button variant="secondary" onClick={doImageExport} disabled={!selectedId}><span className="inline-flex items-center gap-2"><IconFileImage size={18} />导出图片</span></Button> <Button variant="secondary" onClick={doImageExport} disabled={!selectedId}><span className="inline-flex items-center gap-2"><IconFileImage size={18} />导出图片</span></Button>
<Button onClick={save} disabled={!selectedId || saving}>{saving ? '保存中...' : '保存登记'}</Button> <Button onClick={save} disabled={!selectedId || saving}>{saving ? '保存中...' : '保存登记'}</Button>
@@ -158,6 +208,17 @@ export default function Hours() {
<table className="w-full text-sm border-collapse"> <table className="w-full text-sm border-collapse">
<thead> <thead>
<tr className="bg-gray-100 border-b border-gray-200"> <tr className="bg-gray-100 border-b border-gray-200">
<th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800" style={{ width: '42px' }}>
<label className="inline-flex cursor-pointer">
<input
type="checkbox"
className="h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
checked={isAllSelected}
ref={(el) => { if (el) el.indeterminate = isIndeterminate; }}
onChange={toggleAll}
/>
</label>
</th>
<th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">序号</th> <th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">序号</th>
<th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">班级</th> <th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">班级</th>
<th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">姓名</th> <th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">姓名</th>
@@ -169,11 +230,21 @@ export default function Hours() {
</thead> </thead>
<tbody> <tbody>
{participants.length === 0 ? ( {participants.length === 0 ? (
<tr><td colSpan={7} className="px-3 py-10 text-center text-gray-500 border-t border-gray-200"> <tr><td colSpan={8} className="px-3 py-10 text-center text-gray-500 border-t border-gray-200">
该活动暂无部员可登记(若选了工作组,请确认该工作组下有部员) 该活动暂无部员可登记(若选了工作组,请确认该工作组下有部员)
</td></tr> </td></tr>
) : participants.map((p, idx) => ( ) : participants.map((p, idx) => (
<tr key={p.id} className={`border-b border-gray-200 ${idx % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}> <tr key={p.id} className={`border-b border-gray-200 ${selectedIds.has(p.id) ? 'bg-primary-50/70' : idx % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}>
<td className="px-2 py-2">
<label className="inline-flex cursor-pointer">
<input
type="checkbox"
className="h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
checked={selectedIds.has(p.id)}
onChange={() => toggleOne(p.id)}
/>
</label>
</td>
<td className="px-2 py-2 text-gray-700">{idx + 1}</td> <td className="px-2 py-2 text-gray-700">{idx + 1}</td>
<td className="px-2 py-2 whitespace-nowrap text-gray-700">{p.class}</td> <td className="px-2 py-2 whitespace-nowrap text-gray-700">{p.class}</td>
<td className="px-2 py-2 font-semibold whitespace-nowrap text-gray-800">{p.name}</td> <td className="px-2 py-2 font-semibold whitespace-nowrap text-gray-800">{p.name}</td>
@@ -198,6 +269,32 @@ export default function Hours() {
请先在上方选择一个活动开始登记 请先在上方选择一个活动开始登记
</div> </div>
)} )}
<Modal open={batchModalOpen} title={`批量设置时长(已选 ${selectedCount} 人)`} width="max-w-lg" onClose={() => setBatchModalOpen(false)}>
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-1.5 text-gray-700">时长(小时)<span className="text-red-500 ml-1">*</span></label>
<Input type="number" step="0.1" min="0" value={batchHours} onChange={(e) => setBatchHours(e.target.value)} />
<div className="muted mt-1.5 text-xs">将为选中的 {selectedCount} 名部员统一设置此时长值</div>
</div>
<div>
<label className="block text-sm font-semibold mb-1.5 text-gray-700">
<span className="inline-flex items-center gap-2">
<input type="checkbox" className="h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500" checked={batchApplyRemark} onChange={(e) => setBatchApplyRemark(e.target.checked)} />
<span>同时统一设置备注(可选)</span>
</span>
</label>
<Input value={batchRemark} onChange={(e) => setBatchRemark(e.target.value)} disabled={!batchApplyRemark} placeholder={batchApplyRemark ? '例如:校园摄影宣传 / 公众号推文排版' : '先勾选上方"同时设置备注"后启用'} />
</div>
<div className="bg-primary-50 border border-primary-100 rounded-md px-3 py-2 text-sm text-primary-800">
注意:设置后会立即覆盖选中人员的时长/备注(如勾选)。需点击右侧「保存登记」按钮才会写入数据库。
</div>
</div>
<div slot="footer" className="flex items-center justify-end gap-2 pt-3 mt-4 border-t border-gray-200">
<Button variant="secondary" onClick={() => setBatchModalOpen(false)}>取消</Button>
<Button onClick={confirmBatchSet}>确认设置</Button>
</div>
</Modal>
</div> </div>
); );
} }