feat(web): add Ontology menu

This commit is contained in:
zhaoying
2026-01-29 17:13:19 +08:00
parent cca3900678
commit d1b4f2b6c2
16 changed files with 916 additions and 24 deletions

View File

@@ -0,0 +1,173 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, App, Transfer, type TransferProps, Flex } from 'antd';
import { useTranslation } from 'react-i18next';
import type { OntologyClassData, ExtractData, OntologyClassExtractModalData, OntologyClassExtractModalRef } from '../types'
import RbModal from '@/components/RbModal'
import { extractOntologyTypes, createOntologyClass } from '@/api/ontology'
import CustomSelect from '@/components/CustomSelect';
import { getModelListUrl } from '@/api/models'
import RbCard from '@/components/RbCard/Card';
import Tag from '@/components/Tag';
const FormItem = Form.Item;
interface OntologyClassExtractModalProps {
refresh: () => void;
}
const OntologyClassExtractModal = forwardRef<OntologyClassExtractModalRef, OntologyClassExtractModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<OntologyClassExtractModalData>();
const [loading, setLoading] = useState(false)
const [data, setData] = useState<OntologyClassData | null>(null)
const [extractData, setExtractData] = useState<ExtractData | null>(null)
const [targetKeys, setTargetKeys] = useState<TransferProps['targetKeys']>([]);
const [selectedKeys, setSelectedKeys] = useState<TransferProps['selectedKeys']>([]);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
setData(null)
setExtractData(null)
};
const handleOpen = (vo: OntologyClassData) => {
form.resetFields();
setVisible(true);
setData(vo)
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
if (!data?.scene_id) return;
form
.validateFields()
.then((values) => {
setLoading(true)
extractOntologyTypes({
...values,
scene_id: data.scene_id,
domain: data.scene_name,
}).then((res) => {
const response = res as ExtractData
setExtractData(response)
setSelectedKeys([])
setTargetKeys(response.classes.map(vo => vo.id))
})
.finally(() => {
setLoading(false)
})
})
.catch((err) => {
console.log('err', err)
});
}
const handleConfirm = () => {
if (!extractData) {
handleSave()
} else {
if (!data?.scene_id) return;
if (!targetKeys || targetKeys.length === 0) {
message.warning(t('common.selectPlaceholder', { title: t('ontology.classType') }))
return
}
console.log('targetKeys', targetKeys)
createOntologyClass({
scene_id: data?.scene_id,
classes: extractData.classes.filter(vo => targetKeys?.includes(vo.id)).map(vo => ({ class_name: vo.name, class_description: vo.description }))
}).then(() => {
message.success(t('common.createSuccess'))
refresh()
handleClose()
}).finally(() => {
setLoading(false)
})
}
}
const onChange: TransferProps['onChange'] = (nextTargetKeys) => {
setTargetKeys(nextTargetKeys.filter(Boolean));
};
const onSelectChange: TransferProps['onSelectChange'] = (
sourceSelectedKeys,
targetSelectedKeys,
) => {
setSelectedKeys([...sourceSelectedKeys, ...targetSelectedKeys].filter(Boolean));
};
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
}));
return (
<RbModal
title={t('ontology.extract')}
open={visible}
onCancel={handleClose}
okText={extractData ? `${t('ontology.extractConfirm')}(${targetKeys?.length})` : loading ? t('ontology.loadingConfirm') : t('ontology.run')}
onOk={handleConfirm}
confirmLoading={loading}
okButtonProps={{ disabled: extractData !== null && targetKeys?.length === 0 }}
width={1000}
>
<Form
form={form}
layout="vertical"
>
<FormItem
name="llm_id"
label={t('ontology.llm_id')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<CustomSelect
url={getModelListUrl}
valueKey="id"
labelKey="name"
hasAll={false}
placeholder={t('common.pleaseSelect')}
params={{ type: 'llm,chat', pagesize: 100, is_active: true }}
/>
</FormItem>
<FormItem
name="scenario"
label={t('ontology.scenario')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input.TextArea placeholder={t('ontology.scenarioPlaceholder')} />
</FormItem>
</Form>
{extractData && <RbCard
title={t('ontology.classType')}
bodyClassName='rb:flex rb:justify-center rb:h-[450px]!'
>
<Transfer
titles={[t('ontology.source'), t('ontology.target')]}
dataSource={extractData?.classes?.map(vo => ({ ...vo, key: vo.id }))}
targetKeys={targetKeys}
selectedKeys={selectedKeys}
onChange={onChange}
onSelectChange={onSelectChange}
render={(item) => (<div>
{item.name}
<Flex wrap gap={8}>{item.examples.map((vo, index) => <Tag color="default" key={index}>{vo}</Tag>)}</Flex>
</div>)}
listStyle={{ width: '400px', height: '100%' }}
/>
</RbCard>}
</RbModal>
);
});
export default OntologyClassExtractModal;

