This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('保存')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user