Compare commits

...

4 Commits

Author SHA1 Message Date
9a4cd81891 Docs: Added token chunker and title chunker components (#10711)
### What problem does this PR solve?

### Type of change

- [x] Documentation Update
2025-10-21 20:11:23 +08:00
1694f32e8e Fix: Profile page UI adjustment #9869 (#10706)
### What problem does this PR solve?

Fix: Profile page UI adjustment

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
2025-10-21 20:11:07 +08:00
41fade3fe6 Fix:wrong param in manual chunk (#10710)
### What problem does this PR solve?

change:
wrong param in manual chunk

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
2025-10-21 20:10:54 +08:00
8d333f3590 Feat: Change the style of all cards according to the design #10703 (#10704)
### What problem does this PR solve?

Feat: Change the style of all cards according to the design #10703

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
2025-10-21 20:08:55 +08:00
24 changed files with 656 additions and 440 deletions

View File

@ -41,13 +41,13 @@ def vision_figure_parser_docx_wrapper(sections,tbls,callback=None,**kwargs):
except Exception: except Exception:
vision_model = None vision_model = None
if vision_model: if vision_model:
figures_data = vision_figure_parser_figure_data_wrapper(sections) figures_data = vision_figure_parser_figure_data_wrapper(sections)
try: try:
docx_vision_parser = VisionFigureParser(vision_model=vision_model, figures_data=figures_data, **kwargs) docx_vision_parser = VisionFigureParser(vision_model=vision_model, figures_data=figures_data, **kwargs)
boosted_figures = docx_vision_parser(callback=callback) boosted_figures = docx_vision_parser(callback=callback)
tbls.extend(boosted_figures) tbls.extend(boosted_figures)
except Exception as e: except Exception as e:
callback(0.8, f"Visual model error: {e}. Skipping figure parsing enhancement.") callback(0.8, f"Visual model error: {e}. Skipping figure parsing enhancement.")
return tbls return tbls
def vision_figure_parser_pdf_wrapper(tbls,callback=None,**kwargs): def vision_figure_parser_pdf_wrapper(tbls,callback=None,**kwargs):

View File

@ -0,0 +1,40 @@
---
sidebar_position: 31
slug: /chunker_title_component
---
# Title chunker component
A component that splits texts into chunks by heading level.
---
A **Token chunker** component is a text splitter that uses specified heading level as delimiter to define chunk boundaries and create chunks.
## Scenario
A **Title chunker** component is optional, usually placed immediately after **Parser**.
:::caution WARNING
Placing a **Title chunker** after a **Token chunker** is invalid and will cause an error. Please note that this restriction is not currently system-enforced and requires your attention.
:::
## Configurations
### Hierarchy
Specifies the heading level to define chunk boundaries:
- H1
- H2
- H3 (Default)
- H4
Click **+ Add** to add heading levels here or update the corresponding **Regular Expressions** fields for custom heading patterns.
### Output
The global variable name for the output of the **Title chunkder** component, which can be referenced by subsequent components in the ingestion pipeline.
- Default: `chunks`
- Type: `Array<Object>`

View File

@ -3,15 +3,41 @@ sidebar_position: 32
slug: /chunker_token_component slug: /chunker_token_component
--- ---
# Parser component # Token chunker component
A component that sets the parsing rules for your dataset. A component that splits texts into chunks, respecting a maximum token limit and using delimiters to find optimal breakpoints.
--- ---
A **Parser** component defines how various file types should be parsed, including parsing methods for PDFs , fields to parse for Emails, and OCR methods for images. A **Token chunker** component is a text splitter that creates chunks by respecting a recommended maximum token length, using delimiters to ensure logical chunk breakpoints. It splits long texts into appropriately-sized, semantically related chunks.
## Scenario ## Scenario
A **Parser** component is auto-populated on the ingestion pipeline canvas and required in all ingestion pipeline workflows. A **Token chunker** component is optional, usually placed immediately after **Parser** or **Title chunker**.
## Configurations
### Recommended chunk size
The recommended maximum token limit for each created chunk. The **Token chunker** component creates chunks at specified delimiters. If this token limit is reached before a delimiter, a chunk is created at that point.
### Overlapped percent (%)
This defines the overlap percentage between chunks. An appropriate degree of overlap ensures semantic coherence without creating excessive, redundant tokens for the LLM.
- Default: 0
- Maximum: 30%
### Delimiters
Defaults to `\n`. Click the right-hand **Recycle bin** button to remove it, or click **+ Add** to add a delimiter.
### Output
The global variable name for the output of the **Token chunkder** component, which can be referenced by subsequent components in the ingestion pipeline.
- Default: `chunks`
- Type: `Array<Object>`

View File

@ -262,7 +262,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
docx_parser = Docx() docx_parser = Docx()
ti_list, tbls = docx_parser(filename, binary, ti_list, tbls = docx_parser(filename, binary,
from_page=0, to_page=10000, callback=callback) from_page=0, to_page=10000, callback=callback)
tbls=vision_figure_parser_docx_wrapper(sections=sections,tbls=tbls,callback=callback,**kwargs) tbls=vision_figure_parser_docx_wrapper(sections=ti_list,tbls=tbls,callback=callback,**kwargs)
res = tokenize_table(tbls, doc, eng) res = tokenize_table(tbls, doc, eng)
for text, image in ti_list: for text, image in ti_list:
d = copy.deepcopy(doc) d = copy.deepcopy(doc)

View File

@ -1,5 +1,5 @@
import { transformFile2Base64 } from '@/utils/file-util'; import { transformFile2Base64 } from '@/utils/file-util';
import { Pencil, Upload, XIcon } from 'lucide-react'; import { Pencil, Plus, XIcon } from 'lucide-react';
import { import {
ChangeEventHandler, ChangeEventHandler,
forwardRef, forwardRef,
@ -12,10 +12,14 @@ import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
type AvatarUploadProps = { value?: string; onChange?: (value: string) => void }; type AvatarUploadProps = {
value?: string;
onChange?: (value: string) => void;
tips?: string;
};
export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>( export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
function AvatarUpload({ value, onChange }, ref) { function AvatarUpload({ value, onChange, tips }, ref) {
const { t } = useTranslation(); const { t } = useTranslation();
const [avatarBase64Str, setAvatarBase64Str] = useState(''); // Avatar Image base64 const [avatarBase64Str, setAvatarBase64Str] = useState(''); // Avatar Image base64
@ -47,9 +51,9 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
<div className="flex justify-start items-end space-x-2"> <div className="flex justify-start items-end space-x-2">
<div className="relative group"> <div className="relative group">
{!avatarBase64Str ? ( {!avatarBase64Str ? (
<div className="w-[64px] h-[64px] grid place-content-center border border-dashed rounded-md"> <div className="w-[64px] h-[64px] grid place-content-center border border-dashed bg-bg-input rounded-md">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<Upload /> <Plus />
<p>{t('common.upload')}</p> <p>{t('common.upload')}</p>
</div> </div>
</div> </div>
@ -86,8 +90,8 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
ref={ref} ref={ref}
/> />
</div> </div>
<div className="margin-1 text-muted-foreground"> <div className="margin-1 text-text-secondary">
{t('knowledgeConfiguration.photoTip')} {tips ?? t('knowledgeConfiguration.photoTip')}
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,17 @@
import { cn } from '@/lib/utils';
import { PropsWithChildren } from 'react';
type CardContainerProps = { className?: string } & PropsWithChildren;
export function CardContainer({ children, className }: CardContainerProps) {
return (
<section
className={cn(
'grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
className,
)}
>
{children}
</section>
);
}

View File

@ -24,7 +24,6 @@ export function HomeCard({
}: IProps) { }: IProps) {
return ( return (
<Card <Card
className="bg-bg-card border-colors-outline-neutral-standard"
onClick={() => { onClick={() => {
// navigateToSearch(data?.id); // navigateToSearch(data?.id);
onClick?.(); onClick?.();

View File

@ -8,7 +8,10 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn('rounded-lg bg-bg-card shadow-sm', className)} className={cn(
'rounded-lg border-border-default border shadow-sm bg-bg-input',
className,
)}
{...props} {...props}
/> />
)); ));

View File

@ -106,8 +106,10 @@ const FormLabel = React.forwardRef<
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
> >
{required && <span className="text-destructive">*</span>} <section>
{props.children} {required && <span className="text-destructive">*</span>}
{props.children}
</section>
{tooltip && <FormTooltip tooltip={tooltip}></FormTooltip>} {tooltip && <FormTooltip tooltip={tooltip}></FormTooltip>}
</Label> </Label>
); );

View File

@ -140,6 +140,7 @@ const Modal: ModalType = ({
</div> </div>
); );
}, [ }, [
disabled,
footer, footer,
cancelText, cancelText,
t, t,
@ -158,7 +159,7 @@ const Modal: ModalType = ({
onClick={() => maskClosable && onOpenChange?.(false)} onClick={() => maskClosable && onOpenChange?.(false)}
> >
<DialogPrimitive.Content <DialogPrimitive.Content
className={`relative w-[700px] ${full ? 'max-w-full' : sizeClasses[size]} ${className} bg-colors-background-neutral-standard rounded-lg shadow-lg border transition-all focus-visible:!outline-none`} className={`relative w-[700px] ${full ? 'max-w-full' : sizeClasses[size]} ${className} bg-bg-base rounded-lg shadow-lg border border-border-default transition-all focus-visible:!outline-none`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* title */} {/* title */}

View File

@ -83,12 +83,19 @@ export interface IFlowTemplate {
canvas_type: string; canvas_type: string;
create_date: string; create_date: string;
create_time: number; create_time: number;
description: string; canvas_category?: string;
dsl: DSL; dsl: DSL;
id: string; id: string;
title: string;
update_date: string; update_date: string;
update_time: number; update_time: number;
description: {
en: string;
zh: string;
};
title: {
en: string;
zh: string;
};
} }
export interface IGenerateForm { export interface IGenerateForm {

View File

@ -137,7 +137,7 @@ export default {
completed: 'Completed', completed: 'Completed',
datasetLog: 'Dataset Log', datasetLog: 'Dataset Log',
created: 'Created', created: 'Created',
learnMore: 'Learn More', learnMore: 'Built-in pipeline introduction',
general: 'General', general: 'General',
chunkMethodTab: 'Chunk Method', chunkMethodTab: 'Chunk Method',
testResults: 'Test Results', testResults: 'Test Results',
@ -697,7 +697,7 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
system: 'System', system: 'System',
logout: 'Log out', logout: 'Log out',
api: 'API', api: 'API',
username: 'Username', username: 'Name',
usernameMessage: 'Please input your username!', usernameMessage: 'Please input your username!',
photo: 'Your photo', photo: 'Your photo',
photoDescription: 'This will be displayed on your profile.', photoDescription: 'This will be displayed on your profile.',

View File

@ -125,7 +125,7 @@ export default {
completed: '已完成', completed: '已完成',
datasetLog: '知识库日志', datasetLog: '知识库日志',
created: '创建于', created: '创建于',
learnMore: '了解更多', learnMore: '内置pipeline简介',
general: '通用', general: '通用',
chunkMethodTab: '切片方法', chunkMethodTab: '切片方法',
testResults: '测试结果', testResults: '测试结果',

View File

@ -10,9 +10,10 @@ import {
import { useSetModalState } from '@/hooks/common-hooks'; import { useSetModalState } from '@/hooks/common-hooks';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks'; import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { useFetchAgentTemplates, useSetAgent } from '@/hooks/use-agent-request'; import { useFetchAgentTemplates, useSetAgent } from '@/hooks/use-agent-request';
import { IFlowTemplate } from '@/interfaces/database/flow';
import { CardContainer } from '@/components/card-container';
import { AgentCategory } from '@/constants/agent'; import { AgentCategory } from '@/constants/agent';
import { IFlowTemplate } from '@/interfaces/database/agent';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CreateAgentDialog } from './create-agent-dialog'; import { CreateAgentDialog } from './create-agent-dialog';
@ -121,7 +122,7 @@ export default function AgentTemplates() {
></SideBar> ></SideBar>
<main className="flex-1 bg-text-title-invert/50 h-dvh"> <main className="flex-1 bg-text-title-invert/50 h-dvh">
<div className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 max-h-[94vh] overflow-auto px-8 pt-8"> <CardContainer className="max-h-[94vh] overflow-auto px-8 pt-8">
{tempListFilter?.map((x) => { {tempListFilter?.map((x) => {
return ( return (
<TemplateCard <TemplateCard
@ -131,14 +132,13 @@ export default function AgentTemplates() {
></TemplateCard> ></TemplateCard>
); );
})} })}
</div> </CardContainer>
{creatingVisible && ( {creatingVisible && (
<CreateAgentDialog <CreateAgentDialog
loading={loading} loading={loading}
visible={creatingVisible} visible={creatingVisible}
hideModal={hideCreatingModal} hideModal={hideCreatingModal}
onOk={handleOk} onOk={handleOk}
canvasCategory={template?.canvas_category}
></CreateAgentDialog> ></CreateAgentDialog>
)} )}
</main> </main>

View File

@ -1,3 +1,4 @@
import { CardContainer } from '@/components/card-container';
import ListFilterBar from '@/components/list-filter-bar'; import ListFilterBar from '@/components/list-filter-bar';
import { RenameDialog } from '@/components/rename-dialog'; import { RenameDialog } from '@/components/rename-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -113,7 +114,7 @@ export default function Agents() {
</ListFilterBar> </ListFilterBar>
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<div className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 max-h-[calc(100dvh-280px)] overflow-auto px-8"> <CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{data.map((x) => { {data.map((x) => {
return ( return (
<AgentCard <AgentCard
@ -123,7 +124,7 @@ export default function Agents() {
></AgentCard> ></AgentCard>
); );
})} })}
</div> </CardContainer>
</div> </div>
<div className="mt-8 px-8 pb-8"> <div className="mt-8 px-8 pb-8">
<RAGFlowPagination <RAGFlowPagination

View File

@ -1,7 +1,7 @@
import { RAGFlowAvatar } from '@/components/ragflow-avatar'; import { RAGFlowAvatar } from '@/components/ragflow-avatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { IFlowTemplate } from '@/interfaces/database/flow'; import { IFlowTemplate } from '@/interfaces/database/agent';
import i18n from '@/locales/config'; import i18n from '@/locales/config';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';

View File

@ -1,3 +1,4 @@
import { CardContainer } from '@/components/card-container';
import ListFilterBar from '@/components/list-filter-bar'; import ListFilterBar from '@/components/list-filter-bar';
import { RenameDialog } from '@/components/rename-dialog'; import { RenameDialog } from '@/components/rename-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -70,7 +71,7 @@ export default function Datasets() {
</Button> </Button>
</ListFilterBar> </ListFilterBar>
<div className="flex-1"> <div className="flex-1">
<div className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 max-h-[calc(100dvh-280px)] overflow-auto px-8"> <CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{kbs.map((dataset) => { {kbs.map((dataset) => {
return ( return (
<DatasetCard <DatasetCard
@ -80,7 +81,7 @@ export default function Datasets() {
></DatasetCard> ></DatasetCard>
); );
})} })}
</div> </CardContainer>
</div> </div>
<div className="mt-8 px-8"> <div className="mt-8 px-8">
<RAGFlowPagination <RAGFlowPagination

View File

@ -1,3 +1,4 @@
import { CardContainer } from '@/components/card-container';
import ListFilterBar from '@/components/list-filter-bar'; import ListFilterBar from '@/components/list-filter-bar';
import { RenameDialog } from '@/components/rename-dialog'; import { RenameDialog } from '@/components/rename-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -50,7 +51,7 @@ export default function ChatList() {
</ListFilterBar> </ListFilterBar>
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<div className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 max-h-[calc(100dvh-280px)] overflow-auto px-8"> <CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{data.dialogs.map((x) => { {data.dialogs.map((x) => {
return ( return (
<ChatCard <ChatCard
@ -60,7 +61,7 @@ export default function ChatList() {
></ChatCard> ></ChatCard>
); );
})} })}
</div> </CardContainer>
</div> </div>
<div className="mt-8 px-8 pb-8"> <div className="mt-8 px-8 pb-8">
<RAGFlowPagination <RAGFlowPagination

View File

@ -1,3 +1,4 @@
import { CardContainer } from '@/components/card-container';
import { IconFont } from '@/components/icon-font'; import { IconFont } from '@/components/icon-font';
import ListFilterBar from '@/components/list-filter-bar'; import ListFilterBar from '@/components/list-filter-bar';
import { RenameDialog } from '@/components/rename-dialog'; import { RenameDialog } from '@/components/rename-dialog';
@ -64,7 +65,7 @@ export default function SearchList() {
</ListFilterBar> </ListFilterBar>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 max-h-[calc(100dvh-280px)] overflow-auto px-8"> <CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{list?.data.search_apps.map((x) => { {list?.data.search_apps.map((x) => {
return ( return (
<SearchCard <SearchCard
@ -76,7 +77,7 @@ export default function SearchList() {
></SearchCard> ></SearchCard>
); );
})} })}
</div> </CardContainer>
</div> </div>
{list?.data.total && list?.data.total > 0 && ( {list?.data.total && list?.data.total > 0 && (
<div className="px-8 mb-4"> <div className="px-8 mb-4">

View File

@ -1,4 +1,5 @@
import { BulkOperateBar } from '@/components/bulk-operate-bar'; import { BulkOperateBar } from '@/components/bulk-operate-bar';
import { CardContainer } from '@/components/card-container';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { SearchInput } from '@/components/ui/input'; import { SearchInput } from '@/components/ui/input';
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination'; import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
@ -60,7 +61,7 @@ export default function McpServer() {
className="mb-2.5" className="mb-2.5"
></BulkOperateBar> ></BulkOperateBar>
)} )}
<section className="flex gap-5 flex-wrap"> <CardContainer>
{data.mcp_servers.map((item) => ( {data.mcp_servers.map((item) => (
<McpCard <McpCard
key={item.id} key={item.id}
@ -70,8 +71,8 @@ export default function McpServer() {
showEditModal={showEditModal} showEditModal={showEditModal}
></McpCard> ></McpCard>
))} ))}
</section> </CardContainer>
<div className="mt-8 px-8"> <div className="mt-8">
<RAGFlowPagination <RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')} {...pick(pagination, 'current', 'pageSize')}
total={pagination.total || 0} total={pagination.total || 0}

View File

@ -33,7 +33,7 @@ export function McpCard({
} }
}; };
return ( return (
<Card key={data.id} className="w-64"> <Card key={data.id}>
<CardContent className="p-2.5 pt-2 group"> <CardContent className="p-2.5 pt-2 group">
<section className="flex justify-between pb-2"> <section className="flex justify-between pb-2">
<h3 className="text-lg font-semibold truncate flex-1">{data.name}</h3> <h3 className="text-lg font-semibold truncate flex-1">{data.name}</h3>

View File

@ -0,0 +1,151 @@
// src/hooks/useProfile.ts
import {
useFetchUserInfo,
useSaveSetting,
} from '@/hooks/use-user-setting-request';
import { rsaPsw } from '@/utils';
import { useCallback, useEffect, useState } from 'react';
interface ProfileData {
userName: string;
timeZone: string;
currPasswd?: string;
newPasswd?: string;
avatar: string;
email: string;
confirmPasswd?: string;
}
export const EditType = {
editName: 'editName',
editTimeZone: 'editTimeZone',
editPassword: 'editPassword',
} as const;
export type IEditType = keyof typeof EditType;
export const modalTitle = {
[EditType.editName]: 'Edit Name',
[EditType.editTimeZone]: 'Edit Time Zone',
[EditType.editPassword]: 'Edit Password',
} as const;
export const useProfile = () => {
const { data: userInfo } = useFetchUserInfo();
const [profile, setProfile] = useState<ProfileData>({
userName: '',
avatar: '',
timeZone: '',
email: '',
currPasswd: '',
});
const [editType, setEditType] = useState<IEditType>(EditType.editName);
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState<Partial<ProfileData>>({});
const {
saveSetting,
loading: submitLoading,
data: saveSettingData,
} = useSaveSetting();
useEffect(() => {
// form.setValue('currPasswd', ''); // current password
const profile = {
userName: userInfo.nickname,
timeZone: userInfo.timezone,
avatar: userInfo.avatar || '',
email: userInfo.email,
currPasswd: userInfo.password,
};
setProfile(profile);
}, [userInfo, setProfile]);
useEffect(() => {
if (saveSettingData === 0) {
setIsEditing(false);
setEditForm({});
}
}, [saveSettingData]);
const onSubmit = (newProfile: ProfileData) => {
const payload: Partial<{
nickname: string;
password: string;
new_password: string;
avatar: string;
timezone: string;
}> = {
nickname: newProfile.userName,
avatar: newProfile.avatar,
timezone: newProfile.timeZone,
};
if (
'currPasswd' in newProfile &&
'newPasswd' in newProfile &&
newProfile.currPasswd &&
newProfile.newPasswd
) {
payload.password = rsaPsw(newProfile.currPasswd!) as string;
payload.new_password = rsaPsw(newProfile.newPasswd!) as string;
}
console.log('payload', payload);
if (editType === EditType.editName && payload.nickname) {
saveSetting({ nickname: payload.nickname });
setProfile(newProfile);
}
if (editType === EditType.editTimeZone && payload.timezone) {
saveSetting({ timezone: payload.timezone });
setProfile(newProfile);
}
if (editType === EditType.editPassword && payload.password) {
saveSetting({
password: payload.password,
new_password: payload.new_password,
});
setProfile(newProfile);
}
// saveSetting(payload);
};
const handleEditClick = useCallback(
(type: IEditType) => {
setEditForm(profile);
setEditType(type);
setIsEditing(true);
},
[profile],
);
const handleCancel = useCallback(() => {
setIsEditing(false);
setEditForm({});
}, []);
const handleSave = (data: ProfileData) => {
console.log('handleSave', data);
const newProfile = { ...profile, ...data };
onSubmit(newProfile);
// setIsEditing(false);
// setEditForm({});
};
const handleAvatarUpload = (avatar: string) => {
setProfile((prev) => ({ ...prev, avatar }));
saveSetting({ avatar });
};
return {
profile,
setProfile,
submitLoading: submitLoading,
isEditing,
editType,
editForm,
handleEditClick,
handleCancel,
handleSave,
handleAvatarUpload,
};
};

View File

@ -1,5 +1,6 @@
// src/components/ProfilePage.tsx
import { AvatarUpload } from '@/components/avatar-upload';
import PasswordInput from '@/components/originui/password-input'; import PasswordInput from '@/components/originui/password-input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Form, Form,
@ -10,6 +11,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Modal } from '@/components/ui/modal/modal';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -18,434 +20,393 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useTranslate } from '@/hooks/common-hooks'; import { useTranslate } from '@/hooks/common-hooks';
import { useFetchUserInfo, useSaveSetting } from '@/hooks/user-setting-hooks';
import { TimezoneList } from '@/pages/user-setting/constants'; import { TimezoneList } from '@/pages/user-setting/constants';
import { rsaPsw } from '@/utils';
import { transformFile2Base64 } from '@/utils/file-util';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { TFunction } from 'i18next'; import { t } from 'i18next';
import { Loader2Icon, Pencil, Upload } from 'lucide-react'; import { Loader2Icon, PenLine } from 'lucide-react';
import { useEffect, useState } from 'react'; import { FC, useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
function defineSchema( import { EditType, modalTitle, useProfile } from './hooks/use-profile';
t: TFunction<'translation', string>,
showPasswordForm = false,
) {
const baseSchema = z.object({
userName: z
.string()
.min(1, { message: t('usernameMessage') })
.trim(),
avatarUrl: z.string().trim(),
timeZone: z
.string()
.trim()
.min(1, { message: t('timezonePlaceholder') }),
email: z
.string({ required_error: 'Please select an email to display.' })
.trim()
.regex(/^[A-Za-z0-9\u4e00-\u9fa5]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/, {
message: 'Enter a valid email address.',
}),
});
if (showPasswordForm) { const baseSchema = z.object({
return baseSchema userName: z
.extend({ .string()
currPasswd: z .min(1, { message: t('setting.usernameMessage') })
.string({ .trim(),
required_error: t('currentPasswordMessage'), timeZone: z
}) .string()
.trim() .trim()
.min(1, { message: t('currentPasswordMessage') }), .min(1, { message: t('setting.timezonePlaceholder') }),
newPasswd: z });
.string({
required_error: t('confirmPasswordMessage'), const nameSchema = baseSchema.extend({
}) currPasswd: z.string().optional(),
.trim() newPasswd: z.string().optional(),
.min(8, { message: t('confirmPasswordMessage') }), confirmPasswd: z.string().optional(),
confirmPasswd: z });
.string({
required_error: t('newPasswordDescription'), const passwordSchema = baseSchema
}) .extend({
.trim() currPasswd: z
.min(8, { message: t('newPasswordDescription') }), .string({
required_error: t('setting.currentPasswordMessage'),
}) })
.refine((data) => data.newPasswd === data.confirmPasswd, { .trim(),
message: t('confirmPasswordNonMatchMessage'), newPasswd: z
.string({
required_error: t('setting.newPasswordMessage'),
})
.trim()
.min(8, { message: t('setting.newPasswordDescription') }),
confirmPasswd: z
.string({
required_error: t('setting.confirmPasswordMessage'),
})
.trim()
.min(8, { message: t('setting.newPasswordDescription') }),
})
.superRefine((data, ctx) => {
if (
data.newPasswd &&
data.confirmPasswd &&
data.newPasswd !== data.confirmPasswd
) {
ctx.addIssue({
path: ['confirmPasswd'], path: ['confirmPasswd'],
message: t('setting.confirmPasswordNonMatchMessage'),
code: z.ZodIssueCode.custom,
}); });
} }
});
return baseSchema; const ProfilePage: FC = () => {
}
export default function Profile() {
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarBase64Str, setAvatarBase64Str] = useState(''); // Avatar Image base64
const { data: userInfo } = useFetchUserInfo();
const {
saveSetting,
loading: submitLoading,
data: saveUserData,
} = useSaveSetting();
const { t } = useTranslate('setting'); const { t } = useTranslate('setting');
const [showPasswordForm, setShowPasswordForm] = useState(false);
const FormSchema = defineSchema(t, showPasswordForm); const {
const form = useForm<z.infer<typeof FormSchema>>({ profile,
resolver: zodResolver(FormSchema), editType,
isEditing,
submitLoading,
editForm,
handleEditClick,
handleCancel,
handleSave,
handleAvatarUpload,
} = useProfile();
const form = useForm<z.infer<typeof baseSchema | typeof passwordSchema>>({
resolver: zodResolver(
editType === EditType.editPassword ? passwordSchema : nameSchema,
),
defaultValues: { defaultValues: {
userName: '', userName: '',
avatarUrl: '',
timeZone: '', timeZone: '',
email: '',
// currPasswd: '',
// newPasswd: '',
// confirmPasswd: '',
}, },
shouldUnregister: true, // shouldUnregister: true,
}); });
useEffect(() => { useEffect(() => {
// init user info when mounted form.reset({ ...editForm, currPasswd: undefined });
form.setValue('email', userInfo?.email); // email }, [editForm, form]);
form.setValue('userName', userInfo?.nickname); // nickname
form.setValue('timeZone', userInfo?.timezone); // time zone
// form.setValue('currPasswd', ''); // current password
setAvatarBase64Str(userInfo?.avatar ?? '');
}, [userInfo]);
useEffect(() => { // const ModalContent: FC = () => {
if (saveUserData === 0) { // // let content = null;
setShowPasswordForm(false); // // if (editType === EditType.editName) {
form.resetField('currPasswd'); // // content = editName();
form.resetField('newPasswd'); // // }
form.resetField('confirmPasswd'); // return (
} // <>
console.log('saveUserData', saveUserData);
}, [saveUserData]);
useEffect(() => { // </>
if (avatarFile) { // );
// make use of img compression transformFile2Base64 // };
(async () => {
setAvatarBase64Str(await transformFile2Base64(avatarFile));
})();
}
}, [avatarFile]);
function onSubmit(data: z.infer<typeof FormSchema>) {
const payload: Partial<{
nickname: string;
password: string;
new_password: string;
avatar: string;
timezone: string;
}> = {
nickname: data.userName,
avatar: avatarBase64Str,
timezone: data.timeZone,
};
if (showPasswordForm && 'currPasswd' in data && 'newPasswd' in data) {
payload.password = rsaPsw(data.currPasswd!) as string;
payload.new_password = rsaPsw(data.newPasswd!) as string;
}
saveSetting(payload);
}
useEffect(() => {
if (showPasswordForm) {
form.register('currPasswd');
form.register('newPasswd');
form.register('confirmPasswd');
} else {
form.unregister(['currPasswd', 'newPasswd', 'confirmPasswd']);
}
}, [showPasswordForm]);
return ( return (
<section className="p-8"> <div className="min-h-screen bg-bg-base text-text-secondary p-5">
<h1 className="text-3xl font-bold">{t('profile')}</h1> {/* Header */}
<div className="text-sm text-muted-foreground mb-6"> <header className="flex flex-col gap-1 justify-between items-start mb-6">
{t('profileDescription')} <h1 className="text-2xl font-bold text-text-primary">{t('profile')}</h1>
</div> <div className="text-sm text-text-secondary mb-6">
<div> {t('profileDescription')}
<Form {...form}> </div>
<form </header>
onSubmit={form.handleSubmit(onSubmit)}
className="block space-y-6"
>
{/* Username Field */}
<FormField
control={form.control}
name="userName"
render={({ field }) => (
<FormItem className=" items-center space-y-0 ">
<div className="flex w-[640px]">
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4">
<span className="text-red-600">*</span>
{t('username')}
</FormLabel>
<FormControl className="w-3/4">
<Input placeholder="" {...field} />
</FormControl>
</div>
<div className="flex w-[640px] pt-1">
<div className="w-1/4"></div>
<FormMessage />
</div>
</FormItem>
)}
/>
{/* Avatar Field */} {/* Main Content */}
<FormField <div className="max-w-3xl space-y-11 w-3/4">
control={form.control} {/* Name */}
name="avatarUrl" <div className="flex items-start gap-4">
render={({ field }) => ( <label className="w-[190px] text-sm font-medium">
<FormItem className="flex items-center space-y-0"> {t('username')}
<div className="flex w-[640px]"> </label>
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4"> <div className="flex-1 flex items-center gap-4 min-w-60">
Avatar <div className="text-sm text-text-primary border border-border-button flex-1 rounded-md py-1.5 px-2">
</FormLabel> {profile.userName}
<FormControl className="w-3/4"> </div>
<div className="flex justify-start items-end space-x-2"> <Button
<div className="relative group"> variant={'secondary'}
{!avatarBase64Str ? ( type="button"
<div className="w-[64px] h-[64px] grid place-content-center"> onClick={() => handleEditClick(EditType.editName)}
<div className="flex flex-col items-center"> className="text-sm text-text-secondary flex gap-1 px-1"
<Upload /> >
<p>Upload</p> <PenLine size={12} /> Edit
</div> </Button>
</div> </div>
) : ( </div>
<div className="w-[64px] h-[64px] relative grid place-content-center">
<Avatar className="w-[64px] h-[64px] rounded-md"> {/* Avatar */}
<AvatarImage <div className="flex items-start gap-4">
className="block" <label className="w-[190px] text-sm font-medium">{t('avatar')}</label>
src={avatarBase64Str} <div className="flex items-center gap-4">
alt="" <AvatarUpload
/> value={profile.avatar}
<AvatarFallback className="rounded-md"></AvatarFallback> onChange={handleAvatarUpload}
</Avatar> tips={'This will be displayed on your profile.'}
<div className="absolute inset-0 bg-[#000]/20 group-hover:bg-[#000]/60"> />
<Pencil </div>
size={16} </div>
className="absolute right-1 bottom-1 opacity-50 hidden group-hover:block"
/> {/* Time Zone */}
</div> <div className="flex items-start gap-4">
</div> <label className="w-[190px] text-sm font-medium">
)} {t('timezone')}
</label>
<div className="flex-1 flex items-center gap-4">
<div className="text-sm text-text-primary border border-border-button flex-1 rounded-md py-1.5 px-2">
{profile.timeZone}
</div>
<Button
variant={'secondary'}
type="button"
onClick={() => handleEditClick(EditType.editTimeZone)}
className="text-sm text-text-secondary flex gap-1 px-1"
>
<PenLine size={12} /> Edit
</Button>
</div>
</div>
{/* Email Address */}
<div className="flex items-start gap-4">
<label className="w-[190px] text-sm font-medium"> {t('email')}</label>
<div className="flex-1 flex flex-col items-start gap-2">
<div className="text-sm text-text-primary flex-1 rounded-md py-1.5 ">
{profile.email}
</div>
<span className="text-text-secondary text-xs">
{t('emailDescription')}
</span>
</div>
</div>
{/* Password */}
<div className="flex items-start gap-4">
<label className="w-[190px] text-sm font-medium">
{t('password')}
</label>
<div className="flex-1 flex items-center gap-4">
<div className="text-sm text-text-primary border border-border-button flex-1 rounded-md py-1.5 px-2">
{profile.currPasswd ? '********' : ''}
</div>
<Button
variant={'secondary'}
type="button"
onClick={() => handleEditClick(EditType.editPassword)}
className="text-sm text-text-secondary flex gap-1 px-1"
>
<PenLine size={12} /> Edit
</Button>
</div>
</div>
</div>
{editType && (
<Modal
title={modalTitle[editType]}
open={isEditing}
showfooter={false}
onOpenChange={(open) => {
if (!open) {
handleCancel();
}
}}
className="!w-[480px]"
>
{/* <ModalContent /> */}
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => handleSave(data as any))}
className="flex flex-col mt-6 mb-8 ml-2 space-y-6 "
>
{editType === EditType.editName && (
<FormField
control={form.control}
name="userName"
render={({ field }) => (
<FormItem className=" items-center space-y-0 ">
<div className="flex flex-col w-full gap-2">
<FormLabel className="text-sm text-text-secondary whitespace-nowrap">
{t('username')}
</FormLabel>
<FormControl className="w-full">
<Input <Input
placeholder="" placeholder=""
{...field} {...field}
type="file" className="bg-bg-input border-border-default"
title=""
accept="image/*"
className="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
onChange={(ev) => {
const file = ev.target?.files?.[0];
if (
/\.(jpg|jpeg|png|webp|bmp)$/i.test(
file?.name ?? '',
)
) {
setAvatarFile(file!);
}
ev.target.value = '';
}}
/> />
</div> </FormControl>
<div className="margin-1 text-muted-foreground">
{t('avatarTip')}
</div>
</div> </div>
</FormControl> <div className="flex w-full pt-1">
</div> <div className="w-1/4"></div>
<div className="flex w-[640px] pt-1"> <FormMessage />
<div className="w-1/4"></div> </div>
<FormMessage /> </FormItem>
</div> )}
</FormItem> />
)} )}
/>
{/* Time Zone Field */} {editType === EditType.editTimeZone && (
<FormField
control={form.control}
name="timeZone"
render={({ field }) => (
<FormItem className="items-center space-y-0">
<div className="flex w-[640px]">
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4">
<span className="text-red-600">*</span>
{t('timezone')}
</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl className="w-3/4">
<SelectTrigger>
<SelectValue placeholder="Select a timeZone" />
</SelectTrigger>
</FormControl>
<SelectContent>
{TimezoneList.map((timeStr) => (
<SelectItem key={timeStr} value={timeStr}>
{timeStr}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-[640px] pt-1">
<div className="w-1/4"></div>
<FormMessage />
</div>
</FormItem>
)}
/>
{/* Email Address Field */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<div>
<FormItem className="items-center space-y-0">
<div className="flex w-[640px]">
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-1/4">
{t('email')}
</FormLabel>
<FormControl className="w-3/4">
<>{field.value}</>
</FormControl>
</div>
<div className="flex w-[640px] pt-1">
<div className="w-1/4"></div>
<FormMessage />
</div>
</FormItem>
<div className="flex w-[640px] pt-1">
<p className="w-1/4">&nbsp;</p>
<p className="text-sm text-muted-foreground whitespace-nowrap w-3/4">
{t('emailDescription')}
</p>
</div>
</div>
)}
/>
{/* Password Section */}
<div className="pb-6">
<div className="flex items-center justify-start">
<h1 className="text-3xl font-bold">{t('password')}</h1>
<Button
type="button"
className="bg-transparent hover:bg-transparent border text-muted-foreground hover:text-white ml-10"
onClick={() => {
setShowPasswordForm(!showPasswordForm);
}}
>
{t('changePassword')}
</Button>
</div>
<div className="text-sm text-muted-foreground">
{t('passwordDescription')}
</div>
</div>
{/* Password Form */}
{showPasswordForm && (
<>
<FormField <FormField
control={form.control} control={form.control}
name="currPasswd" name="timeZone"
render={({ field }) => ( render={({ field }) => (
<FormItem className="items-center space-y-0"> <FormItem className="items-center space-y-0">
<div className="flex w-[640px]"> <div className="flex flex-col w-full gap-2">
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5"> <FormLabel className="text-sm text-text-secondary whitespace-nowrap">
<span className="text-red-600">*</span> {t('timezone')}
{t('currentPassword')}
</FormLabel> </FormLabel>
<FormControl className="w-3/5"> <Select
<PasswordInput {...field} /> onValueChange={field.onChange}
</FormControl> value={field.value}
>
<FormControl className="w-full bg-bg-input border-border-default">
<SelectTrigger>
<SelectValue placeholder="Select a timeZone" />
</SelectTrigger>
</FormControl>
<SelectContent>
{TimezoneList.map((timeStr) => (
<SelectItem key={timeStr} value={timeStr}>
{timeStr}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="flex w-[640px] pt-1"> <div className="flex w-full pt-1">
<div className="min-w-[170px] max-w-[170px]"></div> <div className="w-1/4"></div>
<FormMessage /> <FormMessage />
</div> </div>
</FormItem> </FormItem>
)} )}
/> />
<FormField )}
control={form.control}
name="newPasswd" {editType === EditType.editPassword && (
render={({ field }) => ( <>
<FormItem className=" items-center space-y-0"> <FormField
<div className="flex w-[640px]"> control={form.control}
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5"> name="currPasswd"
<span className="text-red-600">*</span> render={({ field }) => (
{t('newPassword')} <FormItem className="items-center space-y-0">
</FormLabel> <div className="flex flex-col w-full gap-2">
<FormControl className="w-3/5"> <FormLabel
<PasswordInput {...field} /> required
</FormControl> className="text-sm flex justify-between text-text-secondary whitespace-nowrap"
</div> >
<div className="flex w-[640px] pt-1"> {t('currentPassword')}
<div className="min-w-[170px] max-w-[170px]"></div> </FormLabel>
<FormMessage /> <FormControl className="w-full">
</div> <PasswordInput
</FormItem> {...field}
)} autoComplete="current-password"
/> className="bg-bg-input border-border-default"
<FormField />
control={form.control} </FormControl>
name="confirmPasswd"
render={({ field }) => (
<FormItem className=" items-center space-y-0">
<div className="flex w-[640px]">
<FormLabel className="text-sm text-muted-foreground whitespace-nowrap w-2/5">
<span className="text-red-600">*</span>
{t('confirmPassword')}
</FormLabel>
<FormControl className="w-3/5">
<PasswordInput
{...field}
onBlur={() => {
form.trigger('confirmPasswd');
}}
onChange={(ev) => {
form.setValue(
'confirmPasswd',
ev.target.value.trim(),
);
}}
/>
</FormControl>
</div>
<div className="flex w-[640px] pt-1">
<div className="min-w-[170px] max-w-[170px]">
&nbsp;
</div> </div>
<FormMessage /> <div className="flex w-full pt-1">
</div> <FormMessage />
</FormItem> </div>
)} </FormItem>
/> )}
</> />
)} <FormField
<div className="w-[640px] text-right space-x-4"> control={form.control}
<Button type="reset" variant="secondary"> name="newPasswd"
{t('cancel')} render={({ field }) => (
</Button> <FormItem className=" items-center space-y-0">
<Button type="submit" disabled={submitLoading}> <div className="flex flex-col w-full gap-2">
{submitLoading && <Loader2Icon className="animate-spin" />} <FormLabel
{t('save', { keyPrefix: 'common' })} required
</Button> className="text-sm text-text-secondary whitespace-nowrap"
</div> >
</form> {t('newPassword')}
</Form> </FormLabel>
</div> <FormControl className="w-full">
</section> <PasswordInput
{...field}
autoComplete="new-password"
className="bg-bg-input border-border-default"
/>
</FormControl>
</div>
<div className="flex w-full pt-1">
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPasswd"
render={({ field }) => (
<FormItem className=" items-center space-y-0">
<div className="flex flex-col w-full gap-2">
<FormLabel
required
className="text-sm text-text-secondary whitespace-nowrap"
>
{t('confirmPassword')}
</FormLabel>
<FormControl className="w-full">
<PasswordInput
{...field}
className="bg-bg-input border-border-default"
autoComplete="new-password"
onBlur={() => {
form.trigger('confirmPasswd');
}}
onChange={(ev) => {
form.setValue(
'confirmPasswd',
ev.target.value.trim(),
);
}}
/>
</FormControl>
</div>
<div className="flex w-full pt-1">
<FormMessage />
</div>
</FormItem>
)}
/>
</>
)}
<div className="w-full text-right space-x-4 !mt-11">
<Button type="reset" variant="secondary">
{t('cancel')}
</Button>
<Button type="submit" disabled={submitLoading}>
{submitLoading && <Loader2Icon className="animate-spin" />}
{t('save', { keyPrefix: 'common' })}
</Button>
</div>
</form>
</Form>
</Modal>
)}
</div>
); );
} };
export default ProfilePage;

View File

@ -14,7 +14,7 @@ module.exports = {
center: true, center: true,
padding: '2rem', padding: '2rem',
screens: { screens: {
'2xl': '1400px', '2xl': '1536px',
}, },
}, },
screens: { screens: {
@ -22,7 +22,7 @@ module.exports = {
md: '768px', md: '768px',
lg: '1024px', lg: '1024px',
xl: '1280px', xl: '1280px',
'2xl': '1400px', '2xl': '1536px',
'3xl': '1780px', '3xl': '1780px',
'4xl': '1980px', '4xl': '1980px',
}, },