feat: 添加万川AI平台对接功能

- 新增万川AI平台API接口层,支持登录、获取知识库列表、上传文件等操作
- 实现万川平台配置界面,支持平台地址、账号密码配置和知识库绑定
- 在知识库页面添加上传功能,支持单个或批量上传售后报告到万川知识库
- 提供检查配置的工具脚本便于调试
This commit is contained in:
2026-06-11 16:14:38 +08:00
parent d332844459
commit b137fd7915
5 changed files with 796 additions and 14 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { Copy, Check, Save } from 'lucide-react'
import { useState, useEffect } from 'react'
import { Copy, Check, Save, Link2, Loader, AlertCircle, ChevronDown, ChevronRight, RefreshCw, BookOpen, ExternalLink, FileText } from 'lucide-react'
import { wanchuanLogin, getWanchuanKnowledgeBases, uploadToKnowledgeBase, clearWanchuanToken } from '../api/wanchuan'
const CONFIG_ITEMS = [
{
@@ -212,6 +213,452 @@ function AISettingsForm() {
)
}
/**
* 万川 AI 平台对接配置
* 支持输入平台地址 + 账号密码,测试连接后可展示平台模块内容(预留)
*/
function WanchuanPlatformForm() {
const STORAGE_KEY = 'chatlab_wanchuan_config'
const [form, setForm] = useState({ platformUrl: '', username: '', password: '' })
const [connecting, setConnecting] = useState(false)
const [connected, setConnected] = useState(false)
const [connectError, setConnectError] = useState('')
const [modules, setModules] = useState([])
const [modulesExpanded, setModulesExpanded] = useState(false)
const [knowledgeBases, setKnowledgeBases] = useState([])
const [selectedKbId, setSelectedKbId] = useState('')
// 本地保存的已选知识库信息:列表尚未拉取时用于即时回显
const [savedKbInfo, setSavedKbInfo] = useState(null)
const [kbLoading, setKbLoading] = useState(false)
const [kbError, setKbError] = useState('')
const [kbExpanded, setKbExpanded] = useState(false)
// 从 localStorage 加载已保存配置
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) setForm(JSON.parse(raw))
} catch {}
}, [])
// 从 localStorage 加载已选知识库;若有缓存 token + 平台地址则自动拉取列表回显
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const saved = JSON.parse(raw)
if (saved.selectedKbId) setSelectedKbId(saved.selectedKbId)
if (saved.selectedKbInfo) setSavedKbInfo(saved.selectedKbInfo)
// 已登录过(有缓存 token则自动拉取知识库列表无需手动点连接
const token = localStorage.getItem('chatlab_wanchuan_token')
if (token && saved.platformUrl) {
fetchKnowledgeBases(saved.platformUrl)
}
} catch {}
}, [])
const handleChange = (key, value) => {
setForm(prev => ({ ...prev, [key]: value }))
if (connected) setConnected(false)
}
// 知识库的唯一标识:上传接口需要的是 datasetId没有则回退到 id。
// 全程以此作为 selectedKbId保证选择、回显、持久化、上传四处一致。
const kbKey = (kb) => String(kb?.datasetId ?? kb?.id ?? '')
// 持久化整个知识库配置。
// kbIdOverride / listOverride 用于在 React 状态尚未刷新时setState 异步)
// 传入即时值,避免读到旧的 selectedKbId 或旧的知识库列表。
const handleSave = (kbIdOverride, listOverride) => {
try {
const effectiveKbId = kbIdOverride !== undefined ? kbIdOverride : selectedKbId
const kbs = listOverride || knowledgeBases
const selectedKb = kbs.find(k => kbKey(k) === effectiveKbId) || null
const data = {
...form,
selectedKbId: effectiveKbId,
selectedKbInfo: selectedKb ? {
selectedKbId: kbKey(selectedKb),
kbName: selectedKb.relationName || selectedKb.id,
kbDatasetId: selectedKb.datasetId || selectedKb.id,
kbType: selectedKb.kbType,
} : null,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
} catch {}
}
const handleSelectKb = (key) => {
const kb = knowledgeBases.find(k => kbKey(k) === key)
if (!kb) return
const nextKey = kbKey(kb)
setSelectedKbId(nextKey)
// 同步兜底回显信息
setSavedKbInfo({
selectedKbId: nextKey,
kbName: kb.relationName || kb.id,
kbDatasetId: kb.datasetId || kb.id,
kbType: kb.kbType,
})
// 立即用新值持久化,不依赖尚未刷新的 selectedKbId 状态
handleSave(nextKey)
}
const selectedKb = knowledgeBases.find(kb => kbKey(kb) === selectedKbId)
// 回显用的已选信息:优先用列表中的完整对象,否则用本地保存的兜底信息
const selectedKbName = selectedKb?.relationName || (selectedKbId ? savedKbInfo?.kbName : null)
// 上传文件到知识库(预留,供后续调用)
const handleUploadToKB = async (datasetId, file) => {
if (!form.platformUrl || !file) return
// 优先使用传入的 datasetId否则使用已选知识库的 datasetId
const targetDatasetId = datasetId || selectedKb?.datasetId
if (!targetDatasetId) return
return uploadToKnowledgeBase(form.platformUrl, targetDatasetId, file)
}
// 获取当前绑定的知识库信息(供外部调用)
const getBoundKnowledgeBase = () => {
return selectedKb || null
}
const handleTestConnection = async () => {
if (!form.platformUrl) {
setConnectError('请输入平台地址')
return
}
if (!form.username || !form.password) {
setConnectError('请输入账号和密码')
return
}
setConnecting(true)
setConnectError('')
setModules([])
setConnected(false)
setKnowledgeBases([])
// 登录成功后保留已选知识库 ID如果新列表中包含该 ID
try {
const { token, data } = await wanchuanLogin(form.platformUrl, form.username, form.password)
if (!token) throw new Error('登录成功但未获取到 token')
setModules(data.modules || [])
setConnected(true)
handleSave()
// 登录成功后自动拉取岗位知识库列表
fetchKnowledgeBases(form.platformUrl)
} catch (e) {
setConnectError(e.message || '登录失败')
} finally {
setConnecting(false)
}
}
const fetchKnowledgeBases = async (baseUrl) => {
setKbLoading(true)
setKbError('')
try {
const { list } = await getWanchuanKnowledgeBases(baseUrl, { relationType: 'position', page: 1, pageSize: 100 })
setKnowledgeBases(list)
// 登录后恢复之前保存的已选知识库(按统一的 kbKey 匹配)
const keys = new Set(list.map(kb => kbKey(kb)))
if (selectedKbId && keys.has(selectedKbId)) {
// 已选项仍在列表中:用完整信息重新持久化(登录时曾因列表为空被写成 null
handleSave(selectedKbId, list)
} else if (selectedKbId) {
// 兼容旧数据:之前可能保存的是 kb.id尝试映射到 datasetId
const matchById = list.find(kb => String(kb.id) === selectedKbId)
if (matchById) {
const newKey = kbKey(matchById)
setSelectedKbId(newKey)
handleSave(newKey, list)
} else {
setSelectedKbId('')
handleSave('', list)
}
}
setKbExpanded(true)
} catch (e) {
setKbError(e.message || '加载知识库列表失败')
} finally {
setKbLoading(false)
}
}
const handleRefreshKB = () => {
if (form.platformUrl) fetchKnowledgeBases(form.platformUrl)
}
const kbTypeLabel = (type) => ({
self_built: '自建',
external: '外部',
shared: '共享',
}[type] || type)
const statusBadge = (status) => {
if (status === 0) return { label: '正常', color: 'var(--success, #10b981)' }
if (status === 1) return { label: '停用', color: 'var(--warning, #d97706)' }
return { label: String(status), color: 'var(--text-muted)' }
}
const isConfigured = form.platformUrl && form.username && form.password
return (
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
万川 AI 平台对接
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 12 }}>
配置第三方平台地址和登录凭证用于推送数据测试连接后可查看平台模块
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
{/* 平台地址 */}
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 16px', borderBottom: '1px solid var(--border)', gap: 12 }}>
<div style={{ flex: '0 0 130px' }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>平台地址</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>万川 AI 平台 Base URL</div>
</div>
<input
value={form.platformUrl || ''}
placeholder="https://wanchuan.example.com"
onChange={(e) => handleChange('platformUrl', e.target.value)}
style={{ flex: 1, fontSize: 13, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface-2)', color: 'var(--text)', outline: 'none' }}
/>
<div title={form.platformUrl ? '已配置' : '未配置'} style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: form.platformUrl ? 'var(--success, #10b981)' : '#ef4444', boxShadow: form.platformUrl ? '0 0 4px rgba(16,185,129,0.5)' : '0 0 4px rgba(239,68,68,0.5)' }} />
</div>
{/* 账号 */}
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 16px', borderBottom: '1px solid var(--border)', gap: 12 }}>
<div style={{ flex: '0 0 130px' }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>账号</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>平台登录账号</div>
</div>
<input
value={form.username || ''}
placeholder="请输入账号"
onChange={(e) => handleChange('username', e.target.value)}
style={{ flex: 1, fontSize: 13, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface-2)', color: 'var(--text)', outline: 'none' }}
/>
<div title={form.username ? '已配置' : '未配置'} style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: form.username ? 'var(--success, #10b981)' : '#ef4444', boxShadow: form.username ? '0 0 4px rgba(16,185,129,0.5)' : '0 0 4px rgba(239,68,68,0.5)' }} />
</div>
{/* 密码 */}
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 16px', gap: 12 }}>
<div style={{ flex: '0 0 130px' }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>密码</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>平台登录密码</div>
</div>
<input
type="password"
value={form.password || ''}
placeholder="请输入密码"
onChange={(e) => handleChange('password', e.target.value)}
style={{ flex: 1, fontSize: 13, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface-2)', color: 'var(--text)', outline: 'none' }}
/>
<div title={form.password ? '已配置' : '未配置'} style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: form.password ? 'var(--success, #10b981)' : '#ef4444', boxShadow: form.password ? '0 0 4px rgba(16,185,129,0.5)' : '0 0 4px rgba(239,68,68,0.5)' }} />
</div>
</div>
{/* 操作按钮 + 状态 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 14 }}>
<button
onClick={handleTestConnection}
disabled={connecting || !isConfigured}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 20px',
background: connected ? 'var(--success, #10b981)' : 'var(--accent, #6366f1)',
color: '#fff',
border: 'none',
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
cursor: (connecting || !isConfigured) ? 'not-allowed' : 'pointer',
opacity: (connecting || !isConfigured) ? 0.6 : 1,
}}
>
{connecting ? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} /> : connected ? <Check size={14} /> : <Link2 size={14} />}
{connecting ? '连接中...' : connected ? '已连接' : '测试连接'}
</button>
{connectError && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--danger, #dc2626)' }}>
<AlertCircle size={12} /> {connectError}
</span>
)}
{kbExpanded && !kbLoading && knowledgeBases.length === 0 && (
<div style={{ padding: '20px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-muted)' }}>
暂无岗位知识库请先点击测试连接登录平台
</div>
)}
</div>
{/* 平台模块列表(预留) */}
{modules.length > 0 && (
<div style={{ marginTop: 16, border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<div
onClick={() => setModulesExpanded(!modulesExpanded)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 16px',
background: 'var(--surface-2)',
cursor: 'pointer',
fontSize: 13,
fontWeight: 500,
}}
>
{modulesExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
平台模块{modules.length}
</div>
{modulesExpanded && (
<div style={{ padding: '8px 16px 12px' }}>
{modules.map((mod, i) => (
<div key={mod.id || mod.name || i} style={{ padding: '6px 0', fontSize: 12.5, color: 'var(--text-primary)', borderBottom: i < modules.length - 1 ? '1px solid var(--border)' : 'none' }}>
{mod.name || mod.label || mod.id || '未命名模块'}
{mod.desc && <span style={{ marginLeft: 8, color: 'var(--text-muted)' }}>- {mod.desc}</span>}
</div>
))}
</div>
)}
</div>
)}
{/* 岗位知识库列表 - 始终显示 */}
<div style={{ marginTop: 16, border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<div
onClick={() => setKbExpanded(!kbExpanded)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 16px',
background: 'var(--surface-2)',
cursor: 'pointer',
fontSize: 13,
fontWeight: 500,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<BookOpen size={14} color="var(--accent-light)" />
岗位知识库列表
{selectedKbName && (
<span style={{ fontSize: 11, color: 'var(--success)', fontWeight: 500 }}>· 已选: {selectedKbName}</span>
)}
{!selectedKbName && knowledgeBases.length > 0 && (
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 400 }}>{knowledgeBases.length}</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{kbLoading && <Loader size={13} style={{ animation: 'spin 1s linear infinite' }} />}
<button
onClick={(e) => { e.stopPropagation(); handleRefreshKB() }}
disabled={kbLoading}
style={{ background: 'none', border: 'none', cursor: kbLoading ? 'not-allowed' : 'pointer', color: 'var(--text-muted)', padding: 2, display: 'flex' }}
title="刷新"
>
<RefreshCw size={13} />
</button>
{kbExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div>
</div>
{kbExpanded && knowledgeBases.length > 0 && (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{knowledgeBases.map((kb, i) => {
const badge = statusBadge(kb.status)
const docCount = kb.content?.document_count ?? 0
const isSelected = kbKey(kb) === selectedKbId
return (
<div
key={kbKey(kb) || i}
onClick={() => handleSelectKb(kbKey(kb))}
style={{
padding: '12px 16px',
borderBottom: i < knowledgeBases.length - 1 ? '1px solid var(--border)' : 'none',
display: 'flex',
alignItems: 'flex-start',
gap: 10,
background: isSelected ? 'var(--accent-dim)' : 'transparent',
cursor: 'pointer',
}}
>
{/* 单选指示器 */}
<div style={{
width: 16, height: 16, borderRadius: '50%', flexShrink: 0, marginTop: 2,
border: `2px solid ${isSelected ? 'var(--accent)' : 'var(--border)'}`,
background: isSelected ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all var(--transition)',
}}>
{isSelected && <Check size={10} color="#fff" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<FileText size={13} color="var(--accent-light)" />
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>
{kb.relationName || `知识库 #${kb.id}`}
</span>
{isSelected && <span style={{ fontSize: 10, color: 'var(--accent)', background: 'var(--accent-dim)', padding: '1px 6px', borderRadius: 8, fontWeight: 600 }}>已选</span>}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, fontSize: 11, color: 'var(--text-muted)' }}>
<span>ID: {kb.id}</span>
<span>类型: {kbTypeLabel(kb.kbType)}</span>
<span>文档数: {docCount}</span>
<span style={{ color: badge.color }}> {badge.label}</span>
{kb.datasetId && <span>Dataset: {kb.datasetId}</span>}
</div>
<div style={{ marginTop: 3, fontSize: 11, color: 'var(--text-muted)' }}>
创建人: {kb.createBy} · {kb.createTime}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
{/* 已选知识库摘要 */}
{selectedKb ? (
<div style={{ marginTop: 10, padding: '8px 14px', fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span>当前推送目标知识库</span>
<span style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{selectedKb.relationName}</span>
<span style={{ fontSize: 11 }}>(ID: {selectedKb.id}, Dataset: {selectedKb.datasetId}, {kbTypeLabel(selectedKb.kbType)}, {selectedKb.content?.document_count ?? 0} 文档)</span>
</div>
) : selectedKbName ? (
<div style={{ marginTop: 10, padding: '8px 14px', fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span>当前推送目标知识库</span>
<span style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{selectedKbName}</span>
{savedKbInfo?.kbDatasetId && <span style={{ fontSize: 11 }}>(Dataset: {savedKbInfo.kbDatasetId}{savedKbInfo.kbType ? `, ${kbTypeLabel(savedKbInfo.kbType)}` : ''})</span>}
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>· 连接后显示完整信息</span>
</div>
) : null}
</div>
)
}
// 导出供外部调用获取已绑定知识库信息
export function getBoundKnowledgeBase() {
try {
const STORAGE_KEY = 'chatlab_wanchuan_config'
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const saved = JSON.parse(raw)
// 返回完整的知识库信息,包括保存的详细信息
if (!saved.selectedKbId) return null
return {
id: saved.selectedKbInfo?.kbDatasetId || saved.selectedKbId,
platformUrl: saved.platformUrl,
kbName: saved.selectedKbInfo?.kbName || saved.selectedKbId,
kbDatasetId: saved.selectedKbInfo?.kbDatasetId || saved.selectedKbId,
kbType: saved.selectedKbInfo?.kbType,
}
} catch {
return null
}
}
export default function SettingsPage() {
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '28px 36px' }}>
@@ -224,6 +671,9 @@ export default function SettingsPage() {
{/* AI 配置表单 */}
<AISettingsForm />
{/* 万川 AI 平台对接 */}
<WanchuanPlatformForm />
{CONFIG_ITEMS.map((group) => (
<div key={group.group} style={{ marginBottom: 28 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>