From 336b60b3af75e3b935dd1a22a42995da0b3054ec Mon Sep 17 00:00:00 2001 From: v6ole Date: Mon, 6 Jul 2026 11:51:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=80=9A=E6=8A=A5=E6=97=B6=E9=97=B4=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20+=20=E4=BA=AE=E7=81=AF=E8=A1=A830=E5=A4=A9=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E7=AA=97=E5=8F=A3=20+=20=E7=9B=B8=E5=85=B3=E4=BA=BA?= =?UTF-8?q?=E5=91=98=E6=96=87=E6=A1=88=E7=BB=9F=E4=B8=80=20+=20=E5=91=A8?= =?UTF-8?q?=E6=8A=A5=E6=95=B0=E6=8D=AE=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 亮灯表从自然月改为30天滚动窗口(30/60天阈值),解决月底覆盖断崖 - 系统设置新增「定时通报时间」选择器,支局长可配置每日企微通报时刻 - 新增 SystemConfig 键值表 + scheduler_manager APScheduler 运行时重调度 - 拜访记录/导入模板/弹窗表单统一「同访人员」→「相关人员」 - 仪表盘周报数据补全:companion_names_resolved、visitor_name/phone 等字段 - 新增 weekly_report_template.xlsx 模板文件 Co-Authored-By: Claude --- backend/app/api/system_config.py | 85 ++++++++++++++++++++++ backend/app/main.py | 45 ++++++------ backend/app/models/__init__.py | 2 + backend/app/models/system_config.py | 12 +++ backend/app/services/scheduler_manager.py | 59 +++++++++++++++ frontend/src/components.d.ts | 1 + frontend/src/views/desktop/Settings.vue | 46 +++++++++++- weekly_report_template.xlsx | Bin 0 -> 18344 bytes 8 files changed, 226 insertions(+), 24 deletions(-) create mode 100644 backend/app/api/system_config.py create mode 100644 backend/app/models/system_config.py create mode 100644 backend/app/services/scheduler_manager.py create mode 100644 weekly_report_template.xlsx diff --git a/backend/app/api/system_config.py b/backend/app/api/system_config.py new file mode 100644 index 0000000..5894f31 --- /dev/null +++ b/backend/app/api/system_config.py @@ -0,0 +1,85 @@ +"""System configuration API — get/set runtime settings like notification time.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.middleware.auth import get_current_user, require_director +from app.models.system_config import SystemConfig +from app.services.scheduler_manager import reschedule_daily_check + +router = APIRouter(prefix="/system-config", tags=["SystemConfig"]) + +DEFAULT_NOTIFICATION_TIME = "17:30" + + +def _parse_time(time_str: str) -> tuple[int, int]: + """Parse 'HH:MM' string to (hour, minute). Raises ValueError on bad input.""" + parts = time_str.strip().split(":") + if len(parts) != 2: + raise ValueError("时间格式必须为 HH:MM") + h, m = int(parts[0]), int(parts[1]) + if not (0 <= h <= 23 and 0 <= m <= 59): + raise ValueError("小时 0-23,分钟 0-59") + return h, m + + +async def _get_config(db: AsyncSession, key: str) -> str | None: + row = await db.get(SystemConfig, key) + return row.value if row else None + + +async def _set_config(db: AsyncSession, key: str, value: str): + row = await db.get(SystemConfig, key) + if row: + row.value = value + else: + db.add(SystemConfig(key=key, value=value)) + await db.commit() + + +async def get_notification_time(db: AsyncSession) -> str: + """Read notification_time from DB, falling back to default.""" + val = await _get_config(db, "notification_time") + return val if val else DEFAULT_NOTIFICATION_TIME + + +@router.get("") +async def list_config(db: AsyncSession = Depends(get_db)): + """Return all system config as {key: value} dict.""" + result = await db.execute(select(SystemConfig)) + rows = result.scalars().all() + config = {r.key: r.value for r in rows} + # Ensure notification_time always has a value + if "notification_time" not in config: + config["notification_time"] = DEFAULT_NOTIFICATION_TIME + return config + + +@router.put("/{key}") +async def update_config( + key: str, + body: dict, + current_user: dict = Depends(require_director), + db: AsyncSession = Depends(get_db), +): + """Update a single config key. Only directors can change settings.""" + value = body.get("value", "") + valid_keys = {"notification_time"} + + if key not in valid_keys: + raise HTTPException(status_code=400, detail=f"不支持的配置项: {key}") + + if key == "notification_time": + try: + 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}"} + + await _set_config(db, key, value) + return {"key": key, "value": value} diff --git a/backend/app/main.py b/backend/app/main.py index a33303f..a758c37 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,27 +1,14 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from apscheduler.schedulers.asyncio import AsyncIOScheduler +from sqlalchemy import select from app.config import settings from app.database import engine, Base, async_session from app.api import router as api_router from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits -from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary -from app.services.scheduler import check_daily_reporting, check_overdue_plans - -_scheduler = AsyncIOScheduler() - - -async def _scheduled_check(): - """Wrapper for APScheduler: create a fresh session and run the daily check.""" - async with async_session() as db: - await check_daily_reporting(db) - - -async def _scheduled_overdue_check(): - """Check for overdue plans and remind managers (9:00 AM).""" - async with async_session() as db: - await check_overdue_plans(db) +from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config +from app.models.system_config import SystemConfig +from app.services.scheduler_manager import start_scheduler, shutdown_scheduler @asynccontextmanager @@ -58,16 +45,29 @@ async def lifespan(app: FastAPI): await conn.run_sync(lambda c: c.exec_driver_sql( "ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'" )) + # system_config table for v0.5 + await conn.run_sync(lambda c: c.exec_driver_sql( + "CREATE TABLE IF NOT EXISTS system_config (key VARCHAR(64) PRIMARY KEY, value TEXT DEFAULT '')" + )) - # Start daily reporting scheduler (17:30 CST = 09:30 UTC) - _scheduler.add_job(_scheduled_check, "cron", hour=17, minute=30, id="daily_check") - _scheduler.add_job(_scheduled_overdue_check, "cron", hour=9, minute=0, id="overdue_check") - _scheduler.start() + # Read notification_time from DB (or use default 17:30) + notification_hour, notification_minute = 17, 30 + async with async_session() as db: + row = await db.get(SystemConfig, "notification_time") + if row and row.value: + try: + parts = row.value.strip().split(":") + notification_hour, notification_minute = int(parts[0]), int(parts[1]) + except (ValueError, IndexError): + pass + + # Start daily reporting scheduler + start_scheduler(notification_hour, notification_minute) yield # Shutdown - _scheduler.shutdown(wait=False) + shutdown_scheduler() await engine.dispose() @@ -101,6 +101,7 @@ app.include_router(import_data.router, prefix="/api") app.include_router(wecom.router, prefix="/api") app.include_router(daily_notes.router, prefix="/api") app.include_router(ai_summary.router, prefix="/api") +app.include_router(system_config.router, prefix="/api") @app.get("/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ac27995..215f04f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,6 +8,7 @@ from app.models.mini_business import MiniBusiness from app.models.key_visit import KeyVisit from app.models.daily_note import DailyNote from app.models.ai_summary import AISummary +from app.models.system_config import SystemConfig __all__ = [ "User", @@ -20,4 +21,5 @@ __all__ = [ "KeyVisit", "DailyNote", "AISummary", + "SystemConfig", ] diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py new file mode 100644 index 0000000..bd1f256 --- /dev/null +++ b/backend/app/models/system_config.py @@ -0,0 +1,12 @@ +"""System-wide configuration key-value store.""" + +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base + + +class SystemConfig(Base): + __tablename__ = "system_config" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[str] = mapped_column(Text, default="") diff --git a/backend/app/services/scheduler_manager.py b/backend/app/services/scheduler_manager.py new file mode 100644 index 0000000..b992ef0 --- /dev/null +++ b/backend/app/services/scheduler_manager.py @@ -0,0 +1,59 @@ +"""APScheduler singleton — allows runtime reschedule of notification jobs.""" + +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +_scheduler: AsyncIOScheduler | None = None + + +def get_scheduler() -> AsyncIOScheduler: + """Lazy-init and return the global scheduler.""" + global _scheduler + if _scheduler is None: + _scheduler = AsyncIOScheduler() + return _scheduler + + +async def reschedule_daily_check(hour: int, minute: int): + """Update the daily_check cron job to a new time.""" + sched = get_scheduler() + # Remove and re-add — APScheduler reschedule_job is inconsistent with cron triggers + try: + sched.remove_job("daily_check") + except Exception: + pass + from app.services.scheduler import check_daily_reporting + from app.database import async_session + + async def _wrapper(): + async with async_session() as db: + await check_daily_reporting(db) + + sched.add_job(_wrapper, "cron", hour=hour, minute=minute, id="daily_check") + + +def start_scheduler(notification_hour: int = 17, notification_minute: int = 30): + """Start the scheduler with configured notification times.""" + sched = get_scheduler() + + from app.services.scheduler import check_daily_reporting, check_overdue_plans + from app.database import async_session + + async def _daily(): + async with async_session() as db: + await check_daily_reporting(db) + + async def _overdue(): + async with async_session() as db: + await check_overdue_plans(db) + + sched.add_job(_daily, "cron", hour=notification_hour, minute=notification_minute, id="daily_check") + sched.add_job(_overdue, "cron", hour=9, minute=0, id="overdue_check") + sched.start() + + +def shutdown_scheduler(): + """Shutdown the scheduler gracefully.""" + global _scheduler + if _scheduler is not None: + _scheduler.shutdown(wait=False) + _scheduler = None diff --git a/frontend/src/components.d.ts b/frontend/src/components.d.ts index fe0de78..e77789a 100644 --- a/frontend/src/components.d.ts +++ b/frontend/src/components.d.ts @@ -40,6 +40,7 @@ declare module 'vue' { ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] ElTimePicker: typeof import('element-plus/es')['ElTimePicker'] + ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] ImagePreview: typeof import('./components/ImagePreview.vue')['default'] MobileLayout: typeof import('./components/MobileLayout.vue')['default'] diff --git a/frontend/src/views/desktop/Settings.vue b/frontend/src/views/desktop/Settings.vue index 959fb52..c2d9166 100644 --- a/frontend/src/views/desktop/Settings.vue +++ b/frontend/src/views/desktop/Settings.vue @@ -10,6 +10,8 @@ const announcementContent = ref('') const remindLoading = ref(false) const announceLoading = ref(false) const dailyCheckLoading = ref(false) +const notificationTime = ref('17:30') +const notificationTimeLoading = ref(false) const importLoading = ref(false) const importFile = ref(null) @@ -21,6 +23,13 @@ onMounted(async () => { const res = await api.get('/users/', { params: { role: 'manager' } }) managers.value = res.data } catch (_) {} + // Load current notification time + try { + const res = await api.get('/system-config') + if (res.data?.notification_time) { + notificationTime.value = res.data.notification_time + } + } catch (_) {} }) async function handleRemind() { @@ -53,6 +62,16 @@ async function handleDailyCheck() { finally { dailyCheckLoading.value = false } } +async function handleSaveNotificationTime() { + notificationTimeLoading.value = true + try { + await api.put('/system-config/notification_time', { value: notificationTime.value }) + ElMessage.success(`通报时间已更新为 ${notificationTime.value}`) + } catch (e: any) { + ElMessage.error(e.response?.data?.detail || '保存失败') + } finally { notificationTimeLoading.value = false } +} + function handleImportFile(e: Event) { const target = e.target as HTMLInputElement if (target.files?.[0]) importFile.value = target.files[0] @@ -141,8 +160,28 @@ async function handleImportPreview() { 填报检查 -

手动触发今日填报检查(通常每日 18:00 自动执行)

- 立即检查 +
+
+ +
+ + 保存 +
+

每日定时向未填报人员推送催办提醒,并向支局长发送汇总

+
+
+ + 立即检查 +
+
@@ -222,4 +261,7 @@ async function handleImportPreview() { font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 15px; color: var(--ink); letter-spacing: 0.05em; } +.setting-row { display: flex; gap: 32px; align-items: flex-start; flex-wrap: wrap; } +.setting-col { display: flex; flex-direction: column; gap: 8px; } +.setting-label { font-family: 'Noto Serif SC', STSong, serif; font-size: 13px; color: var(--ink); } diff --git a/weekly_report_template.xlsx b/weekly_report_template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ba8b0bce012de96d2469a01b0092e6694a7e7bcc GIT binary patch literal 18344 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 literal 0 HcmV?d00001