feat: 系统设置新增通报时间配置 + 亮灯表30天滚动窗口 + 相关人员文案统一 + 周报数据补全

- 亮灯表从自然月改为30天滚动窗口(30/60天阈值),解决月底覆盖断崖
- 系统设置新增「定时通报时间」选择器,支局长可配置每日企微通报时刻
- 新增 SystemConfig 键值表 + scheduler_manager APScheduler 运行时重调度
- 拜访记录/导入模板/弹窗表单统一「同访人员」→「相关人员」
- 仪表盘周报数据补全:companion_names_resolved、visitor_name/phone 等字段
- 新增 weekly_report_template.xlsx 模板文件

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-06 11:51:07 +08:00
parent cd47d1aed5
commit 336b60b3af
8 changed files with 226 additions and 24 deletions
+85
View File
@@ -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}
+23 -22
View File
@@ -1,27 +1,14 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from apscheduler.schedulers.asyncio import AsyncIOScheduler from sqlalchemy import select
from app.config import settings from app.config import settings
from app.database import engine, Base, async_session from app.database import engine, Base, async_session
from app.api import router as api_router 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 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.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config
from app.services.scheduler import check_daily_reporting, check_overdue_plans from app.models.system_config import SystemConfig
from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
_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)
@asynccontextmanager @asynccontextmanager
@@ -58,16 +45,29 @@ async def lifespan(app: FastAPI):
await conn.run_sync(lambda c: c.exec_driver_sql( await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'" "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) # Read notification_time from DB (or use default 17:30)
_scheduler.add_job(_scheduled_check, "cron", hour=17, minute=30, id="daily_check") notification_hour, notification_minute = 17, 30
_scheduler.add_job(_scheduled_overdue_check, "cron", hour=9, minute=0, id="overdue_check") async with async_session() as db:
_scheduler.start() 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 yield
# Shutdown # Shutdown
_scheduler.shutdown(wait=False) shutdown_scheduler()
await engine.dispose() 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(wecom.router, prefix="/api")
app.include_router(daily_notes.router, prefix="/api") app.include_router(daily_notes.router, prefix="/api")
app.include_router(ai_summary.router, prefix="/api") app.include_router(ai_summary.router, prefix="/api")
app.include_router(system_config.router, prefix="/api")
@app.get("/health") @app.get("/health")
+2
View File
@@ -8,6 +8,7 @@ from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote from app.models.daily_note import DailyNote
from app.models.ai_summary import AISummary from app.models.ai_summary import AISummary
from app.models.system_config import SystemConfig
__all__ = [ __all__ = [
"User", "User",
@@ -20,4 +21,5 @@ __all__ = [
"KeyVisit", "KeyVisit",
"DailyNote", "DailyNote",
"AISummary", "AISummary",
"SystemConfig",
] ]
+12
View File
@@ -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="")
+59
View File
@@ -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
+1
View File
@@ -40,6 +40,7 @@ declare module 'vue' {
ElTabs: typeof import('element-plus/es')['ElTabs'] ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker'] ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ImagePreview: typeof import('./components/ImagePreview.vue')['default'] ImagePreview: typeof import('./components/ImagePreview.vue')['default']
MobileLayout: typeof import('./components/MobileLayout.vue')['default'] MobileLayout: typeof import('./components/MobileLayout.vue')['default']
+43 -1
View File
@@ -10,6 +10,8 @@ const announcementContent = ref('')
const remindLoading = ref(false) const remindLoading = ref(false)
const announceLoading = ref(false) const announceLoading = ref(false)
const dailyCheckLoading = ref(false) const dailyCheckLoading = ref(false)
const notificationTime = ref('17:30')
const notificationTimeLoading = ref(false)
const importLoading = ref(false) const importLoading = ref(false)
const importFile = ref<File | null>(null) const importFile = ref<File | null>(null)
@@ -21,6 +23,13 @@ onMounted(async () => {
const res = await api.get('/users/', { params: { role: 'manager' } }) const res = await api.get('/users/', { params: { role: 'manager' } })
managers.value = res.data managers.value = res.data
} catch (_) {} } 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() { async function handleRemind() {
@@ -53,6 +62,16 @@ async function handleDailyCheck() {
finally { dailyCheckLoading.value = false } 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) { function handleImportFile(e: Event) {
const target = e.target as HTMLInputElement const target = e.target as HTMLInputElement
if (target.files?.[0]) importFile.value = target.files[0] if (target.files?.[0]) importFile.value = target.files[0]
@@ -141,8 +160,28 @@ async function handleImportPreview() {
填报检查 填报检查
</div> </div>
</template> </template>
<p style="color: var(--warm-gray); font-family: 'Noto Serif SC', STSong, serif;">手动触发今日填报检查通常每日 18:00 自动执行</p> <div class="setting-row">
<div class="setting-col">
<label class="setting-label">定时通报时间</label>
<div style="display:flex;gap:8px;align-items:center">
<el-time-select
v-model="notificationTime"
start="08:00"
step="00:05"
end="21:00"
placeholder="选择时间"
format="HH:mm"
style="width:140px"
/>
<el-button type="primary" :loading="notificationTimeLoading" @click="handleSaveNotificationTime" size="small">保存</el-button>
</div>
<p style="color:var(--warm-gray);font-size:12px;margin-top:6px">每日定时向未填报人员推送催办提醒并向支局长发送汇总</p>
</div>
<div class="setting-col">
<label class="setting-label">手动触发</label>
<el-button :loading="dailyCheckLoading" @click="handleDailyCheck">立即检查</el-button> <el-button :loading="dailyCheckLoading" @click="handleDailyCheck">立即检查</el-button>
</div>
</div>
</el-card> </el-card>
<!-- Import --> <!-- Import -->
@@ -222,4 +261,7 @@ async function handleImportPreview() {
font-family: 'ZCOOL XiaoWei', STSong, serif; font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 15px; color: var(--ink); letter-spacing: 0.05em; 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); }
</style> </style>
Binary file not shown.