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:
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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="")
|
||||||
@@ -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
|
||||||
Vendored
+1
@@ -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']
|
||||||
|
|||||||
@@ -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.
Reference in New Issue
Block a user