feat: 初始化义工时长管理系统

- 管理台:活动设置、时长登记、用户管理、数据导出(Excel/PNG)
- 用户端:姓名查询个人义工时长与活动记录
- 技术栈:React + Vite + TailwindCSS / Node.js + Express + sql.js (SQLite)
- 支持 Docker 部署,附带 Gitea Actions 自动部署工作流
- 响应式界面,兼容移动端与 PC 端
- 默认管理员账号:admin / admin123
This commit is contained in:
Trae Assistant
2026-10-04 18:06:24 +08:00
commit 25b2b8f381
50 changed files with 7918 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
import initSqlJs, { SqlJsStatic, Database } from 'sql.js';
import path from 'path';
import fs from 'fs';
import bcrypt from 'bcryptjs';
let SQL: SqlJsStatic | null = null;
let db: (Database & {
prepareCompat(sql: string): StmtCompat;
execCompat(sql: string): void;
pragmaCompat(expr: string): void;
save(): void;
}) | null = null;
let dbFilePath: string = '';
interface StmtCompat {
run(...params: any[]): { changes: number; lastInsertRowid: number };
get(...params: any[]): any;
all(...params: any[]): any[];
}
function wrapStmt(db: Database & any, sql: string): StmtCompat {
const bindArray = (params: any[]): any[] => {
return params.map(p => {
if (p === undefined) return null;
return p;
});
};
return {
run(...params) {
const stmt = db.prepare(sql);
try {
stmt.bind(bindArray(params));
stmt.step();
const changes = db.getRowsModified();
const lastInsertRowid = (db as any).lastInsertRowid?.() ?? 0;
return { changes, lastInsertRowid };
} finally {
stmt.free();
}
},
get(...params) {
const stmt = db.prepare(sql);
try {
stmt.bind(bindArray(params));
if (stmt.step()) return stmt.getAsObject();
return undefined;
} finally {
stmt.free();
}
},
all(...params) {
const stmt = db.prepare(sql);
const rows: any[] = [];
try {
stmt.bind(bindArray(params));
while (stmt.step()) rows.push(stmt.getAsObject());
return rows;
} finally {
stmt.free();
}
},
};
}
export async function initDb() {
if (db) return db;
SQL = await initSqlJs({
locateFile: (file) => {
const p = path.join(__dirname, '..', '..', 'node_modules', 'sql.js', 'dist', file);
if (fs.existsSync(p)) return p;
return path.join(require.resolve('sql.js').replace(/index\.js$/, 'dist'), file);
},
});
const dataDir = path.join(__dirname, '..', '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
dbFilePath = path.join(dataDir, 'volunteer.db');
let rawDb: Database;
if (fs.existsSync(dbFilePath)) {
const buf = fs.readFileSync(dbFilePath);
rawDb = new SQL.Database(new Uint8Array(buf));
} else {
rawDb = new SQL.Database();
}
const extended = rawDb as any;
extended.prepareCompat = function (sql: string) { return wrapStmt(extended, sql); };
extended.execCompat = function (sql: string) { extended.exec(sql); };
extended.pragmaCompat = function (expr: string) {
if (/foreign_keys\s*=\s*ON/i.test(expr)) {
// sql.js 默认开启 FK?尝试显式设置
try { extended.exec('PRAGMA foreign_keys = ON;'); } catch {}
}
};
extended.save = function () {
const data = extended.export();
fs.writeFileSync(dbFilePath, Buffer.from(data));
};
db = extended;
const d = extended;
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
try { d.execCompat(schema); d.save(); } catch (e) { console.error('Schema init error', e); }
const adminCount = d.prepareCompat('SELECT COUNT(*) as c FROM admins').get();
if (!adminCount || adminCount.c === 0) {
const hash = bcrypt.hashSync('admin123', 10);
d.prepareCompat('INSERT INTO admins (username, password_hash) VALUES (?, ?)').run('admin', hash);
d.save();
}
return db;
}
export function getDb() {
if (!db) throw new Error('DB not initialized, call initDb() first');
return db;
}
export type DbInstance = NonNullable<typeof db>;
+50
View File
@@ -0,0 +1,50 @@
CREATE TABLE IF NOT EXISTS work_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class TEXT NOT NULL,
name TEXT NOT NULL,
volunteer_no TEXT,
group_id INTEGER,
FOREIGN KEY (group_id) REFERENCES work_groups(id) ON DELETE SET NULL,
UNIQUE(class, name)
);
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS activity_groups (
activity_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY (activity_id, group_id),
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES work_groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS hours (
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','localtime')),
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)
);
CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);