初始化新时空数据应用

This commit is contained in:
Codex
2026-06-12 02:11:47 +08:00
commit a18ef4fc42
18 changed files with 3492 additions and 0 deletions
+9
View File
@@ -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
View File
@@ -0,0 +1,13 @@
.env
.env.*
!.env.example
*.log
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
data/
.DS_Store
+16
View File
@@ -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"]
+102
View File
@@ -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/王鑫鹏`:单个学生账户。
+1
View File
@@ -0,0 +1 @@
+841
View File
@@ -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
View File
@@ -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}")
+60
View File
@@ -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>
+116
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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();
+458
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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();
+115
View File
@@ -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>
+695
View File
@@ -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;
}
}
+18
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
fastapi==0.122.0
uvicorn[standard]==0.38.0
+148
View File
@@ -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())
+60
View File
@@ -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())
+204
View File
@@ -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())