View File

@@ -0,0 +1,96 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, App } from 'antd';
import { useTranslation } from 'react-i18next';
import type { AddClassItem, OntologyClassModalRef } from '../types'
import RbModal from '@/components/RbModal'
import { createOntologyClass } from '@/api/ontology'
const FormItem = Form.Item;
interface OntologyClassModalProps {
refresh: () => void;
}
const OntologyClassModal = forwardRef<OntologyClassModalRef, OntologyClassModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<AddClassItem>();
const [loading, setLoading] = useState(false)
const [scene_id, setSceneId] = useState<string | null>(null)
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
const handleOpen = (scene_id: string) => {
form.resetFields();
setVisible(true);
setSceneId(scene_id)
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
if (!scene_id) return;
form
.validateFields()
.then((values) => {
setLoading(true)
createOntologyClass({
scene_id: scene_id,
classes: [{ ...values }]
}).then(() => {
message.success(t('common.saveSuccess'));
handleClose();
refresh();
})
.finally(() => setLoading(false))
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
}));
return (
<RbModal
title={t('ontology.addClass')}
open={visible}
onCancel={handleClose}
okText={t('common.create')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<FormItem
name="class_name"
label={t('ontology.class_name')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="class_description"
label={t('ontology.class_description')}
>
<Input.TextArea placeholder={t('ontology.classDescriptionPlaceholder')} />
</FormItem>
</Form>
</RbModal>
);
});
export default OntologyClassModal;

View File

@@ -0,0 +1,99 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, App } from 'antd';
import { useTranslation } from 'react-i18next';
import type { OntologyItem, OntologyModalData, OntologyModalRef } from '../types'
import RbModal from '@/components/RbModal'
import { createOntologyScene, updateOntologyScene } from '@/api/ontology'
const FormItem = Form.Item;
interface OntologyModalProps {
refresh: () => void;
}
const OntologyModal = forwardRef<OntologyModalRef, OntologyModalProps>(({
refresh
}, ref) => {
const { t } = useTranslation();
const { message } = App.useApp();
const [visible, setVisible] = useState(false);
const [editVo, setEditVo] = useState<OntologyItem | null>(null)
const [form] = Form.useForm<OntologyModalData>();
const [loading, setLoading] = useState(false)
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
setEditVo(null)
};
const handleOpen = (vo?: OntologyItem) => {
if (vo) {
setEditVo(vo);
form.setFieldsValue(vo);
} else {
form.resetFields();
}
setVisible(true);
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form
.validateFields()
.then((values) => {
setLoading(true)
const request = editVo?.scene_id ? updateOntologyScene(editVo.scene_id, values) : createOntologyScene(values)
request
.then(() => {
message.success(t('common.saveSuccess'));
handleClose();
refresh();
})
.finally(() => setLoading(false))
})
.catch((err) => {
console.log('err', err)
});
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
}));
return (
<RbModal
title={editVo?.scene_id ? t('ontology.edit') : t('ontology.create')}
open={visible}
onCancel={handleClose}
okText={editVo?.scene_id ? t('common.save') : t('common.create')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<FormItem
name="scene_name"
label={t('ontology.scene_name')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="scene_description"
label={t('ontology.scene_description')}
>
<Input.TextArea placeholder={t('ontology.descriptionPlaceholder')} />
</FormItem>
</Form>
</RbModal>
);
});
export default OntologyModal;

View File

