mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 15:39:26 +08:00
Initial commit
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import React, { FC, CSSProperties } from "react";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import InfiniteLoader from "react-window-infinite-loader";
|
||||
import type { SegmentDetailModel } from "@/models/datasets";
|
||||
import SegmentCard from "./SegmentCard";
|
||||
import s from "./style.module.css";
|
||||
|
||||
type IInfiniteVirtualListProps = {
|
||||
hasNextPage?: boolean; // Are there more items to load? (This information comes from the most recent API request.)
|
||||
isNextPageLoading: boolean; // Are we currently loading a page of items? (This may be an in-flight flag in your Redux store for example.)
|
||||
items: Array<SegmentDetailModel[]>; // Array of items loaded so far.
|
||||
loadNextPage: () => Promise<any>; // Callback function responsible for loading the next page of items.
|
||||
onClick: (detail: SegmentDetailModel) => void;
|
||||
onChangeSwitch: (segId: string, enabled: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
const InfiniteVirtualList: FC<IInfiniteVirtualListProps> = ({
|
||||
hasNextPage,
|
||||
isNextPageLoading,
|
||||
items,
|
||||
loadNextPage,
|
||||
onClick: onClickCard,
|
||||
onChangeSwitch,
|
||||
}) => {
|
||||
// If there are more items to be loaded then add an extra row to hold a loading indicator.
|
||||
const itemCount = hasNextPage ? items.length + 1 : items.length;
|
||||
|
||||
// Only load 1 page of items at a time.
|
||||
// Pass an empty callback to InfiniteLoader in case it asks us to load more than once.
|
||||
const loadMoreItems = isNextPageLoading ? () => { } : loadNextPage;
|
||||
|
||||
// Every row is loaded except for our loading indicator row.
|
||||
const isItemLoaded = (index: number) => !hasNextPage || index < items.length;
|
||||
|
||||
// Render an item or a loading indicator.
|
||||
const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
|
||||
let content;
|
||||
if (!isItemLoaded(index)) {
|
||||
content = (
|
||||
<>
|
||||
{[1, 2, 3].map((v) => (
|
||||
<SegmentCard loading={true} detail={{ position: v } as any} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
content = items[index].map((segItem) => (
|
||||
<SegmentCard
|
||||
key={segItem.id}
|
||||
detail={segItem}
|
||||
onClick={() => onClickCard(segItem)}
|
||||
onChangeSwitch={onChangeSwitch}
|
||||
loading={false}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style} className={s.cardWrapper}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<InfiniteLoader
|
||||
itemCount={itemCount}
|
||||
isItemLoaded={isItemLoaded}
|
||||
loadMoreItems={loadMoreItems}
|
||||
>
|
||||
{({ onItemsRendered, ref }) => (
|
||||
<List
|
||||
ref={ref}
|
||||
className="List"
|
||||
height={800}
|
||||
width={"100%"}
|
||||
itemSize={200}
|
||||
itemCount={itemCount}
|
||||
onItemsRendered={onItemsRendered}
|
||||
>
|
||||
{Item}
|
||||
</List>
|
||||
)}
|
||||
</InfiniteLoader>
|
||||
);
|
||||
};
|
||||
export default InfiniteVirtualList;
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import cn from "classnames";
|
||||
import { ArrowUpRightIcon } from "@heroicons/react/24/outline";
|
||||
import Switch from "@/app/components/base/switch";
|
||||
import Divider from "@/app/components/base/divider";
|
||||
import Indicator from "@/app/components/header/indicator";
|
||||
import { formatNumber } from "@/utils/format";
|
||||
import type { SegmentDetailModel } from "@/models/datasets";
|
||||
import { StatusItem } from "../../list";
|
||||
import s from "./style.module.css";
|
||||
import { SegmentIndexTag } from "./index";
|
||||
import { DocumentTitle } from '../index'
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ProgressBar: FC<{ percent: number; loading: boolean }> = ({ percent, loading }) => {
|
||||
return (
|
||||
<div className={s.progressWrapper}>
|
||||
<div className={cn(s.progress, loading ? s.progressLoading : '')}>
|
||||
<div
|
||||
className={s.progressInner}
|
||||
style={{ width: `${loading ? 0 : (percent * 100).toFixed(2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={loading ? s.progressTextLoading : s.progressText}>{loading ? null : percent.toFixed(2)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type UsageScene = 'doc' | 'hitTesting'
|
||||
|
||||
type ISegmentCardProps = {
|
||||
loading: boolean;
|
||||
detail?: SegmentDetailModel & { document: { name: string } };
|
||||
score?: number
|
||||
onClick?: () => void;
|
||||
onChangeSwitch?: (segId: string, enabled: boolean) => Promise<void>;
|
||||
scene?: UsageScene
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const SegmentCard: FC<ISegmentCardProps> = ({
|
||||
detail = {},
|
||||
score,
|
||||
onClick,
|
||||
onChangeSwitch,
|
||||
loading = true,
|
||||
scene = 'doc',
|
||||
className = ''
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
id,
|
||||
position,
|
||||
enabled,
|
||||
content,
|
||||
word_count,
|
||||
hit_count,
|
||||
index_node_hash,
|
||||
} = detail as any;
|
||||
const isDocScene = scene === 'doc'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
s.segWrapper,
|
||||
isDocScene && !enabled ? "bg-gray-25" : "",
|
||||
"group",
|
||||
!loading ? "pb-4" : "",
|
||||
className,
|
||||
)}
|
||||
onClick={() => onClick?.()}
|
||||
>
|
||||
<div className={s.segTitleWrapper}>
|
||||
{isDocScene ? <>
|
||||
<SegmentIndexTag positionId={position} className={cn("w-fit group-hover:opacity-100", isDocScene && !enabled ? 'opacity-50' : '')} />
|
||||
<div className={s.segStatusWrapper}>
|
||||
{loading ? (
|
||||
<Indicator
|
||||
color="gray"
|
||||
className="bg-gray-200 border-gray-300 shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<StatusItem status={enabled ? "enabled" : "disabled"} reverse textCls="text-gray-500 text-xs" />
|
||||
<div className="hidden group-hover:inline-flex items-center">
|
||||
<Divider type="vertical" className="!h-2" />
|
||||
<div
|
||||
onClick={(e: React.MouseEvent<HTMLDivElement, MouseEvent>) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
<Switch
|
||||
size='md'
|
||||
defaultValue={enabled}
|
||||
onChange={async (val) => {
|
||||
await onChangeSwitch?.(id, val)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</> : <div className={s.hitTitleWrapper}>
|
||||
<div className={cn(s.commonIcon, s.targetIcon, loading ? '!bg-gray-300' : '', '!w-3.5 !h-3.5')} />
|
||||
<ProgressBar percent={score ?? 0} loading={loading} />
|
||||
</div>}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className={cn(s.cardLoadingWrapper, s.cardLoadingIcon)}>
|
||||
<div className={cn(s.cardLoadingBg)} />
|
||||
</div>
|
||||
) : (
|
||||
isDocScene ? <>
|
||||
<div
|
||||
className={cn(
|
||||
s.segContent,
|
||||
enabled ? "" : "opacity-50",
|
||||
"group-hover:text-transparent group-hover:bg-clip-text group-hover:bg-gradient-to-b"
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
<div className={cn('group-hover:flex', s.segData)}>
|
||||
<div className="flex items-center mr-6">
|
||||
<div className={cn(s.commonIcon, s.typeSquareIcon)}></div>
|
||||
<div className={s.segDataText}>{formatNumber(word_count)}</div>
|
||||
</div>
|
||||
<div className="flex items-center mr-6">
|
||||
<div className={cn(s.commonIcon, s.targetIcon)} />
|
||||
<div className={s.segDataText}>{formatNumber(hit_count)}</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className={cn(s.commonIcon, s.bezierCurveIcon)} />
|
||||
<div className={s.segDataText}>{index_node_hash}</div>
|
||||
</div>
|
||||
</div>
|
||||
</> : <>
|
||||
<div className="h-[140px] overflow-hidden text-ellipsis text-sm font-normal text-gray-800">
|
||||
{content}
|
||||
</div>
|
||||
<div className={cn("w-full bg-gray-50 group-hover:bg-white")}>
|
||||
<Divider />
|
||||
<div className="relative flex items-center w-full">
|
||||
<DocumentTitle
|
||||
name={detail?.document?.name || ''}
|
||||
extension={(detail?.document?.name || '').split('.').pop() || 'txt'}
|
||||
wrapperCls='w-full'
|
||||
iconCls="!h-4 !w-4 !bg-contain"
|
||||
textCls="text-xs text-gray-700 !font-normal overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
/>
|
||||
<div className={cn(s.chartLinkText, 'group-hover:inline-flex')}>
|
||||
{t('datasetHitTesting.viewChart')}
|
||||
<ArrowUpRightIcon className="w-3 h-3 ml-1 stroke-current stroke-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SegmentCard;
|
||||
206
web/app/components/datasets/documents/detail/completed/index.tsx
Normal file
206
web/app/components/datasets/documents/detail/completed/index.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { memo, useState, useEffect, useMemo } from 'react'
|
||||
import { HashtagIcon } from '@heroicons/react/24/solid'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { omitBy, isNil, debounce } from 'lodash-es'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { StatusItem } from '../../list'
|
||||
import { DocumentContext } from '../index'
|
||||
import s from './style.module.css'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { SimpleSelect, Item } from '@/app/components/base/select'
|
||||
import { disableSegment, enableSegment, fetchSegments } from '@/service/datasets'
|
||||
import type { SegmentDetailModel, SegmentsResponse, SegmentsQuery } from '@/models/datasets'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import InfiniteVirtualList from "./InfiniteVirtualList";
|
||||
import cn from 'classnames'
|
||||
|
||||
export const SegmentIndexTag: FC<{ positionId: string | number; className?: string }> = ({ positionId, className }) => {
|
||||
const localPositionId = useMemo(() => {
|
||||
const positionIdStr = String(positionId)
|
||||
if (positionIdStr.length >= 3)
|
||||
return positionId
|
||||
return positionIdStr.padStart(3, '0')
|
||||
}, [positionId])
|
||||
return (
|
||||
<div className={`text-gray-500 border border-gray-200 box-border flex items-center rounded-md italic text-[11px] pl-1 pr-1.5 font-medium ${className ?? ''}`}>
|
||||
<HashtagIcon className='w-3 h-3 text-gray-400 fill-current mr-1 stroke-current stroke-1' />
|
||||
{localPositionId}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ISegmentDetailProps = {
|
||||
segInfo?: Partial<SegmentDetailModel> & { id: string }
|
||||
onChangeSwitch?: (segId: string, enabled: boolean) => Promise<void>
|
||||
}
|
||||
/**
|
||||
* Show all the contents of the segment
|
||||
*/
|
||||
export const SegmentDetail: FC<ISegmentDetailProps> = memo(({
|
||||
segInfo,
|
||||
onChangeSwitch }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col'}>
|
||||
<SegmentIndexTag positionId={segInfo?.position || ''} className='w-fit mb-6' />
|
||||
<div className={s.segModalContent}>{segInfo?.content}</div>
|
||||
<div className={s.keywordTitle}>{t('datasetDocuments.segment.keywords')}</div>
|
||||
<div className={s.keywordWrapper}>
|
||||
{!segInfo?.keywords?.length
|
||||
? '-'
|
||||
: segInfo?.keywords?.map((word: any) => {
|
||||
return <div className={s.keyword}>{word}</div>
|
||||
})}
|
||||
</div>
|
||||
<div className={cn(s.footer, s.numberInfo)}>
|
||||
<div className='flex items-center'>
|
||||
<div className={cn(s.commonIcon, s.typeSquareIcon)} /><span className='mr-8'>{formatNumber(segInfo?.word_count as any)} {t('datasetDocuments.segment.characters')}</span>
|
||||
<div className={cn(s.commonIcon, s.targetIcon)} /><span className='mr-8'>{formatNumber(segInfo?.hit_count as any)} {t('datasetDocuments.segment.hitCount')}</span>
|
||||
<div className={cn(s.commonIcon, s.bezierCurveIcon)} /><span className={s.hashText}>{t('datasetDocuments.segment.vectorHash')}{segInfo?.index_node_hash}</span>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<StatusItem status={segInfo?.enabled ? 'enabled' : 'disabled'} reverse textCls='text-gray-500 text-xs' />
|
||||
<Divider type='vertical' className='!h-2' />
|
||||
<Switch
|
||||
size='md'
|
||||
defaultValue={segInfo?.enabled}
|
||||
onChange={async val => {
|
||||
await onChangeSwitch?.(segInfo?.id || '', val)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const splitArray = (arr: any[], size = 3) => {
|
||||
if (!arr || !arr.length)
|
||||
return []
|
||||
const result = []
|
||||
for (let i = 0; i < arr.length; i += size)
|
||||
result.push(arr.slice(i, i + size))
|
||||
return result
|
||||
}
|
||||
|
||||
type ICompletedProps = {
|
||||
// data: Array<{}> // all/part segments
|
||||
}
|
||||
/**
|
||||
* Embedding done, show list of all segments
|
||||
* Support search and filter
|
||||
*/
|
||||
const Completed: FC<ICompletedProps> = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { datasetId = '', documentId = '' } = useContext(DocumentContext)
|
||||
// the current segment id and whether to show the modal
|
||||
const [currSegment, setCurrSegment] = useState<{ segInfo?: SegmentDetailModel; showModal: boolean }>({ showModal: false })
|
||||
|
||||
const [searchValue, setSearchValue] = useState() // the search value
|
||||
const [selectedStatus, setSelectedStatus] = useState<boolean | 'all'>('all') // the selected status, enabled/disabled/undefined
|
||||
|
||||
const [lastSegmentsRes, setLastSegmentsRes] = useState<SegmentsResponse | undefined>(undefined)
|
||||
const [allSegments, setAllSegments] = useState<Array<SegmentDetailModel[]>>([]) // all segments data
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [total, setTotal] = useState<number | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSegmentsRes !== undefined) {
|
||||
getSegments(false)
|
||||
}
|
||||
}, [selectedStatus, searchValue])
|
||||
|
||||
const onChangeStatus = ({ value }: Item) => {
|
||||
setSelectedStatus(value === 'all' ? 'all' : !!value)
|
||||
}
|
||||
|
||||
const getSegments = async (needLastId?: boolean) => {
|
||||
const finalLastId = lastSegmentsRes?.data?.[lastSegmentsRes.data.length - 1]?.id || '';
|
||||
setLoading(true)
|
||||
const [e, res] = await asyncRunSafe<SegmentsResponse>(fetchSegments({
|
||||
datasetId,
|
||||
documentId,
|
||||
params: omitBy({
|
||||
last_id: !needLastId ? undefined : finalLastId,
|
||||
limit: 9,
|
||||
keyword: searchValue,
|
||||
enabled: selectedStatus === 'all' ? 'all' : !!selectedStatus,
|
||||
}, isNil) as SegmentsQuery
|
||||
}) as Promise<SegmentsResponse>)
|
||||
if (!e) {
|
||||
setAllSegments([...(!needLastId ? [] : allSegments), ...splitArray(res.data || [])])
|
||||
setLastSegmentsRes(res)
|
||||
if (!lastSegmentsRes) { setTotal(res?.total || 0) }
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const onClickCard = (detail: SegmentDetailModel) => {
|
||||
setCurrSegment({ segInfo: detail, showModal: true })
|
||||
}
|
||||
|
||||
const onCloseModal = () => {
|
||||
setCurrSegment({ ...currSegment, showModal: false })
|
||||
}
|
||||
|
||||
const onChangeSwitch = async (segId: string, enabled: boolean) => {
|
||||
const opApi = enabled ? enableSegment : disableSegment
|
||||
const [e] = await asyncRunSafe<CommonResponse>(opApi({ datasetId, segmentId: segId }) as Promise<CommonResponse>)
|
||||
if (!e) {
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
for (const item of allSegments) {
|
||||
for (const seg of item) {
|
||||
if (seg.id === segId) {
|
||||
seg.enabled = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
setAllSegments([...allSegments])
|
||||
} else {
|
||||
notify({ type: 'error', message: t('common.actionMsg.modificationFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={s.docSearchWrapper}>
|
||||
<div className={s.totalText}>{total ? formatNumber(total) : '--'} {t('datasetDocuments.segment.paragraphs')}</div>
|
||||
<SimpleSelect
|
||||
onSelect={onChangeStatus}
|
||||
items={[
|
||||
{ value: 'all', name: t('datasetDocuments.list.index.all') },
|
||||
{ value: 0, name: t('datasetDocuments.list.status.disabled') },
|
||||
{ value: 1, name: t('datasetDocuments.list.status.enabled') },
|
||||
]}
|
||||
defaultValue={'all'}
|
||||
className={s.select}
|
||||
wrapperClassName='h-fit w-[120px] mr-2' />
|
||||
<Input showPrefix wrapperClassName='!w-52' className='!h-8' onChange={debounce(setSearchValue, 500)} />
|
||||
</div>
|
||||
<InfiniteVirtualList
|
||||
hasNextPage={lastSegmentsRes?.has_more ?? true}
|
||||
isNextPageLoading={loading}
|
||||
items={allSegments}
|
||||
loadNextPage={getSegments}
|
||||
onChangeSwitch={onChangeSwitch}
|
||||
onClick={onClickCard}
|
||||
/>
|
||||
<Modal isShow={currSegment.showModal} onClose={onCloseModal} className='!max-w-[640px]' closable>
|
||||
<SegmentDetail segInfo={currSegment.segInfo ?? { id: '' }} onChangeSwitch={onChangeSwitch} />
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Completed
|
||||
@@ -0,0 +1,130 @@
|
||||
/* .cardWrapper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(290px, auto));
|
||||
grid-gap: 16px;
|
||||
grid-auto-rows: 180px;
|
||||
} */
|
||||
.totalText {
|
||||
@apply text-gray-900 font-medium text-base flex-1;
|
||||
}
|
||||
.docSearchWrapper {
|
||||
@apply sticky w-full h-10 -top-3 bg-white flex items-center mb-3 justify-between z-10;
|
||||
}
|
||||
.listContainer {
|
||||
height: calc(100% - 3.25rem);
|
||||
@apply box-border pb-[30px];
|
||||
}
|
||||
.cardWrapper {
|
||||
@apply grid gap-4 grid-cols-3 min-w-[902px] last:mb-[30px];
|
||||
}
|
||||
.segWrapper {
|
||||
@apply box-border h-[180px] min-w-[290px] bg-gray-50 px-4 pt-4 flex flex-col text-opacity-50 rounded-xl border border-transparent hover:border-gray-200 hover:shadow-lg hover:cursor-pointer hover:bg-white;
|
||||
}
|
||||
.segTitleWrapper {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
.segStatusWrapper {
|
||||
@apply flex items-center box-border;
|
||||
}
|
||||
.segContent {
|
||||
white-space: wrap;
|
||||
@apply flex-1 h-0 min-h-0 mt-2 text-sm text-gray-800 overflow-ellipsis overflow-hidden from-gray-800 to-white;
|
||||
}
|
||||
.segData {
|
||||
@apply hidden text-gray-500 text-xs pt-2;
|
||||
}
|
||||
.segDataText {
|
||||
@apply max-w-[80px] truncate;
|
||||
}
|
||||
.chartLinkText {
|
||||
background: linear-gradient(to left, white, 90%, transparent);
|
||||
@apply text-primary-600 font-semibold text-xs absolute right-0 hidden h-12 pl-12 items-center;
|
||||
}
|
||||
.select {
|
||||
@apply h-8 py-0 bg-gray-50 hover:bg-gray-100 rounded-lg shadow-none !important;
|
||||
}
|
||||
.segModalContent {
|
||||
@apply h-96 text-gray-800 text-base overflow-y-scroll;
|
||||
}
|
||||
.footer {
|
||||
@apply flex items-center justify-between box-border border-t-gray-200 border-t-[0.5px] pt-3 mt-4;
|
||||
}
|
||||
.numberInfo {
|
||||
@apply text-gray-500 text-xs font-medium;
|
||||
}
|
||||
.keywordTitle {
|
||||
@apply text-gray-500 mb-2 mt-1 text-xs uppercase;
|
||||
}
|
||||
.keywordWrapper {
|
||||
@apply text-gray-700 w-full max-h-[200px] overflow-auto flex flex-wrap;
|
||||
}
|
||||
.keyword {
|
||||
@apply text-sm border border-gray-200 max-w-[200px] max-h-[100px] whitespace-pre-line overflow-y-auto mr-1 mb-2 last:mr-0 px-2 py-1 rounded-lg;
|
||||
}
|
||||
.hashText {
|
||||
@apply w-48 inline-block truncate;
|
||||
}
|
||||
.commonIcon {
|
||||
@apply w-3 h-3 inline-block align-middle mr-1 bg-gray-500;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
mask-position: center center;
|
||||
}
|
||||
.targetIcon {
|
||||
mask-image: url(../../assets/target.svg);
|
||||
}
|
||||
.typeSquareIcon {
|
||||
mask-image: url(../../assets/typeSquare.svg);
|
||||
}
|
||||
.bezierCurveIcon {
|
||||
mask-image: url(../../assets/bezierCurve.svg);
|
||||
}
|
||||
.cardLoadingWrapper {
|
||||
@apply relative w-full h-full inline-block rounded-b-xl;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
background-origin: content-box;
|
||||
}
|
||||
.cardLoadingIcon {
|
||||
background-image: url(../../assets/cardLoading.svg);
|
||||
}
|
||||
/* .hitLoadingIcon {
|
||||
background-image: url(../../assets/hitLoading.svg);
|
||||
} */
|
||||
.cardLoadingBg {
|
||||
@apply h-full relative rounded-b-xl mt-4;
|
||||
left: calc(-1rem - 1px);
|
||||
width: calc(100% + 2rem + 2px);
|
||||
height: calc(100% - 1rem + 1px);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(252, 252, 253, 0) 0%,
|
||||
#fcfcfd 74.15%
|
||||
);
|
||||
}
|
||||
|
||||
.hitTitleWrapper {
|
||||
@apply w-full flex items-center justify-between mb-2;
|
||||
}
|
||||
.progressWrapper {
|
||||
@apply flex items-center justify-between w-full;
|
||||
}
|
||||
.progress {
|
||||
border-radius: 3px;
|
||||
@apply relative h-1.5 box-border border border-gray-300 flex-1 mr-2;
|
||||
}
|
||||
.progressLoading {
|
||||
@apply border-[#EAECF0] bg-[#EAECF0];
|
||||
}
|
||||
.progressInner {
|
||||
@apply absolute top-0 h-full bg-gray-300;
|
||||
}
|
||||
.progressText {
|
||||
font-size: 13px;
|
||||
@apply text-gray-700 font-bold;
|
||||
}
|
||||
.progressTextLoading {
|
||||
border-radius: 5px;
|
||||
@apply h-3.5 w-3.5 bg-[#EAECF0];
|
||||
}
|
||||
268
web/app/components/datasets/documents/detail/embedding/index.tsx
Normal file
268
web/app/components/datasets/documents/detail/embedding/index.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { FC, useCallback, useMemo, useState } from 'react'
|
||||
import React from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { omit } from 'lodash-es'
|
||||
import cn from 'classnames'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { FullDocumentDetail, ProcessRuleResponse } from '@/models/datasets'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { fetchProcessRule, fetchIndexingEstimate, fetchIndexingStatus, pauseDocIndexing, resumeDocIndexing } from '@/service/datasets'
|
||||
import SegmentCard from '../completed/SegmentCard'
|
||||
import { FieldInfo } from '../metadata'
|
||||
import s from './style.module.css'
|
||||
import style from '../completed/style.module.css'
|
||||
import { DocumentContext } from '../index'
|
||||
import DatasetDetailContext from '@/context/dataset-detail'
|
||||
import StopEmbeddingModal from '@/app/components/datasets/create/stop-embedding-modal'
|
||||
import { ArrowRightIcon } from '@heroicons/react/24/solid'
|
||||
|
||||
type Props = {
|
||||
detail?: FullDocumentDetail
|
||||
stopPosition?: 'top' | 'bottom'
|
||||
datasetId?: string
|
||||
documentId?: string
|
||||
indexingType?: string
|
||||
}
|
||||
|
||||
const StopIcon: FC<{ className?: string }> = ({ className }) => {
|
||||
return <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
|
||||
<g clip-path="url(#clip0_2328_2798)">
|
||||
<path d="M1.5 3.9C1.5 3.05992 1.5 2.63988 1.66349 2.31901C1.8073 2.03677 2.03677 1.8073 2.31901 1.66349C2.63988 1.5 3.05992 1.5 3.9 1.5H8.1C8.94008 1.5 9.36012 1.5 9.68099 1.66349C9.96323 1.8073 10.1927 2.03677 10.3365 2.31901C10.5 2.63988 10.5 3.05992 10.5 3.9V8.1C10.5 8.94008 10.5 9.36012 10.3365 9.68099C10.1927 9.96323 9.96323 10.1927 9.68099 10.3365C9.36012 10.5 8.94008 10.5 8.1 10.5H3.9C3.05992 10.5 2.63988 10.5 2.31901 10.3365C2.03677 10.1927 1.8073 9.96323 1.66349 9.68099C1.5 9.36012 1.5 8.94008 1.5 8.1V3.9Z" stroke="#344054" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2328_2798">
|
||||
<rect width="12" height="12" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
}
|
||||
|
||||
const ResumeIcon: FC<{ className?: string }> = ({ className }) => {
|
||||
return <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
|
||||
<path d="M10 3.5H5C3.34315 3.5 2 4.84315 2 6.5C2 8.15685 3.34315 9.5 5 9.5H10M10 3.5L8 1.5M10 3.5L8 5.5" stroke="#344054" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
||||
}
|
||||
|
||||
const RuleDetail: FC<{ sourceData?: ProcessRuleResponse; docName?: string }> = ({ sourceData, docName }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const segmentationRuleMap = {
|
||||
docName: t('datasetDocuments.embedding.docName'),
|
||||
mode: t('datasetDocuments.embedding.mode'),
|
||||
segmentLength: t('datasetDocuments.embedding.segmentLength'),
|
||||
textCleaning: t('datasetDocuments.embedding.textCleaning'),
|
||||
}
|
||||
const getValue = useCallback((field: string) => {
|
||||
let value: string | number | undefined = '-';
|
||||
switch (field) {
|
||||
case 'docName':
|
||||
value = docName
|
||||
break;
|
||||
case 'mode':
|
||||
value = sourceData?.mode === 'automatic' ? (t('datasetDocuments.embedding.automatic') as string) : (t('datasetDocuments.embedding.custom') as string);
|
||||
break;
|
||||
case 'segmentLength':
|
||||
value = sourceData?.rules?.segmentation?.max_tokens
|
||||
break;
|
||||
default:
|
||||
value = sourceData?.mode === 'automatic' ?
|
||||
(t('datasetDocuments.embedding.automatic') as string) :
|
||||
sourceData?.rules?.pre_processing_rules?.map(rule => {
|
||||
if (rule.enabled) {
|
||||
return getRuleName(rule.id)
|
||||
}
|
||||
}).filter(Boolean).join(';')
|
||||
break;
|
||||
}
|
||||
return value
|
||||
}, [sourceData, docName])
|
||||
|
||||
const getRuleName = (key: string) => {
|
||||
if (key === 'remove_extra_spaces') {
|
||||
return t('datasetCreation.stepTwo.removeExtraSpaces')
|
||||
}
|
||||
if (key === 'remove_urls_emails') {
|
||||
return t('datasetCreation.stepTwo.removeUrlEmails')
|
||||
}
|
||||
if (key === 'remove_stopwords') {
|
||||
return t('datasetCreation.stepTwo.removeStopwords')
|
||||
}
|
||||
}
|
||||
|
||||
return <div className='flex flex-col pt-8 pb-10 first:mt-0'>
|
||||
{Object.keys(segmentationRuleMap).map((field) => {
|
||||
return <FieldInfo
|
||||
key={field}
|
||||
label={segmentationRuleMap[field as keyof typeof segmentationRuleMap]}
|
||||
displayedValue={String(getValue(field))}
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
const EmbeddingDetail: FC<Props> = ({ detail, stopPosition = 'top', datasetId: dstId, documentId: docId, indexingType }) => {
|
||||
const onTop = stopPosition === 'top'
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
|
||||
const { datasetId = '', documentId = '' } = useContext(DocumentContext)
|
||||
const { indexingTechnique } = useContext(DatasetDetailContext)
|
||||
const localDatasetId = dstId ?? datasetId
|
||||
const localDocumentId = docId ?? documentId
|
||||
const localIndexingTechnique = indexingType ?? indexingTechnique
|
||||
|
||||
const { data: indexingStatusDetail, error: indexingStatusErr, mutate: statusMutate } = useSWR({
|
||||
action: 'fetchIndexingStatus',
|
||||
datasetId: localDatasetId,
|
||||
documentId: localDocumentId,
|
||||
}, apiParams => fetchIndexingStatus(omit(apiParams, 'action')), {
|
||||
refreshInterval: 5000,
|
||||
revalidateOnFocus: false,
|
||||
})
|
||||
|
||||
const { data: indexingEstimateDetail, error: indexingEstimateErr } = useSWR({
|
||||
action: 'fetchIndexingEstimate',
|
||||
datasetId: localDatasetId,
|
||||
documentId: localDocumentId,
|
||||
}, apiParams => fetchIndexingEstimate(omit(apiParams, 'action')), {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
const { data: ruleDetail, error: ruleError } = useSWR({
|
||||
action: 'fetchProcessRule',
|
||||
params: { documentId: localDocumentId }
|
||||
}, apiParams => fetchProcessRule(omit(apiParams, 'action')), {
|
||||
revalidateOnFocus: false,
|
||||
})
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const modalShowHandle = () => setShowModal(true)
|
||||
const modalCloseHandle = () => setShowModal(false)
|
||||
const router = useRouter()
|
||||
const navToDocument = () => {
|
||||
router.push(`/datasets/${localDatasetId}/documents/${localDocumentId}`)
|
||||
}
|
||||
|
||||
const isEmbedding = useMemo(() => ['indexing', 'splitting', 'parsing', 'cleaning'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
|
||||
const isEmbeddingCompleted = useMemo(() => ['completed'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
|
||||
const isEmbeddingPaused = useMemo(() => ['paused'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
|
||||
const isEmbeddingError = useMemo(() => ['error'].includes(indexingStatusDetail?.indexing_status || ''), [indexingStatusDetail])
|
||||
const percent = useMemo(() => {
|
||||
const completedCount = indexingStatusDetail?.completed_segments || 0
|
||||
const totalCount = indexingStatusDetail?.total_segments || 0
|
||||
if (totalCount === 0) return 0
|
||||
const percent = Math.round(completedCount * 100 / totalCount)
|
||||
return percent > 100 ? 100 : percent
|
||||
}, [indexingStatusDetail])
|
||||
|
||||
const handleSwitch = async () => {
|
||||
const opApi = isEmbedding ? pauseDocIndexing : resumeDocIndexing
|
||||
const [e] = await asyncRunSafe<CommonResponse>(opApi({ datasetId: localDatasetId, documentId: localDocumentId }) as Promise<CommonResponse>)
|
||||
if (!e) {
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
statusMutate()
|
||||
} else {
|
||||
notify({ type: 'error', message: t('common.actionMsg.modificationFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
// if (!ruleDetail && !error)
|
||||
// return <Loading type='app' />
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={s.embeddingStatus}>
|
||||
{isEmbedding && t('datasetDocuments.embedding.processing')}
|
||||
{isEmbeddingCompleted && t('datasetDocuments.embedding.completed')}
|
||||
{isEmbeddingPaused && t('datasetDocuments.embedding.paused')}
|
||||
{isEmbeddingError && t('datasetDocuments.embedding.error')}
|
||||
{onTop && isEmbedding && (
|
||||
<Button onClick={handleSwitch} className={s.opBtn}>
|
||||
<StopIcon className={s.opIcon} />
|
||||
{t('datasetDocuments.embedding.stop')}
|
||||
</Button>
|
||||
)}
|
||||
{onTop && isEmbeddingPaused && (
|
||||
<Button onClick={handleSwitch} className={s.opBtn}>
|
||||
<ResumeIcon className={s.opIcon} />
|
||||
{t('datasetDocuments.embedding.resume')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* progress bar */}
|
||||
<div className={s.progressContainer}>
|
||||
{new Array(10).fill('').map((_, idx) => <div
|
||||
key={idx}
|
||||
className={cn(s.progressBgItem, isEmbedding ? 'bg-primary-50' : 'bg-gray-100')}
|
||||
/>)}
|
||||
<div className={
|
||||
cn('rounded-l-md',
|
||||
s.progressBar,
|
||||
(isEmbedding || isEmbeddingCompleted) && s.barProcessing,
|
||||
(isEmbeddingPaused || isEmbeddingError) && s.barPaused,
|
||||
indexingStatusDetail?.indexing_status === 'completed' && 'rounded-r-md')
|
||||
}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.progressData}>
|
||||
<div>{t('datasetDocuments.embedding.segments')} {indexingStatusDetail?.completed_segments}/{indexingStatusDetail?.total_segments} · {percent}%</div>
|
||||
{localIndexingTechnique === 'high_quaility' && (
|
||||
<div className='flex items-center'>
|
||||
<div className={cn(s.commonIcon, s.highIcon)} />
|
||||
{t('datasetDocuments.embedding.highQuality')} · {t('datasetDocuments.embedding.estimate')}
|
||||
<span className={s.tokens}>{formatNumber(indexingEstimateDetail?.tokens || 0)}</span>tokens
|
||||
(<span className={s.price}>${formatNumber(indexingEstimateDetail?.total_price || 0)}</span>)
|
||||
</div>
|
||||
)}
|
||||
{localIndexingTechnique === 'economy' && (
|
||||
<div className='flex items-center'>
|
||||
<div className={cn(s.commonIcon, s.economyIcon)} />
|
||||
{t('datasetDocuments.embedding.economy')} · {t('datasetDocuments.embedding.estimate')}
|
||||
<span className={s.tokens}>0</span>tokens
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RuleDetail sourceData={ruleDetail} docName={detail?.name} />
|
||||
{!onTop && (
|
||||
<div className='flex items-center gap-2 mt-10'>
|
||||
{isEmbedding && (
|
||||
<Button onClick={modalShowHandle} className='w-fit'>
|
||||
{t('datasetCreation.stepThree.stop')}
|
||||
</Button>
|
||||
)}
|
||||
{isEmbeddingPaused && (
|
||||
<Button onClick={handleSwitch} className='w-fit'>
|
||||
{t('datasetCreation.stepThree.resume')}
|
||||
</Button>
|
||||
)}
|
||||
<Button className='w-fit' type='primary' onClick={navToDocument}>
|
||||
<span>{t('datasetCreation.stepThree.navTo')}</span>
|
||||
<ArrowRightIcon className='h-4 w-4 ml-2 stroke-current stroke-1' />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{onTop && <>
|
||||
<Divider />
|
||||
<div className={s.previewTip}>{t('datasetDocuments.embedding.previewTip')}</div>
|
||||
<div className={style.cardWrapper}>
|
||||
{[1, 2, 3].map((v) => (
|
||||
<SegmentCard loading={true} detail={{ position: v } as any} />
|
||||
))}
|
||||
</div>
|
||||
</>}
|
||||
<StopEmbeddingModal show={showModal} onConfirm={handleSwitch} onHide={modalCloseHandle} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmbeddingDetail
|
||||
@@ -0,0 +1,59 @@
|
||||
.progressBar {
|
||||
@apply absolute top-0 h-4;
|
||||
}
|
||||
.barPaused {
|
||||
background: linear-gradient(
|
||||
270deg,
|
||||
rgba(208, 213, 221, 0.8) -2.21%,
|
||||
rgba(208, 213, 221, 0.5) 100%
|
||||
);
|
||||
}
|
||||
.barProcessing {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(41, 112, 255, 0.9) 0%,
|
||||
rgba(21, 94, 239, 0.9) 100%
|
||||
);
|
||||
}
|
||||
.opBtn {
|
||||
@apply w-fit h-6 text-xs px-2 py-1 text-gray-700 rounded-md !important;
|
||||
}
|
||||
.opIcon {
|
||||
@apply mr-1 stroke-current text-gray-700 w-3 h-3;
|
||||
}
|
||||
.progressContainer {
|
||||
@apply relative flex mb-2 h-4 rounded-md w-full;
|
||||
}
|
||||
.progressBgItem {
|
||||
@apply flex-1 border-r border-r-white first:rounded-l-md;
|
||||
}
|
||||
.progressBgItem:nth-last-child(2) {
|
||||
@apply rounded-r-md;
|
||||
}
|
||||
.progressData {
|
||||
@apply w-full flex justify-between items-center text-xs text-gray-700;
|
||||
}
|
||||
.previewTip {
|
||||
@apply pb-1 pt-12 text-gray-900 text-sm font-medium;
|
||||
}
|
||||
.embeddingStatus {
|
||||
@apply flex items-center justify-between text-gray-900 font-medium text-base mb-3;
|
||||
}
|
||||
.commonIcon {
|
||||
@apply w-3 h-3 mr-1 inline-block align-middle;
|
||||
}
|
||||
.highIcon {
|
||||
mask-image: url(../../assets/star.svg);
|
||||
@apply bg-orange-500;
|
||||
}
|
||||
.economyIcon {
|
||||
background-color: #444ce7;
|
||||
mask-image: url(../../assets/normal.svg);
|
||||
}
|
||||
.tokens {
|
||||
@apply text-xs font-medium px-1;
|
||||
}
|
||||
.price {
|
||||
color: #f79009;
|
||||
@apply text-xs font-medium;
|
||||
}
|
||||
121
web/app/components/datasets/documents/detail/index.tsx
Normal file
121
web/app/components/datasets/documents/detail/index.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { ArrowLeftIcon } from '@heroicons/react/24/solid'
|
||||
import { createContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { omit } from 'lodash-es'
|
||||
import cn from 'classnames'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { fetchDocumentDetail, MetadataType } from '@/service/datasets'
|
||||
import { OperationAction, StatusItem } from '../list'
|
||||
import Completed from './completed'
|
||||
import Embedding from './embedding'
|
||||
import Metadata from './metadata'
|
||||
import s from '../style.module.css'
|
||||
import style from './style.module.css'
|
||||
|
||||
export const BackCircleBtn: FC<{ onClick: () => void }> = ({ onClick }) => {
|
||||
return (
|
||||
<div onClick={onClick} className={'rounded-full w-8 h-8 flex justify-center items-center border-gray-100 cursor-pointer border hover:border-gray-300 shadow-[0px_12px_16px_-4px_rgba(16,24,40,0.08),0px_4px_6px_-2px_rgba(16,24,40,0.03)]'}>
|
||||
<ArrowLeftIcon className='text-primary-600 fill-current stroke-current h-4 w-4' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DocumentContext = createContext<{ datasetId?: string; documentId?: string }>({})
|
||||
|
||||
type DocumentTitleProps = {
|
||||
extension?: string;
|
||||
name?: string;
|
||||
iconCls?: string;
|
||||
textCls?: string;
|
||||
wrapperCls?: string;
|
||||
}
|
||||
|
||||
export const DocumentTitle: FC<DocumentTitleProps> = ({ extension, name, iconCls, textCls, wrapperCls }) => {
|
||||
const localExtension = extension?.toLowerCase() || name?.split('.')?.pop()?.toLowerCase()
|
||||
return <div className={cn('flex items-center justify-start flex-1', wrapperCls)}>
|
||||
<div className={cn(s[`${localExtension || 'txt'}Icon`], style.titleIcon, iconCls)}></div>
|
||||
<span className={cn('font-semibold text-lg text-gray-900 ml-1', textCls)}> {name || '--'}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
type Props = {
|
||||
datasetId: string
|
||||
documentId: string
|
||||
}
|
||||
|
||||
const DocumentDetail: FC<Props> = ({ datasetId, documentId }) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const [showMetadata, setShowMetadata] = useState(true)
|
||||
|
||||
const { data: documentDetail, error, mutate: detailMutate } = useSWR({
|
||||
action: 'fetchDocumentDetail',
|
||||
datasetId,
|
||||
documentId,
|
||||
params: { metadata: 'without' as MetadataType }
|
||||
}, apiParams => fetchDocumentDetail(omit(apiParams, 'action')))
|
||||
|
||||
const { data: documentMetadata, error: metadataErr, mutate: metadataMutate } = useSWR({
|
||||
action: 'fetchDocumentDetail',
|
||||
datasetId,
|
||||
documentId,
|
||||
params: { metadata: 'only' as MetadataType }
|
||||
}, apiParams => fetchDocumentDetail(omit(apiParams, 'action')))
|
||||
|
||||
const backToPrev = () => {
|
||||
router.push(`/datasets/${datasetId}/documents`)
|
||||
}
|
||||
|
||||
const isDetailLoading = !documentDetail && !error
|
||||
const isMetadataLoading = !documentMetadata && !metadataErr
|
||||
|
||||
const embedding = ['queuing', 'indexing', 'paused'].includes((documentDetail?.display_status || '').toLowerCase())
|
||||
|
||||
return (
|
||||
<DocumentContext.Provider value={{ datasetId, documentId }}>
|
||||
<div className='flex flex-col h-full'>
|
||||
<div className='flex h-16 border-b-gray-100 border-b items-center p-4'>
|
||||
<BackCircleBtn onClick={backToPrev} />
|
||||
<Divider className='!h-4' type='vertical' />
|
||||
<DocumentTitle extension={documentDetail?.data_source_info?.upload_file?.extension} name={documentDetail?.name} />
|
||||
<StatusItem status={documentDetail?.display_status || 'available'} scene='detail' />
|
||||
<OperationAction
|
||||
scene='detail'
|
||||
detail={{
|
||||
enabled: documentDetail?.enabled || false,
|
||||
archived: documentDetail?.archived || false,
|
||||
id: documentId
|
||||
}}
|
||||
datasetId={datasetId}
|
||||
onUpdate={detailMutate}
|
||||
className='!w-[216px]'
|
||||
/>
|
||||
<button
|
||||
className={cn(style.layoutRightIcon, showMetadata ? style.iconShow : style.iconClose)}
|
||||
onClick={() => setShowMetadata(!showMetadata)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-row flex-1' style={{ height: 'calc(100% - 4rem)' }}>
|
||||
{isDetailLoading ? <Loading type='app' /> :
|
||||
<div className={`box-border h-full w-full overflow-y-scroll ${embedding ? 'py-12 px-16' : 'pb-[30px] pt-3 px-6'}`}>
|
||||
{embedding ? <Embedding detail={documentDetail} /> : <Completed />}
|
||||
</div>
|
||||
}
|
||||
{showMetadata && <Metadata
|
||||
docDetail={{ ...documentDetail, ...documentMetadata } as any}
|
||||
loading={isMetadataLoading}
|
||||
onUpdate={metadataMutate}
|
||||
/>}
|
||||
</div>
|
||||
</div>
|
||||
</DocumentContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default DocumentDetail
|
||||
363
web/app/components/datasets/documents/detail/metadata/index.tsx
Normal file
363
web/app/components/datasets/documents/detail/metadata/index.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { PencilIcon } from '@heroicons/react/24/outline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { get } from 'lodash-es'
|
||||
import cn from 'classnames'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
|
||||
import { asyncRunSafe, getTextWidthWithCanvas } from '@/utils'
|
||||
import { modifyDocMetadata } from '@/service/datasets'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { FullDocumentDetail, DocType } from '@/models/datasets'
|
||||
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
|
||||
import type { metadataType, inputType } from '@/hooks/use-metadata'
|
||||
import { useMetadataMap, useLanguages, useBookCategories, usePersonalDocCategories, useBusinessDocCategories } from '@/hooks/use-metadata'
|
||||
import { DocumentContext } from '../index'
|
||||
import s from './style.module.css'
|
||||
|
||||
const map2Options = (map: { [key: string]: string }) => {
|
||||
return Object.keys(map).map(key => ({ value: key, name: map[key] }))
|
||||
}
|
||||
|
||||
type IFieldInfoProps = {
|
||||
label: string
|
||||
value?: string
|
||||
displayedValue?: string
|
||||
defaultValue?: string
|
||||
showEdit?: boolean
|
||||
inputType?: inputType
|
||||
selectOptions?: Array<{ value: string; name: string }>
|
||||
onUpdate?: (v: any) => void
|
||||
}
|
||||
|
||||
export const FieldInfo: FC<IFieldInfoProps> = ({
|
||||
label,
|
||||
value = '',
|
||||
displayedValue = '',
|
||||
defaultValue,
|
||||
showEdit = false,
|
||||
inputType = 'input',
|
||||
selectOptions = [],
|
||||
onUpdate
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190
|
||||
const editAlignTop = showEdit && inputType === 'textarea'
|
||||
const readAlignTop = !showEdit && textNeedWrap
|
||||
|
||||
return (
|
||||
<div className={cn(s.fieldInfo, editAlignTop && `!items-start`, readAlignTop && '!items-start pt-1')}>
|
||||
<div className={cn(s.label, editAlignTop && 'pt-1')}>{label}</div>
|
||||
<div className={s.value}>
|
||||
{!showEdit
|
||||
? displayedValue
|
||||
: inputType === 'select'
|
||||
? <SimpleSelect
|
||||
onSelect={({ value }) => onUpdate && onUpdate(value as string)}
|
||||
items={selectOptions}
|
||||
defaultValue={value}
|
||||
className={s.select}
|
||||
wrapperClassName={s.selectWrapper}
|
||||
placeholder={`${t('datasetDocuments.metadata.placeholder.select')}${label}`}
|
||||
/>
|
||||
: inputType === 'textarea'
|
||||
? <AutoHeightTextarea
|
||||
onChange={e => onUpdate && onUpdate(e.target.value)}
|
||||
value={value}
|
||||
className={s.textArea}
|
||||
placeholder={`${t('datasetDocuments.metadata.placeholder.add')}${label}`}
|
||||
/>
|
||||
: <Input
|
||||
className={s.input}
|
||||
onChange={onUpdate}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={`${t('datasetDocuments.metadata.placeholder.add')}${label}`}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TypeIcon: FC<{ iconName: string; className?: string }> = ({ iconName, className = '' }) => {
|
||||
return <div className={cn(s.commonIcon, s[`${iconName}Icon`], className)}
|
||||
/>
|
||||
}
|
||||
|
||||
const IconButton: FC<{
|
||||
type: DocType
|
||||
isChecked: boolean
|
||||
}> = ({ type, isChecked = false }) => {
|
||||
const metadataMap = useMetadataMap()
|
||||
|
||||
return (
|
||||
<Tooltip content={metadataMap[type].text} selector={`doc-metadata-${type}`}>
|
||||
<button className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
|
||||
<TypeIcon
|
||||
iconName={metadataMap[type].iconName || ''}
|
||||
className={`group-hover:bg-primary-600 ${isChecked ? '!bg-primary-600' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type IMetadataProps = {
|
||||
docDetail?: FullDocumentDetail
|
||||
loading: boolean
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
const Metadata: FC<IMetadataProps> = ({ docDetail, loading, onUpdate }) => {
|
||||
const { doc_metadata = {} } = docDetail || {}
|
||||
const doc_type = docDetail?.doc_type || ''
|
||||
|
||||
const { t } = useTranslation()
|
||||
const metadataMap = useMetadataMap()
|
||||
const languageMap = useLanguages()
|
||||
const bookCategoryMap = useBookCategories()
|
||||
const personalDocCategoryMap = usePersonalDocCategories()
|
||||
const businessDocCategoryMap = useBusinessDocCategories()
|
||||
const [editStatus, setEditStatus] = useState(!doc_type) // if no documentType, in editing status by default
|
||||
// the initial values are according to the documentType
|
||||
const [metadataParams, setMetadataParams] = useState<{
|
||||
documentType?: DocType | '';
|
||||
metadata: { [key: string]: string }
|
||||
}>(
|
||||
doc_type ? {
|
||||
documentType: doc_type,
|
||||
metadata: doc_metadata || {}
|
||||
} : { metadata: {} })
|
||||
const [showDocTypes, setShowDocTypes] = useState(!doc_type) // whether show doc types
|
||||
const [tempDocType, setTempDocType] = useState<DocType | undefined | ''>('') // for remember icon click
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { datasetId = '', documentId = '' } = useContext(DocumentContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (docDetail?.doc_type) {
|
||||
setEditStatus(false)
|
||||
setShowDocTypes(false)
|
||||
setTempDocType(docDetail?.doc_type)
|
||||
setMetadataParams({
|
||||
documentType: docDetail?.doc_type,
|
||||
metadata: docDetail?.doc_metadata || {}
|
||||
})
|
||||
}
|
||||
}, [docDetail?.doc_type])
|
||||
|
||||
// confirm doc type
|
||||
const confirmDocType = () => {
|
||||
if (!tempDocType) return;
|
||||
setMetadataParams({
|
||||
documentType: tempDocType,
|
||||
metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {} // change doc type, clear metadata
|
||||
})
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
|
||||
// cancel doc type
|
||||
const cancelDocType = () => {
|
||||
setTempDocType(metadataParams.documentType)
|
||||
setEditStatus(true)
|
||||
setShowDocTypes(false)
|
||||
}
|
||||
|
||||
// show doc type select
|
||||
const renderSelectDocType = () => {
|
||||
const { documentType } = metadataParams
|
||||
|
||||
return (
|
||||
<>
|
||||
{!doc_type && !documentType && <>
|
||||
<div className={s.desc}>{t('datasetDocuments.metadata.desc')}</div>
|
||||
</>}
|
||||
<div className={s.operationWrapper}>
|
||||
{!doc_type && !documentType && <>
|
||||
<span className={s.title}>{t('datasetDocuments.metadata.docTypeSelectTitle')}</span>
|
||||
</>}
|
||||
{documentType && <>
|
||||
<span className={s.title}>{t('datasetDocuments.metadata.docTypeChangeTitle')}</span>
|
||||
<span className={s.changeTip}>{t('datasetDocuments.metadata.docTypeSelectWarning')}</span>
|
||||
</>}
|
||||
<Radio.Group value={tempDocType ?? documentType} onChange={setTempDocType} className={s.radioGroup}>
|
||||
{CUSTOMIZABLE_DOC_TYPES.map((type) => {
|
||||
const currValue = tempDocType ?? documentType
|
||||
return <Radio value={type} className={`${s.radio} ${currValue === type ? 'shadow-none' : ''}`}>
|
||||
<IconButton
|
||||
type={type}
|
||||
isChecked={currValue === type}
|
||||
/>
|
||||
</Radio>
|
||||
})}
|
||||
</Radio.Group>
|
||||
{!doc_type && !documentType && (
|
||||
<Button type='primary'
|
||||
onClick={confirmDocType}
|
||||
disabled={!tempDocType}
|
||||
>
|
||||
{t('datasetDocuments.metadata.firstMetaAction')}
|
||||
</Button>
|
||||
)}
|
||||
{documentType && <div className={s.opBtnWrapper}>
|
||||
<Button onClick={confirmDocType} className={`${s.opBtn} ${s.opSaveBtn}`} type='primary' >{t('common.operation.save')}</Button>
|
||||
<Button onClick={cancelDocType} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('common.operation.cancel')}</Button>
|
||||
</div>}
|
||||
</div >
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// show metadata info and edit
|
||||
const renderFieldInfos = ({ mainField = 'book', canEdit }: { mainField?: metadataType | ''; canEdit?: boolean }) => {
|
||||
if (!mainField) return null
|
||||
const fieldMap = metadataMap[mainField]?.subFieldsMap
|
||||
const sourceData = ['originInfo', 'technicalParameters'].includes(mainField) ? docDetail : metadataParams.metadata
|
||||
|
||||
const getTargetMap = (field: string) => {
|
||||
if (field === 'language') return languageMap
|
||||
if (field === 'category' && mainField === 'book') {
|
||||
return bookCategoryMap
|
||||
}
|
||||
if (field === 'document_type') {
|
||||
if (mainField === 'personal_document') return personalDocCategoryMap
|
||||
if (mainField === 'business_document') return businessDocCategoryMap
|
||||
}
|
||||
return {} as any
|
||||
}
|
||||
|
||||
const getTargetValue = (field: string) => {
|
||||
const val = get(sourceData, field, '')
|
||||
if (!val && val !== 0) return '-'
|
||||
if (fieldMap[field]?.inputType === 'select')
|
||||
return getTargetMap(field)[val]
|
||||
if (fieldMap[field]?.render)
|
||||
return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined)
|
||||
return val
|
||||
}
|
||||
|
||||
return <div className='flex flex-col gap-1'>
|
||||
{Object.keys(fieldMap).map((field) => {
|
||||
return <FieldInfo
|
||||
key={fieldMap[field]?.label}
|
||||
label={fieldMap[field]?.label}
|
||||
displayedValue={getTargetValue(field)}
|
||||
value={get(sourceData, field, '')}
|
||||
inputType={fieldMap[field]?.inputType || 'input'}
|
||||
showEdit={canEdit}
|
||||
onUpdate={(val) => {
|
||||
setMetadataParams(pre => ({ ...pre, metadata: { ...pre.metadata, [field]: val } }))
|
||||
}}
|
||||
selectOptions={map2Options(getTargetMap(field))}
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
const enabledEdit = () => {
|
||||
setEditStatus(true)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setMetadataParams({ documentType: doc_type || '', metadata: { ...(docDetail?.doc_metadata || {}) } })
|
||||
setEditStatus(!doc_type)
|
||||
if (!doc_type)
|
||||
setShowDocTypes(true)
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
console.log('metadataParams:', metadataParams)
|
||||
setSaveLoading(true)
|
||||
const [e] = await asyncRunSafe<CommonResponse>(modifyDocMetadata({
|
||||
datasetId,
|
||||
documentId,
|
||||
body: {
|
||||
doc_type: metadataParams.documentType || doc_type || '',
|
||||
doc_metadata: metadataParams.metadata
|
||||
}
|
||||
}) as Promise<CommonResponse>)
|
||||
if (!e)
|
||||
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
|
||||
else
|
||||
notify({ type: 'error', message: t('common.actionMsg.modificationFailed') })
|
||||
onUpdate?.()
|
||||
setEditStatus(false)
|
||||
setSaveLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${s.main} ${editStatus ? 'bg-white' : 'bg-gray-25'}`}>
|
||||
{loading ? <Loading type='app' /> : (
|
||||
<>
|
||||
<div className={s.titleWrapper}>
|
||||
<span className={s.title}>{t('datasetDocuments.metadata.title')}</span>
|
||||
{!editStatus
|
||||
? <Button onClick={enabledEdit} className={`${s.opBtn} ${s.opEditBtn}`}>
|
||||
<PencilIcon className={s.opIcon} />
|
||||
{t('common.operation.edit')}
|
||||
</Button>
|
||||
: showDocTypes
|
||||
? null
|
||||
: <div className={s.opBtnWrapper}>
|
||||
<Button onClick={onCancel} className={`${s.opBtn} ${s.opCancelBtn}`}>{t('common.operation.cancel')}</Button>
|
||||
<Button onClick={onSave}
|
||||
className={`${s.opBtn} ${s.opSaveBtn}`}
|
||||
type='primary'
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
{/* show selected doc type and changing entry */}
|
||||
{!editStatus
|
||||
? <div className={s.documentTypeShow}>
|
||||
<TypeIcon iconName={metadataMap[doc_type || 'book']?.iconName || ''} className={s.iconShow} />
|
||||
{metadataMap[doc_type || 'book'].text}
|
||||
</div>
|
||||
: showDocTypes
|
||||
? null
|
||||
: <div className={s.documentTypeShow}>
|
||||
{metadataParams.documentType && <>
|
||||
<TypeIcon iconName={metadataMap[metadataParams.documentType || 'book'].iconName || ''} className={s.iconShow} />
|
||||
{metadataMap[metadataParams.documentType || 'book'].text}
|
||||
{editStatus && <div className='inline-flex items-center gap-1 ml-1'>
|
||||
·
|
||||
<div
|
||||
onClick={() => { setShowDocTypes(true) }}
|
||||
className='cursor-pointer hover:text-[#155EEF]'
|
||||
>
|
||||
{t('common.operation.change')}
|
||||
</div>
|
||||
</div>}
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
{(!doc_type && showDocTypes) ? null : <Divider />}
|
||||
{showDocTypes ? renderSelectDocType() : renderFieldInfos({ mainField: metadataParams.documentType, canEdit: editStatus })}
|
||||
{/* show fixed fields */}
|
||||
<Divider />
|
||||
{renderFieldInfos({ mainField: 'originInfo', canEdit: false })}
|
||||
<div className={`${s.title} mt-8`}>{metadataMap.technicalParameters.text}</div>
|
||||
<Divider />
|
||||
{renderFieldInfos({ mainField: 'technicalParameters', canEdit: false })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Metadata
|
||||
@@ -0,0 +1,114 @@
|
||||
.main {
|
||||
@apply w-96 xl:w-[360px] flex-shrink-0 px-6 py-5 overflow-y-auto border-l-gray-100 border-l;
|
||||
}
|
||||
.operationWrapper {
|
||||
@apply flex flex-col items-center gap-4 mt-7 mb-8;
|
||||
}
|
||||
.iconWrapper {
|
||||
@apply box-border cursor-pointer h-8 w-8 inline-flex items-center justify-center;
|
||||
@apply border-[#EAECF5] border rounded-lg hover:border-primary-200 hover:bg-primary-25 hover:shadow-[0px_4px_8px_-2px_rgba(16,24,40,0.1),0px_2px_4px_-2px_rgba(16,24,40,0.06)];
|
||||
}
|
||||
.icon {
|
||||
@apply h-4 w-4 stroke-current stroke-[2px] text-gray-700 group-hover:stroke-primary-600;
|
||||
}
|
||||
.iconCheck {
|
||||
@apply border-primary-400 border-[1.5px] bg-primary-25 shadow-[0px_1px_3px_rgba(16,24,40,0.1),0px_1px_2px_rgba(16,24,40,0.06)] !important;
|
||||
}
|
||||
.commonIcon {
|
||||
@apply w-4 h-4 inline-block align-middle bg-gray-700 hover:bg-primary-600;
|
||||
}
|
||||
.bookOpenIcon {
|
||||
mask-image: url(../../assets/bookOpen.svg);
|
||||
}
|
||||
.globeIcon {
|
||||
mask-image: url(../../assets/globe.svg);
|
||||
}
|
||||
.graduationHatIcon {
|
||||
mask-image: url(../../assets/graduationHat.svg);
|
||||
}
|
||||
.fileIcon {
|
||||
mask-image: url(../../assets/file.svg);
|
||||
}
|
||||
.briefcaseIcon {
|
||||
mask-image: url(../../assets/briefcase.svg);
|
||||
}
|
||||
.atSignIcon {
|
||||
mask-image: url(../../assets/atSign.svg);
|
||||
}
|
||||
.messageTextCircleIcon {
|
||||
mask-image: url(../../assets/messageTextCircle.svg);
|
||||
}
|
||||
.radioGroup {
|
||||
@apply !bg-transparent !gap-2;
|
||||
}
|
||||
.radio {
|
||||
@apply !p-0 !mr-0 hover:bg-transparent !rounded-lg;
|
||||
}
|
||||
.title {
|
||||
@apply text-sm text-gray-800 font-medium leading-6;
|
||||
}
|
||||
.titleWrapper {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
.desc {
|
||||
@apply text-gray-500 text-xs;
|
||||
}
|
||||
.fieldInfo {
|
||||
/* height: 1.75rem; */
|
||||
min-height: 1.75rem;
|
||||
@apply flex flex-row items-center gap-4;
|
||||
}
|
||||
.fieldInfo > .label {
|
||||
@apply w-2/5 max-w-[128px] text-gray-500 text-xs font-medium overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
.fieldInfo > .value {
|
||||
overflow-wrap: anywhere;
|
||||
@apply w-3/5 text-gray-700 font-normal text-xs;
|
||||
}
|
||||
.changeTip {
|
||||
@apply text-[#D92D20] text-xs text-center;
|
||||
}
|
||||
.opBtnWrapper {
|
||||
@apply flex items-center justify-center gap-1;
|
||||
}
|
||||
.opBtn {
|
||||
@apply h-6 w-14 px-0 text-xs font-medium rounded-md !important;
|
||||
}
|
||||
.opEditBtn {
|
||||
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
|
||||
@apply border-[0.5px] border-gray-200 bg-white !important;
|
||||
}
|
||||
.opCancelBtn {
|
||||
@apply border-none bg-gray-50 font-medium text-gray-700 hover:bg-gray-100 !important;
|
||||
}
|
||||
.opSaveBtn {
|
||||
@apply border-primary-700 border-[0.5px] font-medium hover:border-none !important;
|
||||
}
|
||||
.opIcon {
|
||||
@apply h-3 w-3 stroke-current stroke-2 mr-1;
|
||||
}
|
||||
.select {
|
||||
@apply h-7 py-0 pl-2 text-xs bg-gray-50 hover:bg-gray-100 rounded-md shadow-none !important;
|
||||
}
|
||||
.selectWrapper {
|
||||
@apply !h-7 w-full
|
||||
}
|
||||
.selectWrapper ul {
|
||||
@apply text-xs
|
||||
}
|
||||
.selectWrapper li {
|
||||
@apply flex items-center h-8
|
||||
}
|
||||
.documentTypeShow {
|
||||
@apply flex items-center text-xs text-gray-500;
|
||||
}
|
||||
.iconShow {
|
||||
mask-size: contain;
|
||||
@apply w-3 h-3 bg-gray-500 hover:bg-none mr-1 !important;
|
||||
}
|
||||
.textArea {
|
||||
@apply placeholder:text-gray-400 bg-gray-50 px-2 py-1 caret-primary-600 rounded-md hover:bg-gray-100 focus-visible:outline-none focus-visible:bg-white focus-visible:border focus-visible:border-gray-300 hover:shadow-[0_1px_2px_rgba(16,24,40,0.05);];
|
||||
}
|
||||
.input {
|
||||
@apply bg-gray-50 hover:bg-gray-100 focus-visible:bg-white !important
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.titleIcon {
|
||||
background-position-x: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 28px 28px;
|
||||
@apply h-6 w-6 !important;
|
||||
}
|
||||
.layoutRightIcon {
|
||||
@apply w-8 h-8 ml-2 box-border border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer hover:shadow-[0_1px_2px_rgba(16,24,40,0.05)];
|
||||
}
|
||||
.iconShow {
|
||||
background: center center url(../assets/layoutRightShow.svg) no-repeat;
|
||||
}
|
||||
.iconClose {
|
||||
background: center center url(../assets/layoutRightClose.svg) no-repeat;
|
||||
}
|
||||
Reference in New Issue
Block a user