The previous condition 'c === "normal" && !t' required status_text to be empty, but enriched records carry text suffixes like '正常 + CL4h' which are truthy and dropped normal records into the '異常' fallback. 3 Kaka records (id=22/28/30) were affected — act_in 10:38/10:43 (early), act_out 20:00 (on time), so they should render the green '正常' badge but were showing '異常' instead.
AARS - Attendance & Accident Record System
出勤及意外記錄系統
🚀 快速開始
方式一:Docker 部署(推薦)
# 复制到 VPS
scp -r ~/aars root@187.127.116.15:/root/
# SSH 到 VPS
ssh root@187.127.116.15
# 進入目录并运行
cd /root/aars
chmod +x setup.sh
./setup.sh
访问:http://187.127.116.15:18775
方式二:本地开发
# Backend
cd backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python test_backend.py # 测试
uvicorn main:app --reload --port 8000
# Frontend (另一个terminal)
cd frontend
npm install
npm run dev
🔐 默认账户
- Email: admin@aars.hk
- Password: admin123
⚠️ 首次登录后请立即修改密码!
📋 功能
出勤 Attendance
- ✅ 列表视图 (Filter/Sort/分页)
- ✅ 新增/编辑/删除记录
- ✅ Excel 批量导入(自动去重)
- ✅ 导出 Excel / PDF
- ✅ 自定义字段
意外 Accident
- ✅ 列表视图 (Filter/Sort/分页)
- ✅ 新增/编辑/删除记录
- ✅ Excel 批量导入(自动去重)
- ✅ 导出 Excel / PDF
- ✅ 自定义字段
Dashboard
- 📊 统计卡片(总计/今日/本月)
- 📈 趋势图表
- ⚠️ 最近意外列表
🏗️ 技术栈
| Layer | Technology |
|---|---|
| Frontend | React 18 + Vite + TailwindCSS |
| Backend | FastAPI + SQLAlchemy |
| Database | SQLite |
| Charts | Recharts |
| Excel | openpyxl + xlsxwriter |
| ReportLab |
📁 项目结构
aars/
├── backend/
│ ├── main.py # FastAPI app
│ ├── models.py # Database models
│ ├── schemas.py # Pydantic schemas
│ ├── auth.py # JWT auth
│ ├── database.py # SQLite setup
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── pages/ # React pages
│ │ ├── components/ # Reusable components
│ │ └── context/ # Auth context
│ └── ...
├── docker-compose.yml
├── Dockerfile
└── SPEC.md
🔧 配置
端口
- 默认:
18775 - 修改: 编辑
docker-compose.yml
JWT Secret
- 开发: 自动生成
- 生产: 修改
backend/secret.py
📝 API 文档
启动后访问:http://localhost:8000/docs (Swagger UI)
🐛 常见问题
Q: Docker build 失败?
docker compose down
docker compose build --no-cache
docker compose up -d
Q: 无法登录? 检查浏览器 console 的 CORS 错误,或确认 JWT secret 未变更。
Q: Excel 导入失败?
确保 Excel 文件第一行是表头,且日期格式为 YYYY-MM-DD。
📊 Attendance System
Data Source
- Primary: SQLite table
attendance_records(SQLAlchemy ORM) - Legacy: Excel file at
/app/data/attendance.xlsx(still saved on upload for export compatibility)
DB Schema (attendance_records)
| Column | Type | Notes |
|---|---|---|
id |
INTEGER PK | auto |
employee_name |
VARCHAR(255) | index |
company, department |
VARCHAR(100) | nullable |
date |
DATE | index |
weekday |
VARCHAR(20) | e.g. "Friday" |
check_in, check_out |
DATETIME | nullable |
shift_code |
VARCHAR(20) | e.g. "S7a" |
status_code |
VARCHAR(20) | normal / late / early / late_early / late_ot / early_ot / ot / missing / abnormal |
status_text |
VARCHAR(100) | Chinese e.g. 「遲到46分 / OT60分」 |
late_minutes, early_minutes, ot_minutes |
INTEGER | computed |
expected_in, expected_out, actual_in, actual_out |
VARCHAR(10) | "HH:MM" string |
is_manually_edited |
BOOLEAN | preserves manual edits from import overwrite |
raw_data |
JSON | source row |
shift_id |
INTEGER FK | → shifts.id |
UNIQUE constraint on (employee_name, date) — one record per staff per day.
Shifts Table (shifts)
- 11 rows pre-loaded (S7a, S8a, ...)
- Columns:
shift_code+ per-day (mon_start/mon_end...sun_start/sun_end), allHH:MMstrings - Method:
Shift.get_schedule(weekday)→(start_str, end_str)or(None, None)on day off
Status Calculation (calculate_attendance_status in main.py)
| Status | Rule |
|---|---|
missing |
No check-in or no check-out |
abnormal |
check_in == check_out (exact same second) |
late |
check_in > expected start (late_minutes = round(diff)) |
early |
check_out < expected end (early_minutes = round(diff)) |
ot |
check_out > expected end (ot_minutes = round(diff)) |
late_early, late_ot, early_ot |
combined codes |
Notes:
int()was buggy → now usesround()(off-by-one fix)18:00:00exact default check (NOT18:00:03which is real swipe)
API Endpoints (current, attendance scope)
| Method | Path | Source | Notes |
|---|---|---|---|
| GET | /api/dashboard/summary |
SQL | Returns total_records, *_count, staff_count, by_status, by_department (frontend-compatible fields) |
| GET | /api/attendance/stats |
SQL (alias) | Per-employee: count + minutes + avg + max + consecutive_missing + last_attendance_date + attendance_rate |
| GET | /api/attendance/records |
SQL (alias) | Supports staff, date_from, date_to, status, search, sort_by, sort_order, page, per_page |
| GET | /api/attendance/{id} |
SQL | Single record by ID |
| PUT | /api/attendance/{id} |
SQL | Update + mark is_manually_edited=true (protected from import) |
| POST | /api/upload/attendance |
Excel + SQL | Save file + import to DB (skips records where is_manually_edited=true) |
| GET | /api/export/attendance/excel |
SQL → xlsxwriter | On-the-fly 16-col xlsx with status colors |
Excel Export Columns (16)
| Col | Name | Notes |
|---|---|---|
| A | Staff Name | |
| B | Company | |
| C | Department | |
| D | Date | ISO YYYY-MM-DD |
| E | Weekday | |
| F | Shift | |
| G | Expected In | |
| H | Expected Out | |
| I | Actual In | |
| J | Actual Out | |
| K | Status Code | color-coded per status |
| L | Status Text | Chinese |
| M | Late Min | |
| N | Early Min | |
| O | OT Min | |
| P | Manual Edit | "Yes" if manually edited |
Status Colors (xlsxwriter)
| Status | BG | Font |
|---|---|---|
| Header | #DBEAFE (light blue) |
#1E3A8A (dark blue, bold) — frozen top row |
| normal | #D1FAE5 (green) |
#065F46 |
| late / late_early | #FEF3C7 (amber) |
#92400E |
| late_ot | #FED7AA (orange) |
#7C2D12 |
| early | #DBEAFE (blue) |
#1E40AF |
| early_ot | #E0E7FF (indigo) |
#1E40AF |
| ot | #EDE9FE (purple) |
#5B21B6 |
| abnormal | #FEE2E2 (red) |
#7F1D1D |
| missing | #E5E7EB (gray) |
#374151 |
Frontend Components
- Dashboard (
pages/Dashboard.jsx):- 8 stats cards (total / normal / late / early / OT / missing / abnormal / staff)
- 4 leaderboards (Late / OT / Missing / Attendance Rate)
- 5 date presets (今日 / 本週 / 上週 / 本月 / 清除)
- Per-staff detail row: avg / max / consecutive_missing / last_attendance_date / attendance_rate
- Attendance List (
pages/attendance/List.jsx):- Table with filters (status, date range, staff, search)
- Pagination + sort
- "Dashboard" back-link button (
to="/", NOTto="/dashboard")
- Attendance Detail (
pages/attendance/Detail.jsx):- Per-record view + edit
Manual Edit Flow
- User edits record via UI (Detail page) →
PUT /api/attendance/{id}→ marksis_manually_edited=true - Next upload of Excel → import loop checks
is_manually_edited→ skips this record (preserves manual change)
Known Data Quirks
expected_in/expected_outstored as strings ("11:00") for direct UI displayactual_in/actual_outsame — strings, NOT datetimesShift.get_schedule()returns strings — caller mustdatetime.strptime(start_str, "%H:%M").time()before passing tocalculate_attendance_status
Files
- Backend:
/root/aars/backend/main.py(~2100 lines),models.py,database.py,schemas.py,auth.py - DB file:
/root/aars/data/aars.db(SQLite, bind-mounted into container) - Frontend:
/root/aars/frontend/src/pages/Dashboard.jsx,pages/attendance/List.jsx,pages/attendance/Detail.jsx - Excel data:
/root/aars/data/attendance.xlsx(legacy),roster.xlsx,accident.xlsx
Computed Backend Data Caveats
- Status counts are dynamic — computed from
attendance_recordstable at query time - No materialized views — query heavy if data grows beyond ~100k records
- Roster changes affect historical data — if you update
Shift.mon_startfor S7a, all past records withshift_code="S7a"are NOT auto-recalculated (run recompute manually if needed)
🐛 Debug Logging
Structured JSON logs written to /root/aars/logs/ (bind-mounted into container at /app/logs/). Designed for production debugging — NOT compliance/audit.
Files
| File | Contents | Retention |
|---|---|---|
app-YYYY-MM-DD.jsonl |
General INFO+ events | 7 days |
error-YYYY-MM-DD.jsonl |
Server + client errors (with stack) | 30 days |
access-YYYY-MM-DD.jsonl |
One entry per HTTP request | 7 days |
Files auto-rotate daily via in-process DailyJSONLHandler. Old files deleted by /root/aars/scripts/logs-cleanup.sh (cron at 03:00 recommended).
Log Schema
Every entry is single-line JSON:
{
"ts": "2026-07-17T08:30:00.123Z",
"level": "info",
"service": "aars-backend",
"env": "production",
"request_id": "abc123-...",
"route": "/api/dashboard/summary",
"method": "GET",
"status": 200,
"duration_ms": 114,
"msg": "human readable summary",
"error": { "name": "...", "message": "...", "stack": "..." }
}
ts: ISO 8601 UTClevel:debug/info/warn/errorservice:aars-backend(frontend errors tagged with"source": "client"incontext)env: hardcodedproductionrequest_id: fromX-Request-IDheader (auto-generated if missing, validated if present)- Sensitive fields auto-redacted (see below)
Sensitive Redaction
Backend redactor scrubs these substrings from any log payload (dicts/strings walked recursively):
password,passwd,pwd,passtoken,access_token,refresh_token,id_token,jwt,bearerapi_key,apikey,secret,client_secretauthorization,cookie,set-cookie,sessiondatabase_url,db_url,connection_string
JWT regex: eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+ → [REDACTED:JWT]
Verification:
grep -l password /root/aars/logs/*.jsonl # should return nothing
grep admin123 /root/aars/logs/*.jsonl # should return nothing
Reproducing an Error (workflow for ITdog)
- User reports an issue with
request_id(visible in browser DevTools → Network → Response Headers →X-Request-ID) - SSH to VPS:
ssh root@187.127.116.15 - Search by request_id across all 3 files:
grep '"request_id":"paste-id-here"' /root/aars/logs/{app,error,access}-2026-07-17.jsonl | jq . - Search by route / status / message:
grep '"route":"/api/dashboard/summary"' /root/aars/logs/error-*.jsonl | jq . grep '"status":500' /root/aars/logs/access-*.jsonl | jq . grep 'login failed' /root/aars/logs/app-*.jsonl | jq . - Live tail for new errors:
tail -f /root/aars/logs/error-*.jsonl | jq .
Live Tailing
tail -f /root/aars/logs/app-2026-07-17.jsonl | jq . # app events
tail -f /root/aars/logs/access-2026-07-17.jsonl | jq . # HTTP requests
tail -f /root/aars/logs/error-2026-07-17.jsonl | jq . # errors
(Requires jq — apt install -y jq on VPS if not present.)
npm Scripts (from frontend dir)
cd /root/aars/frontend
npm run logs:tail # tail app log
npm run logs:errors # tail error log
npm run logs:access # tail access log
npm run logs:search -- '"route":"/api/dashboard"' # search all 3 files
npm run logs:clean # manually trigger retention cleanup
Triggering a Test Client Error
From browser console:
import('/src/lib/logger.js').then(m => m.logger.error('manual test', { stack: 'fake stack' }))
Or via curl (no auth required):
curl -X POST https://excel.donton.cloud/api/client-logs \
-H "Content-Type: application/json" \
-H "X-Request-ID: my-debug-test" \
-d '{"level":"error","msg":"manual curl test","stack":"foo","page_url":"https://excel.donton.cloud/dashboard","app_version":"1.0.0"}'
Then verify:
grep "my-debug-test" /root/aars/logs/error-*.jsonl | jq .
Manual Retention Cleanup
npm run logs:clean
# or directly
bash /root/aars/scripts/logs-cleanup.sh
To schedule daily, add to crontab on the host:
0 3 * * * /root/aars/scripts/logs-cleanup.sh >> /var/log/aars-cleanup.log 2>&1
Architecture
Backend (FastAPI stdlib only, no new deps):
backend/logging_config.py:JSONFormatter— formatsLogRecord→ single-line JSONDailyJSONLHandler— in-process daily file rotation (not stdlibTimedRotatingFileHandlerwhich has awkward filename semantics)_scrub(obj)— recursive dict walker + JWT regexContextVarrequest_id_var+user_id_varfor cross-function context- Console handler with
ContextFilterfor human-readable stdout output
backend/middleware.py:RequestIDMiddleware— generate uuid4 ifX-Request-IDmissing, validate if present ([A-Za-z0-9_-]{1,128}regex)AccessLogMiddleware— log every request with route/method/status/duration_msunhandled_exception_handler— catch-all 500 with stack trace
Frontend (vanilla JS, no extra deps):
frontend/src/lib/logger.js:- Buffer (50 entries max), flush every 10s + on
pagehide+ on visibility hidden navigator.sendBeacon(preferred) →fetchfallback- Captures
console.error,unhandledrejection, ErrorBoundary errors - Redacts same keys as backend (client-side filter)
- Buffer (50 entries max), flush every 10s + on
frontend/src/api.js:- Axios interceptor — generate
X-Request-IDper request, capture from response
- Axios interceptor — generate
frontend/src/components/ErrorBoundary.jsx:- React error boundary that posts to
/api/client-logs
- React error boundary that posts to
frontend/src/main.jsx:- Wraps app in ErrorBoundary, installs global error handlers
Server endpoint:
POST /api/client-logs— no auth, validates viaClientLogEntryPydantic schema, writes to error log withsource: clienttag
Excluded (by design)
- ❌ Rate limiting on
/api/client-logs— debug use, no DDoS concern - ❌ PII pattern scrubbing (emails, HKID) — DB has no such data
- ❌ Lint / type / unit tests — user said no testing around
- ❌ Compliance / audit logging — no requirements
- ❌ Centralized log aggregation (ELK, Datadog) — VPS-only, SSH access suffices
Files
| Path | Purpose |
|---|---|
/root/aars/backend/logging_config.py |
Formatter + handler + redactor |
/root/aars/backend/middleware.py |
RequestID + AccessLog + exception handler |
/root/aars/scripts/logs-cleanup.sh |
Retention cron (run daily) |
/root/aars/logs/app-*.jsonl |
App events (7d) |
/root/aars/logs/error-*.jsonl |
Errors + stack traces (30d) |
/root/aars/logs/access-*.jsonl |
HTTP access log (7d) |
/root/aars/frontend/src/lib/logger.js |
Client logger |
/root/aars/frontend/src/api.js |
Axios + X-Request-ID interceptor |
/root/aars/frontend/src/components/ErrorBoundary.jsx |
React error boundary |
Known Issues
jqnot pre-installed on VPS —apt install -y jqrequired for pretty-printed logs- Crontab for
logs-cleanup.shnot auto-installed — manually add0 3 * * *cron entry on host - Container healthcheck always reports unhealthy — uses
/api/auth/me(returns 403 without auth). Cosmetic only, doesn't affect functionality.