- 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() 刷新列表累计时长列,若详情弹窗开着自动重新拉详情
This commit is contained in:
@@ -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<HoursActivityResult>(`/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 = {
|
||||
|
||||
@@ -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<Set<number>>(new Set());
|
||||
|
||||
const [directOpen, setDirectOpen] = useState(false);
|
||||
const [directUserIds, setDirectUserIds] = useState<number[]>([]);
|
||||
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() {
|
||||
/>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{selectedCount > 0 && (
|
||||
<Button variant="danger" onClick={batchDelete}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<IconTrash size={18} />删除选中({selectedCount})
|
||||
</span>
|
||||
</Button>
|
||||
<>
|
||||
<Button onClick={openBatchDirectAdd}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<IconPlus size={18} />批量加义工时({selectedCount})
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="danger" onClick={batchDelete}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<IconTrash size={18} />删除选中({selectedCount})
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setBatchOpen(true)}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
@@ -657,11 +727,18 @@ export default function Users() {
|
||||
<div className="muted mb-1">义工号</div>
|
||||
<div className="font-semibold text-gray-800">{detail.volunteer_no || '—'}</div>
|
||||
</div>
|
||||
<div className="card p-3 bg-primary-50/60">
|
||||
<div className="muted mb-1">累计时长</div>
|
||||
<div className="font-semibold text-lg text-primary-600">
|
||||
{Number(detail.total_hours || 0).toFixed(1)}h
|
||||
<div className="card p-3 bg-primary-50/60 flex flex-col justify-between gap-2">
|
||||
<div>
|
||||
<div className="muted mb-1">累计时长</div>
|
||||
<div className="font-semibold text-lg text-primary-600">
|
||||
{Number(detail.total_hours || 0).toFixed(1)}h
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => openSingleDirectAdd(detail)} className="justify-center">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<IconPlus size={14} />加义工时
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-3">
|
||||
@@ -692,6 +769,86 @@ export default function Users() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={directOpen}
|
||||
title="直接加义工时"
|
||||
onClose={() => !directSubmitting && setDirectOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDirectOpen(false)} disabled={directSubmitting}>取消</Button>
|
||||
<Button onClick={submitDirectAdd} disabled={directSubmitting}>
|
||||
{directSubmitting ? '提交中...' : `确认加义工时(${directUserIds.length} 人)`}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="card p-3 bg-primary-50/50 border border-primary-100 rounded-md">
|
||||
<div className="text-sm text-gray-700 leading-relaxed">
|
||||
<div className="font-semibold text-gray-800 mb-1">说明</div>
|
||||
<div className="muted text-xs mb-2 leading-relaxed">
|
||||
· 录入后会作为独立的时长记录挂在虚拟活动「【补录】直接加义工时长」名下<br />
|
||||
· 每次直接加时都会单独保存一条完整记录(含原因),可在明细里逐条追溯<br />
|
||||
· 「原因」将作为该条时长的备注显示
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t border-primary-100">
|
||||
<div className="text-sm font-medium text-gray-700 mb-1">
|
||||
已选择 <span className="text-primary-600 font-bold">{directUserIds.length}</span> 名部员:
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 max-h-24 overflow-auto">
|
||||
{(() => {
|
||||
const namesMap = new Map<number, string>();
|
||||
for (const u of list) namesMap.set(u.id, `${u.class}${u.name}`);
|
||||
return directUserIds.map((id, i) => (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-block mr-2 mb-1 px-2 py-0.5 bg-white rounded border border-gray-200"
|
||||
>
|
||||
{i + 1}. {namesMap.get(id) || `部员ID#${id}`}
|
||||
</span>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-1.5">
|
||||
时长(小时)*
|
||||
<span className="ml-2 text-xs text-gray-500 font-normal">支持 0.5h / 1h / 1.5h 等</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
value={directHours}
|
||||
onChange={(e) => setDirectHours(e.target.value)}
|
||||
placeholder="请输入要加的义工小时数(必填,> 0)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold mb-1.5">
|
||||
原因 / 备注 *
|
||||
<span className="ml-2 text-xs text-red-600 font-normal">必填,将作为该条时长记录的备注保留</span>
|
||||
</label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={directReason}
|
||||
onChange={(e) => setDirectReason(e.target.value)}
|
||||
placeholder="例如:协助完成宣传部招新面试;临时值班;特殊贡献奖励等"
|
||||
/>
|
||||
<div className="mt-1 text-xs muted">
|
||||
已输入 {directReason.length} 字{directReason.length === 0 ? '(请填写原因后再提交)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{directError && (
|
||||
<div className="text-sm text-red-600 rounded-md border border-red-200 bg-red-50 px-3 py-2 leading-relaxed">
|
||||
{directError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,6 +136,47 @@ export async function initDb() {
|
||||
}
|
||||
} catch (e) { console.error('User_groups migration error', e); }
|
||||
|
||||
try {
|
||||
const hoursExists = d.prepareCompat(`SELECT name FROM sqlite_master WHERE type='table' AND name='hours'`).get();
|
||||
if (hoursExists) {
|
||||
const uniqueIdx = d.prepareCompat(`
|
||||
SELECT name, sql FROM sqlite_master
|
||||
WHERE type='index' AND tbl_name='hours' AND (unique=1 OR sql LIKE '%UNIQUE%')
|
||||
`).all();
|
||||
const tblSql = (d.prepareCompat(`SELECT sql FROM sqlite_master WHERE type='table' AND name='hours'`).get() as any)?.sql || '';
|
||||
const hasOldUnique = uniqueIdx.length > 0 || tblSql.includes('UNIQUE(activity_id, user_id)');
|
||||
if (hasOldUnique) {
|
||||
console.log('[DB] Migrating hours: drop UNIQUE(activity_id,user_id) constraint, allow multiple rows per activity+user');
|
||||
d.execCompat('BEGIN');
|
||||
d.execCompat(`CREATE TABLE IF NOT EXISTS hours_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
hours REAL NOT NULL DEFAULT 0,
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
|
||||
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`);
|
||||
d.execCompat(`
|
||||
INSERT INTO hours_new (id, activity_id, user_id, hours, remark, created_at)
|
||||
SELECT id, activity_id, user_id, hours, remark, created_at FROM hours
|
||||
`);
|
||||
for (const idx of uniqueIdx) {
|
||||
try { d.execCompat(`DROP INDEX IF EXISTS "${idx.name}"`); } catch {}
|
||||
}
|
||||
d.execCompat('DROP TABLE IF EXISTS hours');
|
||||
d.execCompat('ALTER TABLE hours_new RENAME TO hours');
|
||||
d.execCompat('COMMIT');
|
||||
d.save();
|
||||
console.log('[DB] hours UNIQUE constraint migration done');
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Hours migration error', e);
|
||||
try { d.execCompat('ROLLBACK'); } catch {}
|
||||
}
|
||||
|
||||
const adminCount = d.prepareCompat('SELECT COUNT(*) as c FROM admins').get();
|
||||
if (!adminCount || adminCount.c === 0) {
|
||||
const hash = bcrypt.hashSync('admin123', 10);
|
||||
|
||||
@@ -46,8 +46,7 @@ CREATE TABLE IF NOT EXISTS hours (
|
||||
remark TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
|
||||
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(activity_id, user_id)
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
|
||||
@@ -12,19 +12,19 @@ router.post('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const db = getDb();
|
||||
try {
|
||||
db.execCompat('BEGIN');
|
||||
const upsert = db.prepareCompat(`
|
||||
const delOld = db.prepareCompat('DELETE FROM hours WHERE activity_id = ? AND user_id = ?');
|
||||
const insertNew = db.prepareCompat(`
|
||||
INSERT INTO hours (activity_id, user_id, hours, remark)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(activity_id, user_id) DO UPDATE SET hours=excluded.hours, remark=excluded.remark
|
||||
`);
|
||||
const delZero = db.prepareCompat('DELETE FROM hours WHERE activity_id = ? AND user_id = ? AND hours = 0 AND (remark IS NULL OR remark = \'\')');
|
||||
for (const it of items) {
|
||||
const h = Number(it.hours) || 0;
|
||||
const r = (it.remark ?? '').toString();
|
||||
if (h === 0 && !r) {
|
||||
delZero.run(activity_id, it.user_id);
|
||||
delOld.run(activity_id, it.user_id);
|
||||
} else {
|
||||
upsert.run(activity_id, it.user_id, h, r);
|
||||
delOld.run(activity_id, it.user_id);
|
||||
insertNew.run(activity_id, it.user_id, h, r);
|
||||
}
|
||||
}
|
||||
db.execCompat('COMMIT');
|
||||
@@ -36,6 +36,77 @@ router.post('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
const DIRECT_ADD_TITLE = '【补录】直接加义工时长';
|
||||
|
||||
router.post('/direct-add', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const { items } = req.body || {};
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ error: '请至少选择一名部员' });
|
||||
}
|
||||
const db = getDb();
|
||||
try {
|
||||
const errors: string[] = [];
|
||||
const clean: Array<{ uid: number; user: any; hours: number; reason: string }> = [];
|
||||
for (const it of items) {
|
||||
const uid = Number(it?.user_id);
|
||||
const h = Number(it?.hours);
|
||||
const reason = String(it?.reason ?? '').trim();
|
||||
if (!uid) { errors.push(`无效部员:${JSON.stringify(it)}`); continue; }
|
||||
const user = db.prepareCompat('SELECT id, class, name, volunteer_no FROM users WHERE id = ?').get(uid);
|
||||
if (!user) { errors.push(`部员不存在:${uid}`); continue; }
|
||||
if (!Number.isFinite(h) || h <= 0) {
|
||||
errors.push(`${user.class} ${user.name} 的时长必须大于 0`);
|
||||
continue;
|
||||
}
|
||||
if (!reason) {
|
||||
errors.push(`${user.class} ${user.name} 必须填写原因`);
|
||||
continue;
|
||||
}
|
||||
clean.push({ uid, user, hours: h, reason });
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({ error: errors.join(';') });
|
||||
}
|
||||
db.execCompat('BEGIN');
|
||||
let act = db.prepareCompat('SELECT * FROM activities WHERE title = ?').get(DIRECT_ADD_TITLE) as any;
|
||||
if (!act) {
|
||||
db.prepareCompat(`
|
||||
INSERT INTO activities (title, content, start_time, end_time)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(DIRECT_ADD_TITLE, '管理台直接补录义工时长,不绑定真实活动', '2024-01-01 00:00', '2099-12-31 23:59');
|
||||
act = db.prepareCompat('SELECT * FROM activities WHERE title = ?').get(DIRECT_ADD_TITLE) as any;
|
||||
}
|
||||
const insert = db.prepareCompat(`
|
||||
INSERT INTO hours (activity_id, user_id, hours, remark)
|
||||
VALUES (?,?,?,?)
|
||||
`);
|
||||
const results: any[] = [];
|
||||
for (const c of clean) {
|
||||
insert.run(act.id, c.uid, c.hours, c.reason);
|
||||
results.push({
|
||||
user_id: c.uid,
|
||||
class: c.user.class,
|
||||
name: c.user.name,
|
||||
volunteer_no: c.user.volunteer_no || null,
|
||||
hours: c.hours,
|
||||
reason: c.reason,
|
||||
});
|
||||
}
|
||||
db.execCompat('COMMIT');
|
||||
db.save();
|
||||
res.json({
|
||||
ok: true,
|
||||
added: results.length,
|
||||
total_hours: results.reduce((s, r) => s + r.hours, 0),
|
||||
results,
|
||||
direct_add_activity_id: act.id,
|
||||
});
|
||||
} catch (e: any) {
|
||||
try { db.execCompat('ROLLBACK'); } catch {}
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/activity/:id', (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const db = getDb();
|
||||
|
||||
Reference in New Issue
Block a user