diff --git a/.github/workflows/_tests.yml b/.github/workflows/_tests.yml index 2745c0b6..d75125df 100644 --- a/.github/workflows/_tests.yml +++ b/.github/workflows/_tests.yml @@ -97,6 +97,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Audit production dependencies + run: pnpm audit --prod --audit-level=high + - name: Type check run: pnpm type-check diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index eb3e23c9..cd8a2415 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -149,6 +149,18 @@ src/quickquip/ --- +## `frontend/` — Web 管理后台前端 + +Web Admin 前端是独立的 pnpm 工程(`quickquip-admin-v2`),技术栈为 Vue 3.5 + TypeScript strict + Vite 8 + vue-router 4(hash 模式,base `/ops/`)。关键约定: + +- 路由页面在 `src/config/nav.ts` 登记,路由由其派生;除首页静态引入外,页面组件一律路由级动态 import。 +- 后端接口封装按领域收在 `src/api/`,可复用状态与请求编排收在 `src/composables/`,`src/components/ui/` 是设计系统组件。 +- 构建产物 `frontend/dist/`(gitignore)由 `app/web/` 以 StaticFiles 挂载到 `/ops/`;前端不设路由守卫,鉴权由后端强制。 + +开发命令、测试双栈与部署形态见 [`../../frontend/README.md`](../../frontend/README.md)。 + +--- + ## `config/` — 配置文件目录 | 文件 | 层 | 说明 | diff --git a/docs/dev/style.md b/docs/dev/style.md index e4779568..2e290cf1 100644 --- a/docs/dev/style.md +++ b/docs/dev/style.md @@ -86,6 +86,8 @@ God file、God function、God class、mega-controller、service locator、宽 co - ` @@ -190,15 +195,6 @@ onBeforeUnmount(() => { width: 100%; } -/* 需要占满 .content 高度的编辑器类视图在根节点加 page-view-fill, - 只为这些页接通高度链,避免全局拉伸其他视图 */ -.page-stage:has(> .page-view-fill) { - display: flex; - flex-direction: column; - flex-grow: 1; - min-height: 0; -} - .auth-shell { position: relative; z-index: 1; diff --git a/frontend/src/api/tieba.ts b/frontend/src/api/tieba.ts index 8d892965..602c59ae 100644 --- a/frontend/src/api/tieba.ts +++ b/frontend/src/api/tieba.ts @@ -64,14 +64,15 @@ export function tiebaImgProxyUrl(url: string) { } export interface TiebaSyncStream { - /** 中止同步流(组件卸载时调用);服务端的抓取任务可能继续执行至完成 */ + /** 中止同步流(组件卸载时调用);中止后不再触发 onMessage/onDone;服务端的抓取任务可能继续执行至完成 */ close: () => void } /** * 触发贴吧同步并消费其 SSE 进度流。同步有副作用,走 POST + fetch 流式读取 * (EventSource 只支持 GET),返回的句柄用于主动关闭。 - * 结束语义:收到 [done]、流结束或连接出错即视为完成(不重连),回调 onDone。 + * 结束语义:收到 [done]、流结束或连接出错即视为完成(不重连),回调 onDone; + * HTTP 错误先以一行状态文本走 onMessage。主动 close() 后不再有任何回调。 */ export function startTiebaSync( forum: string, @@ -80,9 +81,10 @@ export function startTiebaSync( ): TiebaSyncStream { const controller = new AbortController() const params = forum ? `?forum=${encodeURIComponent(forum)}` : '' + let aborted = false let doneFired = false const fireDone = () => { - if (doneFired) return + if (doneFired || aborted) return doneFired = true onDone() } @@ -93,7 +95,10 @@ export function startTiebaSync( signal: controller.signal, }) .then(async (res) => { - if (!res.ok || !res.body) return + if (!res.ok || !res.body) { + if (!aborted) onMessage(`✕ 同步请求失败(HTTP ${res.status}),请查看服务日志`) + return + } const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' @@ -108,14 +113,19 @@ export function startTiebaSync( sep = buffer.indexOf('\n\n') const data = frame.startsWith('data: ') ? frame.slice(6) : null if (data === '[done]') return - if (data) onMessage(data) + if (data && !aborted) onMessage(data) } } }) .catch(() => {}) .finally(fireDone) - return { close: () => controller.abort() } + return { + close: () => { + aborted = true + controller.abort() + }, + } } export async function listTiebaForums(): Promise { diff --git a/frontend/src/components/background/LightField.vue b/frontend/src/components/background/LightField.vue index e4648e53..ee9c1749 100644 --- a/frontend/src/components/background/LightField.vue +++ b/frontend/src/components/background/LightField.vue @@ -32,7 +32,8 @@ const stage = createLightFieldStage({ const canvasRef = ref(null) let raf = 0 -const onMouseMove = (e: MouseEvent) => stage.onMouseMove(e) +// 静态模式(低动效/reduced-motion)不消费指针视差,跳过坐标更新 +const onMouseMove = (e: MouseEvent) => { if (!shouldStatic()) stage.onMouseMove(e) } /** 静态渲染判定:系统 reduced-motion 或用户开启低动态 */ function shouldStatic(): boolean { diff --git a/frontend/src/components/layout/StatusBar.vue b/frontend/src/components/layout/StatusBar.vue index d19a36a5..c2af851b 100644 --- a/frontend/src/components/layout/StatusBar.vue +++ b/frontend/src/components/layout/StatusBar.vue @@ -47,7 +47,8 @@ function tick() { onMounted(() => { tick() - timer = setInterval(tick, 1000) + // 隐藏标签页不刷时钟:无人观看,跳过重渲染 + timer = setInterval(() => { if (!document.hidden) tick() }, 1000) }) onUnmounted(() => { diff --git a/frontend/src/components/llm-config/McpPanel.vue b/frontend/src/components/llm-config/McpPanel.vue index fbc5c5e4..2dc5559d 100644 --- a/frontend/src/components/llm-config/McpPanel.vue +++ b/frontend/src/components/llm-config/McpPanel.vue @@ -87,7 +87,7 @@ @@ -16,17 +16,31 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import UiPageHeader from '../components/ui/UiPageHeader.vue'; import UiButton from '../components/ui/UiButton.vue'; import UiCard from '../components/ui/UiCard.vue'; import UiTag from '../components/ui/UiTag.vue'; import UiIcon from '../components/ui/UiIcon.vue'; import UiEmpty from '../components/ui/UiEmpty.vue'; import UiSkeleton from '../components/ui/UiSkeleton.vue'; import UiInfoTip from '../components/ui/UiInfoTip.vue' import { fetchCronDashboard, type CronJob } from '../api/cronDashboard' -const jobs = ref([]); const loading = ref(false); const loadError = ref(null); const snapshotAt = ref(null); let _timer: ReturnType | null = null +const jobs = ref([]); const loading = ref(false); const loadError = ref(null); const snapshotAt = ref(null) +const POLL_INTERVAL = 30_000; const POLL_BACKOFF_CAP = 120_000 +let _timer: ReturnType | null = null; let _failures = 0; let _stopped = false function formatTime(iso: string | null): string { if (!iso) return '—'; try { const d = new Date(iso); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` } catch { return iso } } function formatTrigger(raw: string): string { const m = raw.match(/^cron\[(.*)\]$/); return m ? m[1] : raw } const snapshotText = computed(() => snapshotAt.value ? formatTime(snapshotAt.value) : '—') -const snapshotStale = computed(() => { if (!snapshotAt.value) return false; const t = new Date(snapshotAt.value).getTime(); return Number.isFinite(t) && Date.now() - t > 5 * 60_000 }) +// nowTick 随每轮 loadJobs 推进,让 snapshotStale 在页面存活期间也能随时间翻转 +const nowTick = ref(Date.now()) +const snapshotStale = computed(() => { if (!snapshotAt.value) return false; const t = new Date(snapshotAt.value).getTime(); return Number.isFinite(t) && nowTick.value - t > 5 * 60_000 }) -async function loadJobs() { loading.value = true; loadError.value = null; try { const d = await fetchCronDashboard(); jobs.value = d.jobs || []; snapshotAt.value = d.updated_at ?? null } catch (e: unknown) { loadError.value = (e as Error).message } finally { loading.value = false } } -onMounted(() => { loadJobs(); _timer = setInterval(loadJobs, 30_000) }) -onUnmounted(() => { if (_timer) { clearInterval(_timer); _timer = null } }) +// 空态区分「真无任务」与「取不到数」:快照缺失/过期都指向 bot 进程侧问题,而非任务清单为空 +const emptyTitle = computed(() => !snapshotAt.value ? 'Bot 尚未上报调度快照' : snapshotStale.value ? '调度快照已过期' : '暂无调度任务') +const emptyDescription = computed(() => { + if (!snapshotAt.value) return '数据来自 Bot 进程定期写入的状态文件;Bot 未运行或从未产生快照时这里为空,启动 Bot 后任务清单会自动出现。' + if (snapshotStale.value) return '快照超过 5 分钟未更新,Bot 进程可能未运行;此处展示的是最后一次快照的内容(无任务记录)。' + return 'Bot 调度器快照正常:当前没有登记的 Cron 任务。' +}) + +async function loadJobs() { loading.value = true; loadError.value = null; try { const d = await fetchCronDashboard(); jobs.value = d.jobs || []; snapshotAt.value = d.updated_at ?? null; _failures = 0 } catch (e: unknown) { loadError.value = (e as Error).message; _failures += 1 } finally { nowTick.value = Date.now(); loading.value = false } } +function scheduleNext() { if (!_stopped) _timer = setTimeout(tick, Math.min(POLL_INTERVAL * 2 ** _failures, POLL_BACKOFF_CAP)) } +async function tick() { if (!document.hidden) await loadJobs(); scheduleNext() } // 隐藏标签页跳过本轮,节拍本身保持 +onMounted(() => { _stopped = false; void loadJobs().then(scheduleNext) }) +onUnmounted(() => { _stopped = true; if (_timer) { clearTimeout(_timer); _timer = null } }) diff --git a/frontend/src/views/LlmAboutView.vue b/frontend/src/views/LlmAboutView.vue index ad78152a..ffc0b952 100644 --- a/frontend/src/views/LlmAboutView.vue +++ b/frontend/src/views/LlmAboutView.vue @@ -1,5 +1,5 @@