feat(backend): 调整课程日期为周日并优化计划提交时间窗口
- 将上课日从周六调整为周日,新增 CLASS_WEEKDAY 常量 - 更新 get_class_date 和 get_sunday_of_week 函数以支持周日计算 - 修改 generate_week_list 函数生成周日日期而非周六日期 - 重构月计划和年计划的时间窗口逻辑,增加提前提交功能 - 新增 monthly_window 和 yearly_window 函数定义准确的提交窗口期 - 调整默认周期选择逻辑,支持跨月年的时间窗口 - 添加 period_labels 字段提供前端周期标签显示 - 移除旧的 _validate_current_period 函数 - 完善计划提交验证逻辑,支持多种时间周期类型 chore(miniapp): 更新小程序项目配置 - 补充 project.config.json 中的设置选项 - 调整 project.private.config.json 配置结构 - 移除冗余的页面调试条件配置 - 更新编辑器和编译相关配置项
This commit is contained in:
203
routes/plan.py
203
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
|
||||
|
||||
Reference in New Issue
Block a user