Files
knowledge-governance/web/src/SourceWizard.tsx
T
hermes 1e8d8e3cdf
build / build (push) Successful in 1m6s
fix(qa): 数据源配置去掉页面噪声
去掉页头副标题与刷新状态按钮、工程师/管理员权限条、活动版本横幅、
与向导重复的模块说明,以及「去掉来源」按钮(保留停用来源与修订历史)。
2026-10-26 17:48:33 +08:00

320 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { DingtalkRequestError, registerDingtalkSpace } from './dingtalkSession'
import { DingtalkSessionControl } from './DingtalkSessionControl'
import {
discoveryCandidates,
fetchAvailableDingtalkSources,
type AvailableDingtalkSources,
type DiscoveryCandidate,
} from './dingtalkSources'
import './sourceWizard.css'
// 「系统设置 → 数据源配置」的统一添加向导。
// 一个入口、五步固定:类型 → 授权 → 范围 → 校验 → 保存。
// 类型只决定「候选从哪来」与「授权怎么说明」,版式和动作词完全一致。
// 对内/对外不在这里配置:策略只在「来源与目录」标注。
// 单篇钉钉文档/表格不进这里——把链接发给我,我用 dws 导入。
type SourceKind = 'wiki' | 'drive' | 'pp'
const kinds: { key: SourceKind; title: string; detail: string; icon: string }[] = [
{ key: 'wiki', title: '钉钉知识库', detail: '组织 / 我的知识库', icon: '库' },
{ key: 'drive', title: '钉盘', detail: '我的文件 / 组织空间', icon: '盘' },
{ key: 'pp', title: 'Partner Portal', detail: '公开只读资料中心', icon: '↧' },
]
const steps = ['类型', '授权', '范围', '校验', '保存'] as const
export function SourceWizard({
registeredNames,
ppConnected,
onRegistered,
}: {
registeredNames: string[]
// PP 是一个门户一条来源:已接入时向导只做说明,不重复登记。
ppConnected: boolean
onRegistered: () => void
}) {
const [open, setOpen] = useState(false)
const [step, setStep] = useState(0)
const [kind, setKind] = useState<SourceKind | null>(null)
const [list, setList] = useState<AvailableDingtalkSources | null>(null)
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const [checked, setChecked] = useState<string[]>([])
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [loginRequired, setLoginRequired] = useState(false)
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true)
setLoadError(null)
try {
setList(await fetchAvailableDingtalkSources(signal))
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') return
setLoadError(cause instanceof Error ? cause.message : '列举数据源失败')
setList(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!open || (kind !== 'wiki' && kind !== 'drive')) return
const controller = new AbortController()
void load(controller.signal)
return () => controller.abort()
}, [open, kind, load])
const candidates = useMemo(() => discoveryCandidates(list, registeredNames), [list, registeredNames])
const visible = useMemo(
() =>
candidates.filter((item) =>
kind === 'wiki' ? item.kind === 'knowledge_base' : kind === 'drive' ? item.kind === 'drive_space' : false
),
[candidates, kind]
)
const selected = visible.filter((item) => checked.includes(item.key))
const close = () => {
setOpen(false)
setStep(0)
setKind(null)
setChecked([])
setFailure(null)
setMessage(null)
setLoginRequired(false)
}
const toggle = (key: string) =>
setChecked((current) => (current.includes(key) ? current.filter((item) => item !== key) : [...current, key]))
const submit = async (retryAfterLogin = false) => {
setBusy(true)
setFailure(null)
const added: string[] = []
const failed: string[] = []
let needLogin = false
for (const candidate of selected) {
try {
const result = await registerDingtalkSpace(candidate.url)
added.push(result.name || candidate.name)
} catch (cause) {
if (cause instanceof DingtalkRequestError && cause.code === 'login_required' && !retryAfterLogin) {
needLogin = true
break
}
failed.push(`${candidate.name}:${cause instanceof Error ? cause.message : '登记失败'}`)
}
}
setBusy(false)
if (needLogin) {
setLoginRequired(true)
setFailure('钉钉扫码会话已失效:扫码后会自动继续这次添加。')
return
}
if (added.length) {
setMessage(`已加入 ${added.length} 个来源:${added.join('、')}。首次目录扫描已在后台排队。`)
onRegistered()
void load()
}
if (failed.length) setFailure(failed.join(';'))
}
const canAdvance =
(step === 0 && kind !== null) ||
(step === 1 && kind !== null) ||
(step === 2 && (kind === 'pp' || selected.length > 0)) ||
(step === 3 && (kind === 'pp' || selected.length > 0))
return (
<section className="source-wizard" aria-label="添加数据源">
<div className="source-wizard-bar">
<div>
<h3>数据源</h3>
</div>
<button type="button" className="source-wizard-open" onClick={() => setOpen(true)}>
+ 添加数据源
</button>
</div>
{open ? (
<div className="source-wizard-panel" role="dialog" aria-label="添加数据源向导">
<ol className="source-wizard-steps">
{steps.map((label, index) => (
<li key={label} className={index === step ? 'now' : index < step ? 'done' : ''}>
{index + 1} {label}
</li>
))}
</ol>
{step === 0 ? (
<div className="source-wizard-types">
{kinds.map((item) => (
<button
key={item.key}
type="button"
className={kind === item.key ? 'type sel' : 'type'}
onClick={() => {
setKind(item.key)
setChecked([])
}}
>
<span aria-hidden="true">{item.icon}</span>
<b>{item.title}</b>
<small>{item.detail}</small>
</button>
))}
</div>
) : null}
{step === 1 ? (
<div className="source-wizard-auth">
{kind === 'pp' ? (
<>
<div className="row">
<span>授权方式</span>
<b>公开只读</b>
</div>
<div className="row">
<span>凭据</span>
<span>由服务端持有,无需填写</span>
</div>
<p className="hint">资料下载中心的登录凭据由运维放在服务端,向导里不录入任何密码。</p>
</>
) : (
<>
<div className="row">
<span>授权方式</span>
<b>当前钉钉账号扫码会话</b>
</div>
<div className="row">
<span>读取权限</span>
<span>只读;不写回、不删除源文件</span>
</div>
<p className="hint">
用的是平台已登录的 dws 凭据,不需要每个来源单独扫码;会话失效时会在保存时弹二维码。
</p>
</>
)}
</div>
) : null}
{step === 2 ? (
<div className="source-wizard-scope">
{kind === 'pp' ? (
<p className="hint">
{ppConnected
? '已接入 PP 资料下载中心(一个门户一条来源),无需重复添加;刷新与归档在下方清单里做。'
: 'PP 需要服务端放置只读凭据后才能接入;凭据到位后联系我开通,向导本身不录密码。'}
</p>
) : loading ? (
<p className="hint">正在列举当前账号可见的来源…</p>
) : loadError ? (
<p role="alert">{loadError}</p>
) : visible.length === 0 ? (
<p className="hint">
当前账号没有可添加的{kind === 'wiki' ? '知识库' : '钉盘空间'};已加入的不会重复列出。
</p>
) : (
<>
<div className="source-wizard-check">
{visible.map((item) => (
<label key={item.key}>
<input type="checkbox" checked={checked.includes(item.key)} onChange={() => toggle(item.key)} />
<span>{item.name}</span>
</label>
))}
</div>
<p className="hint">
已选 {selected.length} 项。勾选空间即可,目录细节加入后再在「来源与目录」里标注。
</p>
</>
)}
</div>
) : null}
{step === 3 ? (
<div className="source-wizard-verify">
<ul>
{(kind === 'pp' ? [] : selected).map((item: DiscoveryCandidate) => (
<li key={item.key}>
<span>{item.name}</span>
<b className="ok">仍可见</b>
</li>
))}
{kind === 'pp' ? (
<li>
<span>Partner Portal 公开目录</span>
<b className="ok">公开只读可达</b>
</li>
) : null}
</ul>
<p className="hint">添加时会按当前账号再读一次;如果已看不到,会拒绝并提示刷新。</p>
</div>
) : null}
{step === 4 ? (
<div className="source-wizard-save">
{message ? <p role="status">{message}</p> : null}
{failure ? <p role="alert">{failure}</p> : null}
{!message && !failure ? (
<p className="hint">
将加入{' '}
{(kind === 'pp' ? ['Partner Portal'] : selected.map((item) => item.name)).join('、') || '(未选择)'}
。
</p>
) : null}
{loginRequired ? (
<DingtalkSessionControl
autoStart
hideTrigger
space={selected[0]?.url}
onSuccess={() => {
setLoginRequired(false)
void submit(true)
}}
/>
) : null}
</div>
) : null}
<div className="source-wizard-foot">
<button type="button" className="ghost" onClick={() => (step === 0 ? close() : setStep(step - 1))}>
{step === 0 ? '取消' : '上一步'}
</button>
{kind === 'pp' ? (
<button type="button" className="primary" onClick={close}>
完成
</button>
) : step < 3 ? (
<button type="button" className="primary" disabled={!canAdvance} onClick={() => setStep(step + 1)}>
下一步
</button>
) : step === 3 ? (
<button
type="button"
className="primary"
disabled={busy || !canAdvance}
onClick={() => {
setStep(4)
void submit()
}}
>
{busy ? '正在加入…' : '保存并首次扫描'}
</button>
) : (
<button type="button" className="primary" onClick={close}>
完成
</button>
)}
</div>
</div>
) : null}
</section>
)
}