From a63bd6874cf4606e84a38373fbf5255bacd8e1a7 Mon Sep 17 00:00:00 2001 From: v6ole Date: Sun, 12 Jul 2026 14:13:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=AA=E8=A1=A8=E7=9B=98+=E5=82=AC?= =?UTF-8?q?=E5=8A=9E=E5=9C=A8=E4=BC=91=E6=81=AF=E6=97=A5=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E5=A1=AB=E6=8A=A5=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 is_working_day() 工具函数,自动跳过周末 + 可配置法定假日 - 仪表盘填报进度新增 is_rest_day 字段,休息日显示灰「休」印章 - 催办调度改用 is_working_day() 替代原始 weekday 检查 - 系统设置新增「法定节假日」配置卡片,支局长可管理假日日期 Co-Authored-By: Claude --- backend/app/api/system_config.py | 18 ++++++++-- backend/app/services/dashboard.py | 18 +++++++++- backend/app/services/scheduler.py | 21 ++++++++--- backend/app/utils/timezone.py | 18 ++++++++++ frontend/src/views/desktop/Dashboard.vue | 22 ++++++++---- frontend/src/views/desktop/Settings.vue | 46 ++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 14 deletions(-) diff --git a/backend/app/api/system_config.py b/backend/app/api/system_config.py index 5894f31..860965d 100644 --- a/backend/app/api/system_config.py +++ b/backend/app/api/system_config.py @@ -65,7 +65,7 @@ async def update_config( ): """Update a single config key. Only directors can change settings.""" value = body.get("value", "") - valid_keys = {"notification_time"} + valid_keys = {"notification_time", "holidays"} if key not in valid_keys: raise HTTPException(status_code=400, detail=f"不支持的配置项: {key}") @@ -75,11 +75,23 @@ async def update_config( h, m = _parse_time(value) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) - # Persist to DB await _set_config(db, key, value) - # Reschedule the APScheduler job await reschedule_daily_check(h, m) return {"key": key, "value": value, "scheduled": f"{h:02d}:{m:02d}"} + if key == "holidays": + # Validate: comma-separated YYYY-MM-DD dates + if value.strip(): + for s in value.split(","): + s = s.strip() + if s: + try: + from datetime import date + date.fromisoformat(s) + except ValueError: + raise HTTPException(status_code=400, detail=f"日期格式错误: {s},应为 YYYY-MM-DD") + await _set_config(db, key, value) + return {"key": key, "value": value} + await _set_config(db, key, value) return {"key": key, "value": value} diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index e072e5a..8c7feb0 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -11,7 +11,8 @@ from app.models.mini_business import MiniBusiness from app.models.key_visit import KeyVisit from app.models.daily_note import DailyNote from app.models.leave import Leave -from app.utils.timezone import today_cst +from app.models.system_config import SystemConfig +from app.utils.timezone import today_cst, is_working_day def get_week_range(reference_date: date | None = None): @@ -78,8 +79,22 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None = ) visit_map = {str(uid): cnt for uid, cnt in visits_result.all()} + # Load holiday config + holiday_row = await db.get(SystemConfig, "holidays") + holidays: set[date] = set() + if holiday_row and holiday_row.value: + for s in holiday_row.value.split(","): + s = s.strip() + if s: + try: + holidays.add(date.fromisoformat(s)) + except ValueError: + pass + # Query leave records for today — build a set of managers on leave today = today_cst() + is_rest = not is_working_day(today, holidays) + leaves_result = await db.execute( select(Leave).where(and_( Leave.start_date <= today, @@ -107,6 +122,7 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None = "completed": count >= expected, "has_reported_today": False, # Will be set below "on_leave": leave is not None, + "is_rest_day": is_rest, "leave_info": { "leave_type": leave.leave_type, "start_date": str(leave.start_date), diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 5a40788..7c202dd 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -7,16 +7,29 @@ from app.models.user import User from app.models.leave import Leave from app.models.work_plan import WorkPlan from app.models.customer import Customer +from app.models.system_config import SystemConfig from app.services.wecom import wecom_client -from app.utils.timezone import today_cst +from app.utils.timezone import today_cst, is_working_day async def check_daily_reporting(db: AsyncSession) -> dict: """Check today's reporting progress and send wecom reminders to managers who haven't reported.""" today = today_cst() - weekday = today.weekday() - if weekday >= 5: # Skip weekends - return {"status": "weekend", "date": str(today)} + + # Load holiday config + holiday_row = await db.get(SystemConfig, "holidays") + holidays: set[date] = set() + if holiday_row and holiday_row.value: + for s in holiday_row.value.split(","): + s = s.strip() + if s: + try: + holidays.add(date.fromisoformat(s)) + except ValueError: + pass + + if not is_working_day(today, holidays): + return {"status": "rest_day", "date": str(today)} # Get all users who need to report result = await db.execute( diff --git a/backend/app/utils/timezone.py b/backend/app/utils/timezone.py index 3ae4de2..7afc14a 100644 --- a/backend/app/utils/timezone.py +++ b/backend/app/utils/timezone.py @@ -11,3 +11,21 @@ def today_cst() -> date: def parse_date(s: str) -> date: """Parse a date string that may be YYYY-MM-DD or a full ISO datetime.""" return date.fromisoformat(s[:10]) + + +def is_working_day(d: date | None = None, holidays: set[date] | None = None) -> bool: + """Check if a date is a Chinese working day. + + Returns False for: + - Saturdays and Sundays (weekday >= 5) + - Dates in the holidays set (configurable via system_config) + + Note: makeup workdays (调休, where Saturday/Sunday becomes a workday) + are not currently handled. If needed, add a 'workdays' config set. + """ + d = d or today_cst() + if d.weekday() >= 5: # Saturday or Sunday + return False + if holidays and d in holidays: + return False + return True diff --git a/frontend/src/views/desktop/Dashboard.vue b/frontend/src/views/desktop/Dashboard.vue index fcdee82..a8716c4 100644 --- a/frontend/src/views/desktop/Dashboard.vue +++ b/frontend/src/views/desktop/Dashboard.vue @@ -76,7 +76,8 @@ async function handleExport() { } catch (e: any) { ElMessage.error('导出失败') } } -function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' { +function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_day' { + if (p.is_rest_day) return 'rest_day' if (p.on_leave) return 'on_leave' if (p.has_reported_today && p.completed) return 'full' if (p.has_reported_today && !p.completed) return 'catching' @@ -206,26 +207,27 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' { >
{{ p.manager_name }} - {{ { full: '本周已满', catching: '今日已填', missing: '今日未填', on_leave: '请假中' }[rowState(p)] }} + {{ { full: '本周已满', catching: '今日已填', missing: '今日未填', on_leave: '请假中', rest_day: '休息日' }[rowState(p)] }} {{ p.visit_count }} / {{ p.expected }} 条
- 请假中 + 请假中 + 休息日 @@ -325,6 +327,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' { .progress-row--catching { border-left-color: var(--gold); background: rgba(196,147,74,0.03); } .progress-row--full { border-left-color: var(--sage); background: rgba(74,103,65,0.02); } .progress-row--on_leave { border-left-color: #5B7FA5; } +.progress-row--rest_day { border-left-color: #9CA3AF; background: rgba(156,163,175,0.02); } /* ═══ Chinese Seal Stamp Watermark ═══ */ .seal-stamp { @@ -381,6 +384,12 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' { color: #5B7FA5; } +.seal-stamp--rest_day { + border-color: #9CA3AF; + outline-color: #9CA3AF; + color: #9CA3AF; +} + .seal-char { font-family: var(--font-heading); font-size: 20px; @@ -415,5 +424,6 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' { } .leave-text { font-size: 13px; color: #5B7FA5; font-family: var(--font-body); } +.rest-text { font-size: 13px; color: #9CA3AF; font-family: var(--font-body); } .empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); } diff --git a/frontend/src/views/desktop/Settings.vue b/frontend/src/views/desktop/Settings.vue index 713a511..2d145d9 100644 --- a/frontend/src/views/desktop/Settings.vue +++ b/frontend/src/views/desktop/Settings.vue @@ -15,6 +15,8 @@ const announceLoading = ref(false) const dailyCheckLoading = ref(false) const notificationTime = ref('17:30') const notificationTimeLoading = ref(false) +const holidays = ref('') +const holidaysLoading = ref(false) const importLoading = ref(false) const importFile = ref(null) @@ -32,6 +34,9 @@ onMounted(async () => { if (res.data?.notification_time) { notificationTime.value = res.data.notification_time } + if (res.data?.holidays) { + holidays.value = res.data.holidays + } } catch (_) {} }) @@ -75,6 +80,16 @@ async function handleSaveNotificationTime() { } finally { notificationTimeLoading.value = false } } +async function handleSaveHolidays() { + holidaysLoading.value = true + try { + await api.put('/system-config/holidays', { value: holidays.value }) + ElMessage.success('节假日已更新') + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || '保存失败') + } finally { holidaysLoading.value = false } +} + function handleImportFile(e: Event) { const target = e.target as HTMLInputElement if (target.files?.[0]) importFile.value = target.files[0] @@ -205,6 +220,37 @@ async function handleImportPreview() { + + + +
+
+ +
+ + 保存 +
+

配置后,仪表盘和催办提醒在这些日期将跳过填报检查(周末自动跳过无需配置)

+
+
+
+