@@ -0,0 +1,45 @@
import { type FC, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout, Button } from 'antd';
import { useTranslation } from 'react-i18next';
import logoutIcon from '@/assets/images/logout_hover.svg'
const { Header } = Layout;
interface ConfigHeaderProps {
name?: string;
subTitle?: ReactNode | string;
extra?: ReactNode;
}
const PageHeader: FC<ConfigHeaderProps> = ({
name,
subTitle,
extra
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const goBack = () => {
navigate(-1)
}
return (
<Header className="rb:w-full rb:h-16 rb:flex rb:justify-between rb:p-[0_16px_0_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-8">
<div className="rb:flex rb:flex-col rb:justify-center rb:gap-1 rb:mr-4">
<div className="rb:text-[16px] rb:leading-6 rb:font-medium">
{name}
</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:leading-4">{subTitle}</div>
</div>
<div className="rb:flex rb:items-center rb:gap-3">
<Button type="primary" ghost className="rb:h-6! rb:px-2! rb:leading-5.5!" onClick={goBack}>
<img src={logoutIcon} className="rb:w-4 rb:h-4" />
{t('common.return')}
</Button>
{extra}
</div>
</Header>
);
};
export default PageHeader;

View File

@@ -0,0 +1,133 @@
import { type FC, useState, useRef, type MouseEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Row, Col, Button, Flex, Divider, Space, App, Tooltip } from 'antd'
import SearchInput from '@/components/SearchInput';
import OntologyModal from './components/OntologyModal'
import type { OntologyModalRef, OntologyItem, Query } from './types'
import RbCard from '@/components/RbCard/Card'
import Tag from '@/components/Tag'
import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList'
import { getOntologyScenesUrl, deleteOntologyScene } from '@/api/ontology'
import { formatDateTime } from '@/utils/format'
const Ontology: FC = () => {
const { t } = useTranslation();
const navigate = useNavigate()
const { modal, message } = App.useApp();
const [query, setQuery] = useState<Query>({});
const scrollListRef = useRef<PageScrollListRef>(null)
const entityModalRef = useRef<OntologyModalRef>(null)
const handleCreate = () => {
entityModalRef.current?.handleOpen()
}
const handleEdit = (record: OntologyItem, e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
entityModalRef.current?.handleOpen(record)
}
const handleDelete = (item: OntologyItem, e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
modal.confirm({
title: t('common.confirmDeleteDesc', { name: item.scene_name }),
okText: t('common.delete'),
cancelText: t('common.cancel'),
okType: 'danger',
onOk: () => {
deleteOntologyScene(item.scene_id)
.then(() => {
message.success(t('common.deleteSuccess'))
scrollListRef.current?.refresh()
})
}
})
}
const handleJump = (record: OntologyItem) => {
navigate(`/ontology/${record.scene_id}`)
}
return (
<>
<Row gutter={16} className="rb:mb-4">
<Col span={8}>
<SearchInput
placeholder={t('ontology.searchPlaceholder')}
onSearch={(value) => setQuery({ scene_name: value })}
className="rb:w-full!"
/>
</Col>
<Col span={16} className="rb:text-right">
<Button type="primary" onClick={handleCreate}>
+ {t('ontology.create')}
</Button>
</Col>
</Row>
<PageScrollList<OntologyItem, Query>
ref={scrollListRef}
url={getOntologyScenesUrl}
query={query}
column={3}
renderItem={(item) =>(
<RbCard
title={item.scene_name}
extra={<Tag>{item.type_num} {t('ontology.typeCount')}</Tag>}
onClick={() => handleJump(item)}
className="rb:cursor-pointer"
>
<div
className="rb:flex rb:gap-2 rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3"
>
<span className="rb:whitespace-nowrap">{t(`ontology.scene_description`)}</span>
<Tooltip title={item.scene_description} placement="topRight">
<span className="rb:font-medium rb:flex-1 rb:text-right rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{item.scene_description}</span>
</Tooltip>
</div>
{(['created_at', 'updated_at'] as const).map(key => (
<div
key={key}
className="rb:flex rb:gap-2 rb:justify-between rb:text-[#5B6167] rb:text-[14px] rb:leading-5 rb:mb-3"
>
<span className="rb:whitespace-nowrap">{t(`ontology.${key}`)}</span>
<span className="rb:font-medium">{formatDateTime(item[key])}</span>
</div>
))}
<Divider size="middle" />
<Flex gap={8} wrap>
<div className="rb:text-[#5B6167] rb:leading-4.5">{t('ontology.entityTypes')}: </div>
{item.entity_type?.map((type, i) => (
<Tag key={i} color={i % 2 ? 'processing' : 'success'}>{type}</Tag>
))}
{item.type_num > 3 && (
<Tag color="default">+{item.type_num - 3}</Tag>
)}
</Flex>
<div className="rb:mt-4 rb:text-[12px] rb:leading-4 rb:font-regular rb:text-[#5B6167] rb:flex rb:items-center rb:justify-end">
<Space size={16}>
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
onClick={(e) => handleEdit(item, e)}
></div>
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
onClick={(e) => handleDelete(item, e)}
></div>
</Space>
</div>
</RbCard>
)}
/>
<OntologyModal
ref={entityModalRef}
refresh={() => scrollListRef.current?.refresh()}
/>
</>
)
}
export default Ontology

View File

@@ -0,0 +1,122 @@
import { type FC, useEffect, useState, useRef } from 'react'
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { App, Row, Col, Tooltip, Space, Button } from 'antd'
import PageHeader from '../components/PageHeader'
import { getOntologyClassList, deleteOntologyClass } from '@/api/ontology'
import type { OntologyClassData, OntologyClassModalRef, OntologyClassExtractModalRef, OntologyClassItem } from '@/views/Ontology/types'
import RbCard from '@/components/RbCard/Card';
import OntologyClassModal from '../components/OntologyClassModal'
import SearchInput from '@/components/SearchInput';
import OntologyClassExtractModal from '../components/OntologyClassExtractModal'
import BodyWrapper from '@/components/Empty/BodyWrapper'
const Detail: FC = () => {
const { t } = useTranslation();
const { id } = useParams()
const { modal, message } = App.useApp()
const ontologyClassModalRef = useRef<OntologyClassModalRef>(null)
const ontologyClassExtractModalRef = useRef<OntologyClassExtractModalRef>(null)
const [query, setQuery] = useState<{
class_name?: string;
}>({});
const [loading, setLoading] = useState(false)
const [data, setData] = useState<OntologyClassData>({} as OntologyClassData)
useEffect(() => {
getData()
}, [id, query])
const getData = () => {
if (!id) return;
setLoading(true)
getOntologyClassList({
...query,
scene_id: id
})
.then(res => {
setData(res as OntologyClassData)
})
.finally(() => {
setLoading(false)
})
}
const handleDelete = (item: OntologyClassItem) => {
modal.confirm({
title: t('common.confirmDeleteDesc', { name: item.class_name }),
okText: t('common.delete'),
cancelText: t('common.cancel'),
okType: 'danger',
onOk: () => {
deleteOntologyClass(item.class_id)
.then(() => {
getData();
message.success(t('common.deleteSuccess'))
})
}
})
}
const handleAdd = () => {
ontologyClassModalRef.current?.handleOpen(data.scene_id)
}
const handleExtract = () => {
ontologyClassExtractModalRef.current?.handleOpen(data)
}
return (
<>
<PageHeader
name={data.scene_name}
subTitle={<div>{data.scene_description}</div>}
extra={<Space>
<Button type="primary" ghost className="rb:h-6! rb:px-2! rb:leading-5.5!" onClick={handleAdd}>+ {t('ontology.addClass')}</Button>
<Button className="rb:h-6! rb:px-2! rb:leading-5.5!" type="primary" onClick={handleExtract}>+ {t('ontology.extract')}</Button>
</Space>}
/>
<div className="rb:h-[calc(100vh-64px)] rb:overflow-y-auto rb:py-3 rb:px-4">
<Row gutter={16} className="rb:mb-4">
<Col span={6} offset={18}>
<SearchInput
placeholder={t('ontology.searchPlaceholder')}
onSearch={(value) => setQuery({ class_name: value })}
className="rb:w-full!"
/>
</Col>
</Row>
<BodyWrapper loading={loading} empty={!data.items?.length}>
<Row gutter={[16, 16]}>
{data.items?.map(item => (
<Col key={item.class_id} span={6}>
<RbCard
title={item.class_name}
extra={<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
onClick={() => handleDelete(item)}
></div>}
className="rb:bg-transparent!"
>
<Tooltip title={item.class_description}>
<div className="rb:h-8.5 rb:text-[#5B6167] rb:text-[12px] rb:leading-4.25 rb:font-regular rb:-mt-1 rb:wrap-break-word rb:line-clamp-2">{item.class_description}</div>
</Tooltip>
</RbCard>
</Col>
))}
</Row>
</BodyWrapper>
</div>
<OntologyClassModal
ref={ontologyClassModalRef}
refresh={getData}
/>
<OntologyClassExtractModal
ref={ontologyClassExtractModalRef}
refresh={getData}
/>
</>
)
}
export default Detail

View File

@@ -0,0 +1,79 @@
export interface Query {
pagesize?: number;
page?: number;
scene_name?: string;
}
export interface OntologyItem {
scene_id: string;
scene_name: string;
scene_description: string;
type_num: number;
entity_type: string[];
workspace_id: string;
created_at: number;
updated_at: number;
classes_count: number;
}
export interface OntologyModalData {
scene_name: string;
scene_description: string;
}
export interface OntologyModalRef {
handleOpen: (data?: OntologyItem) => void;
}
export interface OntologyClassItem {
class_id: string;
class_name: string;
class_description: string;
scene_id: string;
created_at: number;
updated_at: number;
}
export interface OntologyClassData {
total: number;
scene_id: string;
scene_name: string;
scene_description: string;
items: OntologyClassItem[];
}
export interface AddClassItem {
class_name: string;
class_description: string;
}
export interface OntologyClassModalData {
scene_id: string;
classes: AddClassItem[]
}
export interface OntologyClassModalRef {
handleOpen: (scene_id: string) => void;
}
export interface OntologyClassExtractModalData {
llm_id: string;
scene_id: string;
scenario: string;
domain: string; // scene_name
}
export interface OntologyClassExtractModalRef {
handleOpen: (vo: OntologyClassData) => void;
}
export interface ExtractClassItem {
id: string;
name: string;
name_chinese: string;
description: string;
examples: string[];
parent_class: string | null;
entity_type: string;
domain: string;
}
export interface ExtractData {
domain: string;
extracted_count: number;
classes: ExtractClassItem[]
}