- 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 }>) =>
|
batch: (activity_id: number, items: Array<{ user_id: number; hours: number; remark: string }>) =>
|
||||||
api.post('/hours/batch', { activity_id, items }).then(r => r.data),
|
api.post('/hours/batch', { activity_id, items }).then(r => r.data),
|
||||||
byActivity: (id: number) => api.get<HoursActivityResult>(`/hours/activity/${id}`).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 = {
|
export const exportApi = {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Input from '../../components/Input';
|
|||||||
import DataTable from '../../components/DataTable';
|
import DataTable from '../../components/DataTable';
|
||||||
import Modal from '../../components/Modal';
|
import Modal from '../../components/Modal';
|
||||||
import Textarea from '../../components/Textarea';
|
import Textarea from '../../components/Textarea';
|
||||||
import { groupsApi, usersApi, errMsg } from '../../api';
|
import { groupsApi, usersApi, hoursApi, errMsg } from '../../api';
|
||||||
import { formatDateRange } from '../../utils/export';
|
import { formatDateRange } from '../../utils/export';
|
||||||
import type { UserItem, UserDetail, WorkGroup } from '../../types';
|
import type { UserItem, UserDetail, WorkGroup } from '../../types';
|
||||||
import { IconUsers, IconPlus, IconEdit, IconTrash, IconEye } from '../../components/Icons';
|
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 [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 () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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 () => {
|
const submit = async () => {
|
||||||
if (!form.class.trim() || !form.name.trim()) {
|
if (!form.class.trim() || !form.name.trim()) {
|
||||||
setError('班级与姓名不能为空');
|
setError('班级与姓名不能为空');
|
||||||
@@ -402,11 +465,18 @@ export default function Users() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
|
<>
|
||||||
|
<Button onClick={openBatchDirectAdd}>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<IconPlus size={18} />批量加义工时({selectedCount})
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
<Button variant="danger" onClick={batchDelete}>
|
<Button variant="danger" onClick={batchDelete}>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<IconTrash size={18} />删除选中({selectedCount})
|
<IconTrash size={18} />删除选中({selectedCount})
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button variant="secondary" onClick={() => setBatchOpen(true)}>
|
<Button variant="secondary" onClick={() => setBatchOpen(true)}>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
@@ -657,12 +727,19 @@ export default function Users() {
|
|||||||
<div className="muted mb-1">义工号</div>
|
<div className="muted mb-1">义工号</div>
|
||||||
<div className="font-semibold text-gray-800">{detail.volunteer_no || '—'}</div>
|
<div className="font-semibold text-gray-800">{detail.volunteer_no || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card p-3 bg-primary-50/60">
|
<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="muted mb-1">累计时长</div>
|
||||||
<div className="font-semibold text-lg text-primary-600">
|
<div className="font-semibold text-lg text-primary-600">
|
||||||
{Number(detail.total_hours || 0).toFixed(1)}h
|
{Number(detail.total_hours || 0).toFixed(1)}h
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div className="card p-3">
|
<div className="card p-3">
|
||||||
<div className="muted mb-2">所属工作组</div>
|
<div className="muted mb-2">所属工作组</div>
|
||||||
@@ -692,6 +769,86 @@ export default function Users() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,47 @@ export async function initDb() {
|
|||||||
}
|
}
|
||||||
} catch (e) { console.error('User_groups migration error', e); }
|
} 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();
|
const adminCount = d.prepareCompat('SELECT COUNT(*) as c FROM admins').get();
|
||||||
if (!adminCount || adminCount.c === 0) {
|
if (!adminCount || adminCount.c === 0) {
|
||||||
const hash = bcrypt.hashSync('admin123', 10);
|
const hash = bcrypt.hashSync('admin123', 10);
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ CREATE TABLE IF NOT EXISTS hours (
|
|||||||
remark TEXT,
|
remark TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
|
||||||
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
|
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
UNIQUE(activity_id, user_id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS admins (
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
|||||||
@@ -12,19 +12,19 @@ router.post('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
try {
|
try {
|
||||||
db.execCompat('BEGIN');
|
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)
|
INSERT INTO hours (activity_id, user_id, hours, remark)
|
||||||
VALUES (?,?,?,?)
|
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) {
|
for (const it of items) {
|
||||||
const h = Number(it.hours) || 0;
|
const h = Number(it.hours) || 0;
|
||||||
const r = (it.remark ?? '').toString();
|
const r = (it.remark ?? '').toString();
|
||||||
if (h === 0 && !r) {
|
if (h === 0 && !r) {
|
||||||
delZero.run(activity_id, it.user_id);
|
delOld.run(activity_id, it.user_id);
|
||||||
} else {
|
} 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');
|
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) => {
|
router.get('/activity/:id', (req: Request, res: Response) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
Reference in New Issue
Block a user