初始化新时空数据应用
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