1e8d8e3cdf
build / build (push) Successful in 1m6s
去掉页头副标题与刷新状态按钮、工程师/管理员权限条、活动版本横幅、 与向导重复的模块说明,以及「去掉来源」按钮(保留停用来源与修订历史)。
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|