feat(settings): 集中刷新策略与账号授权并独立设置审计
build / build (push) Successful in 1m11s

This commit is contained in:
姚章浩
2026-10-27 09:28:16 +08:00
parent a55d7f454e
commit 8930ef29cd
8 changed files with 520 additions and 214 deletions
+3 -2
View File
@@ -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<DingtalkSessionView | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [starting, setStarting] = useState(false)
@@ -89,7 +90,7 @@ export function DingtalkSessionControl({ autoStart = false, hideTrigger = false,
return <div className="dingtalk-session-control">
{!hideTrigger ? <button className="secondary" disabled={starting || (session ? !finished.has(session.state) : false)} onClick={() => void start()}>
{session && !finished.has(session.state) ? '扫码进行中…' : '扫码刷新会话'}
{session && !finished.has(session.state) ? '扫码进行中…' : triggerLabel}
</button> : null}
{lastSuccess && !hideTrigger ? <p className="success-message" role="status">✅ 钉钉会话已刷新</p> : null}
{failure && !open ? <p className="error-message" role="alert">{failure}</p> : null}
+46
View File
@@ -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;
}
+31
View File
@@ -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(<SourceSettingsCard connection={row} onSourcesChanged={vi.fn()} />)
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(<SourceRefreshPolicy connections={[row]} onSourcesChanged={vi.fn()} />)
expect((html.match(/<select/g) ?? []).length).toBe(1)
expect(html).toContain('保留各来源的暂停状态')
expect(html).toContain('应用到现有来源')
})
it('provides a dedicated read-only audit section', () => {
const html = renderToStaticMarkup(<SourceSettingsAudit connections={[row]} />)
expect(html).toContain('数据源设置审计')
expect(html).not.toContain('保存')
})
})
+340
View File
@@ -0,0 +1,340 @@
import './SourceManagement.css'
import { useEffect, useState } from 'react'
import { DingtalkSessionControl } from './DingtalkSessionControl'
import { fetchAvailableDingtalkSources } from './dingtalkSources'
import { fetchSourceConnectionList, fetchSourceSettingsRevisions } from './sourceConnections'
import type { SourceConnectionListItem, SourceSettingsRevision } from './sourceConnections'
import { configuredInterval, saveSourceRefresh } from './sourceRefreshPolicy'
const types: Record<string, string> = {
dingtalk: '钉钉知识库',
pp: 'PP 资料下载中心',
alist: 'AList 文件源',
manual: '本地手工导入',
}
const choices = [
{ value: 0, label: '仅手动刷新' },
{ value: 3600, label: '每小时' },
{ value: 21600, label: '每6小时' },
{ value: 86400, label: '每24小时' },
]
const errorText = (error: unknown) => (error instanceof Error ? error.message : '操作失败,请重试')
export function DingtalkAccountStatus() {
const [status, setStatus] = useState('检查中…')
const [error, setError] = useState('')
const [revision, setRevision] = useState(0)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
const controller = new AbortController()
setStatus('检查中…')
setError('')
void fetchAvailableDingtalkSources(controller.signal)
.then((data) => {
if (!controller.signal.aborted)
setStatus(data.knowledge_notice || data.drive_notice ? '已连接,部分目录受限' : '已连接')
})
.catch((cause) => {
if (!controller.signal.aborted) {
setStatus('状态暂不可用')
setError(errorText(cause))
}
})
return () => controller.abort()
}, [revision])
return (
<div className="source-account">
<button className="link" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>
钉钉账号 · {status}
</button>
{expanded ? (
<div className="source-account-detail">
<p>此账号授权由所有钉钉来源共享。</p>
{error ? <p role="alert">{error}</p> : null}
<DingtalkSessionControl triggerLabel="重新授权" onSuccess={() => setRevision((value) => value + 1)} />
<button className="link" onClick={() => setRevision((value) => value + 1)}>
重新检查
</button>
</div>
) : null}
</div>
)
}
export function SourceSettingsCard({
connection,
onSourcesChanged,
}: {
connection: SourceConnectionListItem
onSourcesChanged: () => void
}) {
const [open, setOpen] = useState(false)
const [confirm, setConfirm] = useState(false)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const toggle = async () => {
setBusy(true)
setError('')
setMessage('')
try {
await saveSourceRefresh(connection, !connection.enabled)
setMessage(connection.enabled ? '已暂停自动刷新;已有知识不受影响。' : '已恢复刷新调度;仅手动策略不会自动扫描。')
setConfirm(false)
onSourcesChanged()
} catch (cause) {
setError(errorText(cause))
} finally {
setBusy(false)
}
}
const manual = connection.source_type === 'manual'
return (
<article className="settings-source-row">
<div className="settings-source-name">
<strong>{connection.name}</strong>
<span>{types[connection.source_type] ?? connection.source_type}</span>
</div>
<div className="settings-source-status">
<span className={connection.enabled ? 'source-enabled' : 'source-disabled'}>
{manual
? '手工来源'
: !connection.enabled
? '自动刷新已暂停'
: connection.next_refresh_at
? '自动刷新中'
: '仅手动刷新'}
</span>
</div>
{!manual ? (
<button
className="secondary source-more"
aria-expanded={open}
aria-controls={`source-options-${connection.id}`}
onClick={() => setOpen(!open)}
>
{open ? '收起' : '更多'}
</button>
) : null}
{open ? (
<div className="source-schedule-form" id={`source-options-${connection.id}`}>
<button
className={connection.enabled ? 'danger' : 'secondary'}
disabled={busy}
onClick={() => (connection.enabled ? setConfirm(true) : void toggle())}
>
{busy ? '处理中…' : connection.enabled ? '暂停自动刷新' : '恢复自动刷新'}
</button>
<small>仅控制自动扫描,不下架或删除已有知识。</small>
</div>
) : null}
{message ? (
<p role="status" className="success-message">
{message}
</p>
) : null}
{error ? (
<p role="alert" className="error-message">
{error}
</p>
) : null}
{confirm ? (
<div className="modal-backdrop" role="dialog" aria-modal="true" aria-label="暂停自动刷新">
<div className="modal-card">
<h3>暂停「{connection.name}」自动刷新?</h3>
<p>停止新的自动扫描任务入队,不取消已运行任务。已有知识、目录快照和标注均保留。</p>
<div className="modal-actions">
<button disabled={busy} className="danger" onClick={() => void toggle()}>
确认暂停
</button>
<button disabled={busy} className="secondary" onClick={() => setConfirm(false)}>
取消
</button>
</div>
</div>
</div>
) : null}
</article>
)
}
export function SourceRefreshPolicy({
connections,
onSourcesChanged,
}: {
connections: SourceConnectionListItem[]
onSourcesChanged: () => void
}) {
const [interval, setInterval] = useState<number | null>(null)
const [summary, setSummary] = useState('读取当前策略…')
const [busy, setBusy] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [result, setResult] = useState('')
useEffect(() => {
let cancelled = false
setLoading(true)
setError('')
const rows = connections.filter((item) => item.source_type !== 'manual')
void Promise.all(rows.map(async (row) => configuredInterval(row, await fetchSourceSettingsRevisions(row.id))))
.then((values) => {
if (cancelled) return
const unique = [...new Set(values)]
setInterval(unique.length === 1 ? unique[0] : null)
setSummary(
!values.length
? '尚无在线来源'
: unique.length === 1
? `当前策略:${choices.find((c) => c.value === unique[0])?.label ?? `每 ${unique[0]} 秒`}`
: '当前来源策略不一致,请选择统一周期。'
)
})
.catch((cause) => {
if (!cancelled) setError(errorText(cause))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [connections])
const save = async () => {
if (interval === null) return
setBusy(true)
setError('')
setResult('')
let completed = 0
const failed: string[] = []
try {
const rows = (await fetchSourceConnectionList()).filter((row) => row.source_type !== 'manual')
for (const row of rows) {
try {
await saveSourceRefresh(row, row.enabled, interval)
completed++
} catch (cause) {
failed.push(`${row.name}:${errorText(cause)}`)
}
}
setResult(`已保存 ${completed} 个来源${failed.length ? `,失败 ${failed.length} 个` : ''}。`)
if (failed.length) setError(failed.join(';'))
onSourcesChanged()
} catch (cause) {
setError(errorText(cause))
} finally {
setBusy(false)
}
}
return (
<section className="source-policy-panel">
<h3>数据源自动刷新</h3>
<p>{summary}</p>
<p>
统一应用到当前已接入的在线来源,保留各来源的暂停状态。新增来源仍为手动刷新,需在此应用策略;不触发文件入库。
</p>
<div className="source-policy-controls">
<label>
统一刷新周期
<select
disabled={loading || busy}
value={interval ?? ''}
onChange={(event) => setInterval(Number(event.target.value))}
>
<option value="" disabled>
选择周期
</option>
{choices.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
</option>
))}
</select>
</label>
<button
className="secondary"
disabled={loading || busy || interval === null || !connections.some((row) => row.source_type !== 'manual')}
onClick={() => void save()}
>
{busy ? '保存中…' : '应用到现有来源'}
</button>
</div>
{result ? <p role="status">{result}</p> : null}
{error ? (
<p role="alert" className="error-message">
{error}
</p>
) : null}
</section>
)
}
export function SourceSettingsAudit({ connections }: { connections: SourceConnectionListItem[] }) {
const [id, setID] = useState(connections[0]?.id ?? '')
const [rows, setRows] = useState<SourceSettingsRevision[]>([])
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 (
<section className="settings-panel wide">
<h2>数据源设置审计</h2>
<label>
数据源
<select value={id} onChange={(event) => setID(event.target.value)}>
{connections.map((row) => (
<option key={row.id} value={row.id}>
{row.name}
</option>
))}
</select>
</label>
{error ? (
<p role="alert" className="error-message">
{error}
</p>
) : loading ? (
<p>加载中…</p>
) : !rows.length ? (
<p>暂无设置变更记录。</p>
) : (
<ul className="revision-list">
{rows.map((row) => (
<li key={row.id}>
<b>修订 {row.revision_number}</b>
<span>
{row.state === 'active'
? '当前生效'
: row.state === 'retired'
? '历史版本'
: row.state === 'validated'
? '待启用'
: '草稿或失败'}
</span>
<small>
{row.created_by} · {new Date(row.created_at).toLocaleString('zh-CN')} ·{' '}
{row.enabled ? '允许自动刷新' : '已暂停'} ·{' '}
{row.refresh_interval_seconds ? `周期 ${row.refresh_interval_seconds} 秒` : '仅手动'}
</small>
</li>
))}
</ul>
)}
</section>
)
}
+4 -2
View File
@@ -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')
+9 -210
View File
@@ -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<string, string> = {
@@ -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<number>(() => {
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<string | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [showHistory, setShowHistory] = useState(false)
const [history, setHistory] = useState<SourceSettingsRevision[] | null>(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 (
<article className="settings-source-row">
<div className="settings-source-name">
<strong>{connection.name}</strong>
<span>{sourceTypeLabels[connection.source_type] ?? connection.source_type}</span>
</div>
<div className="settings-source-status">
<span className={connection.enabled ? 'source-enabled' : 'source-disabled'}>
{connection.enabled ? '已启用' : '已停用'}
</span>
<small>{connection.enabled ? formatHorizon(connection.next_refresh_at) : '自动刷新已暂停'}</small>
</div>
<button
className="secondary source-more"
aria-expanded={showOptions}
aria-controls={`source-options-${connection.id}`}
onClick={() => setShowOptions(!showOptions)}
>
{showOptions ? '收起' : '更多'}
</button>
{showOptions ? (
<div className="source-schedule-form" id={`source-options-${connection.id}`}>
{scheduled ? (
<label>
自动刷新周期
<select value={interval} onChange={(event) => setInterval(Number(event.target.value))}>
{intervalChoices.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
</option>
))}
</select>
</label>
) : (
<small className="manual-source-note">本地手工导入来源不支持定时刷新,只能手动触发扫描。</small>
)}
{connection.enabled ? (
<div className="source-schedule-actions">
<button
className="secondary"
disabled={!canApply || (scheduled && interval === 0 && !connection.schedule_configured)}
onClick={() => void submit(true)}
>
{busy ? '保存中…' : '保存设置'}
</button>
<button className="danger" disabled={!canApply} onClick={() => setPendingDisable(true)}>
停用来源
</button>
</div>
) : (
<div className="source-schedule-actions">
<button className="secondary" disabled={!canApply} onClick={() => void submit(true)}>
保存并启用
</button>
</div>
)}
{connection.source_type === 'dingtalk' && isAdmin ? <DingtalkSessionControl /> : null}
<div className="source-schedule-links">
<button className="link" onClick={() => void loadHistory()}>
{showHistory ? '收起修订历史' : '查看修订历史'}
</button>
</div>
</div>
) : null}
{message ? (
<p className="success-message" role="status">
{message}
</p>
) : null}
{failure ? (
<p className="error-message" role="alert">
{failure}
</p>
) : null}
{showOptions && showHistory && history ? (
<ul className="revision-list">
{history.map((item) => (
<li key={item.id}>
<b>修订 {item.revision_number}</b>
<span>
{item.state === 'active'
? '当前启用'
: item.state === 'validated'
? '待启用'
: item.state === 'retired'
? '已退休'
: '失败'}
</span>
<small>
{item.created_by} · {new Date(item.created_at).toLocaleString('zh-CN', { hour12: false })} ·{' '}
{item.refresh_interval_seconds ? `每${secondsLabel(Number(item.refresh_interval_seconds))}` : '仅手动'}
</small>
</li>
))}
</ul>
) : null}
{showOptions && showHistory && !history ? <small>正在加载修订历史…</small> : null}
{pendingDisable ? (
<div className="modal-backdrop" role="dialog" aria-modal="true">
<div className="modal-card">
<h3>停用「{connection.name}」?</h3>
<p>
停用会立即停止该来源的自动扫描与新刷新任务入队;历史快照、标注和审计全部保留,随时可以重新启用。此操作会先由后端再次校验权限。
</p>
<div className="modal-actions">
<button className="danger" disabled={busy} onClick={() => void submit(false)}>
{busy ? '正在停用…' : '确认停用'}
</button>
<button className="secondary" disabled={busy} onClick={() => setPendingDisable(false)}>
取消
</button>
</div>
</div>
</div>
) : null}
</article>
)
}
export function SystemSettingsView({
sources,
connections,
@@ -441,6 +237,7 @@ export function SystemSettingsView({
onRegistered={onSourcesChanged}
/>
) : null}
{identity?.role === 'admin' ? <DingtalkAccountStatus /> : null}
<div className="settings-source-group" id="settings-sources-pp">
{listedConnections.length ? (
<div className="settings-source-list">
@@ -600,8 +397,10 @@ export function SystemSettingsView({
{section === 'production-bindings' ? <ProductionBindingsSection /> : null}
{section === 'storage' ? <BackupScheduleSection /> : null}
{section === 'members' && identity?.role === 'admin' ? <MembersSection /> : null}
{section === 'source-audit' ? <SourceSettingsAudit connections={listedConnections} /> : null}
{section === 'refresh' ? (
<section className="settings-panel wide" id="settings-refresh">
<SourceRefreshPolicy connections={listedConnections} onSourcesChanged={onSourcesChanged} />
<div className="settings-section-heading">
<div>
<h2>刷新策略</h2>
+52
View File
@@ -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 },
})
})
})
+35
View File
@@ -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<void> {
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)
}