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 0000000..ba8b0bc Binary files /dev/null and b/weekly_report_template.xlsx differ