From 8930ef29cdfd091dfb0897c195799fba002e12bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Tue, 27 Oct 2026 09:28:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E9=9B=86=E4=B8=AD=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E7=AD=96=E7=95=A5=E4=B8=8E=E8=B4=A6=E5=8F=B7=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E5=B9=B6=E7=8B=AC=E7=AB=8B=E8=AE=BE=E7=BD=AE=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/DingtalkSessionControl.tsx | 5 +- web/src/SourceManagement.css | 46 ++++ web/src/SourceManagement.test.tsx | 31 +++ web/src/SourceManagement.tsx | 340 ++++++++++++++++++++++++++++ web/src/SystemSettings.test.tsx | 6 +- web/src/SystemSettings.tsx | 219 +----------------- web/src/sourceRefreshPolicy.test.ts | 52 +++++ web/src/sourceRefreshPolicy.ts | 35 +++ 8 files changed, 520 insertions(+), 214 deletions(-) create mode 100644 web/src/SourceManagement.css create mode 100644 web/src/SourceManagement.test.tsx create mode 100644 web/src/SourceManagement.tsx create mode 100644 web/src/sourceRefreshPolicy.test.ts create mode 100644 web/src/sourceRefreshPolicy.ts diff --git a/web/src/DingtalkSessionControl.tsx b/web/src/DingtalkSessionControl.tsx index 88b543f..5429388 100644 --- a/web/src/DingtalkSessionControl.tsx +++ b/web/src/DingtalkSessionControl.tsx @@ -10,6 +10,7 @@ import type { DingtalkSessionView } from './dingtalkSession' const finished = new Set(['success', 'expired', 'failed', 'closed']) interface DingtalkSessionControlProps { + triggerLabel?: string autoStart?: boolean hideTrigger?: boolean space?: string @@ -17,7 +18,7 @@ interface DingtalkSessionControlProps { onClose?: () => void } -export function DingtalkSessionControl({ autoStart = false, hideTrigger = false, space, onSuccess, onClose }: DingtalkSessionControlProps = {}) { +export function DingtalkSessionControl({ autoStart = false, hideTrigger = false, space, onSuccess, onClose, triggerLabel = '扫码刷新会话' }: DingtalkSessionControlProps = {}) { const [session, setSession] = useState(null) const [failure, setFailure] = useState(null) const [starting, setStarting] = useState(false) @@ -89,7 +90,7 @@ export function DingtalkSessionControl({ autoStart = false, hideTrigger = false, return
{!hideTrigger ? : null} {lastSuccess && !hideTrigger ?

✅ 钉钉会话已刷新

: null} {failure && !open ?

{failure}

: null} diff --git a/web/src/SourceManagement.css b/web/src/SourceManagement.css new file mode 100644 index 0000000..d6007b4 --- /dev/null +++ b/web/src/SourceManagement.css @@ -0,0 +1,46 @@ +.source-account { + margin: 0 0 14px; + font-size: 12px; +} +.source-account > button { + padding: 5px 0; + color: var(--ink-mute); +} +.source-account-detail { + padding: 12px 16px; + border: 1px solid var(--border-base); + border-radius: 8px; + margin-top: 8px; +} +.source-account-detail p { + margin: 0 0 10px; +} +.source-policy-panel { + padding-bottom: 24px; + margin-bottom: 24px; + border-bottom: 1px solid var(--border-base); +} +.source-policy-panel p { + color: var(--ink-mute); + font-size: 12px; + line-height: 1.7; +} +.source-policy-controls { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 12px; +} +.source-policy-controls label { + display: grid; + gap: 8px; + font-size: 12px; +} +.source-policy-controls select { + padding: 8px 12px; + min-width: 180px; +} +.source-schedule-form > small { + color: var(--ink-mute); + font-size: 12px; +} diff --git a/web/src/SourceManagement.test.tsx b/web/src/SourceManagement.test.tsx new file mode 100644 index 0000000..6ddea8a --- /dev/null +++ b/web/src/SourceManagement.test.tsx @@ -0,0 +1,31 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { SourceSettingsCard, SourceRefreshPolicy, SourceSettingsAudit } from './SourceManagement' +import type { SourceConnectionListItem } from './sourceConnections' +const row = { + id: 's1', + name: '测试来源', + source_type: 'dingtalk', + enabled: false, + schedule_configured: true, + next_refresh_at: null, +} as SourceConnectionListItem + +describe('source management separation', () => { + it('keeps schedule editors, authorization and audit out of source rows', () => { + const html = renderToStaticMarkup() + expect(html).toContain('自动刷新已暂停') + for (const text of ['保存设置', '自动刷新周期', '扫码刷新会话', '修订历史']) expect(html).not.toContain(text) + }) + it('has a single shared period selector without silently resuming sources', () => { + const html = renderToStaticMarkup() + expect((html.match(/ setInterval(Number(event.target.value))} + > + + {choices.map((choice) => ( + + ))} + + + +
+ {result ?

{result}

: null} + {error ? ( +

+ {error} +

+ ) : null} + + ) +} + +export function SourceSettingsAudit({ connections }: { connections: SourceConnectionListItem[] }) { + const [id, setID] = useState(connections[0]?.id ?? '') + const [rows, setRows] = useState([]) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + useEffect(() => { + const controller = new AbortController() + setRows([]) + setError('') + if (!id) return + setLoading(true) + void fetchSourceSettingsRevisions(id, controller.signal) + .then((items) => { + if (!controller.signal.aborted) setRows(items) + }) + .catch((cause) => { + if (!controller.signal.aborted) setError(errorText(cause)) + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false) + }) + return () => controller.abort() + }, [id]) + return ( +
+

数据源设置审计

+ + {error ? ( +

+ {error} +

+ ) : loading ? ( +

加载中…

+ ) : !rows.length ? ( +

暂无设置变更记录。

+ ) : ( +
    + {rows.map((row) => ( +
  • + 修订 {row.revision_number} + + {row.state === 'active' + ? '当前生效' + : row.state === 'retired' + ? '历史版本' + : row.state === 'validated' + ? '待启用' + : '草稿或失败'} + + + {row.created_by} · {new Date(row.created_at).toLocaleString('zh-CN')} ·{' '} + {row.enabled ? '允许自动刷新' : '已暂停'} ·{' '} + {row.refresh_interval_seconds ? `周期 ${row.refresh_interval_seconds} 秒` : '仅手动'} + +
  • + ))} +
+ )} +
+ ) +} diff --git a/web/src/SystemSettings.test.tsx b/web/src/SystemSettings.test.tsx index 4df0ada..e23ec47 100644 --- a/web/src/SystemSettings.test.tsx +++ b/web/src/SystemSettings.test.tsx @@ -290,7 +290,7 @@ describe('SystemSettingsView', () => { expect(servicesHtml).toContain('端点探测:暂无真实探测结果') // V1: left-nav section controls, per-source schedule editing, danger modal & history affordances. expect(html).toContain('settings-nav') - for (const kept of ['更多', '下次自动刷新']) expect(html).toContain(kept) + for (const kept of ['更多', '自动刷新中']) expect(html).toContain(kept) for (const hidden of ['查看修订历史', '保存并启用调度', '停用来源']) expect(html).not.toContain(hidden) expect(html).not.toContain('本页只列 PP 来源') expect(html).not.toContain('本地手工导入来源不支持定时刷新') @@ -565,7 +565,9 @@ describe('SystemSettingsView 数据源配置的默认清单', () => { expect(html).not.toContain(removed) expect(html).toContain('更多') expect(html).toContain('aria-expanded="false"') - expect(html).toContain('已启用') + expect(html).toContain('仅手动刷新') + expect(html).toContain('数据源设置审计') + expect((html.match(/钉钉账号 ·/g) ?? []).length).toBe(1) for (const removed of ['+ 新增钉钉知识库', '文件格式筛选']) expect(html).not.toContain(removed) // 文档工程师看不到添加向导与「去掉来源」:两者都是管理员动作。 const engineer = view('engineer') diff --git a/web/src/SystemSettings.tsx b/web/src/SystemSettings.tsx index 1830d0e..a29e668 100644 --- a/web/src/SystemSettings.tsx +++ b/web/src/SystemSettings.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { BackupScheduleSection } from './BackupScheduleSection' import { QAModelSettings } from './QAModelSettings' import { MembersSection } from './MembersSection' -import { DingtalkSessionControl } from './DingtalkSessionControl' +import { DingtalkAccountStatus, SourceRefreshPolicy, SourceSettingsAudit, SourceSettingsCard } from './SourceManagement' import { RoutingBindingsSection } from './RoutingBindingsSection' import { ProductionBindingsSection } from './ProductionBindingsSection' import { SourceWizard } from './SourceWizard' @@ -13,14 +13,8 @@ import type { MonitorOverview } from './monitor' import { fetchReleaseCapabilities } from './reviewRelease' import { fetchCatalogRoutingCapabilities, fetchCatalogSources } from './sourceCatalog' import type { CatalogRoutingCapabilities, CatalogSource } from './sourceCatalog' -import { - activateSourceSettingsRevision, - archiveSourceConnection, - createSourceSettingsRevision, - fetchSourceConnectionList, - fetchSourceSettingsRevisions, -} from './sourceConnections' -import type { SourceConnectionListItem, SourceSettingsRevision } from './sourceConnections' +import { archiveSourceConnection, fetchSourceConnectionList } from './sourceConnections' +import type { SourceConnectionListItem } from './sourceConnections' import { activateUISettings, createUISettingsCandidate, @@ -50,7 +44,8 @@ interface Props { initialSection?: SectionKey } -type SectionKey = 'sources' | 'services' | 'bindings' | 'production-bindings' | 'storage' | 'refresh' | 'members' +type SectionKey = + 'sources' | 'services' | 'bindings' | 'production-bindings' | 'storage' | 'refresh' | 'members' | 'source-audit' const sections: { key: SectionKey; label: string; adminOnly?: boolean }[] = [ { key: 'sources', label: '数据源配置' }, @@ -60,6 +55,7 @@ const sections: { key: SectionKey; label: string; adminOnly?: boolean }[] = [ { key: 'storage', label: '存储与备份' }, { key: 'refresh', label: '刷新策略' }, { key: 'members', label: '成员与角色', adminOnly: true }, + { key: 'source-audit', label: '数据源设置审计' }, ] as { key: SectionKey; label: string; adminOnly?: boolean }[] const sourceTypeLabels: Record = { @@ -124,206 +120,6 @@ export const productionWeKnoraProbe = (monitor: MonitorOverview | null): string return `端点探测:${label}${detail ? `(${detail})` : ''}` } -const intervalChoices: { value: number; label: string }[] = [ - { value: 0, label: '仅手动刷新' }, - { value: 3600, label: '每小时' }, - { value: 21600, label: '每6小时' }, - { value: 86400, label: '每24小时' }, -] - -function formatHorizon(value: string | null): string { - if (!value) return '仅手动刷新' - const at = new Date(value) - if (Number.isNaN(at.getTime())) return '仅手动刷新' - return `下次自动刷新 ${at.toLocaleString('zh-CN', { hour12: false })}` -} - -function SourceSettingsCard({ - connection, - onSourcesChanged, -}: { - connection: SourceConnectionListItem - onSourcesChanged: () => void -}) { - const auth = useOptionalAuth() - const isAdmin = auth?.identity?.role === 'admin' - const [interval, setInterval] = useState(() => { - if (!connection.next_refresh_at) return 0 - const remaining = new Date(connection.next_refresh_at).getTime() - Date.now() - if (remaining <= 0 || remaining > 10 * 3600_000) return 86400 - return remaining > 3 * 3600_000 ? 21600 : 3600 - }) - const [busy, setBusy] = useState(false) - const [message, setMessage] = useState(null) - const [failure, setFailure] = useState(null) - const [showHistory, setShowHistory] = useState(false) - const [history, setHistory] = useState(null) - const [pendingDisable, setPendingDisable] = useState(false) - const [showOptions, setShowOptions] = useState(false) - const scheduled = connection.source_type !== 'manual' - - const submit = async (nextEnabled: boolean) => { - setBusy(true) - setMessage(null) - setFailure(null) - try { - const draft = { - source_type: connection.source_type, - name: connection.name, - endpoint: '', - credential_ref: '', - enabled: nextEnabled, - schedule: { refresh_interval_seconds: nextEnabled && interval > 0 ? interval : null }, - } - const created = await createSourceSettingsRevision(connection.id, draft) - const activated = await activateSourceSettingsRevision(connection.id, created.id) - setMessage( - `${connection.name}:调度设置已验证并启用${activated.operation === 'replayed' ? '(沿用上次结果)' : ''};${nextEnabled ? formatHorizon(new Date(Date.now() + interval * 1000).toISOString()) : '已停用,新的自动扫描与刷新任务不再入队,历史记录全部保留'}。` - ) - onSourcesChanged() - } catch (cause) { - setFailure(cause instanceof Error ? cause.message : '保存来源设置失败') - } finally { - setBusy(false) - setPendingDisable(false) - } - } - - const loadHistory = async () => { - if (showHistory) { - setShowHistory(false) - return - } - setShowHistory(true) - if (history) return - try { - setHistory(await fetchSourceSettingsRevisions(connection.id)) - } catch (cause) { - setFailure(cause instanceof Error ? cause.message : '加载修订历史失败') - } - } - - const canApply = !busy - - return ( -
-
- {connection.name} - {sourceTypeLabels[connection.source_type] ?? connection.source_type} -
-
- - {connection.enabled ? '已启用' : '已停用'} - - {connection.enabled ? formatHorizon(connection.next_refresh_at) : '自动刷新已暂停'} -
- - {showOptions ? ( -
- {scheduled ? ( - - ) : ( - 本地手工导入来源不支持定时刷新,只能手动触发扫描。 - )} - {connection.enabled ? ( -
- - -
- ) : ( -
- -
- )} - {connection.source_type === 'dingtalk' && isAdmin ? : null} -
- -
-
- ) : null} - {message ? ( -

- {message} -

- ) : null} - {failure ? ( -

- {failure} -

- ) : null} - {showOptions && showHistory && history ? ( -
    - {history.map((item) => ( -
  • - 修订 {item.revision_number} - - {item.state === 'active' - ? '当前启用' - : item.state === 'validated' - ? '待启用' - : item.state === 'retired' - ? '已退休' - : '失败'} - - - {item.created_by} · {new Date(item.created_at).toLocaleString('zh-CN', { hour12: false })} ·{' '} - {item.refresh_interval_seconds ? `每${secondsLabel(Number(item.refresh_interval_seconds))}` : '仅手动'} - -
  • - ))} -
- ) : null} - {showOptions && showHistory && !history ? 正在加载修订历史… : null} - {pendingDisable ? ( -
-
-

停用「{connection.name}」?

-

- 停用会立即停止该来源的自动扫描与新刷新任务入队;历史快照、标注和审计全部保留,随时可以重新启用。此操作会先由后端再次校验权限。 -

-
- - -
-
-
- ) : null} -
- ) -} - export function SystemSettingsView({ sources, connections, @@ -441,6 +237,7 @@ export function SystemSettingsView({ onRegistered={onSourcesChanged} /> ) : null} + {identity?.role === 'admin' ? : null}
{listedConnections.length ? (
@@ -600,8 +397,10 @@ export function SystemSettingsView({ {section === 'production-bindings' ? : null} {section === 'storage' ? : null} {section === 'members' && identity?.role === 'admin' ? : null} + {section === 'source-audit' ? : null} {section === 'refresh' ? (
+

刷新策略

diff --git a/web/src/sourceRefreshPolicy.test.ts b/web/src/sourceRefreshPolicy.test.ts new file mode 100644 index 0000000..a9063e8 --- /dev/null +++ b/web/src/sourceRefreshPolicy.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi, afterEach } from 'vitest' +import { configuredInterval, saveSourceRefresh } from './sourceRefreshPolicy' +import type { SourceConnectionListItem, SourceSettingsRevision } from './sourceConnections' +const source = { + id: 'source-1', + name: '测试来源', + source_type: 'dingtalk', + enabled: true, + schedule_configured: true, +} as SourceConnectionListItem +const revision = { id: 'r1', state: 'active', refresh_interval_seconds: '21600' } as SourceSettingsRevision +const response = (data: unknown) => ({ ok: true, json: async () => data }) +afterEach(() => vi.unstubAllGlobals()) +describe('source refresh policy', () => { + it('uses the persisted interval, never the next-run countdown', () => { + expect(configuredInterval(source, [revision])).toBe(21600) + }) + it('refuses to overwrite an unreadable configured policy', () => { + expect(() => configuredInterval(source, [])).toThrow('未修改设置') + }) + it('preserves the interval when pausing', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + response({ + data: [ + { + ...revision, + revision_number: 1, + name: '测试来源', + endpoint: '', + credential_ref: '', + enabled: true, + created_by: 'admin', + created_at: '2026-10-26', + validated_at: null, + activated_at: null, + retired_at: null, + }, + ], + }) + ) + .mockResolvedValueOnce(response({ operation: 'created', data: { id: 'r2' } })) + .mockResolvedValueOnce(response({ operation: 'created', data: {} })) + vi.stubGlobal('fetch', fetch) + await saveSourceRefresh(source, false) + expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({ + enabled: false, + schedule: { refresh_interval_seconds: 21600 }, + }) + }) +}) diff --git a/web/src/sourceRefreshPolicy.ts b/web/src/sourceRefreshPolicy.ts new file mode 100644 index 0000000..44b33ac --- /dev/null +++ b/web/src/sourceRefreshPolicy.ts @@ -0,0 +1,35 @@ +import { + activateSourceSettingsRevision, + createSourceSettingsRevision, + fetchSourceSettingsRevisions, +} from './sourceConnections' +import type { SourceConnectionListItem, SourceSettingsRevision } from './sourceConnections' + +export function configuredInterval(connection: SourceConnectionListItem, revisions: SourceSettingsRevision[]): number { + const active = revisions.find((item) => item.state === 'active') + if (active) { + const seconds = Number(active.refresh_interval_seconds ?? 0) + if (!Number.isInteger(seconds) || seconds < 0) throw new Error('刷新周期无效,未修改设置') + return seconds + } + if (connection.schedule_configured) throw new Error(`${connection.name}:无法读取活动刷新策略,未修改设置`) + return 0 +} + +export async function saveSourceRefresh( + connection: SourceConnectionListItem, + enabled: boolean, + interval?: number +): Promise { + const seconds = interval ?? configuredInterval(connection, await fetchSourceSettingsRevisions(connection.id)) + if (!enabled && connection.source_type === 'manual') throw new Error('手工来源不支持自动刷新') + const created = await createSourceSettingsRevision(connection.id, { + source_type: connection.source_type, + name: connection.name, + endpoint: '', + credential_ref: '', + enabled, + schedule: { refresh_interval_seconds: seconds > 0 ? seconds : null }, + }) + await activateSourceSettingsRevision(connection.id, created.id) +}