From 51b27bdfd4dfd5d72f82fe54b14d0afac206d8f0 Mon Sep 17 00:00:00 2001 From: Trae Assistant Date: Mon, 5 Oct 2026 13:44:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E5=8F=B0=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=AF=B9=E9=83=A8=E5=91=98=E5=8A=A0=E4=B9=89=E5=B7=A5?= =?UTF-8?q?=E6=97=B6=EF=BC=88=E5=BF=85=E5=A1=AB=E5=8E=9F=E5=9B=A0=EF=BC=8C?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=A4=9A=E6=9D=A1=E8=AE=B0=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema.sql + db/index.ts 迁移:hours 表移除 UNIQUE(activity_id,user_id) 约束,改由 id 自增主键唯一定位;启动时检测到旧结构会自动 RENAME+重建表+INSERT 搬迁历史数据;外部 FK pragma 仍生效 - hours.ts batch 接口:为兼容去掉 UNIQUE 后的行为等价,改成 『先 DELETE 旧行 + 再 INSERT 新行』,同人同活动仍为一行覆盖语义 - 新增 POST /hours/direct-add: · items = [{user_id, hours, reason}],每条记录前置校验(uid 存在、 hours>0、reason 非空),错误以分号拼接返回 · 首次调用自动创建活动「【补录】直接加义工时长」(start/end 2024-01-01 ~ 2099-12-31)作为虚拟承载活动,后续复用 · 全事务 INSERT,每次加时独立写入多行(不合并) · 响应:{ ok, added, total_hours, results:[{class,name,...}], direct_add_activity_id } - api/index.ts hoursApi.directAdd:新增带完整返回类型签名的 TS 封装 - Users.tsx 双入口: · 工具栏:选中部员后与『批量删除』并列,出现『批量加义工时(N)』 主按钮 → 打开共用 Modal 一次加多人 · 部员详情弹窗:「累计时长」卡片独立加『加义工时』小按钮, 点后复用同一表单只为当前人录 1 条 - 共用 Direct Add Modal: · 顶部说明卡(每条原因独立保存;挂在【补录】活动;勾选人名 chip 列表) · 时长输入:number,min=0.1 step=0.1,前端校验 >0 · 原因输入:textarea 必填,标红字星号,字数计数,提交前 trim 非空校验 · 提交 loading 锁按钮;成功 toast 显示前 5 人名字 + 总人数 + 总小时; 然后 load() 刷新列表累计时长列,若详情弹窗开着自动重新拉详情 --- client/src/api/index.ts | 8 ++ client/src/pages/admin/Users.tsx | 177 +++++++++++++++++++++++++++++-- server/src/db/index.ts | 41 +++++++ server/src/db/schema.sql | 3 +- server/src/routes/hours.ts | 81 +++++++++++++- 5 files changed, 293 insertions(+), 17 deletions(-) diff --git a/client/src/api/index.ts b/client/src/api/index.ts index 2b570cb..02efee7 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -82,6 +82,14 @@ export const hoursApi = { batch: (activity_id: number, items: Array<{ user_id: number; hours: number; remark: string }>) => api.post('/hours/batch', { activity_id, items }).then(r => r.data), byActivity: (id: number) => api.get(`/hours/activity/${id}`).then(r => r.data), + directAdd: (items: Array<{ user_id: number; hours: number; reason: string }>) => + api.post<{ + ok: boolean; + added: number; + total_hours: number; + results: Array<{ user_id: number; class: string; name: string; volunteer_no: string | null; hours: number; reason: string }>; + direct_add_activity_id: number; + }>('/hours/direct-add', { items }).then(r => r.data), }; export const exportApi = { diff --git a/client/src/pages/admin/Users.tsx b/client/src/pages/admin/Users.tsx index fb65350..d0b9d3d 100644 --- a/client/src/pages/admin/Users.tsx +++ b/client/src/pages/admin/Users.tsx @@ -4,7 +4,7 @@ import Input from '../../components/Input'; import DataTable from '../../components/DataTable'; import Modal from '../../components/Modal'; import Textarea from '../../components/Textarea'; -import { groupsApi, usersApi, errMsg } from '../../api'; +import { groupsApi, usersApi, hoursApi, errMsg } from '../../api'; import { formatDateRange } from '../../utils/export'; import type { UserItem, UserDetail, WorkGroup } from '../../types'; import { IconUsers, IconPlus, IconEdit, IconTrash, IconEye } from '../../components/Icons'; @@ -105,6 +105,13 @@ export default function Users() { const [selectedIds, setSelectedIds] = useState>(new Set()); + const [directOpen, setDirectOpen] = useState(false); + const [directUserIds, setDirectUserIds] = useState([]); + const [directHours, setDirectHours] = useState(''); + const [directReason, setDirectReason] = useState(''); + const [directError, setDirectError] = useState(''); + const [directSubmitting, setDirectSubmitting] = useState(false); + const load = async () => { setLoading(true); try { @@ -208,6 +215,62 @@ export default function Users() { } }; + const openBatchDirectAdd = () => { + if (selectedCount === 0) return; + setDirectUserIds(Array.from(selectedIds)); + setDirectHours(''); + setDirectReason(''); + setDirectError(''); + setDirectOpen(true); + }; + const openSingleDirectAdd = (u: { id: number }) => { + setDirectUserIds([u.id]); + setDirectHours(''); + setDirectReason(''); + setDirectError(''); + setDirectOpen(true); + }; + const refreshDetailAfterDirect = async () => { + if (!detailOpen || !detail) return; + try { + const d = await usersApi.detail(detail.id); + setDetail({ ...d, group_ids: d.group_ids ?? [], group_names: d.group_names ?? [] }); + } catch {} + }; + const submitDirectAdd = async () => { + setDirectError(''); + const h = Number(directHours); + const reason = directReason.trim(); + if (!Number.isFinite(h) || h <= 0) { + setDirectError('时长必须是大于 0 的数字'); + return; + } + if (!reason) { + setDirectError('必须填写原因(将作为备注保存)'); + return; + } + if (directUserIds.length === 0) { + setDirectError('请先选择部员'); + return; + } + setDirectSubmitting(true); + try { + const items = directUserIds.map((id) => ({ user_id: id, hours: h, reason })); + const result = await hoursApi.directAdd(items); + const names = result.results.slice(0, 5).map((r) => `${r.class}${r.name}`).join('、'); + const more = result.results.length > 5 ? ` 等 ${result.results.length} 人` : ''; + alert(`补录完成:已为 ${names}${more} 累计加 ${Number(result.total_hours).toFixed(1)} 小时`); + setDirectOpen(false); + clearSelection(); + await load(); + await refreshDetailAfterDirect(); + } catch (e) { + setDirectError(errMsg(e, '补录义工时失败')); + } finally { + setDirectSubmitting(false); + } + }; + const submit = async () => { if (!form.class.trim() || !form.name.trim()) { setError('班级与姓名不能为空'); @@ -402,11 +465,18 @@ export default function Users() { />
{selectedCount > 0 && ( - + <> + + + )}
-
-
累计时长
-
- {Number(detail.total_hours || 0).toFixed(1)}h +
+
+
累计时长
+
+ {Number(detail.total_hours || 0).toFixed(1)}h +
+
@@ -692,6 +769,86 @@ export default function Users() {
)} + + !directSubmitting && setDirectOpen(false)} + footer={ + <> + + + + } + > +
+
+
+
说明
+
+ · 录入后会作为独立的时长记录挂在虚拟活动「【补录】直接加义工时长」名下
+ · 每次直接加时都会单独保存一条完整记录(含原因),可在明细里逐条追溯
+ · 「原因」将作为该条时长的备注显示 +
+
+
+ 已选择 {directUserIds.length} 名部员: +
+
+ {(() => { + const namesMap = new Map(); + for (const u of list) namesMap.set(u.id, `${u.class}${u.name}`); + return directUserIds.map((id, i) => ( + + {i + 1}. {namesMap.get(id) || `部员ID#${id}`} + + )); + })()} +
+
+
+
+
+ + setDirectHours(e.target.value)} + placeholder="请输入要加的义工小时数(必填,> 0)" + /> +
+
+ +