diff --git a/routes/checkin.py b/routes/checkin.py index 6d8b50f..a71b0b4 100644 --- a/routes/checkin.py +++ b/routes/checkin.py @@ -22,38 +22,34 @@ SELECT_COLS = "id,user_id,class_date,checked_at,status,note" # 周期列表起始年份 PERIOD_START_YEAR = 2022 +# 上课日:每周周日(date.weekday(): 周一=0 ... 周六=5, 周日=6) +CLASS_WEEKDAY = 6 + def get_class_date(ref_date=None): - """获取指定日期所在周的周六日期(上课日)。""" + """获取指定日期所在自然周的周日日期(上课日)。""" today = ref_date or date.today() - return today + timedelta(days=(5 - today.weekday())) + return today + timedelta(days=(CLASS_WEEKDAY - today.weekday())) -def get_saturday_of_week(week_monday: date) -> date: - """给定某周周一,返回该周周六。""" - return week_monday + timedelta(days=5) +def get_sunday_of_week(week_monday: date) -> date: + """给定某周周一,返回该周周日(上课日)。""" + return week_monday + timedelta(days=CLASS_WEEKDAY) def generate_week_list(start_year: int = PERIOD_START_YEAR) -> list: """ - 生成从 start_year 到今天的所有上课周(每周六)列表,倒序排列。 - 返回格式: ["2026-09-19", "2026-09-12", ...] + 生成从 start_year 到今天的所有上课周(每周日)列表,倒序排列。 + 返回格式: ["2026-09-20", "2026-09-13", ...] """ today = date.today() - # 找到 start_year 第一个周六 - # 从1月1日往前找最近的周六,再往后逐周生成 + # 找到 start_year 第一个周日(1月1日之后的第一个周日) d = date(start_year, 1, 1) - # 调整到第一个周六 - days_until_saturday = (5 - d.weekday()) % 7 - if days_until_saturday == 0 and d.weekday() != 5: - days_until_saturday = 0 - first_saturday = d + timedelta(days=days_until_saturday) - # 确保第一个周六在 start_year 内 - if first_saturday.year < start_year: - first_saturday += timedelta(days=7) + days_until_sunday = (CLASS_WEEKDAY - d.weekday()) % 7 + first_sunday = d + timedelta(days=days_until_sunday) weeks = [] - current = first_saturday + current = first_sunday while current <= today + timedelta(days=7): # 多生成一周 weeks.append(current.strftime("%Y-%m-%d")) current += timedelta(days=7) @@ -142,7 +138,7 @@ class CheckinRequest(BaseModel): @router.get("/weeks") def weeks(user=Depends(get_current_user)): """ - 返回所有上课周列表(每周六日期),从 2022 年到当前,倒序排列。 + 返回所有上课周列表(每周日日期),从 2022 年到当前,倒序排列。 用于前端做历史周切换选择器。 """ week_list = generate_week_list() diff --git a/routes/plan.py b/routes/plan.py index 8465740..f681dff 100644 --- a/routes/plan.py +++ b/routes/plan.py @@ -89,30 +89,64 @@ def weekly_submit_state(period_key): PERIOD_START_YEAR = 2022 -def monthly_submit_state(period_key): - """月计划提交状态:当前月内可提交,历史月/未来月均不可提交。""" +def parse_month_key(period_key): + """把月周期 key 解析为该月 1 日;非法返回 None。""" if not _validate_month_key(period_key): - return False, "无效的月周期" - today = date.today() + return None year, month = period_key.split("-") - period_year, period_month = int(year), int(month) - if period_year < today.year or (period_year == today.year and period_month < today.month): - return False, "已过提交时间" - if period_year > today.year or (period_year == today.year and period_month > today.month): + return date(int(year), int(month), 1) + + +def monthly_window(period_key): + """月计划填写窗口:上个月最后 5 天 00:00 ~ 本月第 10 天 23:59:59。 + + 例:2026-09 的窗口 = 2026-08-27 00:00 ~ 2026-09-10 23:59:59。 + """ + first_day = parse_month_key(period_key) + if not first_day: + return None, None + prev_month_last = first_day - timedelta(days=1) # 上个月最后一天 + open_at = datetime.combine(prev_month_last - timedelta(days=4), time.min) + deadline = datetime.combine(first_day + timedelta(days=9), time(23, 59, 59)) + return open_at, deadline + + +def yearly_window(period_key): + """年计划填写窗口:上一年 12 月 1 日 00:00 ~ 本年 1 月 31 日 23:59:59。 + + 例:2026 的窗口 = 2025-12-01 00:00 ~ 2026-01-31 23:59:59。 + """ + if not _validate_year_key(period_key): + return None, None + year = int(period_key) + open_at = datetime(year - 1, 12, 1, 0, 0, 0) + deadline = datetime(year, 1, 31, 23, 59, 59) + return open_at, deadline + + +def monthly_submit_state(period_key): + """月计划提交状态:窗口内(上月最后 5 天 ~ 本月第 10 天)可提交,其余时间不可。""" + open_at, deadline = monthly_window(period_key) + if not open_at or not deadline: + return False, "无效的月周期" + now = datetime.now() + if now < open_at: return False, "未到提交时间" + if now > deadline: + return False, "已过提交时间" return True, "" def yearly_submit_state(period_key): - """年计划提交状态:当前年内可提交,往年/未来年均不可提交。""" - if not _validate_year_key(period_key): + """年计划提交状态:窗口内(上一年 12 月 ~ 本年 1 月)可提交,其余时间不可。""" + open_at, deadline = yearly_window(period_key) + if not open_at or not deadline: return False, "无效的年周期" - today = date.today() - period_year = int(period_key) - if period_year < today.year: - return False, "已过提交时间" - if period_year > today.year: + now = datetime.now() + if now < open_at: return False, "未到提交时间" + if now > deadline: + return False, "已过提交时间" return True, "" @@ -123,6 +157,10 @@ def generate_period_list(plan_type, start_year: int = PERIOD_START_YEAR): - weekly: ISO 周格式 YYYY-Www - monthly: YYYY-MM - yearly: YYYY + + 三种类型的终点统一取「当前应填周期」而非「今天所在周期」:周计划周五起填下一周, + 月计划上月末 5 天起填下一月,年计划上年 12 月起填下一年。终点若不随之延伸, + current_period 会落在列表之外,前端选不到、也就无法提交。 """ today = date.today() @@ -131,11 +169,13 @@ def generate_period_list(plan_type, start_year: int = PERIOD_START_YEAR): # 从 start_year 第一个 ISO 周开始 jan4 = date(start_year, 1, 4) first_monday = jan4 - timedelta(days=jan4.isoweekday() - 1) - # 截止到当前 ISO 周 - iso_today = today.isocalendar() - last_jan4 = date(iso_today[0], 1, 4) - last_monday = last_jan4 - timedelta(days=last_jan4.isoweekday() - 1) - end_monday = last_monday + timedelta(weeks=iso_today[1] - 1) + # 截止到「当前应填周期」(周一~周四为本周期,周五~周日为下一周期) + end_monday = parse_iso_week(default_weekly_period(today)) + if end_monday is None: # 兜底:理论上不会发生 + iso_today = today.isocalendar() + base_jan4 = date(iso_today[0], 1, 4) + end_monday = (base_jan4 - timedelta(days=base_jan4.isoweekday() - 1) + + timedelta(weeks=iso_today[1] - 1)) current = first_monday while current <= end_monday: weeks.append(iso_week_key(current)) @@ -145,9 +185,12 @@ def generate_period_list(plan_type, start_year: int = PERIOD_START_YEAR): if plan_type == "monthly": months = [] - for y in range(start_year, today.year + 1): + # 终点取「当前应填周期」(窗口开放月,可能是下个月) + end_key = default_monthly_period(today) + end_year, end_month = int(end_key[:4]), int(end_key[5:7]) + for y in range(start_year, end_year + 1): for m in range(1, 13): - if y == today.year and m > today.month: + if y == end_year and m > end_month: break months.append("%04d-%02d" % (y, m)) months.reverse() @@ -155,7 +198,9 @@ def generate_period_list(plan_type, start_year: int = PERIOD_START_YEAR): if plan_type == "yearly": years = [] - for y in range(start_year, today.year + 1): + # 终点取「当前应填周期」(窗口开放年,可能是下一年) + end_year = int(default_yearly_period(today)) + for y in range(start_year, end_year + 1): years.append(str(y)) years.reverse() return years @@ -163,14 +208,60 @@ def generate_period_list(plan_type, start_year: int = PERIOD_START_YEAR): return [] +def _month_keys_around(today): + """返回 (上上月 … 下下月) 的月周期 key,按时间升序。""" + keys = [] + for offset in (-2, -1, 0, 1, 2): + total = today.year * 12 + (today.month - 1) + offset + year, month = divmod(total, 12) + keys.append("%04d-%02d" % (year, month + 1)) + return keys + + +def default_monthly_period(today=None): + """月计划应填周期:窗口正在开放的那个月;都不开放时取最近将要开放的月。""" + today = today or date.today() + now = datetime.now() + next_open = None + for key in _month_keys_around(today): + open_at, deadline = monthly_window(key) + if not open_at: + continue + if open_at <= now <= deadline: + return key + if open_at > now and (next_open is None or open_at < next_open[0]): + next_open = (open_at, key) + if next_open: + return next_open[1] + return "%04d-%02d" % (today.year, today.month) + + +def default_yearly_period(today=None): + """年计划应填周期:窗口正在开放的那一年;都不开放时取最近将要开放的年。""" + today = today or date.today() + now = datetime.now() + next_open = None + for key in (str(today.year - 1), str(today.year), str(today.year + 1), str(today.year + 2)): + open_at, deadline = yearly_window(key) + if not open_at: + continue + if open_at <= now <= deadline: + return key + if open_at > now and (next_open is None or open_at < next_open[0]): + next_open = (open_at, key) + if next_open: + return next_open[1] + return str(today.year) + + def get_period_key(plan_type): today = date.today() if plan_type == "weekly": return default_weekly_period(today) if plan_type == "monthly": - return today.strftime("%Y-%m") + return default_monthly_period(today) if plan_type == "yearly": - return today.strftime("%Y") + return default_yearly_period(today) return "" @@ -222,13 +313,33 @@ def periods(type: str, user=Depends(get_current_user)): """ if type not in PLAN_TYPES: raise HTTPException(status_code=400, detail="无效的计划类型") + today = date.today() period_list = generate_period_list(type) current = get_period_key(type) - return { + resp = { "type": type, "periods": period_list, "current_period": current, + "period_labels": {}, } + if type == "weekly": + # 「本周 / 下周」:周五~周日填写的是下一周期 + resp["current_week"] = iso_week_key(today) + resp["period_labels"] = { + iso_week_key(today): "本周", + iso_week_key(today + timedelta(days=7)): "下周", + } + elif type == "monthly": + # 「本月 / 下月」:上月末 5 天起填写的是下一周期 + next_year, next_month = divmod(today.year * 12 + today.month, 12) + resp["period_labels"] = { + "%04d-%02d" % (today.year, today.month): "本月", + "%04d-%02d" % (next_year, next_month + 1): "下月", + } + elif type == "yearly": + # 「今年 / 明年」:上年 12 月起填写的是下一周期 + resp["period_labels"] = {str(today.year): "今年", str(today.year + 1): "明年"} + return resp @router.get("/status") @@ -302,27 +413,6 @@ def _normalize_period(plan_type, period_key): return selected, can_submit, message - - -def _validate_current_period(plan_type, period_key): - """Normalize to the current period for the given plan type. - - Returns (selected, can_submit, message); raises 400 for invalid types/periods. - """ - selected = period_key.strip() or get_period_key(plan_type) - if plan_type == "weekly": - if parse_iso_week(selected) is None: - raise HTTPException(status_code=400, detail="无效的周周期") - if selected != default_weekly_period(date.today()): - raise HTTPException(status_code=400, detail="仅可查看当前周期") - can_submit, message = weekly_submit_state(selected) - else: - if selected != get_period_key(plan_type): - raise HTTPException(status_code=400, detail="仅可查看当前周期") - can_submit, message = (True, "") - return selected, can_submit, message - - def _groups_stats(plan_type, selected, group_filter=None): """单条 JOIN 聚合 SQL:提交计划即已完成,未提交即未完成。 @@ -483,6 +573,25 @@ def checkin(req: PlanCheckinRequest, user=Depends(get_current_user)): can_submit, message = weekly_submit_state(default_period) if not can_submit: raise HTTPException(status_code=400, detail=message) + elif plan_type == "monthly": + first_day = parse_month_key(period_key) + if first_day is None: + raise HTTPException(status_code=400, detail="无效的月周期") + # 归一化为规范格式,避免 2026-9 / 2026-09 在同一逻辑月产生两行 + period_key = "%04d-%02d" % (first_day.year, first_day.month) + if period_key != default_monthly_period(date.today()): + raise HTTPException(status_code=400, detail="仅可填写当前周期") + can_submit, message = monthly_submit_state(period_key) + if not can_submit: + raise HTTPException(status_code=400, detail=message) + elif plan_type == "yearly": + if not _validate_year_key(period_key): + raise HTTPException(status_code=400, detail="无效的年周期") + if period_key != default_yearly_period(date.today()): + raise HTTPException(status_code=400, detail="仅可填写当前周期") + can_submit, message = yearly_submit_state(period_key) + if not can_submit: + raise HTTPException(status_code=400, detail=message) status = req.status.strip() if status not in PLAN_STATUSES: status = DEFAULT_STATUS