初始化新时空数据应用
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
APP_PORT=18080
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
PYTHON_IMAGE=python:3.12-slim
|
||||||
|
|
||||||
|
BASIC_AUTH_USERNAME=wolfydw
|
||||||
|
BASIC_AUTH_PASSWORD=change-me
|
||||||
|
|
||||||
|
CLASSNOTES_PATH=/data/classnotes.txt
|
||||||
|
ACCOUNTS_PATH=/data/学生课时账户.md
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
data/
|
||||||
|
.DS_Store
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
ARG PYTHON_IMAGE=python:3.12-slim
|
||||||
|
FROM ${PYTHON_IMAGE}
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 新时空课程记录查询网页
|
||||||
|
|
||||||
|
这是一个只读查询工具,用于把本机正式业务源中的 `classnotes.txt` 和 `学生课时账户.md` 同步到 VPS,并通过网页查询上课记录和课时账户。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- `app/`:FastAPI 后端和内置前端页面。
|
||||||
|
- `scripts/deploy_to_vps.py`:部署网页服务到 VPS。
|
||||||
|
- `scripts/sync_to_vps.py`:同步正式数据文件到 VPS。
|
||||||
|
- `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。
|
||||||
|
- `launchd/com.xsk.records.sync.plist.template`:LaunchAgent 模板。
|
||||||
|
|
||||||
|
## 首次部署
|
||||||
|
|
||||||
|
推荐先配置 SSH key。若临时使用密码,可通过环境变量传入,不要写入仓库文件。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/yangdawei/Desktop/新时空业务源数据/tools/xsk-records-web
|
||||||
|
XSK_USE_SSHPASS=1 \
|
||||||
|
XSK_SSH_PASSWORD='填写SSH密码' \
|
||||||
|
XSK_WEB_PASSWORD='填写网页访问密码' \
|
||||||
|
python3 scripts/deploy_to_vps.py --use-sshpass
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://121.199.172.246:18080/
|
||||||
|
```
|
||||||
|
|
||||||
|
默认网页用户名为 `wolfydw`。网页密码只写入 VPS 的 `/root/新时空数据/app/.env`,不会提交进 Git。
|
||||||
|
|
||||||
|
如果 VPS 无法访问 Docker Hub,部署脚本会默认使用 `swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/python:3.12-slim` 作为基础镜像。需要更换时设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XSK_PYTHON_IMAGE='python:3.12-slim'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 手动同步数据
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XSK_USE_SSHPASS=1 \
|
||||||
|
XSK_SSH_PASSWORD='填写SSH密码' \
|
||||||
|
python3 scripts/sync_to_vps.py --once --use-sshpass
|
||||||
|
```
|
||||||
|
|
||||||
|
同步文件:
|
||||||
|
|
||||||
|
- `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/classnotes.txt`
|
||||||
|
- `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/学生课时账户.md`
|
||||||
|
|
||||||
|
远端数据目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/root/新时空数据/data/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装自动同步
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XSK_USE_SSHPASS=1 \
|
||||||
|
XSK_SSH_PASSWORD='填写SSH密码' \
|
||||||
|
python3 scripts/install_launch_agent.py --use-sshpass
|
||||||
|
```
|
||||||
|
|
||||||
|
日志位置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Library/Logs/xsk-records-web/sync.log
|
||||||
|
~/Library/Logs/xsk-records-web/sync.err.log
|
||||||
|
```
|
||||||
|
|
||||||
|
查看任务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
launchctl list | grep com.xsk.records.sync
|
||||||
|
```
|
||||||
|
|
||||||
|
卸载任务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
launchctl unload ~/Library/LaunchAgents/com.xsk.records.sync.plist
|
||||||
|
rm ~/Library/LaunchAgents/com.xsk.records.sync.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
## 更换网页访问密码
|
||||||
|
|
||||||
|
登录 VPS 后修改 `/root/新时空数据/app/.env` 中的 `BASIC_AUTH_PASSWORD`,然后重启:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/新时空数据/app
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /api/health`:数据状态。
|
||||||
|
- `GET /api/records?q=王鑫鹏5月数学课`:自然语言查询上课记录。
|
||||||
|
- `GET /api/accounts`:课时账户列表。
|
||||||
|
- `GET /api/accounts?q=王鑫鹏`:按学生筛选账户。
|
||||||
|
- `GET /api/accounts?status=欠费`:按账户状态筛选。
|
||||||
|
- `GET /api/accounts/王鑫鹏`:单个学生账户。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
+841
@@ -0,0 +1,841 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
SUBJECTS = ["数学", "语文", "英语", "物理", "化学", "生物", "历史", "地理", "政治", "道法"]
|
||||||
|
SUBJECT_ALIASES = {
|
||||||
|
"数": "数学",
|
||||||
|
"语": "语文",
|
||||||
|
"英": "英语",
|
||||||
|
"物": "物理",
|
||||||
|
"化": "化学",
|
||||||
|
"生": "生物",
|
||||||
|
"史": "历史",
|
||||||
|
"地": "地理",
|
||||||
|
"政": "政治",
|
||||||
|
}
|
||||||
|
FALLBACK_ALIASES = {
|
||||||
|
"施亿涵": "施忆涵",
|
||||||
|
"施凯其": "施凯萁",
|
||||||
|
"宇浩": "黄宇澔",
|
||||||
|
"肖恒罄": "肖恒馨",
|
||||||
|
}
|
||||||
|
CLASSNOTE_RE = re.compile(
|
||||||
|
r"^(?P<date>\d{4}\.\d{2}\.\d{2})-"
|
||||||
|
r"(?P<weekday>星期[一二三四五六日])-"
|
||||||
|
r"(?P<time>\d{1,2}:\d{2}-\d{1,2}:\d{2})-"
|
||||||
|
r"(?P<student>[^-]+)-"
|
||||||
|
r"(?P<duration>\d+小时\d+分)-"
|
||||||
|
r"(?P<teacher>[^-]+)-"
|
||||||
|
r"(?P<subject>.+)$"
|
||||||
|
)
|
||||||
|
PAYMENT_LINE_RE = re.compile(r"^(?P<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P<hours>\d+(?:\.\d+)?)$")
|
||||||
|
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
|
||||||
|
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
||||||
|
ROLE_WORDS = {
|
||||||
|
"student": ("学生", "学员", "孩子", "同学"),
|
||||||
|
"teacher": ("老师", "教师"),
|
||||||
|
}
|
||||||
|
DATE_RANGE_SEPARATOR = r"(?:到|至|-|-|~|—|–)"
|
||||||
|
CHINESE_DATE_RANGE_RE = re.compile(
|
||||||
|
rf"(?:(?P<sy>\d{{4}})\s*年\s*)?"
|
||||||
|
rf"(?P<sm>\d{{1,2}})\s*月\s*(?P<sd>\d{{1,2}})\s*[日号]?\s*"
|
||||||
|
rf"{DATE_RANGE_SEPARATOR}\s*"
|
||||||
|
rf"(?:(?P<ey>\d{{4}})\s*年\s*)?"
|
||||||
|
rf"(?:(?P<em>\d{{1,2}})\s*月\s*)?"
|
||||||
|
rf"(?P<ed>\d{{1,2}})\s*[日号]?"
|
||||||
|
)
|
||||||
|
NUMERIC_DATE_RANGE_RE = re.compile(
|
||||||
|
rf"(?<!\d)"
|
||||||
|
rf"(?:(?P<sy>\d{{4}})[./-])?"
|
||||||
|
rf"(?P<sm>\d{{1,2}})[./-](?P<sd>\d{{1,2}})\s*"
|
||||||
|
rf"{DATE_RANGE_SEPARATOR}\s*"
|
||||||
|
rf"(?:(?P<ey>\d{{4}})[./-])?"
|
||||||
|
rf"(?:(?P<em>\d{{1,2}})[./-])?"
|
||||||
|
rf"(?P<ed>\d{{1,2}})"
|
||||||
|
rf"(?!\d)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Payment:
|
||||||
|
date: str
|
||||||
|
hours: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Account:
|
||||||
|
student_id: str
|
||||||
|
student: str
|
||||||
|
payments: list[Payment]
|
||||||
|
remaining: float
|
||||||
|
account_status: str
|
||||||
|
note: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClassRecord:
|
||||||
|
date: str
|
||||||
|
weekday: str
|
||||||
|
time: str
|
||||||
|
student: str
|
||||||
|
duration: str
|
||||||
|
duration_hours: float
|
||||||
|
teacher: str
|
||||||
|
subject: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QuerySpec:
|
||||||
|
raw_query: str
|
||||||
|
start_date: date | None
|
||||||
|
end_date: date | None
|
||||||
|
students: list[str]
|
||||||
|
teachers: list[str]
|
||||||
|
subjects: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateRecordError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_name(name: str) -> str:
|
||||||
|
text = name.strip()
|
||||||
|
return FALLBACK_ALIASES.get(text, text)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hours_text(text: str) -> float:
|
||||||
|
match = re.fullmatch(r"(\d+)小时(\d+)分", text.strip())
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"无法解析时长: {text}")
|
||||||
|
hours, minutes = map(int, match.groups())
|
||||||
|
return hours + minutes / 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def format_number(value: float) -> str:
|
||||||
|
if float(value).is_integer():
|
||||||
|
return str(int(value))
|
||||||
|
return f"{value:.2f}".rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def format_payment(payment: Payment) -> str:
|
||||||
|
return f"{payment.date}:{format_number(payment.hours)}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_account_row(account: Account) -> str:
|
||||||
|
payments = ",".join(format_payment(payment) for payment in account.payments)
|
||||||
|
return (
|
||||||
|
f"| {account.student_id} | {account.student} | {payments} | "
|
||||||
|
f"{format_number(account.remaining)} | {account.account_status} | {account.note} |"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_payments(text: str) -> list[Payment]:
|
||||||
|
payments: list[Payment] = []
|
||||||
|
if not text.strip():
|
||||||
|
return payments
|
||||||
|
for item in text.split(","):
|
||||||
|
item = item.strip()
|
||||||
|
if not item or ":" not in item:
|
||||||
|
continue
|
||||||
|
paid_date, hours_text = item.split(":", 1)
|
||||||
|
payments.append(Payment(date=paid_date.strip(), hours=float(hours_text.strip())))
|
||||||
|
return payments
|
||||||
|
|
||||||
|
|
||||||
|
def read_classnotes(path: Path) -> list[ClassRecord]:
|
||||||
|
records: list[ClassRecord] = []
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
for line_number, raw_line in enumerate(handle, start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
match = CLASSNOTE_RE.fullmatch(line)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"{path}:{line_number} 无法解析课程记录行: {line}")
|
||||||
|
duration = match.group("duration")
|
||||||
|
records.append(
|
||||||
|
ClassRecord(
|
||||||
|
date=match.group("date"),
|
||||||
|
weekday=match.group("weekday"),
|
||||||
|
time=match.group("time"),
|
||||||
|
student=canonical_name(match.group("student")),
|
||||||
|
duration=duration,
|
||||||
|
duration_hours=parse_hours_text(duration),
|
||||||
|
teacher=match.group("teacher").strip(),
|
||||||
|
subject=match.group("subject").strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def read_accounts(path: Path) -> list[Account]:
|
||||||
|
accounts: list[Account] = []
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
for line_number, raw_line in enumerate(handle, start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line.startswith("|") or line.startswith("|---") or "学生ID" in line:
|
||||||
|
continue
|
||||||
|
parts = [part.strip() for part in line.strip("|").split("|")]
|
||||||
|
if len(parts) < 5:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
remaining = float(parts[3])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{path}:{line_number} 无法解析剩余课时: {parts[3]}") from exc
|
||||||
|
accounts.append(
|
||||||
|
Account(
|
||||||
|
student_id=parts[0],
|
||||||
|
student=canonical_name(parts[1]),
|
||||||
|
payments=parse_payments(parts[2]),
|
||||||
|
remaining=remaining,
|
||||||
|
account_status=parts[4],
|
||||||
|
note=parts[5] if len(parts) > 5 else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
def parse_class_record_line(line: str) -> ClassRecord:
|
||||||
|
raw = line.strip()
|
||||||
|
match = CLASSNOTE_RE.fullmatch(raw)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"上课记录格式错误: {line}")
|
||||||
|
|
||||||
|
record_date = parse_record_date(match.group("date"))
|
||||||
|
expected_weekday = WEEKDAYS[record_date.weekday()]
|
||||||
|
if match.group("weekday") != expected_weekday:
|
||||||
|
raise ValueError(f"上课记录星期错误: {line},应为 {expected_weekday}")
|
||||||
|
|
||||||
|
duration = match.group("duration")
|
||||||
|
duration_hours = parse_hours_text(duration)
|
||||||
|
time_hours = parse_time_range_hours(match.group("time"))
|
||||||
|
if abs(duration_hours - time_hours) > 0.01:
|
||||||
|
raise ValueError(f"上课记录时长与时间段不一致: {line}")
|
||||||
|
|
||||||
|
student = canonical_name(match.group("student"))
|
||||||
|
teacher = match.group("teacher").strip()
|
||||||
|
subject = match.group("subject").strip()
|
||||||
|
if not student or not teacher or not subject:
|
||||||
|
raise ValueError(f"上课记录学生、老师、科目不能为空: {line}")
|
||||||
|
|
||||||
|
return ClassRecord(
|
||||||
|
date=match.group("date"),
|
||||||
|
weekday=match.group("weekday"),
|
||||||
|
time=match.group("time"),
|
||||||
|
student=student,
|
||||||
|
duration=duration,
|
||||||
|
duration_hours=duration_hours,
|
||||||
|
teacher=teacher,
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_range_hours(text: str) -> float:
|
||||||
|
match = TIME_RANGE_RE.fullmatch(text.strip())
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"无法解析时间段: {text}")
|
||||||
|
start_hour = int(match.group("sh"))
|
||||||
|
start_minute = int(match.group("sm"))
|
||||||
|
end_hour = int(match.group("eh"))
|
||||||
|
end_minute = int(match.group("em"))
|
||||||
|
if start_hour > 23 or end_hour > 23:
|
||||||
|
raise ValueError(f"时间段小时超出范围: {text}")
|
||||||
|
if start_minute > 59 or end_minute > 59:
|
||||||
|
raise ValueError(f"时间段分钟超出范围: {text}")
|
||||||
|
start_total = start_hour * 60 + start_minute
|
||||||
|
end_total = end_hour * 60 + end_minute
|
||||||
|
if end_total <= start_total:
|
||||||
|
raise ValueError(f"结束时间必须晚于开始时间: {text}")
|
||||||
|
return (end_total - start_total) / 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def class_record_to_line(record: ClassRecord) -> str:
|
||||||
|
return (
|
||||||
|
f"{record.date}-{record.weekday}-{record.time}-{record.student}-"
|
||||||
|
f"{record.duration}-{record.teacher}-{record.subject}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_payment_line(line: str) -> tuple[str, Payment]:
|
||||||
|
raw = line.strip()
|
||||||
|
match = PAYMENT_LINE_RE.fullmatch(raw)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"缴费记录格式错误: {line}")
|
||||||
|
student = canonical_name(match.group("student"))
|
||||||
|
payment_date = match.group("date")
|
||||||
|
try:
|
||||||
|
datetime.strptime(payment_date, "%Y-%m-%d")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"缴费日期不存在: {payment_date}") from exc
|
||||||
|
hours = float(match.group("hours"))
|
||||||
|
if hours <= 0:
|
||||||
|
raise ValueError(f"缴费课时必须大于 0: {line}")
|
||||||
|
return student, Payment(date=payment_date, hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
def recalc_account_status(account: Account) -> str:
|
||||||
|
if account.account_status in {"结课", "退费"}:
|
||||||
|
return account.account_status
|
||||||
|
if account.remaining < 0:
|
||||||
|
return "欠费"
|
||||||
|
if account.remaining < 10:
|
||||||
|
return "预警"
|
||||||
|
return "正常"
|
||||||
|
|
||||||
|
|
||||||
|
def find_account_index(accounts: list[Account], student: str) -> int:
|
||||||
|
canonical_student = canonical_name(student)
|
||||||
|
for index, account in enumerate(accounts):
|
||||||
|
if account.student == canonical_student or account.student_id == canonical_student:
|
||||||
|
return index
|
||||||
|
raise ValueError(f"未找到学生账户: {student}")
|
||||||
|
|
||||||
|
|
||||||
|
def update_account_remaining(account: Account, delta_hours: float) -> Account:
|
||||||
|
updated = replace(account, remaining=round(account.remaining + delta_hours, 2))
|
||||||
|
return replace(updated, account_status=recalc_account_status(updated))
|
||||||
|
|
||||||
|
|
||||||
|
def append_account_payment(account: Account, payment: Payment) -> Account:
|
||||||
|
updated = replace(
|
||||||
|
account,
|
||||||
|
payments=[*account.payments, payment],
|
||||||
|
remaining=round(account.remaining + payment.hours, 2),
|
||||||
|
)
|
||||||
|
return replace(updated, account_status=recalc_account_status(updated))
|
||||||
|
|
||||||
|
|
||||||
|
def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account]) -> str:
|
||||||
|
lines = original_text.splitlines()
|
||||||
|
output: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped:
|
||||||
|
parts = [part.strip() for part in stripped.strip("|").split("|")]
|
||||||
|
if parts and parts[0] in accounts_by_id:
|
||||||
|
output.append(format_account_row(accounts_by_id[parts[0]]))
|
||||||
|
continue
|
||||||
|
output.append(line)
|
||||||
|
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
||||||
|
return "\n".join(output) + trailing_newline
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_text(path: Path, text: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
original_stat = path.stat() if path.exists() else None
|
||||||
|
temp_path: Path | None = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
||||||
|
temp_path = Path(handle.name)
|
||||||
|
handle.write(text)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
if original_stat is not None:
|
||||||
|
os.chmod(temp_path, original_stat.st_mode & 0o7777)
|
||||||
|
try:
|
||||||
|
os.chown(temp_path, original_stat.st_uid, original_stat.st_gid)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
os.replace(temp_path, path)
|
||||||
|
temp_path = None
|
||||||
|
try:
|
||||||
|
dir_fd = os.open(path.parent, os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(dir_fd)
|
||||||
|
finally:
|
||||||
|
os.close(dir_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if temp_path is not None and temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def write_accounts(path: Path, accounts: list[Account]) -> None:
|
||||||
|
original_text = path.read_text(encoding="utf-8")
|
||||||
|
accounts_by_id = {account.student_id: account for account in accounts}
|
||||||
|
atomic_write_text(path, replace_account_lines(original_text, accounts_by_id))
|
||||||
|
|
||||||
|
|
||||||
|
def render_accounts_text(path: Path, accounts: list[Account]) -> str:
|
||||||
|
original_text = path.read_text(encoding="utf-8")
|
||||||
|
accounts_by_id = {account.student_id: account for account in accounts}
|
||||||
|
return replace_account_lines(original_text, accounts_by_id)
|
||||||
|
|
||||||
|
|
||||||
|
def render_accounts_text_from_text(original_text: str, accounts: list[Account]) -> str:
|
||||||
|
accounts_by_id = {account.student_id: account for account in accounts}
|
||||||
|
return replace_account_lines(original_text, accounts_by_id)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_lines(lines: list[str] | None = None, line: str | None = None) -> list[str]:
|
||||||
|
values: list[str] = []
|
||||||
|
if line is not None:
|
||||||
|
values.append(line)
|
||||||
|
if lines is not None:
|
||||||
|
values.extend(lines)
|
||||||
|
result = [item.strip() for item in values if item and item.strip()]
|
||||||
|
if not result:
|
||||||
|
raise ValueError("登记内容不能为空")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def register_class_record_lines(
|
||||||
|
classnotes_path: Path,
|
||||||
|
accounts_path: Path,
|
||||||
|
lines: list[str] | None = None,
|
||||||
|
line: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
input_lines = normalize_lines(lines=lines, line=line)
|
||||||
|
records = [parse_class_record_line(item) for item in input_lines]
|
||||||
|
record_lines = [class_record_to_line(record) for record in records]
|
||||||
|
if len(record_lines) != len(set(record_lines)):
|
||||||
|
raise DuplicateRecordError("本次提交包含重复上课记录")
|
||||||
|
|
||||||
|
existing_lines = {raw.strip() for raw in classnotes_path.read_text(encoding="utf-8").splitlines()}
|
||||||
|
duplicates = [item for item in record_lines if item in existing_lines]
|
||||||
|
if duplicates:
|
||||||
|
raise DuplicateRecordError(f"上课记录已存在: {duplicates[0]}")
|
||||||
|
|
||||||
|
accounts = read_accounts(accounts_path)
|
||||||
|
updated_accounts = list(accounts)
|
||||||
|
updated_account_ids: set[str] = set()
|
||||||
|
for record in records:
|
||||||
|
account_index = find_account_index(updated_accounts, record.student)
|
||||||
|
updated_accounts[account_index] = update_account_remaining(
|
||||||
|
updated_accounts[account_index],
|
||||||
|
-record.duration_hours,
|
||||||
|
)
|
||||||
|
updated_account_ids.add(updated_accounts[account_index].student_id)
|
||||||
|
|
||||||
|
original_classnotes = classnotes_path.read_text(encoding="utf-8")
|
||||||
|
original_accounts = accounts_path.read_text(encoding="utf-8")
|
||||||
|
separator = "" if not original_classnotes or original_classnotes.endswith("\n") else "\n"
|
||||||
|
new_classnotes = f"{original_classnotes}{separator}" + "".join(f"{item}\n" for item in record_lines)
|
||||||
|
updated_accounts_by_id = {
|
||||||
|
account.student_id: account
|
||||||
|
for account in updated_accounts
|
||||||
|
if account.student_id in updated_account_ids
|
||||||
|
}
|
||||||
|
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
|
||||||
|
try:
|
||||||
|
atomic_write_text(accounts_path, new_accounts)
|
||||||
|
atomic_write_text(classnotes_path, new_classnotes)
|
||||||
|
except Exception:
|
||||||
|
atomic_write_text(accounts_path, original_accounts)
|
||||||
|
atomic_write_text(classnotes_path, original_classnotes)
|
||||||
|
raise
|
||||||
|
return {"registered": len(record_lines), "lines": record_lines}
|
||||||
|
|
||||||
|
|
||||||
|
def register_payment_lines(
|
||||||
|
accounts_path: Path,
|
||||||
|
lines: list[str] | None = None,
|
||||||
|
line: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
input_lines = normalize_lines(lines=lines, line=line)
|
||||||
|
payments = [parse_payment_line(item) for item in input_lines]
|
||||||
|
accounts = read_accounts(accounts_path)
|
||||||
|
updated_accounts = list(accounts)
|
||||||
|
updated_account_ids: set[str] = set()
|
||||||
|
registered: list[str] = []
|
||||||
|
for student, payment in payments:
|
||||||
|
account_index = find_account_index(updated_accounts, student)
|
||||||
|
updated_accounts[account_index] = append_account_payment(updated_accounts[account_index], payment)
|
||||||
|
updated_account_ids.add(updated_accounts[account_index].student_id)
|
||||||
|
registered.append(f"{student}-{format_payment(payment)}")
|
||||||
|
original_accounts = accounts_path.read_text(encoding="utf-8")
|
||||||
|
updated_accounts_by_id = {
|
||||||
|
account.student_id: account
|
||||||
|
for account in updated_accounts
|
||||||
|
if account.student_id in updated_account_ids
|
||||||
|
}
|
||||||
|
atomic_write_text(accounts_path, replace_account_lines(original_accounts, updated_accounts_by_id))
|
||||||
|
return {"registered": len(registered), "lines": registered}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_record_date(text: str) -> date:
|
||||||
|
return datetime.strptime(text, "%Y.%m.%d").date()
|
||||||
|
|
||||||
|
|
||||||
|
def month_range(year: int, month: int) -> tuple[date, date]:
|
||||||
|
start = date(year, month, 1)
|
||||||
|
if month == 12:
|
||||||
|
return start, date(year, 12, 31)
|
||||||
|
return start, date(year, month + 1, 1) - timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_date(year: int, month: int, day: int) -> date | None:
|
||||||
|
try:
|
||||||
|
return date(year, month, day)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def safe_month_range(year: int, month: int) -> tuple[date, date] | None:
|
||||||
|
try:
|
||||||
|
return month_range(year, month)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def previous_weekend_range(today: date) -> tuple[date, date]:
|
||||||
|
days_since_monday = today.weekday()
|
||||||
|
current_week_monday = today - timedelta(days=days_since_monday)
|
||||||
|
previous_saturday = current_week_monday - timedelta(days=2)
|
||||||
|
previous_sunday = current_week_monday - timedelta(days=1)
|
||||||
|
return previous_saturday, previous_sunday
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_date_range() -> tuple[date, date]:
|
||||||
|
return date.max, date.min
|
||||||
|
|
||||||
|
|
||||||
|
def merge_optional_range(
|
||||||
|
current: tuple[date | None, date | None],
|
||||||
|
new_range: tuple[date, date] | None,
|
||||||
|
) -> tuple[date, date]:
|
||||||
|
if new_range is None:
|
||||||
|
return merge_range(current, invalid_date_range())
|
||||||
|
return merge_range(current, new_range)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_range(
|
||||||
|
current: tuple[date | None, date | None],
|
||||||
|
new_range: tuple[date, date],
|
||||||
|
) -> tuple[date, date]:
|
||||||
|
current_start, current_end = current
|
||||||
|
new_start, new_end = new_range
|
||||||
|
if current_start is None or current_end is None:
|
||||||
|
return new_start, new_end
|
||||||
|
return max(current_start, new_start), min(current_end, new_end)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_range(
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
end_year_was_explicit: bool,
|
||||||
|
) -> tuple[date, date]:
|
||||||
|
if start <= end:
|
||||||
|
return start, end
|
||||||
|
if not end_year_was_explicit and start.month > end.month:
|
||||||
|
adjusted_end = safe_date(start.year + 1, end.month, end.day)
|
||||||
|
if adjusted_end:
|
||||||
|
return start, adjusted_end
|
||||||
|
return end, start
|
||||||
|
|
||||||
|
|
||||||
|
def parse_explicit_date_range(match: re.Match[str], today: date) -> tuple[date, date] | None:
|
||||||
|
start_year_text = match.group("sy")
|
||||||
|
end_year_text = match.group("ey")
|
||||||
|
start_month = int(match.group("sm"))
|
||||||
|
start_day = int(match.group("sd"))
|
||||||
|
end_month = int(match.group("em")) if match.group("em") else start_month
|
||||||
|
end_day = int(match.group("ed"))
|
||||||
|
start_year = int(start_year_text) if start_year_text else today.year
|
||||||
|
end_year = int(end_year_text) if end_year_text else start_year
|
||||||
|
|
||||||
|
start = safe_date(start_year, start_month, start_day)
|
||||||
|
end = safe_date(end_year, end_month, end_day)
|
||||||
|
if start is None or end is None:
|
||||||
|
return None
|
||||||
|
return normalize_range(start, end, end_year_was_explicit=bool(end_year_text))
|
||||||
|
|
||||||
|
|
||||||
|
def blank_spans(text: str, spans: list[tuple[int, int]]) -> str:
|
||||||
|
chars = list(text)
|
||||||
|
for start, end in spans:
|
||||||
|
for index in range(start, end):
|
||||||
|
chars[index] = " "
|
||||||
|
return "".join(chars)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date_filters(query: str, today: date) -> tuple[date | None, date | None]:
|
||||||
|
result: tuple[date | None, date | None] = (None, None)
|
||||||
|
range_spans: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
for pattern in (CHINESE_DATE_RANGE_RE, NUMERIC_DATE_RANGE_RE):
|
||||||
|
for match in pattern.finditer(query):
|
||||||
|
range_spans.append(match.span())
|
||||||
|
result = merge_optional_range(result, parse_explicit_date_range(match, today))
|
||||||
|
|
||||||
|
single_date_query = blank_spans(query, range_spans)
|
||||||
|
|
||||||
|
if "今天" in query:
|
||||||
|
result = merge_range(result, (today, today))
|
||||||
|
if "昨天" in query:
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
result = merge_range(result, (yesterday, yesterday))
|
||||||
|
if "本月" in query:
|
||||||
|
result = merge_range(result, month_range(today.year, today.month))
|
||||||
|
if "上月" in query:
|
||||||
|
last_month_year = today.year if today.month > 1 else today.year - 1
|
||||||
|
last_month = today.month - 1 if today.month > 1 else 12
|
||||||
|
result = merge_range(result, month_range(last_month_year, last_month))
|
||||||
|
if "上周末" in query:
|
||||||
|
result = merge_range(result, previous_weekend_range(today))
|
||||||
|
|
||||||
|
for match in re.finditer(r"最近\s*(\d+)\s*天", query):
|
||||||
|
days = int(match.group(1))
|
||||||
|
if days < 1:
|
||||||
|
result = merge_range(result, invalid_date_range())
|
||||||
|
else:
|
||||||
|
result = merge_range(result, (today - timedelta(days=days - 1), today))
|
||||||
|
|
||||||
|
for match in re.finditer(r"(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?", single_date_query):
|
||||||
|
year, month, day = map(int, match.groups())
|
||||||
|
day_date = safe_date(year, month, day)
|
||||||
|
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
|
||||||
|
|
||||||
|
for match in re.finditer(r"(?<!\d)(\d{4})[./-](\d{1,2})[./-](\d{1,2})(?!\d)", single_date_query):
|
||||||
|
year, month, day = map(int, match.groups())
|
||||||
|
day_date = safe_date(year, month, day)
|
||||||
|
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
|
||||||
|
|
||||||
|
for match in re.finditer(r"(\d{4})\s*年\s*(\d{1,2})\s*月(?!\s*\d)", single_date_query):
|
||||||
|
year, month = map(int, match.groups())
|
||||||
|
result = merge_optional_range(result, safe_month_range(year, month))
|
||||||
|
|
||||||
|
for match in re.finditer(r"(?<!\d)(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?", single_date_query):
|
||||||
|
month, day = map(int, match.groups())
|
||||||
|
day_date = safe_date(today.year, month, day)
|
||||||
|
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
|
||||||
|
|
||||||
|
for match in re.finditer(r"(?<!\d)(\d{1,2})[./](\d{1,2})(?!\d)", single_date_query):
|
||||||
|
month, day = map(int, match.groups())
|
||||||
|
day_date = safe_date(today.year, month, day)
|
||||||
|
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
|
||||||
|
|
||||||
|
for match in re.finditer(r"(?<!\d)(\d{1,2})\s*月(?!\s*\d)", single_date_query):
|
||||||
|
month = int(match.group(1))
|
||||||
|
result = merge_optional_range(result, safe_month_range(today.year, month))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def unique_sorted(items: Iterable[str]) -> list[str]:
|
||||||
|
return sorted(set(items))
|
||||||
|
|
||||||
|
|
||||||
|
def role_hint_before(query: str, name: str, role: str) -> bool:
|
||||||
|
for match in re.finditer(re.escape(name), query):
|
||||||
|
prefix = query[max(0, match.start() - 4) : match.start()]
|
||||||
|
suffix = query[match.end() : match.end() + 4]
|
||||||
|
if any(word in prefix or word in suffix for word in ROLE_WORDS[role]):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def detect_people(query: str, records: list[ClassRecord]) -> tuple[list[str], list[str]]:
|
||||||
|
students = set()
|
||||||
|
teachers = set()
|
||||||
|
canonical_query = query
|
||||||
|
for alias, canonical in FALLBACK_ALIASES.items():
|
||||||
|
if alias in query and canonical != alias:
|
||||||
|
canonical_query += f" {canonical}"
|
||||||
|
|
||||||
|
for name in unique_sorted(record.student for record in records):
|
||||||
|
if name in canonical_query:
|
||||||
|
students.add(name)
|
||||||
|
for name in unique_sorted(record.teacher for record in records):
|
||||||
|
if name in canonical_query:
|
||||||
|
teachers.add(name)
|
||||||
|
|
||||||
|
for name in sorted(students & teachers):
|
||||||
|
student_hint = role_hint_before(canonical_query, name, "student")
|
||||||
|
teacher_hint = role_hint_before(canonical_query, name, "teacher")
|
||||||
|
if student_hint and not teacher_hint:
|
||||||
|
teachers.discard(name)
|
||||||
|
elif teacher_hint and not student_hint:
|
||||||
|
students.discard(name)
|
||||||
|
|
||||||
|
return sorted(students), sorted(teachers)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_subject_code(text: str) -> str:
|
||||||
|
value = text.strip()
|
||||||
|
if value in SUBJECTS:
|
||||||
|
return value
|
||||||
|
result: list[str] = []
|
||||||
|
index = 0
|
||||||
|
while index < len(value):
|
||||||
|
if value.startswith("道法", index):
|
||||||
|
result.append("道法")
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
char = value[index]
|
||||||
|
result.append(SUBJECT_ALIASES.get(char, char))
|
||||||
|
index += 1
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_subjects(query: str) -> list[str]:
|
||||||
|
subjects = {subject for subject in SUBJECTS if subject in query}
|
||||||
|
for alias, subject in SUBJECT_ALIASES.items():
|
||||||
|
if re.search(rf"(?<![\u4e00-\u9fff]){re.escape(alias)}(?![\u4e00-\u9fff])", query):
|
||||||
|
subjects.add(subject)
|
||||||
|
for match in re.finditer(r"[一二三四五六七八九高初]*([数语英物化生史地政]|道法)+课", query):
|
||||||
|
parsed = parse_subject_code(match.group(0).replace("课", ""))
|
||||||
|
for subject in SUBJECTS:
|
||||||
|
if subject in parsed:
|
||||||
|
subjects.add(subject)
|
||||||
|
return sorted(subjects)
|
||||||
|
|
||||||
|
|
||||||
|
def build_query_spec(query: str, records: list[ClassRecord], today: date | None = None) -> QuerySpec:
|
||||||
|
effective_today = today or date.today()
|
||||||
|
start_date, end_date = parse_date_filters(query, effective_today)
|
||||||
|
students, teachers = detect_people(query, records)
|
||||||
|
subjects = detect_subjects(query)
|
||||||
|
return QuerySpec(
|
||||||
|
raw_query=query,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
students=students,
|
||||||
|
teachers=teachers,
|
||||||
|
subjects=subjects,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_records(records: list[ClassRecord], spec: QuerySpec) -> list[ClassRecord]:
|
||||||
|
matched: list[ClassRecord] = []
|
||||||
|
for record in records:
|
||||||
|
record_date = parse_record_date(record.date)
|
||||||
|
if spec.start_date and record_date < spec.start_date:
|
||||||
|
continue
|
||||||
|
if spec.end_date and record_date > spec.end_date:
|
||||||
|
continue
|
||||||
|
if spec.students and record.student not in spec.students:
|
||||||
|
continue
|
||||||
|
if spec.teachers and record.teacher not in spec.teachers:
|
||||||
|
continue
|
||||||
|
if spec.subjects and not any(subject in record.subject for subject in spec.subjects):
|
||||||
|
continue
|
||||||
|
matched.append(record)
|
||||||
|
return sorted(matched, key=lambda item: (item.date, item.time, item.student, item.teacher, item.subject))
|
||||||
|
|
||||||
|
|
||||||
|
def format_date_range(spec: QuerySpec) -> str:
|
||||||
|
if not spec.start_date and not spec.end_date:
|
||||||
|
return "未限制"
|
||||||
|
if spec.start_date and spec.end_date and spec.start_date > spec.end_date:
|
||||||
|
return "无效日期"
|
||||||
|
if spec.start_date == spec.end_date:
|
||||||
|
return spec.start_date.strftime("%Y.%m.%d")
|
||||||
|
start = spec.start_date.strftime("%Y.%m.%d") if spec.start_date else "不限"
|
||||||
|
end = spec.end_date.strftime("%Y.%m.%d") if spec.end_date else "不限"
|
||||||
|
return f"{start} 至 {end}"
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_records(records: list[ClassRecord]) -> dict:
|
||||||
|
by_student: defaultdict[str, float] = defaultdict(float)
|
||||||
|
by_teacher: defaultdict[str, float] = defaultdict(float)
|
||||||
|
by_subject: defaultdict[str, float] = defaultdict(float)
|
||||||
|
for record in records:
|
||||||
|
by_student[record.student] += record.duration_hours
|
||||||
|
by_teacher[record.teacher] += record.duration_hours
|
||||||
|
by_subject[record.subject] += record.duration_hours
|
||||||
|
return {
|
||||||
|
"count": len(records),
|
||||||
|
"total_hours": round(sum(record.duration_hours for record in records), 2),
|
||||||
|
"students": dict(sorted((key, round(value, 2)) for key, value in by_student.items())),
|
||||||
|
"teachers": dict(sorted((key, round(value, 2)) for key, value in by_teacher.items())),
|
||||||
|
"subjects": dict(sorted((key, round(value, 2)) for key, value in by_subject.items())),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def record_to_dict(record: ClassRecord) -> dict:
|
||||||
|
return {
|
||||||
|
"date": record.date,
|
||||||
|
"weekday": record.weekday,
|
||||||
|
"time": record.time,
|
||||||
|
"student": record.student,
|
||||||
|
"duration": record.duration,
|
||||||
|
"duration_hours": record.duration_hours,
|
||||||
|
"teacher": record.teacher,
|
||||||
|
"subject": record.subject,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def account_to_dict(account: Account) -> dict:
|
||||||
|
return {
|
||||||
|
"student_id": account.student_id,
|
||||||
|
"student": account.student,
|
||||||
|
"payments": [{"date": payment.date, "hours": payment.hours} for payment in account.payments],
|
||||||
|
"payments_count": len(account.payments),
|
||||||
|
"remaining": account.remaining,
|
||||||
|
"account_status": account.account_status,
|
||||||
|
"note": account.note,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def has_filter_condition(spec: QuerySpec) -> bool:
|
||||||
|
return bool(spec.start_date or spec.end_date or spec.students or spec.teachers or spec.subjects)
|
||||||
|
|
||||||
|
|
||||||
|
def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> dict:
|
||||||
|
spec = build_query_spec(query, records)
|
||||||
|
matched = filter_records(records, spec) if has_filter_condition(spec) else []
|
||||||
|
shown = matched[:limit] if limit > 0 else matched
|
||||||
|
return {
|
||||||
|
"query": {
|
||||||
|
"raw_query": spec.raw_query,
|
||||||
|
"date_range": format_date_range(spec),
|
||||||
|
"students": spec.students,
|
||||||
|
"teachers": spec.teachers,
|
||||||
|
"subjects": spec.subjects,
|
||||||
|
},
|
||||||
|
"summary": summarize_records(matched),
|
||||||
|
"records": [record_to_dict(record) for record in shown],
|
||||||
|
"total_records": len(matched),
|
||||||
|
"shown_records": len(shown),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def account_summary(accounts: list[Account]) -> dict:
|
||||||
|
result = {
|
||||||
|
"total": len(accounts),
|
||||||
|
"normal": 0,
|
||||||
|
"warning": 0,
|
||||||
|
"debt": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"refunded": 0,
|
||||||
|
"other": 0,
|
||||||
|
}
|
||||||
|
for account in accounts:
|
||||||
|
status = account.account_status
|
||||||
|
if status == "正常":
|
||||||
|
result["normal"] += 1
|
||||||
|
elif status == "预警":
|
||||||
|
result["warning"] += 1
|
||||||
|
elif status == "欠费":
|
||||||
|
result["debt"] += 1
|
||||||
|
elif status == "结课":
|
||||||
|
result["completed"] += 1
|
||||||
|
elif status == "退费":
|
||||||
|
result["refunded"] += 1
|
||||||
|
else:
|
||||||
|
result["other"] += 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def filter_accounts(accounts: list[Account], keyword: str = "", status: str = "") -> list[Account]:
|
||||||
|
keyword = keyword.strip()
|
||||||
|
status = status.strip()
|
||||||
|
result = []
|
||||||
|
for account in accounts:
|
||||||
|
if keyword and keyword not in account.student and keyword not in account.student_id:
|
||||||
|
continue
|
||||||
|
if status and account.account_status != status:
|
||||||
|
continue
|
||||||
|
result.append(account)
|
||||||
|
return sorted(result, key=lambda item: (item.remaining, item.student))
|
||||||
+608
@@ -0,0 +1,608 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
from urllib.parse import parse_qs, quote
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||||
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
|
from .data import (
|
||||||
|
DuplicateRecordError,
|
||||||
|
account_summary,
|
||||||
|
account_to_dict,
|
||||||
|
filter_accounts,
|
||||||
|
query_records,
|
||||||
|
read_accounts,
|
||||||
|
read_classnotes,
|
||||||
|
register_class_record_lines,
|
||||||
|
register_payment_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
|
STATIC_DIR = APP_DIR / "static"
|
||||||
|
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
|
||||||
|
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
|
||||||
|
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
|
||||||
|
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
|
||||||
|
RECORDS_SESSION_COOKIE = "xsk_records_session"
|
||||||
|
ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
|
||||||
|
SESSION_MAX_AGE = 60 * 60 * 24 * 30
|
||||||
|
|
||||||
|
app = FastAPI(title="新时空课程记录查询", version="1.0.0")
|
||||||
|
security = HTTPBasic(auto_error=False)
|
||||||
|
write_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterLinesPayload(BaseModel):
|
||||||
|
line: str | None = Field(default=None, description="单条原始登记文本")
|
||||||
|
lines: list[str] | None = Field(default=None, description="多条原始登记文本")
|
||||||
|
|
||||||
|
|
||||||
|
async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
||||||
|
body = await request.body()
|
||||||
|
if not body.strip():
|
||||||
|
return RegisterLinesPayload()
|
||||||
|
|
||||||
|
content_type = request.headers.get("content-type", "").lower()
|
||||||
|
if "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
if isinstance(data, str):
|
||||||
|
return RegisterLinesPayload(line=data)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return RegisterLinesPayload(lines=data)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return RegisterLinesPayload(**data)
|
||||||
|
except (json.JSONDecodeError, ValidationError) as exc:
|
||||||
|
raise ValueError("登记 JSON 格式错误") from exc
|
||||||
|
raise ValueError("登记 JSON 必须是字符串、字符串数组,或包含 line/lines 的对象")
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = body.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ValueError("登记内容必须使用 UTF-8 编码") from exc
|
||||||
|
return RegisterLinesPayload(lines=text.splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def configured_password(label: str, password: str) -> str:
|
||||||
|
if not password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"服务未配置{label}访问密码",
|
||||||
|
)
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
|
def session_token(password: str, purpose: bytes) -> str:
|
||||||
|
return hmac.new(
|
||||||
|
password.encode("utf-8"),
|
||||||
|
purpose,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def has_valid_session(request: Request, cookie_name: str, password: str, purpose: bytes) -> bool:
|
||||||
|
token = request.cookies.get(cookie_name, "")
|
||||||
|
return bool(password and token) and hmac.compare_digest(token, session_token(password, purpose))
|
||||||
|
|
||||||
|
|
||||||
|
def has_valid_basic_auth(credentials: HTTPBasicCredentials | None, password: str) -> bool:
|
||||||
|
if credentials is None:
|
||||||
|
return False
|
||||||
|
return bool(password) and hmac.compare_digest(credentials.password, password)
|
||||||
|
|
||||||
|
|
||||||
|
def is_records_authenticated(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = None,
|
||||||
|
) -> bool:
|
||||||
|
return has_valid_session(
|
||||||
|
request,
|
||||||
|
RECORDS_SESSION_COOKIE,
|
||||||
|
BASIC_AUTH_PASSWORD,
|
||||||
|
b"xsk-records-web-session-v1",
|
||||||
|
) or has_valid_basic_auth(credentials, BASIC_AUTH_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
|
def is_accounts_authenticated(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = None,
|
||||||
|
) -> bool:
|
||||||
|
return has_valid_session(
|
||||||
|
request,
|
||||||
|
ACCOUNTS_SESSION_COOKIE,
|
||||||
|
ACCOUNTS_AUTH_PASSWORD,
|
||||||
|
b"xsk-accounts-web-session-v1",
|
||||||
|
) or has_valid_basic_auth(credentials, ACCOUNTS_AUTH_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_records_auth(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
) -> str:
|
||||||
|
configured_password("课程记录", BASIC_AUTH_PASSWORD)
|
||||||
|
if not is_records_authenticated(request, credentials):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="请先登录",
|
||||||
|
)
|
||||||
|
return "records"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_accounts_auth(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
) -> str:
|
||||||
|
configured_password("课时账户", ACCOUNTS_AUTH_PASSWORD)
|
||||||
|
if not is_accounts_authenticated(request, credentials):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="请先登录课时账户",
|
||||||
|
)
|
||||||
|
return "accounts"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_any_auth(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
) -> str:
|
||||||
|
if is_records_authenticated(request, credentials) or is_accounts_authenticated(request, credentials):
|
||||||
|
return "authenticated"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="请先登录",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_next_path(value: str | None) -> str:
|
||||||
|
if not value or not value.startswith("/") or value.startswith("//"):
|
||||||
|
return "/"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def render_login_page(
|
||||||
|
title: str,
|
||||||
|
action: str,
|
||||||
|
next_path: str = "/",
|
||||||
|
has_error: bool = False,
|
||||||
|
) -> str:
|
||||||
|
escaped_title = html.escape(title)
|
||||||
|
escaped_action = html.escape(action, quote=True)
|
||||||
|
escaped_next = html.escape(next_path, quote=True)
|
||||||
|
error_html = (
|
||||||
|
'<p class="error">密码不正确,请重新输入。</p>'
|
||||||
|
if has_error
|
||||||
|
else '<p class="hint">请输入访问密码。</p>'
|
||||||
|
)
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{escaped_title}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #18202a;
|
||||||
|
--muted: #687487;
|
||||||
|
--line: #d9dee7;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-strong: #0b5d57;
|
||||||
|
--danger: #b42318;
|
||||||
|
font-family: Arial, "Songti SC", SimSun, sans-serif;
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}}
|
||||||
|
main {{
|
||||||
|
width: min(100%, 380px);
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 12px 28px rgba(20, 31, 46, 0.08);
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}}
|
||||||
|
.error {{ color: var(--danger); }}
|
||||||
|
label {{
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
input {{
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}}
|
||||||
|
.password-field {{
|
||||||
|
position: relative;
|
||||||
|
}}
|
||||||
|
.password-field input {{
|
||||||
|
padding-right: 48px;
|
||||||
|
}}
|
||||||
|
input:focus {{
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
|
||||||
|
}}
|
||||||
|
button {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-top: 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
button:hover {{ background: var(--accent-strong); }}
|
||||||
|
.password-toggle {{
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 1px;
|
||||||
|
width: 42px;
|
||||||
|
min-height: 42px;
|
||||||
|
margin-top: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
}}
|
||||||
|
.password-toggle:hover {{
|
||||||
|
background: #eef2f6;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}}
|
||||||
|
.password-toggle svg {{
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 0 auto;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}}
|
||||||
|
.password-toggle .eye-open {{
|
||||||
|
display: none;
|
||||||
|
}}
|
||||||
|
.password-toggle.is-visible .eye-open {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.password-toggle.is-visible .eye-closed {{
|
||||||
|
display: none;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>{escaped_title}</h1>
|
||||||
|
{error_html}
|
||||||
|
<form method="post" action="{escaped_action}" autocomplete="off">
|
||||||
|
<input type="hidden" name="next" value="{escaped_next}" />
|
||||||
|
<label for="password">访问密码</label>
|
||||||
|
<div class="password-field">
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" autofocus required />
|
||||||
|
<button id="togglePassword" class="password-toggle" type="button" aria-label="显示密码" title="显示密码">
|
||||||
|
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M17.94 17.94A10.8 10.8 0 0 1 12 19C5.5 19 2 12 2 12a18.4 18.4 0 0 1 5.06-5.94" />
|
||||||
|
<path d="M9.9 4.24A10.9 10.9 0 0 1 12 4.05C18.5 4.05 22 12 22 12a18.5 18.5 0 0 1-2.16 3.19" />
|
||||||
|
<path d="M14.12 14.12A3 3 0 0 1 9.88 9.88" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit">进入</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const passwordInput = document.querySelector("#password");
|
||||||
|
const togglePassword = document.querySelector("#togglePassword");
|
||||||
|
togglePassword.addEventListener("click", () => {{
|
||||||
|
const isVisible = passwordInput.type === "text";
|
||||||
|
passwordInput.type = isVisible ? "password" : "text";
|
||||||
|
togglePassword.classList.toggle("is-visible", !isVisible);
|
||||||
|
const label = isVisible ? "显示密码" : "隐藏密码";
|
||||||
|
togglePassword.setAttribute("aria-label", label);
|
||||||
|
togglePassword.setAttribute("title", label);
|
||||||
|
passwordInput.focus();
|
||||||
|
}});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_records():
|
||||||
|
if not CLASSNOTES_PATH.exists():
|
||||||
|
raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
|
||||||
|
return read_classnotes(CLASSNOTES_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def load_accounts():
|
||||||
|
if not ACCOUNTS_PATH.exists():
|
||||||
|
raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
|
||||||
|
return read_accounts(ACCOUNTS_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def file_meta(path: Path) -> dict:
|
||||||
|
if not path.exists():
|
||||||
|
return {"exists": False, "path": str(path)}
|
||||||
|
stat = path.stat()
|
||||||
|
return {
|
||||||
|
"exists": True,
|
||||||
|
"path": str(path),
|
||||||
|
"size": stat.st_size,
|
||||||
|
"mtime": stat.st_mtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(ValueError)
|
||||||
|
async def value_error_handler(_request: Request, exc: ValueError):
|
||||||
|
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
):
|
||||||
|
if not is_records_authenticated(request, credentials):
|
||||||
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.head("/")
|
||||||
|
def index_head(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
):
|
||||||
|
if not is_records_authenticated(request, credentials):
|
||||||
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
def login_page(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
):
|
||||||
|
next_path = safe_next_path(request.query_params.get("next"))
|
||||||
|
has_error = request.query_params.get("error") == "1"
|
||||||
|
if is_records_authenticated(request, credentials):
|
||||||
|
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
return HTMLResponse(
|
||||||
|
render_login_page(
|
||||||
|
title="新时空课程记录查询",
|
||||||
|
action="/login",
|
||||||
|
next_path=next_path,
|
||||||
|
has_error=has_error,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login")
|
||||||
|
async def login_submit(request: Request):
|
||||||
|
body = (await request.body()).decode("utf-8")
|
||||||
|
form = parse_qs(body, keep_blank_values=True)
|
||||||
|
password = form.get("password", [""])[0]
|
||||||
|
next_path = safe_next_path(form.get("next", ["/"])[0])
|
||||||
|
records_password = configured_password("课程记录", BASIC_AUTH_PASSWORD)
|
||||||
|
if hmac.compare_digest(password, records_password):
|
||||||
|
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.set_cookie(
|
||||||
|
RECORDS_SESSION_COOKIE,
|
||||||
|
session_token(records_password, b"xsk-records-web-session-v1"),
|
||||||
|
max_age=SESSION_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
error_url = f"/login?error=1&next={quote(next_path)}"
|
||||||
|
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logout")
|
||||||
|
def logout():
|
||||||
|
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.delete_cookie(RECORDS_SESSION_COOKIE)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/accounts")
|
||||||
|
def accounts_index(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
):
|
||||||
|
if not is_accounts_authenticated(request, credentials):
|
||||||
|
return RedirectResponse(url="/accounts/login?next=/accounts", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
return FileResponse(STATIC_DIR / "accounts.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/accounts/login")
|
||||||
|
def accounts_login_page(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
):
|
||||||
|
next_path = safe_next_path(request.query_params.get("next") or "/accounts")
|
||||||
|
has_error = request.query_params.get("error") == "1"
|
||||||
|
if is_accounts_authenticated(request, credentials):
|
||||||
|
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
return HTMLResponse(
|
||||||
|
render_login_page(
|
||||||
|
title="课时账户查询",
|
||||||
|
action="/accounts/login",
|
||||||
|
next_path=next_path,
|
||||||
|
has_error=has_error,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/accounts/login")
|
||||||
|
async def accounts_login_submit(request: Request):
|
||||||
|
body = (await request.body()).decode("utf-8")
|
||||||
|
form = parse_qs(body, keep_blank_values=True)
|
||||||
|
password = form.get("password", [""])[0]
|
||||||
|
next_path = safe_next_path(form.get("next", ["/accounts"])[0])
|
||||||
|
accounts_password = configured_password("课时账户", ACCOUNTS_AUTH_PASSWORD)
|
||||||
|
if hmac.compare_digest(password, accounts_password):
|
||||||
|
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.set_cookie(
|
||||||
|
ACCOUNTS_SESSION_COOKIE,
|
||||||
|
session_token(accounts_password, b"xsk-accounts-web-session-v1"),
|
||||||
|
max_age=SESSION_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
error_url = f"/accounts/login?error=1&next={quote(next_path)}"
|
||||||
|
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/accounts/logout")
|
||||||
|
def accounts_logout():
|
||||||
|
response = RedirectResponse(url="/accounts/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/static/{asset_path:path}")
|
||||||
|
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
|
||||||
|
target = (STATIC_DIR / asset_path).resolve()
|
||||||
|
static_root = STATIC_DIR.resolve()
|
||||||
|
if not target.is_file() or static_root not in target.parents:
|
||||||
|
raise HTTPException(status_code=404, detail="静态资源不存在")
|
||||||
|
return FileResponse(target)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/register/class-records")
|
||||||
|
async def register_class_records(request: Request, _user: str = Depends(verify_accounts_auth)):
|
||||||
|
try:
|
||||||
|
payload = await read_register_payload(request)
|
||||||
|
with write_lock:
|
||||||
|
result = register_class_record_lines(
|
||||||
|
CLASSNOTES_PATH,
|
||||||
|
ACCOUNTS_PATH,
|
||||||
|
lines=payload.lines,
|
||||||
|
line=payload.line,
|
||||||
|
)
|
||||||
|
except DuplicateRecordError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/register/payments")
|
||||||
|
async def register_payments(request: Request, _user: str = Depends(verify_accounts_auth)):
|
||||||
|
try:
|
||||||
|
payload = await read_register_payload(request)
|
||||||
|
with write_lock:
|
||||||
|
result = register_payment_lines(ACCOUNTS_PATH, lines=payload.lines, line=payload.line)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health(_user: str = Depends(verify_records_auth)):
|
||||||
|
records = load_records()
|
||||||
|
accounts = load_accounts()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"classnotes": file_meta(CLASSNOTES_PATH),
|
||||||
|
"accounts": file_meta(ACCOUNTS_PATH),
|
||||||
|
"records_count": len(records),
|
||||||
|
"accounts_count": len(accounts),
|
||||||
|
"account_summary": account_summary(accounts),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/records")
|
||||||
|
def records(
|
||||||
|
q: str = Query(..., min_length=1, description="自然语言查询,例如:王鑫鹏5月数学课"),
|
||||||
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
|
_user: str = Depends(verify_records_auth),
|
||||||
|
):
|
||||||
|
return query_records(load_records(), q, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/student-account/{student}")
|
||||||
|
def record_student_account(student: str, _user: str = Depends(verify_records_auth)):
|
||||||
|
for account in load_accounts():
|
||||||
|
if account.student == student or account.student_id == student:
|
||||||
|
return account_to_dict(account)
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/account-health")
|
||||||
|
def account_health(_user: str = Depends(verify_accounts_auth)):
|
||||||
|
accounts = load_accounts()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"accounts": file_meta(ACCOUNTS_PATH),
|
||||||
|
"accounts_count": len(accounts),
|
||||||
|
"account_summary": account_summary(accounts),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/accounts")
|
||||||
|
def accounts(
|
||||||
|
q: str = Query("", description="学生姓名或学生ID"),
|
||||||
|
status_filter: str = Query("", alias="status", description="账户状态"),
|
||||||
|
_user: str = Depends(verify_accounts_auth),
|
||||||
|
):
|
||||||
|
all_accounts = load_accounts()
|
||||||
|
rows = filter_accounts(all_accounts, keyword=q, status=status_filter)
|
||||||
|
return {
|
||||||
|
"summary": account_summary(all_accounts),
|
||||||
|
"count": len(rows),
|
||||||
|
"accounts": [account_to_dict(account) for account in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/accounts/{student}")
|
||||||
|
def account_detail(student: str, _user: str = Depends(verify_accounts_auth)):
|
||||||
|
for account in load_accounts():
|
||||||
|
if account.student == student or account.student_id == student:
|
||||||
|
return account_to_dict(account)
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>课时账户查询</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css?v=20260611-accounts-split" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>课时账户查询</h1>
|
||||||
|
<p id="accountHealthText">正在读取账户状态</p>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<a class="nav-button" href="/">课程记录</a>
|
||||||
|
<button id="refreshBtn" class="icon-button" title="刷新账户" type="button" aria-label="刷新账户">
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout account-layout">
|
||||||
|
<section class="panel accounts-panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>课时账户</h2>
|
||||||
|
<select id="accountStatus" aria-label="账户状态筛选">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="欠费">欠费</option>
|
||||||
|
<option value="预警">预警</option>
|
||||||
|
<option value="正常">正常</option>
|
||||||
|
<option value="结课">结课</option>
|
||||||
|
<option value="退费">退费</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<form id="accountForm" class="search-row account-search-row">
|
||||||
|
<input id="accountQuery" autocomplete="off" placeholder="学生姓名或学生ID" />
|
||||||
|
<button type="submit">查询</button>
|
||||||
|
</form>
|
||||||
|
<div id="accountMeta" class="summary-grid"></div>
|
||||||
|
<div class="table-wrap account-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>学生</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th class="num">剩余课时</th>
|
||||||
|
<th>缴费记录</th>
|
||||||
|
<th>备注</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="accountRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/accounts.js?v=20260611-accounts-split"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
const accountHealthText = document.querySelector("#accountHealthText");
|
||||||
|
const refreshBtn = document.querySelector("#refreshBtn");
|
||||||
|
const accountForm = document.querySelector("#accountForm");
|
||||||
|
const accountQuery = document.querySelector("#accountQuery");
|
||||||
|
const accountStatus = document.querySelector("#accountStatus");
|
||||||
|
const accountMeta = document.querySelector("#accountMeta");
|
||||||
|
const accountRows = document.querySelector("#accountRows");
|
||||||
|
|
||||||
|
function fmtHours(value) {
|
||||||
|
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(seconds) {
|
||||||
|
if (!seconds) return "未知";
|
||||||
|
return new Date(seconds * 1000).toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(label, value) {
|
||||||
|
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status) {
|
||||||
|
if (status === "欠费") return "debt";
|
||||||
|
if (status === "预警") return "warning";
|
||||||
|
if (status === "正常") return "normal";
|
||||||
|
return "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPayments(payments) {
|
||||||
|
if (!payments.length) return "暂无";
|
||||||
|
return payments.map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)} 小时`).join("<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
const response = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = `${response.status} ${response.statusText}`;
|
||||||
|
try {
|
||||||
|
const payload = await response.json();
|
||||||
|
detail = payload.detail || detail;
|
||||||
|
} catch (_error) {
|
||||||
|
detail = response.statusText || detail;
|
||||||
|
}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(summary, count) {
|
||||||
|
accountMeta.innerHTML = [
|
||||||
|
metric("当前结果", `${count} 人`),
|
||||||
|
metric("欠费", summary.debt),
|
||||||
|
metric("预警", summary.warning),
|
||||||
|
metric("正常", summary.normal),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccountHealth() {
|
||||||
|
try {
|
||||||
|
const data = await fetchJson("/api/account-health");
|
||||||
|
accountHealthText.textContent = `账户 ${data.accounts_count} 人;数据更新时间 ${fmtTime(data.accounts.mtime)}`;
|
||||||
|
} catch (error) {
|
||||||
|
accountHealthText.textContent = `读取失败:${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAccounts() {
|
||||||
|
accountRows.innerHTML = `<tr><td colspan="5" class="empty">正在读取</td></tr>`;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (accountQuery.value.trim()) params.set("q", accountQuery.value.trim());
|
||||||
|
if (accountStatus.value) params.set("status", accountStatus.value);
|
||||||
|
try {
|
||||||
|
const data = await fetchJson(`/api/accounts?${params.toString()}`);
|
||||||
|
renderSummary(data.summary, data.count);
|
||||||
|
accountRows.innerHTML = data.accounts
|
||||||
|
.map(
|
||||||
|
(row) => `<tr>
|
||||||
|
<td>${escapeHtml(row.student)}<br><small>${escapeHtml(row.student_id)}</small></td>
|
||||||
|
<td><span class="status ${statusClass(row.account_status)}">${escapeHtml(row.account_status)}</span></td>
|
||||||
|
<td class="num">${fmtHours(row.remaining)}</td>
|
||||||
|
<td>${renderPayments(row.payments)}</td>
|
||||||
|
<td class="note-cell">${escapeHtml(row.note || "")}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
if (!data.accounts.length) {
|
||||||
|
accountRows.innerHTML = `<tr><td colspan="5" class="empty">没有符合条件的账户</td></tr>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
accountRows.innerHTML = `<tr><td colspan="5" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accountForm.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
loadAccounts();
|
||||||
|
});
|
||||||
|
|
||||||
|
accountStatus.addEventListener("change", loadAccounts);
|
||||||
|
|
||||||
|
refreshBtn.addEventListener("click", () => {
|
||||||
|
loadAccountHealth();
|
||||||
|
loadAccounts();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadAccountHealth();
|
||||||
|
loadAccounts();
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
const healthText = document.querySelector("#healthText");
|
||||||
|
const refreshBtn = document.querySelector("#refreshBtn");
|
||||||
|
const recordForm = document.querySelector("#recordForm");
|
||||||
|
const recordQuery = document.querySelector("#recordQuery");
|
||||||
|
const recordMeta = document.querySelector("#recordMeta");
|
||||||
|
const recordRows = document.querySelector("#recordRows");
|
||||||
|
const inlineAccount = document.querySelector("#inlineAccount");
|
||||||
|
const correctionToolbar = document.querySelector("#correctionToolbar");
|
||||||
|
const correctionStatus = document.querySelector("#correctionStatus");
|
||||||
|
const copyCorrectedBtn = document.querySelector("#copyCorrectedBtn");
|
||||||
|
const correctionDialog = document.querySelector("#correctionDialog");
|
||||||
|
const correctionForm = document.querySelector("#correctionForm");
|
||||||
|
const correctionOriginal = document.querySelector("#correctionOriginal");
|
||||||
|
const correctionPreview = document.querySelector("#correctionPreview");
|
||||||
|
const correctionError = document.querySelector("#correctionError");
|
||||||
|
const correctionDate = document.querySelector("#correctionDate");
|
||||||
|
const correctionTime = document.querySelector("#correctionTime");
|
||||||
|
const correctionStudent = document.querySelector("#correctionStudent");
|
||||||
|
const correctionTeacher = document.querySelector("#correctionTeacher");
|
||||||
|
const correctionSubject = document.querySelector("#correctionSubject");
|
||||||
|
const closeCorrectionBtn = document.querySelector("#closeCorrectionBtn");
|
||||||
|
const cancelCorrectionBtn = document.querySelector("#cancelCorrectionBtn");
|
||||||
|
|
||||||
|
const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
|
||||||
|
const COPY_LINE_BREAK = "\r\n";
|
||||||
|
let currentRecords = [];
|
||||||
|
let currentRecordOrder = [];
|
||||||
|
let correctedRecords = new Map();
|
||||||
|
let activeCorrectionKey = "";
|
||||||
|
|
||||||
|
function fmtHours(value) {
|
||||||
|
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(seconds) {
|
||||||
|
if (!seconds) return "未知";
|
||||||
|
return new Date(seconds * 1000).toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(label, value) {
|
||||||
|
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRecordKey(row, index) {
|
||||||
|
return JSON.stringify([index, row.date, row.time, row.student, row.duration, row.teacher, row.subject]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecordLine(row) {
|
||||||
|
return `${row.date}-${row.weekday}-${row.time}-${row.student}-${row.duration}-${row.teacher}-${row.subject}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupRecordsByTeacher(records) {
|
||||||
|
const groups = new Map();
|
||||||
|
records.forEach((row) => {
|
||||||
|
const teacher = row.teacher || "未标注老师";
|
||||||
|
if (!groups.has(teacher)) {
|
||||||
|
groups.set(teacher, { teacher, count: 0, totalHours: 0, records: [] });
|
||||||
|
}
|
||||||
|
const group = groups.get(teacher);
|
||||||
|
group.count += 1;
|
||||||
|
group.totalHours += Number(row.duration_hours || 0);
|
||||||
|
group.records.push(row);
|
||||||
|
});
|
||||||
|
return Array.from(groups.values()).sort((a, b) => {
|
||||||
|
if (b.totalHours !== a.totalHours) return b.totalHours - a.totalHours;
|
||||||
|
return a.teacher.localeCompare(b.teacher, "zh-CN");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecordRow(row) {
|
||||||
|
const key = row._recordKey;
|
||||||
|
const corrected = correctedRecords.get(key);
|
||||||
|
const displayRow = corrected || row;
|
||||||
|
const correctedClass = corrected ? " corrected-row" : "";
|
||||||
|
const actionLabel = corrected ? "编辑" : "纠错";
|
||||||
|
const badge = corrected ? '<span class="correction-badge">已修改</span>' : "";
|
||||||
|
return `<tr class="record-row${correctedClass}">
|
||||||
|
<td>${escapeHtml(displayRow.date)} ${escapeHtml(displayRow.weekday)}</td>
|
||||||
|
<td>${escapeHtml(displayRow.time)}</td>
|
||||||
|
<td>${escapeHtml(displayRow.student)}</td>
|
||||||
|
<td>${escapeHtml(displayRow.teacher)}</td>
|
||||||
|
<td>${escapeHtml(displayRow.subject)}</td>
|
||||||
|
<td class="num">${escapeHtml(displayRow.duration)}</td>
|
||||||
|
<td class="record-action-cell">
|
||||||
|
<div class="record-actions">
|
||||||
|
<button class="small-button correction-edit" type="button" data-record-key="${escapeHtml(key)}">${actionLabel}</button>
|
||||||
|
${badge}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupedRecords(records) {
|
||||||
|
currentRecordOrder = [];
|
||||||
|
return groupRecordsByTeacher(records)
|
||||||
|
.map(
|
||||||
|
(group) => `<tr class="teacher-group">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="teacher-group-title">
|
||||||
|
<strong>${escapeHtml(group.teacher)}</strong>
|
||||||
|
<span>${group.count} 条记录 · ${fmtHours(group.totalHours)} 小时</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>${group.records
|
||||||
|
.map((row) => {
|
||||||
|
currentRecordOrder.push(row._recordKey);
|
||||||
|
return renderRecordRow(row);
|
||||||
|
})
|
||||||
|
.join("")}`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status) {
|
||||||
|
if (status === "欠费") return "debt";
|
||||||
|
if (status === "预警") return "warning";
|
||||||
|
if (status === "正常") return "normal";
|
||||||
|
return "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPayments(payments) {
|
||||||
|
if (!payments.length) return "暂无缴费记录";
|
||||||
|
return payments.map((item) => `${item.date}:${fmtHours(item.hours)} 小时`).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineAccount(account) {
|
||||||
|
inlineAccount.hidden = false;
|
||||||
|
inlineAccount.innerHTML = `<div class="inline-account-head">
|
||||||
|
<div>
|
||||||
|
<h3>${escapeHtml(account.student)} 课时账户</h3>
|
||||||
|
<p>${escapeHtml(account.student_id)}</p>
|
||||||
|
</div>
|
||||||
|
<span class="status ${statusClass(account.account_status)}">${escapeHtml(account.account_status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-account-grid">
|
||||||
|
${metric("剩余课时", fmtHours(account.remaining))}
|
||||||
|
${metric("缴费次数", account.payments_count)}
|
||||||
|
${metric("缴费记录", renderPayments(account.payments))}
|
||||||
|
${metric("备注", account.note || "无")}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInlineAccount(student) {
|
||||||
|
inlineAccount.hidden = false;
|
||||||
|
inlineAccount.innerHTML = `<div class="inline-account-loading">正在读取 ${escapeHtml(student)} 的课时账户</div>`;
|
||||||
|
try {
|
||||||
|
const account = await fetchJson(`/api/student-account/${encodeURIComponent(student)}`);
|
||||||
|
renderInlineAccount(account);
|
||||||
|
} catch (error) {
|
||||||
|
inlineAccount.innerHTML = `<div class="inline-account-loading">课时账户读取失败:${escapeHtml(error.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInlineAccount() {
|
||||||
|
inlineAccount.hidden = true;
|
||||||
|
inlineAccount.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCorrectionToolbar(message = "", isError = false) {
|
||||||
|
const count = correctedRecords.size;
|
||||||
|
correctionToolbar.hidden = count === 0;
|
||||||
|
copyCorrectedBtn.disabled = count === 0;
|
||||||
|
copyCorrectedBtn.textContent = `复制已修改记录 ${count} 条`;
|
||||||
|
correctionStatus.textContent = message || `已修改 ${count} 条记录`;
|
||||||
|
correctionStatus.classList.toggle("is-error", isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCorrections() {
|
||||||
|
correctedRecords = new Map();
|
||||||
|
currentRecordOrder = [];
|
||||||
|
activeCorrectionKey = "";
|
||||||
|
updateCorrectionToolbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCorrectionDate(value) {
|
||||||
|
const match = value.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("日期格式应为 YYYY.MM.DD,例如 2026.06.10");
|
||||||
|
}
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
const parsed = new Date(year, month - 1, day);
|
||||||
|
if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
|
||||||
|
throw new Error("日期不存在,请检查年月日");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
date: `${year}.${String(month).padStart(2, "0")}.${String(day).padStart(2, "0")}`,
|
||||||
|
weekday: WEEKDAYS[parsed.getDay()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCorrectionTime(value) {
|
||||||
|
const match = value.trim().match(/^([01]?\d|2[0-3]):([0-5]\d)-([01]?\d|2[0-3]):([0-5]\d)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("时间格式应为 HH:MM-HH:MM,例如 08:00-10:00");
|
||||||
|
}
|
||||||
|
const startHour = Number(match[1]);
|
||||||
|
const startMinute = Number(match[2]);
|
||||||
|
const endHour = Number(match[3]);
|
||||||
|
const endMinute = Number(match[4]);
|
||||||
|
const startTotal = startHour * 60 + startMinute;
|
||||||
|
const endTotal = endHour * 60 + endMinute;
|
||||||
|
if (endTotal <= startTotal) {
|
||||||
|
throw new Error("结束时间必须晚于开始时间");
|
||||||
|
}
|
||||||
|
const totalMinutes = endTotal - startTotal;
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return {
|
||||||
|
time: `${String(startHour).padStart(2, "0")}:${String(startMinute).padStart(2, "0")}-${String(endHour).padStart(2, "0")}:${String(endMinute).padStart(2, "0")}`,
|
||||||
|
duration: `${hours}小时${minutes}分`,
|
||||||
|
duration_hours: Math.round((totalMinutes / 60) * 100) / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireText(value, label) {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new Error(`${label}不能为空`);
|
||||||
|
}
|
||||||
|
if (text.includes("-")) {
|
||||||
|
throw new Error(`${label}不能包含 -`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCurrentRecord(key) {
|
||||||
|
return currentRecords.find((row) => row._recordKey === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCorrectedRecord(original) {
|
||||||
|
const datePart = parseCorrectionDate(correctionDate.value);
|
||||||
|
const timePart = parseCorrectionTime(correctionTime.value);
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
...datePart,
|
||||||
|
...timePart,
|
||||||
|
student: requireText(correctionStudent.value, "学生"),
|
||||||
|
teacher: requireText(correctionTeacher.value, "老师"),
|
||||||
|
subject: requireText(correctionSubject.value, "科目"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCorrectionError(message) {
|
||||||
|
correctionError.textContent = message;
|
||||||
|
correctionError.hidden = !message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCorrectionPreview() {
|
||||||
|
const original = findCurrentRecord(activeCorrectionKey);
|
||||||
|
if (!original) return;
|
||||||
|
try {
|
||||||
|
const corrected = buildCorrectedRecord(original);
|
||||||
|
correctionPreview.textContent = buildRecordLine(corrected);
|
||||||
|
setCorrectionError("");
|
||||||
|
} catch (error) {
|
||||||
|
correctionPreview.textContent = "请修正上方字段后保存";
|
||||||
|
setCorrectionError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCorrectionDialog(key) {
|
||||||
|
const original = findCurrentRecord(key);
|
||||||
|
if (!original) return;
|
||||||
|
const corrected = correctedRecords.get(key) || original;
|
||||||
|
activeCorrectionKey = key;
|
||||||
|
correctionOriginal.textContent = `原记录:${buildRecordLine(original)}`;
|
||||||
|
correctionDate.value = corrected.date;
|
||||||
|
correctionTime.value = corrected.time;
|
||||||
|
correctionStudent.value = corrected.student;
|
||||||
|
correctionTeacher.value = corrected.teacher;
|
||||||
|
correctionSubject.value = corrected.subject;
|
||||||
|
correctionDialog.hidden = false;
|
||||||
|
updateCorrectionPreview();
|
||||||
|
correctionDate.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCorrectionDialog() {
|
||||||
|
correctionDialog.hidden = true;
|
||||||
|
activeCorrectionKey = "";
|
||||||
|
setCorrectionError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCorrection() {
|
||||||
|
const original = findCurrentRecord(activeCorrectionKey);
|
||||||
|
if (!original) return;
|
||||||
|
const corrected = buildCorrectedRecord(original);
|
||||||
|
if (buildRecordLine(corrected) === buildRecordLine(original)) {
|
||||||
|
correctedRecords.delete(activeCorrectionKey);
|
||||||
|
} else {
|
||||||
|
correctedRecords.set(activeCorrectionKey, corrected);
|
||||||
|
}
|
||||||
|
recordRows.innerHTML = renderGroupedRecords(currentRecords);
|
||||||
|
closeCorrectionDialog();
|
||||||
|
updateCorrectionToolbar(`已保存 ${correctedRecords.size} 条修改`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTextToClipboard(text) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
} catch (_error) {
|
||||||
|
// HTTP access on the VPS usually needs the fallback path below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute("readonly", "");
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
textarea.style.top = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
textarea.remove();
|
||||||
|
if (!copied) {
|
||||||
|
throw new Error("浏览器拒绝写入剪切板");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCorrectedRecords() {
|
||||||
|
const correctedInPageOrder = currentRecordOrder
|
||||||
|
.map((key) => correctedRecords.get(key))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!correctedInPageOrder.length) return;
|
||||||
|
const text = correctedInPageOrder.map(buildRecordLine).join(COPY_LINE_BREAK);
|
||||||
|
try {
|
||||||
|
await copyTextToClipboard(text);
|
||||||
|
updateCorrectionToolbar(`已复制 ${correctedInPageOrder.length} 条修改`);
|
||||||
|
} catch (error) {
|
||||||
|
updateCorrectionToolbar(`复制失败:${error.message}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
const response = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = `${response.status} ${response.statusText}`;
|
||||||
|
try {
|
||||||
|
const payload = await response.json();
|
||||||
|
detail = payload.detail || detail;
|
||||||
|
} catch (_error) {
|
||||||
|
detail = response.statusText || detail;
|
||||||
|
}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHealth() {
|
||||||
|
try {
|
||||||
|
const data = await fetchJson("/api/health");
|
||||||
|
healthText.textContent = `课程记录 ${data.records_count} 条;数据更新时间 ${fmtTime(data.classnotes.mtime)}`;
|
||||||
|
} catch (error) {
|
||||||
|
healthText.textContent = `读取失败:${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryRecords(query) {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return;
|
||||||
|
recordRows.innerHTML = `<tr><td colspan="7" class="empty">正在查询</td></tr>`;
|
||||||
|
recordMeta.innerHTML = "";
|
||||||
|
currentRecords = [];
|
||||||
|
resetCorrections();
|
||||||
|
clearInlineAccount();
|
||||||
|
try {
|
||||||
|
const data = await fetchJson(`/api/records?q=${encodeURIComponent(q)}&limit=500`);
|
||||||
|
const summary = data.summary;
|
||||||
|
recordMeta.innerHTML = [
|
||||||
|
metric("识别日期", data.query.date_range),
|
||||||
|
metric("命中记录", `${summary.count} 条`),
|
||||||
|
metric("总课时", `${fmtHours(summary.total_hours)} 小时`),
|
||||||
|
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
if (data.query.students.length === 1) {
|
||||||
|
await loadInlineAccount(data.query.students[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.records.length) {
|
||||||
|
recordRows.innerHTML = `<tr><td colspan="7" class="empty">未找到符合条件的上课记录</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRecords = data.records.map((row, index) => ({
|
||||||
|
...row,
|
||||||
|
_recordKey: makeRecordKey(row, index),
|
||||||
|
}));
|
||||||
|
recordRows.innerHTML = renderGroupedRecords(currentRecords);
|
||||||
|
} catch (error) {
|
||||||
|
recordRows.innerHTML = `<tr><td colspan="7" class="empty">查询失败:${escapeHtml(error.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordForm.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
queryRecords(recordQuery.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshBtn.addEventListener("click", () => {
|
||||||
|
loadHealth();
|
||||||
|
if (recordQuery.value.trim()) queryRecords(recordQuery.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-query]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
recordQuery.value = button.dataset.query;
|
||||||
|
queryRecords(recordQuery.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
recordRows.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest(".correction-edit");
|
||||||
|
if (!button) return;
|
||||||
|
openCorrectionDialog(button.dataset.recordKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
[correctionDate, correctionTime, correctionStudent, correctionTeacher, correctionSubject].forEach((input) => {
|
||||||
|
input.addEventListener("input", updateCorrectionPreview);
|
||||||
|
});
|
||||||
|
|
||||||
|
correctionForm.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
saveCorrection();
|
||||||
|
} catch (error) {
|
||||||
|
setCorrectionError(error.message);
|
||||||
|
correctionPreview.textContent = "请修正上方字段后保存";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
closeCorrectionBtn.addEventListener("click", closeCorrectionDialog);
|
||||||
|
cancelCorrectionBtn.addEventListener("click", closeCorrectionDialog);
|
||||||
|
correctionDialog.addEventListener("click", (event) => {
|
||||||
|
if (event.target === correctionDialog) closeCorrectionDialog();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && !correctionDialog.hidden) {
|
||||||
|
closeCorrectionDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
copyCorrectedBtn.addEventListener("click", copyCorrectedRecords);
|
||||||
|
|
||||||
|
loadHealth();
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>新时空课程记录查询</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css?v=20260611-weekend-shortcuts" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>新时空课程记录查询</h1>
|
||||||
|
<p id="healthText">正在读取数据状态</p>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<a class="nav-button" href="/accounts">课时账户</a>
|
||||||
|
<button id="refreshBtn" class="icon-button" title="刷新数据" type="button" aria-label="刷新数据">
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout records-layout">
|
||||||
|
<section class="panel records-panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>上课记录</h2>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button class="chip" data-query="今天上课记录" type="button">今天</button>
|
||||||
|
<button class="chip" data-query="上周末上课记录" type="button">上周末</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="recordForm" class="search-row">
|
||||||
|
<input
|
||||||
|
id="recordQuery"
|
||||||
|
name="q"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="例如:王鑫鹏5.5-5.10、王鑫鹏英语"
|
||||||
|
/>
|
||||||
|
<button type="submit">查询</button>
|
||||||
|
</form>
|
||||||
|
<div id="recordMeta" class="summary-grid"></div>
|
||||||
|
<div id="correctionToolbar" class="correction-toolbar" hidden>
|
||||||
|
<span id="correctionStatus">已修改 0 条记录</span>
|
||||||
|
<button id="copyCorrectedBtn" type="button">复制已修改记录</button>
|
||||||
|
</div>
|
||||||
|
<div id="inlineAccount" class="inline-account" hidden></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>日期</th>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>学生</th>
|
||||||
|
<th>老师</th>
|
||||||
|
<th>科目</th>
|
||||||
|
<th class="num">时长</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recordRows">
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="empty">输入查询条件后显示明细</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="correctionDialog" class="modal-backdrop" hidden>
|
||||||
|
<section class="correction-modal" role="dialog" aria-modal="true" aria-labelledby="correctionTitle">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2 id="correctionTitle">纠错上课记录</h2>
|
||||||
|
<button id="closeCorrectionBtn" class="modal-close" type="button" aria-label="关闭">关闭</button>
|
||||||
|
</div>
|
||||||
|
<form id="correctionForm" class="correction-form">
|
||||||
|
<p id="correctionOriginal" class="correction-original"></p>
|
||||||
|
<div class="correction-fields">
|
||||||
|
<label>
|
||||||
|
日期
|
||||||
|
<input id="correctionDate" name="date" autocomplete="off" placeholder="2026.06.10" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
时间
|
||||||
|
<input id="correctionTime" name="time" autocomplete="off" placeholder="08:00-10:00" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
学生
|
||||||
|
<input id="correctionStudent" name="student" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
老师
|
||||||
|
<input id="correctionTeacher" name="teacher" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
科目
|
||||||
|
<input id="correctionSubject" name="subject" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p id="correctionError" class="correction-error" hidden></p>
|
||||||
|
<div class="correction-preview">
|
||||||
|
<span>复制预览</span>
|
||||||
|
<code id="correctionPreview"></code>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="cancelCorrectionBtn" class="secondary-button" type="button">取消</button>
|
||||||
|
<button type="submit">保存修改</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/app.js?v=20260611-weekend-shortcuts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #18202a;
|
||||||
|
--muted: #687487;
|
||||||
|
--line: #d9dee7;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-strong: #0b5d57;
|
||||||
|
--danger: #b42318;
|
||||||
|
--warn: #b54708;
|
||||||
|
--ok: #237a42;
|
||||||
|
--shadow: 0 12px 28px rgba(20, 31, 46, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: Arial, "Songti SC", SimSun, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 24px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(246, 247, 249, 0.96);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.5fr) minmax(340px, 0.8fr);
|
||||||
|
gap: 18px;
|
||||||
|
padding: 18px 24px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-layout,
|
||||||
|
.account-layout {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip,
|
||||||
|
.search-row button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover,
|
||||||
|
.search-row button:hover {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 88px;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row.compact {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
min-width: 0;
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row button {
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-account {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: #fbfcfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-account-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-account h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-account p {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-account-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 130px 130px minmax(0, 1fr) minmax(160px, 0.6fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-account-loading {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: #f8fbfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-toolbar span {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-toolbar span.is-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-toolbar button,
|
||||||
|
.modal-actions button {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-toolbar button {
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-toolbar button:hover,
|
||||||
|
.modal-actions button:hover {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-toolbar button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
max-height: calc(100vh - 292px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-table-wrap {
|
||||||
|
max-height: calc(100vh - 262px);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: #f3f5f8;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-group td {
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-top: 1px solid #b7d8d4;
|
||||||
|
border-bottom: 1px solid #b7d8d4;
|
||||||
|
background: #e9f5f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-group-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-group-title strong {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-group-title span {
|
||||||
|
color: #344054;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-row td:nth-child(4) {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.corrected-row td {
|
||||||
|
background: #fffaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fef0c7;
|
||||||
|
color: var(--warn);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-cell {
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 44px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.debt {
|
||||||
|
background: #fee4e2;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.warning {
|
||||||
|
background: #fef0c7;
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.normal {
|
||||||
|
background: #dcfae6;
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.closed {
|
||||||
|
background: #e4e7ec;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(16, 24, 40, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-modal {
|
||||||
|
width: min(100%, 760px);
|
||||||
|
max-height: calc(100vh - 36px);
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 20px 48px rgba(16, 24, 40, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close,
|
||||||
|
.secondary-button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover,
|
||||||
|
.secondary-button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-form {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-original {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-fields label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-error {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-preview {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-preview span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-preview code {
|
||||||
|
display: block;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: Arial, "Songti SC", SimSun, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions .secondary-button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-account-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.topbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row,
|
||||||
|
.search-row.compact,
|
||||||
|
.account-search-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-toolbar button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-account-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-account-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-group-title {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-group-title span {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
xsk-records-web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.12-slim}
|
||||||
|
container_name: xsk-records-web
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
|
CLASSNOTES_PATH: ${CLASSNOTES_PATH:-/data/classnotes.txt}
|
||||||
|
ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md}
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-18080}:8000"
|
||||||
|
volumes:
|
||||||
|
- ../data:/data
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.xsk.records.sync</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/bin/python3</string>
|
||||||
|
<string>/Users/yangdawei/Desktop/新时空业务源数据/tools/xsk-records-web/scripts/sync_to_vps.py</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/Users/yangdawei/Library/Logs/xsk-records-web/sync.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/Users/yangdawei/Library/Logs/xsk-records-web/sync.err.log</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi==0.122.0
|
||||||
|
uvicorn[standard]==0.38.0
|
||||||
Executable
+148
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
DEFAULT_REMOTE_HOST = "121.199.172.246"
|
||||||
|
DEFAULT_REMOTE_USER = "root"
|
||||||
|
DEFAULT_REMOTE_PORT = 22222
|
||||||
|
DEFAULT_REMOTE_DIR = "/root/新时空数据"
|
||||||
|
DEFAULT_IDENTITY_FILE = Path.home() / ".ssh" / "xsk_records_vps_ed25519"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="部署新时空课程记录查询网页到 VPS")
|
||||||
|
parser.add_argument("--remote-host", default=os.getenv("XSK_REMOTE_HOST", DEFAULT_REMOTE_HOST))
|
||||||
|
parser.add_argument("--remote-user", default=os.getenv("XSK_REMOTE_USER", DEFAULT_REMOTE_USER))
|
||||||
|
parser.add_argument("--remote-port", type=int, default=int(os.getenv("XSK_REMOTE_PORT", str(DEFAULT_REMOTE_PORT))))
|
||||||
|
parser.add_argument("--remote-dir", default=os.getenv("XSK_REMOTE_DIR", DEFAULT_REMOTE_DIR))
|
||||||
|
parser.add_argument(
|
||||||
|
"--identity-file",
|
||||||
|
default=os.getenv("XSK_IDENTITY_FILE", str(DEFAULT_IDENTITY_FILE) if DEFAULT_IDENTITY_FILE.exists() else ""),
|
||||||
|
help="SSH 私钥路径;默认使用 ~/.ssh/xsk_records_vps_ed25519(如果存在)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--app-port", default=os.getenv("XSK_APP_PORT", "18080"))
|
||||||
|
parser.add_argument(
|
||||||
|
"--python-image",
|
||||||
|
default=os.getenv(
|
||||||
|
"XSK_PYTHON_IMAGE",
|
||||||
|
"swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/python:3.12-slim",
|
||||||
|
),
|
||||||
|
help="Docker 基础镜像;VPS 无法访问 Docker Hub 时可使用镜像站",
|
||||||
|
)
|
||||||
|
parser.add_argument("--auth-user", default=os.getenv("XSK_WEB_USER", "wolfydw"))
|
||||||
|
parser.add_argument("--auth-password", default=os.getenv("XSK_WEB_PASSWORD"))
|
||||||
|
parser.add_argument("--use-sshpass", action="store_true", default=os.getenv("XSK_USE_SSHPASS", "") == "1")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: list[str], cwd: Path | None = None) -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
if env.get("XSK_SSH_PASSWORD") and not env.get("SSHPASS"):
|
||||||
|
env["SSHPASS"] = env["XSK_SSH_PASSWORD"]
|
||||||
|
result = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False, env=env)
|
||||||
|
if result.returncode != 0:
|
||||||
|
redacted = ["***" if part == os.getenv("XSK_SSH_PASSWORD") else part for part in command]
|
||||||
|
raise RuntimeError(
|
||||||
|
f"命令失败({result.returncode}): {' '.join(shlex.quote(part) for part in redacted)}\n"
|
||||||
|
f"STDOUT: {result.stdout.strip()}\nSTDERR: {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
if result.stdout.strip():
|
||||||
|
print(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def base_remote(args: argparse.Namespace) -> str:
|
||||||
|
return f"{args.remote_user}@{args.remote_host}"
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_options(args: argparse.Namespace) -> list[str]:
|
||||||
|
options = ["-p", str(args.remote_port), "-o", "StrictHostKeyChecking=accept-new"]
|
||||||
|
if args.identity_file:
|
||||||
|
options.extend(["-i", str(Path(args.identity_file).expanduser()), "-o", "IdentitiesOnly=yes"])
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_command(args: argparse.Namespace) -> list[str]:
|
||||||
|
command: list[str] = []
|
||||||
|
if args.use_sshpass:
|
||||||
|
password = os.getenv("XSK_SSH_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
|
||||||
|
command.extend(["sshpass", "-e"])
|
||||||
|
command.extend(["ssh", *ssh_options(args), base_remote(args)])
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def rsync_command(args: argparse.Namespace) -> list[str]:
|
||||||
|
command: list[str] = []
|
||||||
|
if args.use_sshpass:
|
||||||
|
password = os.getenv("XSK_SSH_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
|
||||||
|
command.extend(["sshpass", "-e"])
|
||||||
|
ssh_command_text = " ".join(shlex.quote(part) for part in ["ssh", *ssh_options(args)])
|
||||||
|
command.extend(
|
||||||
|
[
|
||||||
|
"rsync",
|
||||||
|
"-az",
|
||||||
|
"--delete",
|
||||||
|
"--exclude",
|
||||||
|
".env",
|
||||||
|
"--exclude",
|
||||||
|
"*.log",
|
||||||
|
"-e",
|
||||||
|
ssh_command_text,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def write_env(args: argparse.Namespace) -> Path:
|
||||||
|
if not args.auth_password:
|
||||||
|
raise RuntimeError("必须通过 XSK_WEB_PASSWORD 或 --auth-password 设置网页访问密码")
|
||||||
|
env_text = "\n".join(
|
||||||
|
[
|
||||||
|
f"APP_PORT={args.app_port}",
|
||||||
|
"TZ=Asia/Shanghai",
|
||||||
|
f"PYTHON_IMAGE={args.python_image}",
|
||||||
|
f"BASIC_AUTH_USERNAME={args.auth_user}",
|
||||||
|
f"BASIC_AUTH_PASSWORD={args.auth_password}",
|
||||||
|
"CLASSNOTES_PATH=/data/classnotes.txt",
|
||||||
|
"ACCOUNTS_PATH=/data/学生课时账户.md",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
handle = tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False)
|
||||||
|
try:
|
||||||
|
handle.write(env_text)
|
||||||
|
return Path(handle.name)
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
app_dir = f"{args.remote_dir.rstrip('/')}/app"
|
||||||
|
data_dir = f"{args.remote_dir.rstrip('/')}/data"
|
||||||
|
env_file = write_env(args)
|
||||||
|
try:
|
||||||
|
run(ssh_command(args) + [f"mkdir -p {shlex.quote(app_dir)} {shlex.quote(data_dir)}"])
|
||||||
|
run(rsync_command(args) + [f"{PROJECT_DIR}/", f"{base_remote(args)}:{app_dir}/"])
|
||||||
|
run(rsync_command(args) + [str(env_file), f"{base_remote(args)}:{app_dir}/.env"])
|
||||||
|
run(ssh_command(args) + [f"cd {shlex.quote(app_dir)} && docker compose up -d --build"])
|
||||||
|
print(f"部署完成: http://{args.remote_host}:{args.app_port}/")
|
||||||
|
finally:
|
||||||
|
env_file.unlink(missing_ok=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import plistlib
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
LABEL = "com.xsk.records.sync"
|
||||||
|
PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
||||||
|
LOG_DIR = Path.home() / "Library" / "Logs" / "xsk-records-web"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="安装新时空课程记录同步 LaunchAgent")
|
||||||
|
parser.add_argument("--use-sshpass", action="store_true", default=os.getenv("XSK_USE_SSHPASS", "") == "1")
|
||||||
|
parser.add_argument("--ssh-password", default=os.getenv("XSK_SSH_PASSWORD"))
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
program_arguments = [
|
||||||
|
"/usr/bin/python3",
|
||||||
|
str(PROJECT_DIR / "scripts" / "sync_to_vps.py"),
|
||||||
|
]
|
||||||
|
if args.use_sshpass:
|
||||||
|
program_arguments.append("--use-sshpass")
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
||||||
|
}
|
||||||
|
if args.use_sshpass and args.ssh_password:
|
||||||
|
environment["XSK_SSH_PASSWORD"] = args.ssh_password
|
||||||
|
|
||||||
|
plist = {
|
||||||
|
"Label": LABEL,
|
||||||
|
"ProgramArguments": program_arguments,
|
||||||
|
"RunAtLoad": True,
|
||||||
|
"KeepAlive": True,
|
||||||
|
"StandardOutPath": str(LOG_DIR / "sync.log"),
|
||||||
|
"StandardErrorPath": str(LOG_DIR / "sync.err.log"),
|
||||||
|
"EnvironmentVariables": environment,
|
||||||
|
}
|
||||||
|
with PLIST_PATH.open("wb") as handle:
|
||||||
|
plistlib.dump(plist, handle)
|
||||||
|
|
||||||
|
subprocess.run(["launchctl", "unload", str(PLIST_PATH)], check=False, capture_output=True)
|
||||||
|
subprocess.run(["launchctl", "load", str(PLIST_PATH)], check=True)
|
||||||
|
print(f"已安装并启动 LaunchAgent: {PLIST_PATH}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+204
@@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LOCAL_DIR = Path("/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户")
|
||||||
|
DEFAULT_REMOTE_HOST = "121.199.172.246"
|
||||||
|
DEFAULT_REMOTE_USER = "root"
|
||||||
|
DEFAULT_REMOTE_PORT = 22222
|
||||||
|
DEFAULT_REMOTE_DIR = "/root/新时空数据"
|
||||||
|
DEFAULT_IDENTITY_FILE = Path.home() / ".ssh" / "xsk_records_vps_ed25519"
|
||||||
|
SYNC_FILES = ("classnotes.txt", "学生课时账户.md")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
local_dir: Path
|
||||||
|
remote_host: str
|
||||||
|
remote_user: str
|
||||||
|
remote_port: int
|
||||||
|
remote_dir: str
|
||||||
|
identity_file: Path | None
|
||||||
|
interval: float
|
||||||
|
use_sshpass: bool
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="同步新时空课程记录数据到 VPS")
|
||||||
|
parser.add_argument("--once", action="store_true", help="只同步一次后退出")
|
||||||
|
parser.add_argument("--interval", type=float, default=float(os.getenv("XSK_SYNC_INTERVAL", "3")))
|
||||||
|
parser.add_argument("--local-dir", default=os.getenv("XSK_LOCAL_DIR", str(DEFAULT_LOCAL_DIR)))
|
||||||
|
parser.add_argument("--remote-host", default=os.getenv("XSK_REMOTE_HOST", DEFAULT_REMOTE_HOST))
|
||||||
|
parser.add_argument("--remote-user", default=os.getenv("XSK_REMOTE_USER", DEFAULT_REMOTE_USER))
|
||||||
|
parser.add_argument("--remote-port", type=int, default=int(os.getenv("XSK_REMOTE_PORT", str(DEFAULT_REMOTE_PORT))))
|
||||||
|
parser.add_argument("--remote-dir", default=os.getenv("XSK_REMOTE_DIR", DEFAULT_REMOTE_DIR))
|
||||||
|
parser.add_argument(
|
||||||
|
"--identity-file",
|
||||||
|
default=os.getenv("XSK_IDENTITY_FILE", str(DEFAULT_IDENTITY_FILE) if DEFAULT_IDENTITY_FILE.exists() else ""),
|
||||||
|
help="SSH 私钥路径;默认使用 ~/.ssh/xsk_records_vps_ed25519(如果存在)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--use-sshpass",
|
||||||
|
action="store_true",
|
||||||
|
default=os.getenv("XSK_USE_SSHPASS", "") == "1",
|
||||||
|
help="从 XSK_SSH_PASSWORD 读取密码并通过 sshpass 连接;推荐改用 SSH key",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def log(message: str) -> None:
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
print(f"[{timestamp}] {message}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
if env.get("XSK_SSH_PASSWORD") and not env.get("SSHPASS"):
|
||||||
|
env["SSHPASS"] = env["XSK_SSH_PASSWORD"]
|
||||||
|
result = subprocess.run(command, text=True, capture_output=True, check=False, env=env)
|
||||||
|
if result.returncode != 0:
|
||||||
|
safe_command = " ".join(shlex.quote(part) for part in command if part != os.getenv("XSK_SSH_PASSWORD", ""))
|
||||||
|
raise RuntimeError(
|
||||||
|
f"命令失败({result.returncode}): {safe_command}\n"
|
||||||
|
f"STDOUT: {result.stdout.strip()}\nSTDERR: {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def base_remote(config: Config) -> str:
|
||||||
|
return f"{config.remote_user}@{config.remote_host}"
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_options(config: Config) -> list[str]:
|
||||||
|
options = [
|
||||||
|
"-p",
|
||||||
|
str(config.remote_port),
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=accept-new",
|
||||||
|
]
|
||||||
|
if config.identity_file:
|
||||||
|
options.extend(["-i", str(config.identity_file), "-o", "IdentitiesOnly=yes"])
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_prefix(config: Config) -> list[str]:
|
||||||
|
command: list[str] = []
|
||||||
|
if config.use_sshpass:
|
||||||
|
password = os.getenv("XSK_SSH_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
|
||||||
|
command.extend(["sshpass", "-e"])
|
||||||
|
command.extend(["ssh", *ssh_options(config), base_remote(config)])
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def rsync_prefix(config: Config) -> list[str]:
|
||||||
|
command: list[str] = []
|
||||||
|
if config.use_sshpass:
|
||||||
|
password = os.getenv("XSK_SSH_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
|
||||||
|
command.extend(["sshpass", "-e"])
|
||||||
|
ssh_command = " ".join(shlex.quote(part) for part in ["ssh", *ssh_options(config)])
|
||||||
|
command.extend(
|
||||||
|
[
|
||||||
|
"rsync",
|
||||||
|
"-az",
|
||||||
|
"-e",
|
||||||
|
ssh_command,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def remote_shell_quote(value: str) -> str:
|
||||||
|
return shlex.quote(value)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_remote_dirs(config: Config) -> None:
|
||||||
|
data_dir = f"{config.remote_dir.rstrip('/')}/data"
|
||||||
|
run_command(ssh_prefix(config) + [f"mkdir -p {remote_shell_quote(data_dir)}"])
|
||||||
|
|
||||||
|
|
||||||
|
def sync_once(config: Config) -> None:
|
||||||
|
ensure_remote_dirs(config)
|
||||||
|
data_dir = f"{config.remote_dir.rstrip('/')}/data"
|
||||||
|
for filename in SYNC_FILES:
|
||||||
|
source = config.local_dir / filename
|
||||||
|
if not source.exists():
|
||||||
|
raise FileNotFoundError(f"本地文件不存在: {source}")
|
||||||
|
temp_name = f".{filename}.tmp"
|
||||||
|
remote_temp = f"{base_remote(config)}:{data_dir}/{temp_name}"
|
||||||
|
run_command(rsync_prefix(config) + [str(source), remote_temp])
|
||||||
|
run_command(
|
||||||
|
ssh_prefix(config)
|
||||||
|
+ [
|
||||||
|
"mv "
|
||||||
|
f"{remote_shell_quote(data_dir + '/' + temp_name)} "
|
||||||
|
f"{remote_shell_quote(data_dir + '/' + filename)}"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
log(f"已同步 {source.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def file_signature(path: Path) -> tuple[int, int]:
|
||||||
|
stat = path.stat()
|
||||||
|
return stat.st_mtime_ns, stat.st_size
|
||||||
|
|
||||||
|
|
||||||
|
def current_signatures(local_dir: Path) -> dict[str, tuple[int, int]]:
|
||||||
|
return {filename: file_signature(local_dir / filename) for filename in SYNC_FILES}
|
||||||
|
|
||||||
|
|
||||||
|
def watch(config: Config) -> None:
|
||||||
|
log("启动课程记录同步监听")
|
||||||
|
signatures: dict[str, tuple[int, int]] = {}
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
next_signatures = current_signatures(config.local_dir)
|
||||||
|
if next_signatures != signatures:
|
||||||
|
time.sleep(0.4)
|
||||||
|
sync_once(config)
|
||||||
|
signatures = current_signatures(config.local_dir)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"同步失败: {exc}")
|
||||||
|
time.sleep(config.interval)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
config = Config(
|
||||||
|
local_dir=Path(args.local_dir).expanduser(),
|
||||||
|
remote_host=args.remote_host,
|
||||||
|
remote_user=args.remote_user,
|
||||||
|
remote_port=args.remote_port,
|
||||||
|
remote_dir=args.remote_dir,
|
||||||
|
identity_file=Path(args.identity_file).expanduser() if args.identity_file else None,
|
||||||
|
interval=args.interval,
|
||||||
|
use_sshpass=args.use_sshpass,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if args.once:
|
||||||
|
sync_once(config)
|
||||||
|
else:
|
||||||
|
watch(config)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 130
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"退出: {exc}")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user