Compare commits

..

23 Commits

Author SHA1 Message Date
coltea bec070786d feat: node release user info 2025-11-05 10:30:47 +08:00
coltea 0aeda02985 feat widget setting 2025-11-05 10:30:47 +08:00
xiaomakuaiz d630567a3c
Merge pull request #1463 from guanweiwang/pref/landing
pref: 优化主题
2025-11-04 20:03:09 +08:00
Gavan 20a0a4ded4 style: 样式问题 2025-11-04 19:56:44 +08:00
Gavan 38383b983d pref: 美化样式 2025-11-04 19:46:36 +08:00
Gavan 030a8ac25d pref: 优化主题 2025-11-04 19:13:31 +08:00
xiaomakuaiz 24d1ed1bcd
Merge pull request #1461 from guanweiwang/pref/landing
feat: 常见问题,九宫格
2025-11-04 18:55:07 +08:00
Gavan 487db8e944 feat: add DomainBlockGridConfig and DomainQuestionConfig interfaces; update BlockGridConfig and DragList components 2025-11-04 18:47:13 +08:00
Gavan 2638fcdc0c feat: 常见问题,九宫格 2025-11-04 18:32:29 +08:00
xiaomakuaiz 1aa2855e00
Merge pull request #1459 from KuaiYu95/fe/custom-md
优化 markdown 文档模式
2025-11-04 18:31:18 +08:00
xiaomakuaiz fd81e83807
Merge pull request #1462 from xiaomakuaiz/feat-contribute-content-type
Feat contribute content type
2025-11-04 18:28:52 +08:00
yu.kuai f121494416 fix: pref code 2025-11-04 18:26:21 +08:00
xiaomakuaiz b04aa2d472 feat: add content-type for contribute 2025-11-04 10:19:50 +00:00
xiaomakuaiz 69bf9cbf0e feat: add more landing components 2025-11-04 10:19:26 +00:00
yu.kuai 940282a521 feat: 后台贡献新增 markdown diff view modal 2025-11-04 16:38:22 +08:00
yu.kuai 171cc6c632 feat: 前台文档贡献支持选择文档类型 2025-11-04 16:38:22 +08:00
yu.kuai 78e5e1d70d feat: 前台编辑贡献支持 markdown 2025-11-04 16:38:22 +08:00
yu.kuai c5151ee7fe feat: 优化 markdown 编辑器
feat: subscript, supscript, alert 支持 markdown
2025-11-04 16:38:22 +08:00
yu.kuai c7f764199e feat: 编辑页支持创建最外层文件夹/文件
fix: 修复 alert 展示问题,适配 tiptap 的 markdown 代码设计
2025-11-04 16:38:22 +08:00
yu.kuai 5588a46752 feat: 自定义markdown 解析上标,下标和警告提示 2025-11-04 16:38:22 +08:00
yu.kuai 5fba15654f feat: 点击大纲标题,markdown 和 tiptap 预览同步滚动 2025-11-04 16:38:22 +08:00
xiaomakuaiz 028b872349
Merge pull request #1455 from guanweiwang/hotfix/bug
fix: 暗黑模式下, md渲染问题
2025-11-04 15:01:20 +08:00
Gavan 02d17cb48f fix: 暗黑模式下, md渲染问题 2025-11-04 14:37:48 +08:00
66 changed files with 3170 additions and 912 deletions

View File

@ -4976,6 +4976,34 @@ const docTemplate = `{
} }
} }
}, },
"domain.BlockGridConfig": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
}
}
},
"title": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"domain.BrandGroup": { "domain.BrandGroup": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -6762,6 +6790,31 @@ const docTemplate = `{
} }
} }
}, },
"domain.QuestionConfig": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"question": {
"type": "string"
}
}
}
},
"title": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"domain.RagInfo": { "domain.RagInfo": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -7242,6 +7295,9 @@ const docTemplate = `{
"basic_doc_config": { "basic_doc_config": {
"$ref": "#/definitions/domain.BasicDocConfig" "$ref": "#/definitions/domain.BasicDocConfig"
}, },
"block_grid_config": {
"$ref": "#/definitions/domain.BlockGridConfig"
},
"carousel_config": { "carousel_config": {
"$ref": "#/definitions/domain.CarouselConfig" "$ref": "#/definitions/domain.CarouselConfig"
}, },
@ -7278,6 +7334,9 @@ const docTemplate = `{
"type": "string" "type": "string"
} }
}, },
"question_config": {
"$ref": "#/definitions/domain.QuestionConfig"
},
"simple_doc_config": { "simple_doc_config": {
"$ref": "#/definitions/domain.SimpleDocConfig" "$ref": "#/definitions/domain.SimpleDocConfig"
}, },
@ -7301,6 +7360,9 @@ const docTemplate = `{
"basic_doc_config": { "basic_doc_config": {
"$ref": "#/definitions/domain.BasicDocConfig" "$ref": "#/definitions/domain.BasicDocConfig"
}, },
"block_grid_config": {
"$ref": "#/definitions/domain.BlockGridConfig"
},
"carousel_config": { "carousel_config": {
"$ref": "#/definitions/domain.CarouselConfig" "$ref": "#/definitions/domain.CarouselConfig"
}, },
@ -7343,6 +7405,9 @@ const docTemplate = `{
"$ref": "#/definitions/domain.RecommendNodeListResp" "$ref": "#/definitions/domain.RecommendNodeListResp"
} }
}, },
"question_config": {
"$ref": "#/definitions/domain.QuestionConfig"
},
"simple_doc_config": { "simple_doc_config": {
"$ref": "#/definitions/domain.SimpleDocConfig" "$ref": "#/definitions/domain.SimpleDocConfig"
}, },

View File

@ -4969,6 +4969,34 @@
} }
} }
}, },
"domain.BlockGridConfig": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"url": {
"type": "string"
}
}
}
},
"title": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"domain.BrandGroup": { "domain.BrandGroup": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -6755,6 +6783,31 @@
} }
} }
}, },
"domain.QuestionConfig": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"question": {
"type": "string"
}
}
}
},
"title": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"domain.RagInfo": { "domain.RagInfo": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -7235,6 +7288,9 @@
"basic_doc_config": { "basic_doc_config": {
"$ref": "#/definitions/domain.BasicDocConfig" "$ref": "#/definitions/domain.BasicDocConfig"
}, },
"block_grid_config": {
"$ref": "#/definitions/domain.BlockGridConfig"
},
"carousel_config": { "carousel_config": {
"$ref": "#/definitions/domain.CarouselConfig" "$ref": "#/definitions/domain.CarouselConfig"
}, },
@ -7271,6 +7327,9 @@
"type": "string" "type": "string"
} }
}, },
"question_config": {
"$ref": "#/definitions/domain.QuestionConfig"
},
"simple_doc_config": { "simple_doc_config": {
"$ref": "#/definitions/domain.SimpleDocConfig" "$ref": "#/definitions/domain.SimpleDocConfig"
}, },
@ -7294,6 +7353,9 @@
"basic_doc_config": { "basic_doc_config": {
"$ref": "#/definitions/domain.BasicDocConfig" "$ref": "#/definitions/domain.BasicDocConfig"
}, },
"block_grid_config": {
"$ref": "#/definitions/domain.BlockGridConfig"
},
"carousel_config": { "carousel_config": {
"$ref": "#/definitions/domain.CarouselConfig" "$ref": "#/definitions/domain.CarouselConfig"
}, },
@ -7336,6 +7398,9 @@
"$ref": "#/definitions/domain.RecommendNodeListResp" "$ref": "#/definitions/domain.RecommendNodeListResp"
} }
}, },
"question_config": {
"$ref": "#/definitions/domain.QuestionConfig"
},
"simple_doc_config": { "simple_doc_config": {
"$ref": "#/definitions/domain.SimpleDocConfig" "$ref": "#/definitions/domain.SimpleDocConfig"
}, },

View File

@ -833,6 +833,24 @@ definitions:
- ids - ids
- kb_id - kb_id
type: object type: object
domain.BlockGridConfig:
properties:
list:
items:
properties:
id:
type: string
name:
type: string
url:
type: string
type: object
type: array
title:
type: string
type:
type: string
type: object
domain.BrandGroup: domain.BrandGroup:
properties: properties:
links: links:
@ -1993,6 +2011,22 @@ definitions:
model: model:
type: string type: string
type: object type: object
domain.QuestionConfig:
properties:
list:
items:
properties:
id:
type: string
question:
type: string
type: object
type: array
title:
type: string
type:
type: string
type: object
domain.RagInfo: domain.RagInfo:
properties: properties:
message: message:
@ -2309,6 +2343,8 @@ definitions:
$ref: '#/definitions/domain.BannerConfig' $ref: '#/definitions/domain.BannerConfig'
basic_doc_config: basic_doc_config:
$ref: '#/definitions/domain.BasicDocConfig' $ref: '#/definitions/domain.BasicDocConfig'
block_grid_config:
$ref: '#/definitions/domain.BlockGridConfig'
carousel_config: carousel_config:
$ref: '#/definitions/domain.CarouselConfig' $ref: '#/definitions/domain.CarouselConfig'
case_config: case_config:
@ -2333,6 +2369,8 @@ definitions:
items: items:
type: string type: string
type: array type: array
question_config:
$ref: '#/definitions/domain.QuestionConfig'
simple_doc_config: simple_doc_config:
$ref: '#/definitions/domain.SimpleDocConfig' $ref: '#/definitions/domain.SimpleDocConfig'
text_config: text_config:
@ -2348,6 +2386,8 @@ definitions:
$ref: '#/definitions/domain.BannerConfig' $ref: '#/definitions/domain.BannerConfig'
basic_doc_config: basic_doc_config:
$ref: '#/definitions/domain.BasicDocConfig' $ref: '#/definitions/domain.BasicDocConfig'
block_grid_config:
$ref: '#/definitions/domain.BlockGridConfig'
carousel_config: carousel_config:
$ref: '#/definitions/domain.CarouselConfig' $ref: '#/definitions/domain.CarouselConfig'
case_config: case_config:
@ -2376,6 +2416,8 @@ definitions:
items: items:
$ref: '#/definitions/domain.RecommendNodeListResp' $ref: '#/definitions/domain.RecommendNodeListResp'
type: array type: array
question_config:
$ref: '#/definitions/domain.QuestionConfig'
simple_doc_config: simple_doc_config:
$ref: '#/definitions/domain.SimpleDocConfig' $ref: '#/definitions/domain.SimpleDocConfig'
text_config: text_config:

View File

@ -293,6 +293,23 @@ type TextImgConfig struct {
Desc string `json:"desc"` Desc string `json:"desc"`
} `json:"item"` } `json:"item"`
} }
type QuestionConfig struct {
Type string `json:"type"`
Title string `json:"title"`
List []struct {
ID string `json:"id"`
Question string `json:"question"`
} `json:"list"`
}
type BlockGridConfig struct {
Type string `json:"type"`
Title string `json:"title"`
List []struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
} `json:"list"`
}
type WebAppLandingConfig struct { type WebAppLandingConfig struct {
Type string `json:"type"` Type string `json:"type"`
@ -310,6 +327,8 @@ type WebAppLandingConfig struct {
FeatureConfig *FeatureConfig `json:"feature_config,omitempty"` FeatureConfig *FeatureConfig `json:"feature_config,omitempty"`
ImgTextConfig *ImgTextConfig `json:"img_text_config,omitempty"` ImgTextConfig *ImgTextConfig `json:"img_text_config,omitempty"`
TextImgConfig *TextImgConfig `json:"text_img_config,omitempty"` TextImgConfig *TextImgConfig `json:"text_img_config,omitempty"`
QuestionConfig *QuestionConfig `json:"question_config,omitempty"`
BlockGridConfig *BlockGridConfig `json:"block_grid_config,omitempty"`
ComConfigOrder []string `json:"com_config_order"` ComConfigOrder []string `json:"com_config_order"`
} }
@ -526,6 +545,8 @@ type WebAppLandingConfigResp struct {
FeatureConfig *FeatureConfig `json:"feature_config,omitempty"` FeatureConfig *FeatureConfig `json:"feature_config,omitempty"`
ImgTextConfig *ImgTextConfig `json:"img_text_config,omitempty"` ImgTextConfig *ImgTextConfig `json:"img_text_config,omitempty"`
TextImgConfig *TextImgConfig `json:"text_img_config,omitempty"` TextImgConfig *TextImgConfig `json:"text_img_config,omitempty"`
QuestionConfig *QuestionConfig `json:"question_config,omitempty"`
BlockGridConfig *BlockGridConfig `json:"block_grid_config,omitempty"`
ComConfigOrder []string `json:"com_config_order"` ComConfigOrder []string `json:"com_config_order"`
NodeIds []string `json:"node_ids"` NodeIds []string `json:"node_ids"`
Nodes []*RecommendNodeListResp `json:"nodes" gorm:"-"` Nodes []*RecommendNodeListResp `json:"nodes" gorm:"-"`

View File

@ -422,6 +422,8 @@ func (u *AppUsecase) GetAppDetailByKBIDAndAppType(ctx context.Context, kbID stri
FeatureConfig: app.Settings.WebAppLandingConfigs[i].FeatureConfig, FeatureConfig: app.Settings.WebAppLandingConfigs[i].FeatureConfig,
ImgTextConfig: app.Settings.WebAppLandingConfigs[i].ImgTextConfig, ImgTextConfig: app.Settings.WebAppLandingConfigs[i].ImgTextConfig,
TextImgConfig: app.Settings.WebAppLandingConfigs[i].TextImgConfig, TextImgConfig: app.Settings.WebAppLandingConfigs[i].TextImgConfig,
QuestionConfig: app.Settings.WebAppLandingConfigs[i].QuestionConfig,
BlockGridConfig: app.Settings.WebAppLandingConfigs[i].BlockGridConfig,
ComConfigOrder: app.Settings.WebAppLandingConfigs[i].ComConfigOrder, ComConfigOrder: app.Settings.WebAppLandingConfigs[i].ComConfigOrder,
NodeIds: app.Settings.WebAppLandingConfigs[i].NodeIds, NodeIds: app.Settings.WebAppLandingConfigs[i].NodeIds,
} }
@ -552,6 +554,8 @@ func (u *AppUsecase) ShareGetWebAppInfo(ctx context.Context, kbID string, authId
ImgTextConfig: app.Settings.WebAppLandingConfigs[i].ImgTextConfig, ImgTextConfig: app.Settings.WebAppLandingConfigs[i].ImgTextConfig,
TextImgConfig: app.Settings.WebAppLandingConfigs[i].TextImgConfig, TextImgConfig: app.Settings.WebAppLandingConfigs[i].TextImgConfig,
MetricsConfig: app.Settings.WebAppLandingConfigs[i].MetricsConfig, MetricsConfig: app.Settings.WebAppLandingConfigs[i].MetricsConfig,
QuestionConfig: app.Settings.WebAppLandingConfigs[i].QuestionConfig,
BlockGridConfig: app.Settings.WebAppLandingConfigs[i].BlockGridConfig,
ComConfigOrder: app.Settings.WebAppLandingConfigs[i].ComConfigOrder, ComConfigOrder: app.Settings.WebAppLandingConfigs[i].ComConfigOrder,
NodeIds: app.Settings.WebAppLandingConfigs[i].NodeIds, NodeIds: app.Settings.WebAppLandingConfigs[i].NodeIds,
} }

View File

@ -19,9 +19,6 @@
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@reduxjs/toolkit": "^2.5.0", "@reduxjs/toolkit": "^2.5.0",
"@tiptap/extension-collaboration": "^3.3.0",
"@tiptap/extension-collaboration-caret": "^3.3.0",
"ace-builds": "^1.43.4",
"axios": "^1.7.9", "axios": "^1.7.9",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"echarts": "^5.6.0", "echarts": "^5.6.0",
@ -32,9 +29,9 @@
"lottie-react": "^2.4.1", "lottie-react": "^2.4.1",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"react-ace": "^14.0.1",
"react-color-palette": "^7.3.1", "react-color-palette": "^7.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-diff-viewer": "^3.1.1",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",

View File

@ -385,14 +385,14 @@ const ThemeWrapper = ({ children }: { children: React.ReactNode }) => {
const { appPreviewData } = useAppSelector(state => state.config); const { appPreviewData } = useAppSelector(state => state.config);
const theme = useMemo(() => { const theme = useMemo(() => {
const themeName =
appPreviewData?.settings?.web_app_landing_theme?.name || 'blue';
return createTheme( return createTheme(
// @ts-expect-error themeOptions is not typed // @ts-expect-error themeOptions is not typed
{ {
...themeOptions[0], ...themeOptions[0],
palette: palette:
THEME_TO_PALETTE[ THEME_TO_PALETTE[themeName]?.palette || THEME_TO_PALETTE.blue.palette,
appPreviewData?.settings?.web_app_landing_theme?.name || 'blue'
].palette,
}, },
...themeOptions.slice(1), ...themeOptions.slice(1),
); );

View File

@ -157,6 +157,7 @@ const ComponentBar = ({
ref={setNodeRef} ref={setNodeRef}
direction={'row'} direction={'row'}
sx={{ sx={{
flexShrink: 0,
cursor: 'not-allowed', cursor: 'not-allowed',
height: '40px', height: '40px',
borderRadius: '6px', borderRadius: '6px',
@ -268,7 +269,9 @@ const ComponentBar = ({
<Stack sx={{ pr: '20px', marginTop: '15px' }}> <Stack sx={{ pr: '20px', marginTop: '15px' }}>
<Select <Select
value={ value={
appPreviewData.settings?.web_app_landing_theme?.name || 'blue' THEME_TO_PALETTE[
appPreviewData.settings?.web_app_landing_theme?.name || 'blue'
]?.value || 'blue'
} }
renderValue={value => { renderValue={value => {
return THEME_TO_PALETTE[value]?.label; return THEME_TO_PALETTE[value]?.label;

View File

@ -0,0 +1,113 @@
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { Stack } from '@mui/material';
import { Dispatch, FC, SetStateAction, useCallback, useState } from 'react';
import Item, { type ItemType } from './Item';
import SortableItem from './SortableItem';
interface DragListProps {
data: ItemType[];
onChange: (data: ItemType[]) => void;
setIsEdit: Dispatch<SetStateAction<boolean>>;
}
const DragList: FC<DragListProps> = ({ data, onChange, setIsEdit }) => {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
const oldIndex = data.findIndex(item => item.id === active.id);
const newIndex = data.findIndex(item => item.id === over!.id);
const newData = arrayMove(data, oldIndex, newIndex);
onChange(newData);
}
setActiveId(null);
},
[data, onChange],
);
const handleDragCancel = useCallback(() => {
setActiveId(null);
}, []);
const handleRemove = useCallback(
(id: string) => {
const newData = data.filter(item => item.id !== id);
onChange(newData);
},
[data, onChange],
);
const handleUpdateItem = useCallback(
(updatedItem: ItemType) => {
const newData = data.map(item =>
item.id === updatedItem.id ? updatedItem : item,
);
onChange(newData);
},
[data, onChange],
);
if (data.length === 0) return null;
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext
items={data.map(item => item.id)}
strategy={rectSortingStrategy}
>
<Stack direction={'row'} flexWrap={'wrap'} gap={2}>
{data.map(item => (
<SortableItem
key={item.id}
id={item.id}
item={item}
handleRemove={handleRemove}
handleUpdateItem={handleUpdateItem}
setIsEdit={setIsEdit}
/>
))}
</Stack>
</SortableContext>
<DragOverlay adjustScale style={{ transformOrigin: '0 0' }}>
{activeId ? (
<Item
isDragging
item={data.find(item => item.id === activeId)!}
setIsEdit={setIsEdit}
handleUpdateItem={handleUpdateItem}
/>
) : null}
</DragOverlay>
</DndContext>
);
};
export default DragList;

View File

@ -0,0 +1,155 @@
import { Box, IconButton, Stack, TextField } from '@mui/material';
import { Icon } from '@ctzhian/ui';
import UploadFile from '@/components/UploadFile';
import {
CSSProperties,
Dispatch,
forwardRef,
HTMLAttributes,
SetStateAction,
} from 'react';
export type ItemType = {
id: string;
name: string;
url: string;
};
export type ItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
item: ItemType;
withOpacity?: boolean;
isDragging?: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
handleRemove?: (id: string) => void;
handleUpdateItem?: (item: ItemType) => void;
setIsEdit: Dispatch<SetStateAction<boolean>>;
};
const Item = forwardRef<HTMLDivElement, ItemProps>(
(
{
item,
withOpacity,
isDragging,
style,
dragHandleProps,
handleRemove,
handleUpdateItem,
setIsEdit,
...props
},
ref,
) => {
const inlineStyles: CSSProperties = {
opacity: withOpacity ? '0.5' : '1',
borderRadius: '10px',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: '#ffffff',
width: '100%',
...style,
};
return (
<Box ref={ref} style={inlineStyles} {...props}>
<Stack
direction={'row'}
alignItems={'center'}
justifyContent={'space-between'}
gap={0.5}
sx={{
py: 1.5,
px: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
}}
>
<Stack
direction={'column'}
gap={'20px'}
sx={{
flex: 1,
p: 1.5,
}}
>
<UploadFile
name='url'
id={`${item.id}_image`}
type='url'
disabled={false}
accept='image/*'
width={160}
height={140}
value={item.url}
onChange={(url: string) => {
const updatedItem = { ...item, url: url };
handleUpdateItem?.(updatedItem);
setIsEdit(true);
}}
/>
<TextField
label='名称'
slotProps={{
inputLabel: {
shrink: true,
},
}}
sx={{
height: '36px',
'& .MuiOutlinedInput-root': {
height: '36px',
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
fullWidth
placeholder='请输入名称'
variant='outlined'
value={item.name}
onChange={e => {
const updatedItem = { ...item, name: e.target.value };
handleUpdateItem?.(updatedItem);
setIsEdit(true);
}}
/>
</Stack>
<Stack
direction={'column'}
sx={{ justifyContent: 'space-between', alignSelf: 'stretch' }}
>
<IconButton
size='small'
onClick={e => {
e.stopPropagation();
handleRemove?.(item.id);
}}
sx={{
color: 'text.tertiary',
':hover': { color: 'error.main' },
width: '28px',
height: '28px',
}}
>
<Icon type='icon-shanchu2' sx={{ fontSize: '12px' }} />
</IconButton>
<IconButton
size='small'
sx={{
cursor: 'grab',
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
{...(dragHandleProps as any)}
>
<Icon type='icon-drag' />
</IconButton>
</Stack>
</Stack>
</Box>
);
},
);
export default Item;

View File

@ -0,0 +1,38 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { FC } from 'react';
import Item, { ItemProps } from './Item';
type SortableItemProps = ItemProps & {};
const SortableItem: FC<SortableItemProps> = ({ item, ...rest }) => {
const {
isDragging,
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: item.id });
const style = {
transform: CSS.Transform.toString(transform),
transition: transition || undefined,
};
return (
<Item
ref={setNodeRef}
style={style}
withOpacity={isDragging}
dragHandleProps={{
...attributes,
...listeners,
}}
item={item}
{...rest}
/>
);
};
export default SortableItem;

View File

@ -0,0 +1,101 @@
import React, { useEffect } from 'react';
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
import { TextField } from '@mui/material';
import { Controller, useForm } from 'react-hook-form';
import DragList from './DragList';
import type { ConfigProps } from '../type';
import { useAppSelector } from '@/store';
import useDebounceAppPreviewData from '@/hooks/useDebounceAppPreviewData';
import { Empty } from '@ctzhian/ui';
import { DEFAULT_DATA } from '../../../constants';
import { findConfigById, handleLandingConfigs } from '../../../utils';
const Config = ({ setIsEdit, id }: ConfigProps) => {
const { appPreviewData } = useAppSelector(state => state.config);
const debouncedDispatch = useDebounceAppPreviewData();
const { control, setValue, watch, reset, subscribe } = useForm<
typeof DEFAULT_DATA.block_grid
>({
defaultValues: findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
});
const list = watch('list') || [];
const handleAddFeature = () => {
const nextId = `${Date.now()}`;
setValue('list', [...list, { id: nextId, name: '', url: '' }]);
};
const handleListChange = (
newList: (typeof DEFAULT_DATA.block_grid)['list'],
) => {
setValue('list', newList);
setIsEdit(true);
};
useEffect(() => {
reset(
findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
{ keepDefaultValues: true },
);
}, [id, appPreviewData]);
useEffect(() => {
const callback = subscribe({
formState: {
values: true,
},
callback: ({ values }) => {
const previewData = {
...appPreviewData,
settings: {
...appPreviewData?.settings,
web_app_landing_configs: handleLandingConfigs({
id,
config: appPreviewData?.settings?.web_app_landing_configs || [],
values,
}),
},
};
setIsEdit(true);
debouncedDispatch(previewData);
},
});
return () => {
callback();
};
}, [subscribe, id, appPreviewData]);
return (
<StyledCommonWrapper>
<CommonItem title='标题'>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField label='文字' {...field} placeholder='请输入' />
)}
/>
</CommonItem>
<CommonItem title='宫格列表' onAdd={handleAddFeature}>
{list.length === 0 ? (
<Empty />
) : (
<DragList
data={list}
onChange={handleListChange}
setIsEdit={setIsEdit}
/>
)}
</CommonItem>
</StyledCommonWrapper>
);
};
export default Config;

View File

@ -107,7 +107,7 @@ const FaqConfig = ({ setIsEdit, id }: ConfigProps) => {
)} )}
/> />
</CommonItem> */} </CommonItem> */}
<CommonItem title='问题列表' onAdd={handleAddQuestion}> <CommonItem title='链接列表' onAdd={handleAddQuestion}>
{list.length === 0 ? ( {list.length === 0 ? (
<Empty /> <Empty />
) : ( ) : (

View File

@ -0,0 +1,113 @@
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { Stack } from '@mui/material';
import { Dispatch, FC, SetStateAction, useCallback, useState } from 'react';
import Item, { ItemType } from './Item';
import SortableItem from './SortableItem';
interface FaqDragListProps {
data: ItemType[];
onChange: (data: ItemType[]) => void;
setIsEdit: Dispatch<SetStateAction<boolean>>;
}
const FaqDragList: FC<FaqDragListProps> = ({ data, onChange, setIsEdit }) => {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
const oldIndex = data.findIndex(item => item.id === active.id);
const newIndex = data.findIndex(item => item.id === over!.id);
const newData = arrayMove(data, oldIndex, newIndex);
onChange(newData);
}
setActiveId(null);
},
[data, onChange],
);
const handleDragCancel = useCallback(() => {
setActiveId(null);
}, []);
const handleRemove = useCallback(
(id: string) => {
const newData = data.filter(item => item.id !== id);
onChange(newData);
},
[data, onChange],
);
const handleUpdateItem = useCallback(
(updatedItem: ItemType) => {
const newData = data.map(item =>
item.id === updatedItem.id ? updatedItem : item,
);
onChange(newData);
},
[data, onChange],
);
if (data.length === 0) return null;
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext
items={data.map(item => item.id)}
strategy={rectSortingStrategy}
>
<Stack direction={'row'} flexWrap={'wrap'} gap={2}>
{data.map(item => (
<SortableItem
key={item.id}
id={item.id}
item={item}
handleRemove={handleRemove}
handleUpdateItem={handleUpdateItem}
setIsEdit={setIsEdit}
/>
))}
</Stack>
</SortableContext>
<DragOverlay adjustScale style={{ transformOrigin: '0 0' }}>
{activeId ? (
<Item
isDragging
item={data.find(item => item.id === activeId)!}
setIsEdit={setIsEdit}
handleUpdateItem={handleUpdateItem}
/>
) : null}
</DragOverlay>
</DndContext>
);
};
export default FaqDragList;

View File

@ -0,0 +1,138 @@
import { Box, IconButton, Stack, TextField } from '@mui/material';
import { Icon } from '@ctzhian/ui';
import {
CSSProperties,
Dispatch,
forwardRef,
HTMLAttributes,
SetStateAction,
} from 'react';
export type ItemType = {
id: string;
question: string;
};
export type ItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
item: ItemType;
withOpacity?: boolean;
isDragging?: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
handleRemove?: (id: string) => void;
handleUpdateItem?: (item: ItemType) => void;
setIsEdit: Dispatch<SetStateAction<boolean>>;
};
const Item = forwardRef<HTMLDivElement, ItemProps>(
(
{
item,
withOpacity,
isDragging,
style,
dragHandleProps,
handleRemove,
handleUpdateItem,
setIsEdit,
...props
},
ref,
) => {
const inlineStyles: CSSProperties = {
opacity: withOpacity ? '0.5' : '1',
borderRadius: '10px',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: '#ffffff',
width: '100%',
...style,
};
return (
<Box ref={ref} style={inlineStyles} {...props}>
<Stack
direction={'row'}
alignItems={'center'}
justifyContent={'space-between'}
gap={0.5}
sx={{
py: 1.5,
px: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
}}
>
<Stack
direction={'column'}
gap={'20px'}
sx={{
flex: 1,
p: 1.5,
}}
>
<TextField
label='问题'
slotProps={{
inputLabel: {
shrink: true,
},
}}
sx={{
height: '36px',
'& .MuiOutlinedInput-root': {
height: '36px',
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
fullWidth
placeholder='请输入问题'
variant='outlined'
value={item.question}
onChange={e => {
const updatedItem = { ...item, question: e.target.value };
handleUpdateItem?.(updatedItem);
setIsEdit(true);
}}
/>
</Stack>
<Stack
direction={'column'}
sx={{ justifyContent: 'space-between', alignSelf: 'stretch' }}
>
<IconButton
size='small'
onClick={e => {
e.stopPropagation();
handleRemove?.(item.id);
}}
sx={{
color: 'text.tertiary',
':hover': { color: 'error.main' },
width: '28px',
height: '28px',
}}
>
<Icon type='icon-shanchu2' sx={{ fontSize: '12px' }} />
</IconButton>
<IconButton
size='small'
sx={{
cursor: 'grab',
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
{...(dragHandleProps as any)}
>
<Icon type='icon-drag' />
</IconButton>
</Stack>
</Stack>
</Box>
);
},
);
export default Item;

View File

@ -0,0 +1,38 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { FC } from 'react';
import Item, { ItemProps } from './Item';
type SortableItemProps = ItemProps & {};
const SortableItem: FC<SortableItemProps> = ({ item, ...rest }) => {
const {
isDragging,
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: item.id });
const style = {
transform: CSS.Transform.toString(transform),
transition: transition || undefined,
};
return (
<Item
ref={setNodeRef}
style={style}
withOpacity={isDragging}
dragHandleProps={{
...attributes,
...listeners,
}}
item={item}
{...rest}
/>
);
};
export default SortableItem;

View File

@ -0,0 +1,102 @@
import React, { useEffect } from 'react';
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
import { TextField } from '@mui/material';
import { Controller, useForm } from 'react-hook-form';
import FaqDragList from './DragList';
import type { ConfigProps } from '../type';
import { useAppSelector } from '@/store';
import useDebounceAppPreviewData from '@/hooks/useDebounceAppPreviewData';
import { Empty } from '@ctzhian/ui';
import { DEFAULT_DATA } from '../../../constants';
import { findConfigById, handleLandingConfigs } from '../../../utils';
const FaqConfig = ({ setIsEdit, id }: ConfigProps) => {
const { appPreviewData } = useAppSelector(state => state.config);
const debouncedDispatch = useDebounceAppPreviewData();
const { control, setValue, watch, reset, subscribe } = useForm<
typeof DEFAULT_DATA.question
>({
defaultValues: findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
});
const list = watch('list') || [];
const handleAddQuestion = () => {
const nextId = `${Date.now()}`;
setValue('list', [...list, { id: nextId, question: '' }]);
};
const handleListChange = (
newList: (typeof DEFAULT_DATA.question)['list'],
) => {
setValue('list', newList);
setIsEdit(true);
};
useEffect(() => {
reset(
findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
{ keepDefaultValues: true },
);
}, [id, appPreviewData]);
useEffect(() => {
const callback = subscribe({
formState: {
values: true,
},
callback: ({ values }) => {
const previewData = {
...appPreviewData,
settings: {
...appPreviewData?.settings,
web_app_landing_configs: handleLandingConfigs({
id,
config: appPreviewData?.settings?.web_app_landing_configs || [],
values,
}),
},
};
setIsEdit(true);
debouncedDispatch(previewData);
},
});
return () => {
callback();
};
}, [subscribe, id, appPreviewData]);
return (
<StyledCommonWrapper>
<CommonItem title='标题'>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField label='文字' {...field} placeholder='请输入' />
)}
/>
</CommonItem>
<CommonItem title='常见问题列表' onAdd={handleAddQuestion}>
{list.length === 0 ? (
<Empty />
) : (
<FaqDragList
data={list}
onChange={handleListChange}
setIsEdit={setIsEdit}
/>
)}
</CommonItem>
</StyledCommonWrapper>
);
};
export default FaqConfig;

View File

@ -5,7 +5,6 @@ import {
IconJianyiwendang, IconJianyiwendang,
IconChangjianwenti, IconChangjianwenti,
IconLunbotu, IconLunbotu,
IconShanchu,
IconDanwenzi, IconDanwenzi,
IconShuzikapian, IconShuzikapian,
IconKehuanli, IconKehuanli,
@ -13,6 +12,8 @@ import {
IconZuotuyouzi, IconZuotuyouzi,
IconYoutuzuozi, IconYoutuzuozi,
IconKehupingjia, IconKehupingjia,
IconJiugongge,
IconLianjiezu1,
} from '@panda-wiki/icons'; } from '@panda-wiki/icons';
import { DomainRecommendNodeListResp } from '@/request/types'; import { DomainRecommendNodeListResp } from '@/request/types';
@ -70,6 +71,14 @@ export const DEFAULT_DATA = {
comment: string; comment: string;
}[], }[],
}, },
block_grid: {
title: '区块网格',
list: [] as {
id: string;
url: string;
name: string;
}[],
},
banner: { banner: {
title: '', title: '',
subtitle: '', subtitle: '',
@ -105,13 +114,20 @@ export const DEFAULT_DATA = {
}[], }[],
}, },
faq: { faq: {
title: '常见问题', title: '链接组',
list: [] as { list: [] as {
id: string; id: string;
question: string; question: string;
link: string; link: string;
}[], }[],
}, },
question: {
title: '常见问题',
list: [] as {
id: string;
question: string;
}[],
},
}; };
export const COMPONENTS_MAP = { export const COMPONENTS_MAP = {
@ -176,7 +192,7 @@ export const COMPONENTS_MAP = {
faq: { faq: {
name: 'faq', name: 'faq',
title: '链接组', title: '链接组',
icon: IconChangjianwenti, icon: IconLianjiezu1,
component: lazy(() => import('@panda-wiki/ui/faq')), component: lazy(() => import('@panda-wiki/ui/faq')),
config: lazy(() => import('./components/config/FaqConfig')), config: lazy(() => import('./components/config/FaqConfig')),
fixed: false, fixed: false,
@ -262,6 +278,26 @@ export const COMPONENTS_MAP = {
disabled: false, disabled: false,
hidden: false, hidden: false,
}, },
block_grid: {
name: 'block_grid',
title: '区块网格',
icon: IconJiugongge,
component: lazy(() => import('@panda-wiki/ui/blockGrid')),
config: lazy(() => import('./components/config/BlockGridConfig')),
fixed: false,
disabled: false,
hidden: false,
},
question: {
name: 'question',
title: '常见问题',
icon: IconChangjianwenti,
component: lazy(() => import('@panda-wiki/ui/question')),
config: lazy(() => import('./components/config/QuestionConfig')),
fixed: false,
disabled: false,
hidden: false,
},
}; };
export const TYPE_TO_CONFIG_LABEL = { export const TYPE_TO_CONFIG_LABEL = {
@ -278,4 +314,6 @@ export const TYPE_TO_CONFIG_LABEL = {
text_img: 'text_img_config', text_img: 'text_img_config',
img_text: 'img_text_config', img_text: 'img_text_config',
comment: 'comment_config', comment: 'comment_config',
block_grid: 'block_grid_config',
question: 'question_config',
} as const; } as const;

View File

@ -1,6 +1,3 @@
import { DEFAULT_DATA, TYPE_TO_CONFIG_LABEL } from './constants';
import Logo from '@/assets/images/footer-logo.png';
const handleHeaderProps = (setting: any) => { const handleHeaderProps = (setting: any) => {
return { return {
title: setting.title, title: setting.title,
@ -159,6 +156,20 @@ const handleCommentProps = (config: any = {}) => {
}; };
}; };
const handleBlockGridProps = (config: any = {}) => {
return {
title: config.title || '区块网格',
items: config.list || [],
};
};
const handleQuestionProps = (config: any = {}) => {
return {
title: config.title || '常见问题',
items: config.list || [],
};
};
export const handleComponentProps = ( export const handleComponentProps = (
type: string, type: string,
id: string, id: string,
@ -200,6 +211,10 @@ export const handleComponentProps = (
return handleTextImgProps(config); return handleTextImgProps(config);
case 'comment': case 'comment':
return handleCommentProps(config); return handleCommentProps(config);
case 'block_grid':
return handleBlockGridProps(config);
case 'question':
return handleQuestionProps(config);
} }
} }
}; };

View File

@ -0,0 +1,142 @@
import {
ConstsContributeStatus,
ConstsContributeType,
getApiProV1ContributeDetail,
GithubComChaitinPandaWikiProApiContributeV1ContributeDetailResp,
GithubComChaitinPandaWikiProApiContributeV1ContributeItem,
} from '@/request/pro';
import { useAppSelector } from '@/store';
import { Modal } from '@ctzhian/ui';
import { Box, Button, Stack } from '@mui/material';
import { IconWenjian } from '@panda-wiki/icons';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import ReactDiffViewer from 'react-diff-viewer';
type MarkdownPreviewModalProps = {
open: boolean;
row: GithubComChaitinPandaWikiProApiContributeV1ContributeItem | null;
onClose: () => void;
onAccept: () => void;
onReject: () => void;
};
const MarkdownPreviewModal = ({
open,
row,
onClose,
onAccept,
onReject,
}: MarkdownPreviewModalProps) => {
const { kb_id = '' } = useAppSelector(state => state.config);
const [data, setData] =
useState<GithubComChaitinPandaWikiProApiContributeV1ContributeDetailResp | null>(
null,
);
useEffect(() => {
if (open && row) {
getApiProV1ContributeDetail({ id: row.id!, kb_id }).then(res => {
setData(res);
});
}
}, [open, row, kb_id]);
return (
<Modal
open={open}
onCancel={onClose}
width={'1200px'}
sx={{
'.MuiDialogContent-root': {
display: 'flex',
flexDirection: 'column',
},
}}
title={
<Stack direction='row' alignItems='center' gap={2}>
<Box>
{row?.auth_name || '匿名用户'}
{row?.type === ConstsContributeType.ContributeTypeAdd
? '新增'
: '修改'}
</Box>
<Box sx={{ fontSize: 14, color: 'text.auxiliary', fontWeight: 400 }}>
{dayjs(row?.created_at).fromNow()}
</Box>
</Stack>
}
footer={
row?.status === ConstsContributeStatus.ContributeStatusPending ||
row?.status === ConstsContributeStatus.ContributeStatusRejected ? (
<Stack
direction='row'
gap={1}
justifyContent='flex-end'
sx={{ p: 3, pt: 0 }}
>
{row?.status === ConstsContributeStatus.ContributeStatusPending ? (
<>
<Button
size='small'
variant='outlined'
color='error'
onClick={onReject}
>
</Button>
<Button size='small' variant='contained' onClick={onAccept}>
</Button>
</>
) : (
<Button onClick={onClose} size='small' variant='contained'>
</Button>
)}
</Stack>
) : null
}
>
<Stack direction='row'>
<Stack
spacing={2}
sx={{
overflow: 'auto',
flex: 1,
}}
>
<Stack
direction='row'
gap={1}
sx={{ bgcolor: 'background.paper2', p: 1, borderRadius: '10px' }}
>
<Box sx={{ fontSize: 14, fontWeight: 'bold', flexShrink: 0 }}>
</Box>
<Box sx={{ fontSize: 14, color: 'text.tertiary' }}>
{data?.reason || '-'}
</Box>
</Stack>
<Stack
direction='row'
alignItems='center'
gap={1}
sx={{ fontSize: 24, fontWeight: 700, pb: 2 }}
>
<IconWenjian /> {row?.node_name || '-'}
</Stack>
<Box sx={{ overflowY: 'auto', maxHeight: 'calc(100vh - 400px)' }}>
<ReactDiffViewer
oldValue={data?.original_node?.content || ''}
newValue={data?.content || ''}
/>
</Box>
</Stack>
</Stack>
</Modal>
);
};
export default MarkdownPreviewModal;

View File

@ -1,26 +1,27 @@
import { useState, useEffect } from 'react';
import { styled } from '@mui/material/styles';
import Logo from '@/assets/images/logo.png'; import Logo from '@/assets/images/logo.png';
import { Box, Chip, Stack, TextField } from '@mui/material';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { tableSx } from '@/constant/styles'; import { tableSx } from '@/constant/styles';
import dayjs from 'dayjs'; import { Ellipsis, message, Modal, Table } from '@ctzhian/ui';
import { Table, Ellipsis, message, Modal } from '@ctzhian/ui';
import type { ColumnType } from '@ctzhian/ui/dist/Table'; import type { ColumnType } from '@ctzhian/ui/dist/Table';
import { Box, Chip, Stack, TextField } from '@mui/material';
import { styled } from '@mui/material/styles';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import DocModal from './DocModal'; import DocModal from './DocModal';
import { useURLSearchParams } from '@/hooks';
import { import {
getApiProV1ContributeList, getApiProV1ContributeList,
postApiProV1ContributeAudit, postApiProV1ContributeAudit,
} from '@/request/pro/Contribute'; } from '@/request/pro/Contribute';
import { import {
GithubComChaitinPandaWikiProApiContributeV1ContributeItem,
ConstsContributeStatus, ConstsContributeStatus,
ConstsContributeType, ConstsContributeType,
GithubComChaitinPandaWikiProApiContributeV1ContributeItem,
} from '@/request/pro/types'; } from '@/request/pro/types';
import { useURLSearchParams } from '@/hooks';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import ContributePreviewModal from './ContributePreviewModal'; import ContributePreviewModal from './ContributePreviewModal';
import MarkdownPreviewModal from './MarkdownPreviewModal';
const StyledSearchRow = styled(Stack)(({ theme }) => ({ const StyledSearchRow = styled(Stack)(({ theme }) => ({
padding: theme.spacing(2), padding: theme.spacing(2),
@ -365,13 +366,23 @@ export default function ContributionPage() {
}} }}
/> />
<ContributePreviewModal {previewRow?.meta?.content_type === 'md' ? (
open={open} <MarkdownPreviewModal
row={previewRow} open={open}
onClose={closeDialog} row={previewRow}
onAccept={handleAccept} onClose={closeDialog}
onReject={handleReject} onAccept={handleAccept}
/> onReject={handleReject}
/>
) : (
<ContributePreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
/>
)}
<DocModal <DocModal
open={docModalOpen} open={docModalOpen}
onClose={() => setDocModalOpen(false)} onClose={() => setDocModalOpen(false)}

View File

@ -9,7 +9,6 @@ import { DomainNodeListItemResp, V1NodeDetailResp } from '@/request/types';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import { addOpacityToColor } from '@/utils'; import { addOpacityToColor } from '@/utils';
import { convertToTree } from '@/utils/drag'; import { convertToTree } from '@/utils/drag';
import { filterEmptyFolders } from '@/utils/tree';
import { Ellipsis, Icon } from '@ctzhian/ui'; import { Ellipsis, Icon } from '@ctzhian/ui';
import { alpha, Box, IconButton, Stack, useTheme } from '@mui/material'; import { alpha, Box, IconButton, Stack, useTheme } from '@mui/material';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
@ -66,7 +65,7 @@ const Catalog = ({ curNode, setCatalogOpen }: CatalogProps) => {
kb_id: kb_id || localStorage.getItem('kb_id') || '', kb_id: kb_id || localStorage.getItem('kb_id') || '',
}; };
getApiV1NodeList(params).then(res => { getApiV1NodeList(params).then(res => {
const v = filterEmptyFolders(convertToTree(res || [])); const v = convertToTree(res || []);
setData(v); setData(v);
// 计算当前文档的所有父级文件夹,并默认展开 // 计算当前文档的所有父级文件夹,并默认展开
try { try {
@ -110,6 +109,75 @@ const Catalog = ({ curNode, setCatalogOpen }: CatalogProps) => {
}); });
}; };
const renderAdd = (parentId: string) => {
return (
<Box sx={{ flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<Cascader
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
context={
<IconButton>
<Icon
className='catalog-folder-add-icon'
type='icon-icon_tool_close'
sx={{
fontSize: 16,
color: 'action.selected',
transform: 'rotate(45deg)',
}}
/>
</IconButton>
}
list={Object.entries(ImportContentWays).map(([key, value]) => ({
key,
label: (
<Box key={key}>
<Stack
direction={'row'}
alignItems={'center'}
gap={1}
sx={{
fontSize: 14,
px: 2,
lineHeight: '40px',
height: 40,
width: 180,
borderRadius: '5px',
cursor: 'pointer',
':hover': {
bgcolor: addOpacityToColor(
theme.palette.primary.main,
0.1,
),
},
}}
onClick={() => value.onClick(parentId)}
>
{value.label}
</Stack>
{key === 'OfflineFile' && (
<Box
sx={{
borderTop: '1px solid',
borderColor: theme.palette.divider,
my: 0.5,
}}
/>
)}
</Box>
),
}))}
/>
</Box>
);
};
const renderTree = (items: ITreeItem[], pl = 2.5, depth = 1) => { const renderTree = (items: ITreeItem[], pl = 2.5, depth = 1) => {
const sortedItems = [...items].sort( const sortedItems = [...items].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0), (a, b) => (a.order ?? 0) - (b.order ?? 0),
@ -210,72 +278,7 @@ const Catalog = ({ curNode, setCatalogOpen }: CatalogProps) => {
MD MD
</Box> </Box>
)} )}
{item.type === 1 && ( {item.type === 1 && renderAdd(item.id)}
<Box sx={{ flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<Cascader
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
context={
<IconButton>
<Icon
className='catalog-folder-add-icon'
type='icon-icon_tool_close'
sx={{
fontSize: 16,
color: 'action.selected',
transform: 'rotate(45deg)',
}}
/>
</IconButton>
}
list={Object.entries(ImportContentWays).map(([key, value]) => ({
key,
label: (
<Box key={key}>
<Stack
direction={'row'}
alignItems={'center'}
gap={1}
sx={{
fontSize: 14,
px: 2,
lineHeight: '40px',
height: 40,
width: 180,
borderRadius: '5px',
cursor: 'pointer',
':hover': {
bgcolor: addOpacityToColor(
theme.palette.primary.main,
0.1,
),
},
}}
onClick={() => value.onClick(item.id)}
>
{value.label}
</Stack>
{key === 'OfflineFile' && (
<Box
sx={{
borderTop: '1px solid',
borderColor: theme.palette.divider,
my: 0.5,
}}
/>
)}
</Box>
),
}))}
/>
</Box>
)}
</Stack> </Stack>
{item.children && {item.children &&
item.children.length > 0 && item.children.length > 0 &&
@ -341,16 +344,24 @@ const Catalog = ({ curNode, setCatalogOpen }: CatalogProps) => {
/> />
</Stack> </Stack>
</Stack> </Stack>
<Box <Stack
sx={{ direction={'row'}
px: 2, alignItems={'center'}
fontSize: 14, justifyContent={'space-between'}
fontWeight: 'bold', sx={{ pr: 1 }}
color: 'text.tertiary',
}}
> >
<Box
</Box> sx={{
px: 2,
fontSize: 14,
fontWeight: 'bold',
color: 'text.tertiary',
}}
>
</Box>
{renderAdd('')}
</Stack>
<Stack <Stack
sx={{ sx={{
my: 1, my: 1,
@ -368,38 +379,59 @@ const Catalog = ({ curNode, setCatalogOpen }: CatalogProps) => {
open={customDocOpen} open={customDocOpen}
parentId={opraParentId} parentId={opraParentId}
onCreated={node => { onCreated={node => {
// 复用工具方法findItemDeep / setProperty if (opraParentId) {
setData(prev => { // 复用工具方法findItemDeep / setProperty
const parent = findItemDeep(prev, opraParentId); setData(prev => {
if (!parent) return prev; const parent = findItemDeep(prev, opraParentId);
const children = (parent.children as ITreeItem[] | undefined) ?? []; if (!parent) return prev;
const lastOrder = children.length const children =
? (children[children.length - 1].order ?? children.length - 1) (parent.children as ITreeItem[] | undefined) ?? [];
: -1; const lastOrder = children.length
? (children[children.length - 1].order ?? children.length - 1)
: -1;
const newChild: ITreeItem = {
id: node.id,
name: node.name,
content_type: node.content_type,
type: node.type,
emoji: node.emoji,
parentId: parent.id,
level: (parent.level ?? 0) + 1,
order: lastOrder + 1,
status: 1,
children: node.type === 1 ? [] : undefined,
};
const next = setProperty(prev, opraParentId, 'children', val => [
...((val as ITreeItem[] | undefined) ?? []),
newChild,
]) as ITreeItem[];
return [...next];
});
// 展开父级,确保新项可见
setExpandedFolders(prev => {
const ns = new Set(prev);
if (opraParentId) ns.add(opraParentId);
return ns;
});
} else {
const newChild: ITreeItem = { const newChild: ITreeItem = {
id: node.id, id: node.id,
name: node.name, name: node.name,
content_type: node.content_type, content_type: node.content_type,
type: node.type, type: node.type,
emoji: node.emoji, emoji: node.emoji,
parentId: parent.id, parentId: '',
level: (parent.level ?? 0) + 1, level: 1,
order: lastOrder + 1, order: data.length
? (data[data.length - 1].order ?? data.length - 1)
: -1,
status: 1, status: 1,
children: node.type === 1 ? [] : undefined, children: node.type === 1 ? [] : undefined,
}; };
const next = setProperty(prev, opraParentId, 'children', val => [ setData(prev => {
...((val as ITreeItem[] | undefined) ?? []), return [...prev, newChild];
newChild, });
]) as ITreeItem[]; }
return [...next];
});
// 展开父级,确保新项可见
setExpandedFolders(prev => {
const ns = new Set(prev);
if (opraParentId) ns.add(opraParentId);
return ns;
});
}} }}
onClose={() => setCustomDocOpen(false)} onClose={() => setCustomDocOpen(false)}
/> />

View File

@ -1,11 +1,10 @@
import { Editor, UseTiptapReturn } from '@ctzhian/tiptap'; import {
import { alpha, Box, Divider, Stack, useTheme } from '@mui/material'; EditorMarkdown,
import { useState } from 'react'; MarkdownEditorRef,
import AceEditor from 'react-ace'; UseTiptapReturn,
} from '@ctzhian/tiptap';
import 'ace-builds/src-noconflict/ext-language_tools'; import { Box } from '@mui/material';
import 'ace-builds/src-noconflict/mode-markdown'; import { forwardRef } from 'react';
import 'ace-builds/src-noconflict/theme-github';
interface MarkdownEditorProps { interface MarkdownEditorProps {
editor: UseTiptapReturn['editor']; editor: UseTiptapReturn['editor'];
@ -14,132 +13,29 @@ interface MarkdownEditorProps {
header: React.ReactNode; header: React.ReactNode;
} }
const MarkdownEditor = ({ const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
editor, ({ editor, value, onChange, header }, ref) => {
value, return (
onChange, <Box
header,
}: MarkdownEditorProps) => {
const theme = useTheme();
const [displayMode, setDisplayMode] = useState<'edit' | 'preview' | 'split'>(
'split',
);
return (
<Box
sx={{
mt: '56px',
px: 10,
pt: 4,
flex: 1,
}}
>
<Box sx={{}}>{header}</Box>
<Stack
direction={'row'}
alignItems={'stretch'}
sx={{ sx={{
position: 'relative', mt: '56px',
px: 10,
pt: 4,
flex: 1, flex: 1,
border: '1px solid',
borderColor: 'divider',
}} }}
> >
<Stack <Box sx={{}}>{header}</Box>
direction='row' <EditorMarkdown
sx={{ editor={editor}
position: 'absolute', value={value}
p: 0.5, onAceChange={onChange}
top: -32, height='calc(100vh - 340px)'
left: -1, />
border: '1px solid', </Box>
borderColor: 'divider', );
borderBottom: 'none', },
borderRadius: '4px 4px 0 0', );
fontSize: 12,
color: 'text.tertiary', MarkdownEditor.displayName = 'MarkdownEditor';
'.md-display-mode-active': {
color: 'primary.main',
bgcolor: alpha(theme.palette.primary.main, 0.1),
},
'& :hover:not(.md-display-mode-active)': {
borderRadius: '4px',
bgcolor: 'background.paper3',
},
}}
>
<Box
className={displayMode === 'split' ? 'md-display-mode-active' : ''}
sx={{ px: 1, py: 0.25, cursor: 'pointer', borderRadius: '4px' }}
onClick={() => setDisplayMode('split')}
>
</Box>
<Box
className={displayMode === 'edit' ? 'md-display-mode-active' : ''}
sx={{ px: 1, py: 0.25, cursor: 'pointer', borderRadius: '4px' }}
onClick={() => setDisplayMode('edit')}
>
</Box>
<Box
className={
displayMode === 'preview' ? 'md-display-mode-active' : ''
}
sx={{ px: 1, py: 0.25, cursor: 'pointer', borderRadius: '4px' }}
onClick={() => setDisplayMode('preview')}
>
</Box>
</Stack>
{['edit', 'split'].includes(displayMode) && (
<Stack
direction='column'
sx={{
flex: 1,
fontFamily: 'monospace',
}}
>
<AceEditor
mode='markdown'
theme='twilight'
value={value}
onChange={onChange}
name='project-doc-editor'
wrapEnabled={true}
showPrintMargin={false}
fontSize={16}
editorProps={{ $blockScrolling: true }}
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
showLineNumbers: true,
tabSize: 2,
}}
style={{
width: '100%',
height: 'calc(100vh - 56px)',
}}
/>
</Stack>
)}
{displayMode === 'split' && <Divider orientation='vertical' flexItem />}
{['split', 'preview'].includes(displayMode) && (
<Box
id='markdown-preview-container'
sx={{
overflowY: 'scroll',
flex: 1,
p: 2,
height: 'calc(100vh - 56px)',
}}
>
<Editor editor={editor} />
</Box>
)}
</Stack>
</Box>
);
};
export default MarkdownEditor; export default MarkdownEditor;

View File

@ -17,6 +17,7 @@ interface TocProps {
setFixed: (fixed: boolean) => void; setFixed: (fixed: boolean) => void;
setShowSummary: (showSummary: boolean) => void; setShowSummary: (showSummary: boolean) => void;
isMarkdown: boolean; isMarkdown: boolean;
scrollToHeading?: (headingText: string) => void;
} }
const HeadingIcon = [ const HeadingIcon = [
@ -34,7 +35,13 @@ const HeadingSx = [
{ fontSize: 14, fontWeight: 400, color: 'text.disabled' }, { fontSize: 14, fontWeight: 400, color: 'text.disabled' },
]; ];
const Toc = ({ headings, fixed, setFixed, isMarkdown }: TocProps) => { const Toc = ({
headings,
fixed,
setFixed,
isMarkdown,
scrollToHeading,
}: TocProps) => {
const storageTocOpen = localStorage.getItem('toc-open'); const storageTocOpen = localStorage.getItem('toc-open');
const [open, setOpen] = useState(!!storageTocOpen); const [open, setOpen] = useState(!!storageTocOpen);
const levels = Array.from( const levels = Array.from(
@ -191,6 +198,10 @@ const Toc = ({ headings, fixed, setFixed, isMarkdown }: TocProps) => {
behavior: 'smooth', behavior: 'smooth',
}); });
} }
// 同时滚动 AceEditor
if (scrollToHeading) {
scrollToHeading(it.textContent);
}
} else { } else {
// 在富文本编辑器模式下,滚动整个窗口 // 在富文本编辑器模式下,滚动整个窗口
const offset = 100; const offset = 100;

View File

@ -3,7 +3,13 @@ import Emoji from '@/components/Emoji';
import { postApiV1CreationTabComplete, putApiV1NodeDetail } from '@/request'; import { postApiV1CreationTabComplete, putApiV1NodeDetail } from '@/request';
import { V1NodeDetailResp } from '@/request/types'; import { V1NodeDetailResp } from '@/request/types';
import { useAppSelector } from '@/store'; import { useAppSelector } from '@/store';
import { TocList, useTiptap, UseTiptapReturn } from '@ctzhian/tiptap'; import {
EditorMarkdown,
MarkdownEditorRef,
TocList,
useTiptap,
UseTiptapReturn,
} from '@ctzhian/tiptap';
import { Icon, message } from '@ctzhian/ui'; import { Icon, message } from '@ctzhian/ui';
import { Box, Stack, TextField, Tooltip } from '@mui/material'; import { Box, Stack, TextField, Tooltip } from '@mui/material';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -19,7 +25,6 @@ import { WrapContext } from '..';
import AIGenerate from './AIGenerate'; import AIGenerate from './AIGenerate';
import FullTextEditor from './FullTextEditor'; import FullTextEditor from './FullTextEditor';
import Header from './Header'; import Header from './Header';
import MarkdownEditor from './MarkdownEditor';
import Summary from './Summary'; import Summary from './Summary';
import Toc from './Toc'; import Toc from './Toc';
import Toolbar from './Toolbar'; import Toolbar from './Toolbar';
@ -43,6 +48,8 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
null, null,
); );
const markdownEditorRef = useRef<MarkdownEditorRef>(null);
const isMarkdown = useMemo(() => { const isMarkdown = useMemo(() => {
return defaultDetail.meta?.content_type === 'md'; return defaultDetail.meta?.content_type === 'md';
}, [defaultDetail.meta?.content_type]); }, [defaultDetail.meta?.content_type]);
@ -185,7 +192,7 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
editable: !isMarkdown, editable: !isMarkdown,
contentType: isMarkdown ? 'markdown' : 'html', contentType: isMarkdown ? 'markdown' : 'html',
immediatelyRender: true, immediatelyRender: true,
content: defaultDetail.content || '', content: defaultDetail.content,
exclude: ['invisibleCharacters', 'youtube', 'mention'], exclude: ['invisibleCharacters', 'youtube', 'mention'],
onCreate: ({ editor: tiptapEditor }) => { onCreate: ({ editor: tiptapEditor }) => {
const characterCount = ( const characterCount = (
@ -584,17 +591,27 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
</Box> </Box>
<Box sx={{ ...(fixedToc && { display: 'flex' }) }}> <Box sx={{ ...(fixedToc && { display: 'flex' }) }}>
{isMarkdown ? ( {isMarkdown ? (
<MarkdownEditor <Box
editor={editorRef.editor} sx={{
value={nodeDetail?.content || ''} mt: '56px',
onChange={value => { px: 10,
updateDetail({ pt: 4,
content: value, flex: 1,
});
editorRef.setContent(value);
}} }}
header={renderEditorTitleEmojiSummary()} >
/> <Box sx={{}}>{renderEditorTitleEmojiSummary()}</Box>
<EditorMarkdown
ref={markdownEditorRef}
editor={editorRef.editor}
value={nodeDetail?.content || ''}
onAceChange={value => {
updateDetail({
content: value,
});
}}
height='calc(100vh - 340px)'
/>
</Box>
) : ( ) : (
<FullTextEditor <FullTextEditor
editor={editorRef.editor} editor={editorRef.editor}
@ -609,6 +626,12 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
isMarkdown={isMarkdown} isMarkdown={isMarkdown}
setFixed={setFixedToc} setFixed={setFixedToc}
setShowSummary={setShowSummary} setShowSummary={setShowSummary}
scrollToHeading={
isMarkdown
? headingText =>
markdownEditorRef.current?.scrollToHeading(headingText)
: undefined
}
/> />
<AIGenerate <AIGenerate
open={aiGenerateOpen} open={aiGenerateOpen}

View File

@ -376,6 +376,7 @@ export interface GithubComChaitinPandaWikiProApiContributeV1ContributeListResp {
} }
export interface GithubComChaitinPandaWikiProApiContributeV1NodeMeta { export interface GithubComChaitinPandaWikiProApiContributeV1NodeMeta {
content_type?: string;
doc_width?: string; doc_width?: string;
emoji?: string; emoji?: string;
} }
@ -492,6 +493,7 @@ export type GithubComChaitinPandaWikiProApiShareV1OAuthCallbackResp = Record<
export interface GithubComChaitinPandaWikiProApiShareV1SubmitContributeReq { export interface GithubComChaitinPandaWikiProApiShareV1SubmitContributeReq {
captcha_token: string; captcha_token: string;
content?: string; content?: string;
content_type: "html" | "md";
emoji?: string; emoji?: string;
name?: string; name?: string;
node_id?: string; node_id?: string;

View File

@ -134,6 +134,25 @@ export enum ConstsSourceType {
SourceTypeOpenAIAPI = "openai_api", SourceTypeOpenAIAPI = "openai_api",
} }
export enum ConstsNodeRagInfoStatus {
/** 等待基础处理 */
NodeRagStatusBasicPending = "BASIC_PENDING",
/** 正在进行基础处理(文本分割、向量化等) */
NodeRagStatusBasicRunning = "BASIC_RUNNING",
/** 基础处理失败 */
NodeRagStatusBasicFailed = "BASIC_FAILED",
/** 基础处理成功 */
NodeRagStatusBasicSucceeded = "BASIC_SUCCEEDED",
/** 基础处理完成,等待增强处理 */
NodeRagStatusEnhancePending = "ENHANCE_PENDING",
/** 正在进行增强处理(关键词提取等) */
NodeRagStatusEnhanceRunning = "ENHANCE_RUNNING",
/** 增强处理失败 */
NodeRagStatusEnhanceFailed = "ENHANCE_FAILED",
/** 增强处理成功 */
NodeRagStatusEnhanceSucceeded = "ENHANCE_SUCCEEDED",
}
export enum ConstsNodePermName { export enum ConstsNodePermName {
/** 导航内可见 */ /** 导航内可见 */
NodePermNameVisible = "visible", NodePermNameVisible = "visible",
@ -474,6 +493,16 @@ export interface DomainBatchMoveReq {
parent_id?: string; parent_id?: string;
} }
export interface DomainBlockGridConfig {
list?: {
id?: string;
name?: string;
url?: string;
}[];
title?: string;
type?: string;
}
export interface DomainBrandGroup { export interface DomainBrandGroup {
links?: DomainLink[]; links?: DomainLink[];
name?: string; name?: string;
@ -937,6 +966,7 @@ export interface DomainNodeListItemResp {
parent_id?: string; parent_id?: string;
permissions?: DomainNodePermissions; permissions?: DomainNodePermissions;
position?: number; position?: number;
rag_info?: DomainRagInfo;
status?: DomainNodeStatus; status?: DomainNodeStatus;
summary?: string; summary?: string;
type?: DomainNodeType; type?: DomainNodeType;
@ -1082,6 +1112,20 @@ export interface DomainProviderModelListItem {
model?: string; model?: string;
} }
export interface DomainQuestionConfig {
list?: {
id?: string;
question?: string;
}[];
title?: string;
type?: string;
}
export interface DomainRagInfo {
message?: string;
status?: ConstsNodeRagInfoStatus;
}
export interface DomainRecommendNodeListResp { export interface DomainRecommendNodeListResp {
emoji?: string; emoji?: string;
id?: string; id?: string;
@ -1241,6 +1285,7 @@ export interface DomainWebAppCustomSettings {
export interface DomainWebAppLandingConfig { export interface DomainWebAppLandingConfig {
banner_config?: DomainBannerConfig; banner_config?: DomainBannerConfig;
basic_doc_config?: DomainBasicDocConfig; basic_doc_config?: DomainBasicDocConfig;
block_grid_config?: DomainBlockGridConfig;
carousel_config?: DomainCarouselConfig; carousel_config?: DomainCarouselConfig;
case_config?: DomainCaseConfig; case_config?: DomainCaseConfig;
com_config_order?: string[]; com_config_order?: string[];
@ -1251,6 +1296,7 @@ export interface DomainWebAppLandingConfig {
img_text_config?: DomainImgTextConfig; img_text_config?: DomainImgTextConfig;
metrics_config?: DomainMetricsConfig; metrics_config?: DomainMetricsConfig;
node_ids?: string[]; node_ids?: string[];
question_config?: DomainQuestionConfig;
simple_doc_config?: DomainSimpleDocConfig; simple_doc_config?: DomainSimpleDocConfig;
text_config?: DomainTextConfig; text_config?: DomainTextConfig;
text_img_config?: DomainTextImgConfig; text_img_config?: DomainTextImgConfig;
@ -1260,6 +1306,7 @@ export interface DomainWebAppLandingConfig {
export interface DomainWebAppLandingConfigResp { export interface DomainWebAppLandingConfigResp {
banner_config?: DomainBannerConfig; banner_config?: DomainBannerConfig;
basic_doc_config?: DomainBasicDocConfig; basic_doc_config?: DomainBasicDocConfig;
block_grid_config?: DomainBlockGridConfig;
carousel_config?: DomainCarouselConfig; carousel_config?: DomainCarouselConfig;
case_config?: DomainCaseConfig; case_config?: DomainCaseConfig;
com_config_order?: string[]; com_config_order?: string[];
@ -1271,6 +1318,7 @@ export interface DomainWebAppLandingConfigResp {
metrics_config?: DomainMetricsConfig; metrics_config?: DomainMetricsConfig;
node_ids?: string[]; node_ids?: string[];
nodes?: DomainRecommendNodeListResp[]; nodes?: DomainRecommendNodeListResp[];
question_config?: DomainQuestionConfig;
simple_doc_config?: DomainSimpleDocConfig; simple_doc_config?: DomainSimpleDocConfig;
text_config?: DomainTextConfig; text_config?: DomainTextConfig;
text_img_config?: DomainTextImgConfig; text_img_config?: DomainTextImgConfig;

View File

@ -19,7 +19,9 @@ const HomePage = () => {
cssVariables: { cssVariables: {
cssVarPrefix: 'welcome', cssVarPrefix: 'welcome',
}, },
palette: THEME_TO_PALETTE[themeMode].palette, palette:
THEME_TO_PALETTE[themeMode]?.palette ||
THEME_TO_PALETTE['blue'].palette,
typography: { typography: {
fontFamily: 'var(--font-gilory), PingFang SC, sans-serif', fontFamily: 'var(--font-gilory), PingFang SC, sans-serif',
}, },

View File

@ -55,6 +55,61 @@
--inline-code-color: #ff502c; --inline-code-color: #ff502c;
} }
.md-dark .markdown-body {
/* 语法高亮颜色 - 暗色主题 */
--color-prettylights-syntax-comment: #8b949e;
--color-prettylights-syntax-constant: #79c0ff;
--color-prettylights-syntax-entity: #d2a8ff;
--color-prettylights-syntax-storage-modifier-import: #ff7b72;
--color-prettylights-syntax-entity-tag: #7ee787;
--color-prettylights-syntax-keyword: #ff7b72;
--color-prettylights-syntax-string: #a5d6ff;
--color-prettylights-syntax-variable: #ffa657;
--color-prettylights-syntax-brackethighlighter-unmatched: #f85149;
--color-prettylights-syntax-invalid-illegal-text: #f0f6fc;
--color-prettylights-syntax-invalid-illegal-bg: #f85149;
--color-prettylights-syntax-carriage-return-text: #f0f6fc;
--color-prettylights-syntax-carriage-return-bg: #f85149;
--color-prettylights-syntax-string-regexp: #7ee787;
--color-prettylights-syntax-markup-list: #d2a8ff;
--color-prettylights-syntax-markup-heading: #1f6feb;
--color-prettylights-syntax-markup-italic: #ff7b72;
--color-prettylights-syntax-markup-bold: #ff7b72;
--color-prettylights-syntax-markup-deleted-text: #ffdcd7;
--color-prettylights-syntax-markup-deleted-bg: #67060c;
--color-prettylights-syntax-markup-inserted-text: #aff5b4;
--color-prettylights-syntax-markup-inserted-bg: #033a16;
--color-prettylights-syntax-markup-changed-text: #ffdfb6;
--color-prettylights-syntax-markup-changed-bg: #5a1e02;
--color-prettylights-syntax-markup-ignored-text: #c9d1d9;
--color-prettylights-syntax-markup-ignored-bg: #1158c7;
--color-prettylights-syntax-meta-diff-range: #d2a8ff;
--color-prettylights-syntax-brackethighlighter-angle: #8b949e;
--color-prettylights-syntax-sublimelinter-gutter-mark: #484f58;
--color-prettylights-syntax-constant-other-reference-link: #a5d6ff;
/* 基础颜色 - 暗色主题 */
--color-fg-default: #ffffff;
--color-fg-h: #ffffff;
--color-fg-muted: rgba(255, 255, 255, 0.7);
--color-fg-subtle: rgba(255, 255, 255, 0.5);
--color-canvas-default: #141923;
--color-canvas-subtle: #202531;
--color-border-default: #525770;
--color-border-muted: #525770;
--color-neutral-muted: rgba(110, 118, 129, 0.4);
--color-accent-fg: #6e73fe;
--color-accent-emphasis: #6e73fe;
--color-attention-subtle: rgba(187, 128, 9, 0.15);
--color-danger-fg: #f64e54;
--color-primary-main: #6e73fe;
/* 代码块颜色 - 暗色主题 */
--code-bg: #141923;
--code-color: #ffffff;
--inline-code-bg: rgba(255, 255, 255, 0.1);
--inline-code-color: #ff7b72;
}
/* 暗色主题变量 */ /* 暗色主题变量 */
.markdown-body.md-dark { .markdown-body.md-dark {
/* 语法高亮颜色 - 暗色主题 */ /* 语法高亮颜色 - 暗色主题 */

View File

@ -11,6 +11,7 @@ import { message } from '@ctzhian/ui';
import Feedback from '@/components/feedback'; import Feedback from '@/components/feedback';
import { handleThinkingContent } from './utils'; import { handleThinkingContent } from './utils';
import { useSmartScroll } from '@/hooks'; import { useSmartScroll } from '@/hooks';
import { useTheme } from '@mui/material';
import { import {
IconCai, IconCai,
@ -107,7 +108,7 @@ const AiQaContent: React.FC<{
content: string; content: string;
chunk_result: ChunkResultItem; chunk_result: ChunkResultItem;
}> | null>(null); }> | null>(null);
const { palette } = useTheme();
const messageIdRef = useRef(''); const messageIdRef = useRef('');
const [fullAnswer, setFullAnswer] = useState<string>(''); const [fullAnswer, setFullAnswer] = useState<string>('');
const [conversation, setConversation] = useState<ConversationItem[]>([]); const [conversation, setConversation] = useState<ConversationItem[]>([]);
@ -661,7 +662,7 @@ const AiQaContent: React.FC<{
}, [conversation]); }, [conversation]);
return ( return (
<StyledMainContainer> <StyledMainContainer className={palette.mode === 'dark' ? 'md-dark' : ''}>
<StyledConversationContainer <StyledConversationContainer
direction='column' direction='column'
gap={2} gap={2}

View File

@ -37,7 +37,8 @@ export const StyledAccordion = styled(Accordion)(({ theme }) => ({
content: '""', content: '""',
height: 0, height: 0,
}, },
backgroundColor: theme.palette.background.default, background: 'transparent',
backgroundImage: 'none',
})); }));
export const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ export const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
@ -66,7 +67,8 @@ export const StyledQuestionText = styled(Box)(() => ({
// 搜索结果相关组件 // 搜索结果相关组件
export const StyledChunkAccordion = styled(Accordion)(({ theme }) => ({ export const StyledChunkAccordion = styled(Accordion)(({ theme }) => ({
backgroundColor: 'transparent', backgroundImage: 'none',
background: 'transparent',
border: 'none', border: 'none',
padding: 0, padding: 0,
paddingBottom: theme.spacing(2), paddingBottom: theme.spacing(2),
@ -140,7 +142,7 @@ export const StyledThinkingAccordionDetails = styled(AccordionDetails)(
// 操作区域组件 // 操作区域组件
export const StyledActionStack = styled(Stack)(({ theme }) => ({ export const StyledActionStack = styled(Stack)(({ theme }) => ({
fontSize: 12, fontSize: 12,
color: theme.palette.text.tertiary, color: alpha(theme.palette.text.primary, 0.75),
marginTop: theme.spacing(2), marginTop: theme.spacing(2),
})); }));

View File

@ -3,7 +3,15 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import Logo from '@/assets/images/logo.png'; import Logo from '@/assets/images/logo.png';
import { IconZhinengwenda, IconJinsousuo } from '@panda-wiki/icons'; import { IconZhinengwenda, IconJinsousuo } from '@panda-wiki/icons';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Box, Button, Typography, Modal, Stack, lighten } from '@mui/material'; import {
Box,
Button,
Typography,
Modal,
Stack,
lighten,
alpha,
} from '@mui/material';
import { CusTabs } from '@ctzhian/ui'; import { CusTabs } from '@ctzhian/ui';
import AiQaContent from './AiQaContent'; import AiQaContent from './AiQaContent';
import SearchDocContent from './SearchDocContent'; import SearchDocContent from './SearchDocContent';
@ -159,19 +167,14 @@ const QaModal: React.FC<QaModalProps> = () => {
<Button <Button
onClick={onClose} onClick={onClose}
size='small' size='small'
sx={{ sx={theme => ({
minWidth: 'auto', minWidth: 'auto',
px: 1.5, px: 1.5,
py: 0.5, py: 0.5,
bgcolor: 'background.paper3',
color: 'text.tertiary',
fontSize: 12, fontSize: 12,
fontWeight: 500, fontWeight: 500,
textTransform: 'none', textTransform: 'none',
'&:hover': { })}
bgcolor: 'grey.200',
},
}}
> >
Esc Esc
</Button> </Button>

View File

@ -1,86 +1,128 @@
'use client'; 'use client';
import React, { useState } from 'react';
import { useStore } from '@/provider'; import { useStore } from '@/provider';
import { Stack, Tooltip, Fab, Zoom } from '@mui/material'; import { Modal } from '@ctzhian/ui';
import { usePathname, useParams } from 'next/navigation';
import MenuIcon from '@mui/icons-material/Menu';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import MenuIcon from '@mui/icons-material/Menu';
import {
Fab,
FormControlLabel,
Radio,
RadioGroup,
Stack,
Tooltip,
Zoom,
} from '@mui/material';
import { useParams, usePathname } from 'next/navigation';
import { useState } from 'react';
const DocFab = () => { const DocFab = () => {
const pathname = usePathname(); const pathname = usePathname();
const { id: docId } = useParams() || {}; const { id: docId } = useParams() || {};
const { kbDetail, mobile } = useStore(); const { kbDetail, mobile } = useStore();
const [showActions, setShowActions] = useState(false); const [showActions, setShowActions] = useState(false);
const [contentType, setContentType] = useState<'html' | 'md'>('html');
const [openSelectContentTypeModal, setOpenSelectContentTypeModal] =
useState(false);
if (mobile) return null; if (mobile) return null;
return ( return (
<Stack <>
gap={1} <Modal
sx={{ title='新建文档类型'
position: 'fixed', open={openSelectContentTypeModal}
bottom: 70, onCancel={() => {
right: 16, setOpenSelectContentTypeModal(false);
zIndex: 10000, setContentType('html');
}} }}
onMouseLeave={() => setShowActions(false)} onOk={() => {
> setOpenSelectContentTypeModal(false);
{kbDetail?.settings.contribute_settings?.is_enable && ( window.open(`/editor?contentType=${contentType}`, '_blank');
<> }}
<Zoom >
in={showActions} <RadioGroup
style={{ transitionDelay: showActions ? '100ms' : '0ms' }} value={contentType}
> onChange={e => setContentType(e.target.value as 'html' | 'md')}
<Tooltip title='创建文档' placement='left' arrow> >
<Fab <FormControlLabel
color='primary' value='html'
size='small' control={<Radio size='small' />}
onClick={() => { label='富文本'
window.open(`/editor`, '_blank'); />
}} <FormControlLabel
> value='md'
<AddIcon /> control={<Radio size='small' />}
</Fab> label='Markdown'
</Tooltip> />
</Zoom> </RadioGroup>
{pathname.startsWith('/node/') && ( </Modal>
<Stack
gap={1}
sx={{
position: 'fixed',
bottom: 70,
right: 16,
zIndex: 10000,
}}
onMouseLeave={() => setShowActions(false)}
>
{kbDetail?.settings.contribute_settings?.is_enable && (
<>
<Zoom <Zoom
in={showActions} in={showActions}
style={{ transitionDelay: showActions ? '40ms' : '0ms' }} style={{ transitionDelay: showActions ? '100ms' : '0ms' }}
> >
<Tooltip title='编辑文档' placement='left' arrow> <Tooltip title='创建文档' placement='left' arrow>
<Fab <Fab
color='primary' color='primary'
size='small' size='small'
onClick={() => { onClick={() => {
window.open(`/editor/${docId}`, '_blank'); setOpenSelectContentTypeModal(true);
}} }}
> >
<EditIcon /> <AddIcon />
</Fab> </Fab>
</Tooltip> </Tooltip>
</Zoom> </Zoom>
)} {pathname.startsWith('/node/') && (
<Fab <Zoom
size='small' in={showActions}
sx={{ style={{ transitionDelay: showActions ? '40ms' : '0ms' }}
backgroundColor: 'background.paper2', >
color: 'text.secondary', <Tooltip title='编辑文档' placement='left' arrow>
'&:hover': { backgroundColor: 'background.paper2' }, <Fab
}} color='primary'
onMouseEnter={() => setShowActions(true)} size='small'
> onClick={() => {
<MenuIcon window.open(`/editor/${docId}`, '_blank');
}}
>
<EditIcon />
</Fab>
</Tooltip>
</Zoom>
)}
<Fab
size='small'
sx={{ sx={{
transition: 'transform 200ms', backgroundColor: 'background.paper2',
transform: showActions ? 'rotate(90deg)' : 'rotate(0deg)', color: 'text.secondary',
'&:hover': { backgroundColor: 'background.paper2' },
}} }}
/> onMouseEnter={() => setShowActions(true)}
</Fab> >
</> <MenuIcon
)} sx={{
</Stack> transition: 'transform 200ms',
transform: showActions ? 'rotate(90deg)' : 'rotate(0deg)',
}}
/>
</Fab>
</>
)}
</Stack>
</>
); );
}; };

View File

@ -376,6 +376,7 @@ export interface GithubComChaitinPandaWikiProApiContributeV1ContributeListResp {
} }
export interface GithubComChaitinPandaWikiProApiContributeV1NodeMeta { export interface GithubComChaitinPandaWikiProApiContributeV1NodeMeta {
content_type?: string;
doc_width?: string; doc_width?: string;
emoji?: string; emoji?: string;
} }
@ -492,6 +493,7 @@ export type GithubComChaitinPandaWikiProApiShareV1OAuthCallbackResp = Record<
export interface GithubComChaitinPandaWikiProApiShareV1SubmitContributeReq { export interface GithubComChaitinPandaWikiProApiShareV1SubmitContributeReq {
captcha_token: string; captcha_token: string;
content?: string; content?: string;
content_type: "html" | "md";
emoji?: string; emoji?: string;
name?: string; name?: string;
node_id?: string; node_id?: string;

View File

@ -134,6 +134,25 @@ export enum ConstsSourceType {
SourceTypeOpenAIAPI = "openai_api", SourceTypeOpenAIAPI = "openai_api",
} }
export enum ConstsNodeRagInfoStatus {
/** 等待基础处理 */
NodeRagStatusBasicPending = "BASIC_PENDING",
/** 正在进行基础处理(文本分割、向量化等) */
NodeRagStatusBasicRunning = "BASIC_RUNNING",
/** 基础处理失败 */
NodeRagStatusBasicFailed = "BASIC_FAILED",
/** 基础处理成功 */
NodeRagStatusBasicSucceeded = "BASIC_SUCCEEDED",
/** 基础处理完成,等待增强处理 */
NodeRagStatusEnhancePending = "ENHANCE_PENDING",
/** 正在进行增强处理(关键词提取等) */
NodeRagStatusEnhanceRunning = "ENHANCE_RUNNING",
/** 增强处理失败 */
NodeRagStatusEnhanceFailed = "ENHANCE_FAILED",
/** 增强处理成功 */
NodeRagStatusEnhanceSucceeded = "ENHANCE_SUCCEEDED",
}
export enum ConstsNodePermName { export enum ConstsNodePermName {
/** 导航内可见 */ /** 导航内可见 */
NodePermNameVisible = "visible", NodePermNameVisible = "visible",
@ -474,6 +493,16 @@ export interface DomainBatchMoveReq {
parent_id?: string; parent_id?: string;
} }
export interface DomainBlockGridConfig {
list?: {
id?: string;
name?: string;
url?: string;
}[];
title?: string;
type?: string;
}
export interface DomainBrandGroup { export interface DomainBrandGroup {
links?: DomainLink[]; links?: DomainLink[];
name?: string; name?: string;
@ -937,6 +966,7 @@ export interface DomainNodeListItemResp {
parent_id?: string; parent_id?: string;
permissions?: DomainNodePermissions; permissions?: DomainNodePermissions;
position?: number; position?: number;
rag_info?: DomainRagInfo;
status?: DomainNodeStatus; status?: DomainNodeStatus;
summary?: string; summary?: string;
type?: DomainNodeType; type?: DomainNodeType;
@ -1082,6 +1112,20 @@ export interface DomainProviderModelListItem {
model?: string; model?: string;
} }
export interface DomainQuestionConfig {
list?: {
id?: string;
question?: string;
}[];
title?: string;
type?: string;
}
export interface DomainRagInfo {
message?: string;
status?: ConstsNodeRagInfoStatus;
}
export interface DomainRecommendNodeListResp { export interface DomainRecommendNodeListResp {
emoji?: string; emoji?: string;
id?: string; id?: string;
@ -1241,6 +1285,7 @@ export interface DomainWebAppCustomSettings {
export interface DomainWebAppLandingConfig { export interface DomainWebAppLandingConfig {
banner_config?: DomainBannerConfig; banner_config?: DomainBannerConfig;
basic_doc_config?: DomainBasicDocConfig; basic_doc_config?: DomainBasicDocConfig;
block_grid_config?: DomainBlockGridConfig;
carousel_config?: DomainCarouselConfig; carousel_config?: DomainCarouselConfig;
case_config?: DomainCaseConfig; case_config?: DomainCaseConfig;
com_config_order?: string[]; com_config_order?: string[];
@ -1251,6 +1296,7 @@ export interface DomainWebAppLandingConfig {
img_text_config?: DomainImgTextConfig; img_text_config?: DomainImgTextConfig;
metrics_config?: DomainMetricsConfig; metrics_config?: DomainMetricsConfig;
node_ids?: string[]; node_ids?: string[];
question_config?: DomainQuestionConfig;
simple_doc_config?: DomainSimpleDocConfig; simple_doc_config?: DomainSimpleDocConfig;
text_config?: DomainTextConfig; text_config?: DomainTextConfig;
text_img_config?: DomainTextImgConfig; text_img_config?: DomainTextImgConfig;
@ -1260,6 +1306,7 @@ export interface DomainWebAppLandingConfig {
export interface DomainWebAppLandingConfigResp { export interface DomainWebAppLandingConfigResp {
banner_config?: DomainBannerConfig; banner_config?: DomainBannerConfig;
basic_doc_config?: DomainBasicDocConfig; basic_doc_config?: DomainBasicDocConfig;
block_grid_config?: DomainBlockGridConfig;
carousel_config?: DomainCarouselConfig; carousel_config?: DomainCarouselConfig;
case_config?: DomainCaseConfig; case_config?: DomainCaseConfig;
com_config_order?: string[]; com_config_order?: string[];
@ -1271,6 +1318,7 @@ export interface DomainWebAppLandingConfigResp {
metrics_config?: DomainMetricsConfig; metrics_config?: DomainMetricsConfig;
node_ids?: string[]; node_ids?: string[];
nodes?: DomainRecommendNodeListResp[]; nodes?: DomainRecommendNodeListResp[];
question_config?: DomainQuestionConfig;
simple_doc_config?: DomainSimpleDocConfig; simple_doc_config?: DomainSimpleDocConfig;
text_config?: DomainTextConfig; text_config?: DomainTextConfig;
text_img_config?: DomainTextImgConfig; text_img_config?: DomainTextImgConfig;

View File

@ -1,4 +1,3 @@
import { Box, Drawer, IconButton, Stack } from '@mui/material';
import { import {
H1Icon, H1Icon,
H2Icon, H2Icon,
@ -9,12 +8,15 @@ import {
TocList, TocList,
} from '@ctzhian/tiptap'; } from '@ctzhian/tiptap';
import { Ellipsis, Icon } from '@ctzhian/ui'; import { Ellipsis, Icon } from '@ctzhian/ui';
import { Box, Drawer, IconButton, Stack } from '@mui/material';
import { useState } from 'react'; import { useState } from 'react';
interface TocProps { interface TocProps {
headings: TocList; headings: TocList;
fixed: boolean; fixed: boolean;
setFixed: (fixed: boolean) => void; setFixed: (fixed: boolean) => void;
isMarkdown: boolean;
scrollToHeading?: (headingText: string) => void;
} }
const HeadingIcon = [ const HeadingIcon = [
@ -32,7 +34,13 @@ const HeadingSx = [
{ fontSize: 14, fontWeight: 400, color: 'text.disabled' }, { fontSize: 14, fontWeight: 400, color: 'text.disabled' },
]; ];
const Toc = ({ headings, fixed, setFixed }: TocProps) => { const Toc = ({
headings,
fixed,
setFixed,
scrollToHeading,
isMarkdown,
}: TocProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const levels = Array.from( const levels = Array.from(
new Set(headings.map(it => it.level).sort((a, b) => a - b)), new Set(headings.map(it => it.level).sort((a, b) => a - b)),
@ -164,15 +172,40 @@ const Toc = ({ headings, fixed, setFixed }: TocProps) => {
onClick={() => { onClick={() => {
const element = document.getElementById(it.id); const element = document.getElementById(it.id);
if (element) { if (element) {
const offset = 100; if (isMarkdown) {
const elementPosition = const container = document.getElementById(
element.getBoundingClientRect().top; 'markdown-preview-container',
const offsetPosition = );
elementPosition + window.pageYOffset - offset; if (container) {
window.scrollTo({ const containerRect =
top: offsetPosition, container.getBoundingClientRect();
behavior: 'smooth', const elementRect = element.getBoundingClientRect();
}); const offset = 20; // 顶部偏移
const scrollTop =
container.scrollTop +
elementRect.top -
containerRect.top -
offset;
container.scrollTo({
top: scrollTop,
behavior: 'smooth',
});
}
// 同时滚动 AceEditor
if (scrollToHeading) {
scrollToHeading(it.textContent);
}
} else {
const offset = 100;
const elementPosition =
element.getBoundingClientRect().top;
const offsetPosition =
elementPosition + window.pageYOffset - offset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
});
}
} }
}} }}
> >

View File

@ -2,13 +2,20 @@
import Emoji from '@/components/emoji'; import Emoji from '@/components/emoji';
import { postShareProV1FileUploadWithProgress } from '@/request/pro/otherCustomer'; import { postShareProV1FileUploadWithProgress } from '@/request/pro/otherCustomer';
import { V1NodeDetailResp } from '@/request/types'; import { V1NodeDetailResp } from '@/request/types';
import { Editor, TocList, useTiptap, UseTiptapReturn } from '@ctzhian/tiptap'; import {
Editor,
EditorMarkdown,
MarkdownEditorRef,
TocList,
useTiptap,
UseTiptapReturn,
} from '@ctzhian/tiptap';
import { message } from '@ctzhian/ui'; import { message } from '@ctzhian/ui';
import { Box, Stack, TextField } from '@mui/material'; import { Box, Stack, TextField } from '@mui/material';
import { IconAShijian2, IconZiti } from '@panda-wiki/icons'; import { IconAShijian2, IconZiti } from '@panda-wiki/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useParams } from 'next/navigation'; import { useParams, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWrapContext } from '..'; import { useWrapContext } from '..';
import AIGenerate from './AIGenerate'; import AIGenerate from './AIGenerate';
import ConfirmModal from './ConfirmModal'; import ConfirmModal from './ConfirmModal';
@ -21,9 +28,12 @@ interface WrapProps {
} }
const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => { const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
const { catalogOpen, nodeDetail, setNodeDetail, onSave } = useWrapContext(); const searchParams = useSearchParams();
const contentType = searchParams.get('contentType') || 'html';
const { nodeDetail, setNodeDetail, onSave } = useWrapContext();
const { id } = useParams(); const { id } = useParams();
const markdownEditorRef = useRef<MarkdownEditorRef>(null);
const [characterCount, setCharacterCount] = useState(0); const [characterCount, setCharacterCount] = useState(0);
const [headings, setHeadings] = useState<TocList>([]); const [headings, setHeadings] = useState<TocList>([]);
const [fixedToc, setFixedToc] = useState(false); const [fixedToc, setFixedToc] = useState(false);
@ -31,6 +41,14 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
const [aiGenerateOpen, setAiGenerateOpen] = useState(false); const [aiGenerateOpen, setAiGenerateOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [confirmModalOpen, setConfirmModalOpen] = useState(false); const [confirmModalOpen, setConfirmModalOpen] = useState(false);
const isMarkdown = useMemo(() => {
if (!id && contentType === 'md') {
return true;
}
return defaultDetail.meta?.content_type === 'md';
}, [defaultDetail.meta?.content_type, contentType]);
const updateDetail = (value: V1NodeDetailResp) => { const updateDetail = (value: V1NodeDetailResp) => {
setNodeDetail({ setNodeDetail({
...nodeDetail, ...nodeDetail,
@ -73,8 +91,9 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
}; };
const editorRef = useTiptap({ const editorRef = useTiptap({
editable: true,
immediatelyRender: false, immediatelyRender: false,
editable: !isMarkdown,
contentType: isMarkdown ? 'markdown' : 'html',
content: defaultDetail?.content || '', content: defaultDetail?.content || '',
exclude: ['invisibleCharacters', 'youtube', 'mention'], exclude: ['invisibleCharacters', 'youtube', 'mention'],
onCreate: ({ editor: tiptapEditor }) => { onCreate: ({ editor: tiptapEditor }) => {
@ -106,17 +125,33 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') { if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault(); event.preventDefault();
if (editorRef && editorRef.editor) { if (editorRef && editorRef.editor) {
const html = editorRef.getHTML(); const value = editorRef.getContent();
updateDetail({ updateDetail({
content: html, content: value,
}); });
setConfirmModalOpen(true); setTimeout(() => {
if (checkRequiredFields()) {
setConfirmModalOpen(true);
}
}, 10);
} }
} }
}, },
[editorRef, onSave], [editorRef],
); );
const checkRequiredFields = useCallback(() => {
if (!nodeDetail?.name?.trim()) {
message.error('请先输入文档名称');
return false;
}
if (!nodeDetail?.content?.trim()) {
message.error('请先输入文档内容');
return false;
}
return true;
}, [nodeDetail]);
useEffect(() => { useEffect(() => {
document.addEventListener('keydown', handleGlobalSave); document.addEventListener('keydown', handleGlobalSave);
return () => { return () => {
@ -148,10 +183,14 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
detail={nodeDetail!} detail={nodeDetail!}
updateDetail={updateDetail} updateDetail={updateDetail}
handleSave={async () => { handleSave={async () => {
setConfirmModalOpen(true); if (checkRequiredFields()) {
setConfirmModalOpen(true);
}
}} }}
/> />
<Toolbar editorRef={editorRef} handleAiGenerate={handleAiGenerate} /> {!isMarkdown && (
<Toolbar editorRef={editorRef} handleAiGenerate={handleAiGenerate} />
)}
</Box> </Box>
<Box <Box
sx={{ sx={{
@ -163,8 +202,8 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
<Box <Box
sx={{ sx={{
width: `calc(100vw - 160px - ${fixedToc ? 292 : 0}px)`, width: `calc(100vw - 160px - ${fixedToc ? 292 : 0}px)`,
p: '72px 80px 150px', p: isMarkdown ? '72px 80px' : '72px 80px 150px',
mt: '102px', mt: isMarkdown ? '56px' : '102px',
mx: 'auto', mx: 'auto',
}} }}
> >
@ -253,24 +292,52 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
{characterCount} {characterCount}
</Stack> </Stack>
</Stack> </Stack>
{editorRef.editor && (
<Box <Box sx={{ ...(fixedToc && { display: 'flex' }) }}>
sx={{ {isMarkdown ? (
wordBreak: 'break-all', <EditorMarkdown
'.tiptap.ProseMirror': { ref={markdownEditorRef}
overflowX: 'hidden', editor={editorRef.editor}
minHeight: 'calc(100vh - 102px - 48px)', value={nodeDetail?.content || defaultDetail?.content || ''}
}, onAceChange={value => {
'.tableWrapper': { updateDetail({
width: '100%', content: value,
overflowX: 'auto', });
}, }}
}} height='calc(100vh - 340px)'
> />
{editorRef.editor && <Editor editor={editorRef.editor} />} ) : (
</Box> <Box
sx={{
wordBreak: 'break-all',
'.tiptap.ProseMirror': {
overflowX: 'hidden',
minHeight: 'calc(100vh - 102px - 48px)',
},
'.tableWrapper': {
width: '100%',
overflowX: 'auto',
},
}}
>
<Editor editor={editorRef.editor} />
</Box>
)}
</Box>
)}
</Box> </Box>
<Toc headings={headings} fixed={fixedToc} setFixed={setFixedToc} /> <Toc
headings={headings}
fixed={fixedToc}
setFixed={setFixedToc}
isMarkdown={isMarkdown}
scrollToHeading={
isMarkdown
? headingText =>
markdownEditorRef.current?.scrollToHeading(headingText)
: undefined
}
/>
</Box> </Box>
<AIGenerate <AIGenerate
@ -284,11 +351,11 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
open={confirmModalOpen} open={confirmModalOpen}
onCancel={() => setConfirmModalOpen(false)} onCancel={() => setConfirmModalOpen(false)}
onOk={async (reason: string, token: string) => { onOk={async (reason: string, token: string) => {
const value = editorRef.getHTML(); const value = editorRef.getContent();
updateDetail({ updateDetail({
content: value, content: value,
}); });
await onSave(value, reason, token); await onSave(value, reason, token, isMarkdown ? 'md' : 'html');
setConfirmModalOpen(false); setConfirmModalOpen(false);
}} }}
/> />

View File

@ -1,11 +1,10 @@
'use client'; 'use client';
import { createContext, useContext } from 'react';
import { postShareProV1ContributeSubmit } from '@/request/pro/ShareContribute'; import { postShareProV1ContributeSubmit } from '@/request/pro/ShareContribute';
import { V1NodeDetailResp } from '@/request/types'; import { V1NodeDetailResp } from '@/request/types';
import { useParams, useRouter } from 'next/navigation';
import { Box, Stack, useMediaQuery } from '@mui/material';
import { message } from '@ctzhian/ui'; import { message } from '@ctzhian/ui';
import { useEffect, useState } from 'react'; import { Box, Stack, useMediaQuery } from '@mui/material';
import { useParams } from 'next/navigation';
import { createContext, useContext, useEffect, useState } from 'react';
import Edit from './edit'; import Edit from './edit';
export interface WrapContext { export interface WrapContext {
@ -13,7 +12,12 @@ export interface WrapContext {
setCatalogOpen: (open: boolean) => void; setCatalogOpen: (open: boolean) => void;
nodeDetail: V1NodeDetailResp | null; nodeDetail: V1NodeDetailResp | null;
setNodeDetail: (detail: V1NodeDetailResp) => void; setNodeDetail: (detail: V1NodeDetailResp) => void;
onSave: (content: string, reason: string, token: string) => void; onSave: (
content: string,
reason: string,
token: string,
contentType?: 'html' | 'md',
) => void;
saveLoading: boolean; saveLoading: boolean;
} }
@ -41,7 +45,12 @@ const DocEditor = () => {
); );
const [catalogOpen, setCatalogOpen] = useState(true); const [catalogOpen, setCatalogOpen] = useState(true);
const onSave = (content: string, reason: string, token: string) => { const onSave = (
content: string,
reason: string,
token: string,
contentType?: 'html' | 'md',
) => {
setSaveLoading(true); setSaveLoading(true);
return postShareProV1ContributeSubmit({ return postShareProV1ContributeSubmit({
node_id: id ? id[0] : undefined, node_id: id ? id[0] : undefined,
@ -51,6 +60,7 @@ const DocEditor = () => {
reason, reason,
emoji: nodeDetail?.meta?.emoji, emoji: nodeDetail?.meta?.emoji,
captcha_token: token, captcha_token: token,
content_type: contentType || 'html',
}).then(() => { }).then(() => {
message.success('保存成功, 即将关闭页面'); message.success('保存成功, 即将关闭页面');
setTimeout(() => { setTimeout(() => {

View File

@ -1,42 +1,11 @@
'use client'; 'use client';
import { import { Banner } from '@panda-wiki/ui';
Banner, import dynamic from 'next/dynamic';
Faq,
BasicDoc,
DirDoc,
SimpleDoc,
Carousel,
Text,
Case,
Metrics,
Feature,
ImgText,
Comment,
} from '@panda-wiki/ui';
import { DomainRecommendNodeListResp } from '@/request/types'; import { DomainRecommendNodeListResp } from '@/request/types';
import { useStore } from '@/provider'; import { useStore } from '@/provider';
const handleHeaderProps = (setting: any) => {
return {
title: setting.title,
logo: setting.icon,
btns: setting.btns,
placeholder:
setting.web_app_custom_style?.header_search_placeholder || '搜索...',
};
};
const handleFooterProps = (setting: any) => {
return {
footerSetting: setting.footer_settings,
logo: setting.icon,
showBrand: setting.web_app_custom_style?.show_brand_info || false,
customStyle: setting.web_app_custom_style,
};
};
const handleFaqProps = (config: any = {}) => { const handleFaqProps = (config: any = {}) => {
return { return {
title: config.title || '链接组', title: config.title || '链接组',
@ -173,20 +142,40 @@ const handleCommentProps = (config: any = {}) => {
}; };
}; };
const handleBlockGridProps = (config: any = {}) => {
return {
title: config.title || '区块网格',
items: config.list || [],
};
};
const handleQuestionProps = (config: any = {}) => {
return {
title: config.title || '常见问题',
items: config.list || [],
};
};
const componentMap = { const componentMap = {
banner: Banner, banner: Banner,
basic_doc: BasicDoc, basic_doc: dynamic(() => import('@panda-wiki/ui').then(mod => mod.BasicDoc)),
dir_doc: DirDoc, dir_doc: dynamic(() => import('@panda-wiki/ui').then(mod => mod.DirDoc)),
simple_doc: SimpleDoc, simple_doc: dynamic(() =>
carousel: Carousel, import('@panda-wiki/ui').then(mod => mod.SimpleDoc),
faq: Faq, ),
text: Text, carousel: dynamic(() => import('@panda-wiki/ui').then(mod => mod.Carousel)),
case: Case, faq: dynamic(() => import('@panda-wiki/ui').then(mod => mod.Faq)),
metrics: Metrics, text: dynamic(() => import('@panda-wiki/ui').then(mod => mod.Text)),
feature: Feature, case: dynamic(() => import('@panda-wiki/ui').then(mod => mod.Case)),
text_img: ImgText, metrics: dynamic(() => import('@panda-wiki/ui').then(mod => mod.Metrics)),
img_text: ImgText, feature: dynamic(() => import('@panda-wiki/ui').then(mod => mod.Feature)),
comment: Comment, text_img: dynamic(() => import('@panda-wiki/ui').then(mod => mod.ImgText)),
img_text: dynamic(() => import('@panda-wiki/ui').then(mod => mod.ImgText)),
comment: dynamic(() => import('@panda-wiki/ui').then(mod => mod.Comment)),
block_grid: dynamic(() =>
import('@panda-wiki/ui').then(mod => mod.BlockGrid),
),
question: dynamic(() => import('@panda-wiki/ui').then(mod => mod.Question)),
} as const; } as const;
const Welcome = () => { const Welcome = () => {
@ -220,6 +209,8 @@ const Welcome = () => {
text_img: 'text_img_config', text_img: 'text_img_config',
img_text: 'img_text_config', img_text: 'img_text_config',
comment: 'comment_config', comment: 'comment_config',
block_grid: 'block_grid_config',
question: 'question_config',
} as const; } as const;
const handleComponentProps = (data: any) => { const handleComponentProps = (data: any) => {
@ -243,7 +234,6 @@ const Welcome = () => {
return { return {
...handleBannerProps(config), ...handleBannerProps(config),
onSearch: onBannerSearch, onSearch: onBannerSearch,
onQaClick: () => setQaModalOpen?.(true),
btns: (config?.btns || []).map((item: any) => ({ btns: (config?.btns || []).map((item: any) => ({
...item, ...item,
href: item.href || '/node', href: item.href || '/node',
@ -263,6 +253,15 @@ const Welcome = () => {
return handleImgTextProps(config); return handleImgTextProps(config);
case 'comment': case 'comment':
return handleCommentProps(config); return handleCommentProps(config);
case 'block_grid':
return handleBlockGridProps(config);
case 'question':
return {
...handleQuestionProps(config),
onSearch: (text: string) => {
onBannerSearch(text, 'chat');
},
};
} }
}; };
return ( return (

View File

@ -18,7 +18,7 @@
"license": "ISC", "license": "ISC",
"packageManager": "pnpm@10.12.1", "packageManager": "pnpm@10.12.1",
"dependencies": { "dependencies": {
"@ctzhian/tiptap": "^1.10.3", "@ctzhian/tiptap": "^1.11.4",
"@ctzhian/ui": "^7.0.5", "@ctzhian/ui": "^7.0.5",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",

View File

@ -0,0 +1,17 @@
import React from 'react';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
const IconJiugongge = (props: SvgIconProps) => (
<SvgIcon
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1117 1024'
{...props}
>
<path d='M1061.236364 0H55.854545C22.341818 0 0 22.341818 0 55.854545v893.672728c0 33.512727 22.341818 55.854545 55.854545 55.854545h1005.381819c33.512727 0 55.854545-22.341818 55.854545-55.854545V55.854545c0-33.512727-22.341818-55.854545-55.854545-55.854545zM111.709091 893.672727V111.709091h893.672727v781.963636H111.709091z'></path>
<path d='M279.412364 392.424727h159.604363V232.866909H279.412364v159.557818z m199.493818 0h159.557818V232.866909h-159.557818v159.557818z m199.493818-159.557818v159.557818h159.557818V232.866909h-159.557818z m-398.987636 359.098182h159.604363v-159.650909H279.412364v159.650909z m199.493818 0h159.557818v-159.650909h-159.557818v159.650909z m199.493818 0h159.557818v-159.650909h-159.557818v159.650909z m-398.987636 199.447273h159.604363v-159.557819H279.412364v159.557819z m199.493818 0h159.557818v-159.557819h-159.557818v159.557819z m199.493818 0h159.557818v-159.557819h-159.557818v159.557819z'></path>
</SvgIcon>
);
IconJiugongge.displayName = 'icon-jiugongge';
export default IconJiugongge;

View File

@ -0,0 +1,17 @@
import React from 'react';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
const IconLianjiezu1 = (props: SvgIconProps) => (
<SvgIcon
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1117 1024'
{...props}
>
<path d='M1061.236364 0H55.854545C22.341818 0 0 22.341818 0 55.854545v893.672728c0 33.512727 22.341818 55.854545 55.854545 55.854545h1005.381819c33.512727 0 55.854545-22.341818 55.854545-55.854545V55.854545c0-33.512727-22.341818-55.854545-55.854545-55.854545zM111.709091 893.672727V111.709091h893.672727v781.963636H111.709091z'></path>
<path d='M442.926545 644.096l8.145455 6.283636a174.08 174.08 0 0 0 85.504 35.560728l10.24 1.256727v70.376727l-12.8-1.256727a245.061818 245.061818 0 0 1-130.653091-54.272l-9.914182-8.098909 9.029818-9.122909 33.18691-33.419637 7.26109-7.307636z m230.958546 0l49.803636 49.803636-9.960727 8.145455a244.270545 244.270545 0 0 1-130.653091 54.272l-12.753454 1.256727v-70.376727l10.193454-1.256727a172.357818 172.357818 0 0 0 85.224727-35.514182l8.145455-6.330182z m130.234182-120.087273l-1.256728 12.753455a245.061818 245.061818 0 0 1-54.272 130.653091l-8.145454 9.960727-49.803636-49.803636 6.283636-8.098909c19.362909-24.948364 31.697455-54.225455 35.560727-85.271273l1.256727-10.24h70.376728z m-420.770909-0.232727l1.256727 10.193455c3.863273 31.278545 16.197818 60.509091 35.514182 85.224727l6.330182 8.145454-49.803637 49.803637-8.145454-9.960728a244.270545 244.270545 0 0 1-54.272-130.65309l-1.256728-12.753455h70.376728zM558.545455 372.363636c77.265455 0 139.636364 62.370909 139.636363 139.636364s-62.370909 139.636364-139.636363 139.636364-139.636364-62.370909-139.636364-139.636364 62.370909-139.636364 139.636364-139.636364z m-181.946182-25.460363l9.122909 9.029818 33.419636 33.186909 7.307637 7.261091-6.283637 8.145454a174.08 174.08 0 0 0-35.560727 85.504l-1.256727 10.24H312.971636l1.256728-12.8a244.270545 244.270545 0 0 1 54.272-130.65309l8.098909-9.914182z m363.892363 0l8.098909 9.914182c30.440727 37.236364 49.477818 82.385455 54.272 130.65309l1.256728 12.753455h-70.376728l-1.256727-10.193455a174.08 174.08 0 0 0-35.560727-85.504l-6.283636-8.145454 7.307636-7.261091 33.419636-33.186909 9.122909-9.029818z m-170.170181-80.523637l12.753454 1.303273a244.270545 244.270545 0 0 1 130.653091 54.272l9.914182 8.098909-9.029818 9.122909-33.186909 33.419637-7.261091 7.307636-8.145455-6.283636a174.08 174.08 0 0 0-85.504-35.560728l-10.24-1.256727V266.426182z m-23.552 0v70.423273l-10.193455 1.256727a174.08 174.08 0 0 0-85.504 35.560728l-8.145455 6.283636-7.26109-7.307636-33.18691-33.419637-9.029818-9.122909 9.914182-8.098909a244.270545 244.270545 0 0 1 130.653091-54.272l12.753455-1.256727z'></path>
</SvgIcon>
);
IconLianjiezu1.displayName = 'icon-lianjiezu1';
export default IconLianjiezu1;

View File

@ -0,0 +1,16 @@
import React from 'react';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
const IconWenhao = (props: SvgIconProps) => (
<SvgIcon
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1024 1024'
{...props}
>
<path d='M469.3504 768h85.2992v-85.3504H469.3504V768zM512 85.3504A426.8032 426.8032 0 0 0 85.3504 512c0 235.52 191.1296 426.6496 426.6496 426.6496S938.6496 747.52 938.6496 512 747.52 85.3504 512 85.3504z m0 768A341.8112 341.8112 0 0 1 170.6496 512 341.8112 341.8112 0 0 1 512 170.6496 341.8112 341.8112 0 0 1 853.3504 512 341.8112 341.8112 0 0 1 512 853.3504zM512 256a170.5984 170.5984 0 0 0-170.6496 170.6496h85.2992c0-46.8992 38.4-85.2992 85.3504-85.2992s85.3504 38.4 85.3504 85.2992c0 85.3504-128 74.7008-128 213.3504h85.2992c0-96 128-106.6496 128-213.3504A170.5984 170.5984 0 0 0 512 256z'></path>
</SvgIcon>
);
IconWenhao.displayName = 'icon-wenhao';
export default IconWenhao;

View File

@ -109,6 +109,7 @@ export { default as IconJichuwendang } from './IconJichuwendang';
export { default as IconJina } from './IconJina'; export { default as IconJina } from './IconJina';
export { default as IconJinggao } from './IconJinggao'; export { default as IconJinggao } from './IconJinggao';
export { default as IconJinsousuo } from './IconJinsousuo'; export { default as IconJinsousuo } from './IconJinsousuo';
export { default as IconJiugongge } from './IconJiugongge';
export { default as IconJushou } from './IconJushou'; export { default as IconJushou } from './IconJushou';
export { default as IconKefu } from './IconKefu'; export { default as IconKefu } from './IconKefu';
export { default as IconKehuanli } from './IconKehuanli'; export { default as IconKehuanli } from './IconKehuanli';
@ -120,6 +121,7 @@ export { default as IconLDAP } from './IconLDAP';
export { default as IconLanyun } from './IconLanyun'; export { default as IconLanyun } from './IconLanyun';
export { default as IconLepton } from './IconLepton'; export { default as IconLepton } from './IconLepton';
export { default as IconLianjiezu } from './IconLianjiezu'; export { default as IconLianjiezu } from './IconLianjiezu';
export { default as IconLianjiezu1 } from './IconLianjiezu1';
export { default as IconLingyiwanwu } from './IconLingyiwanwu'; export { default as IconLingyiwanwu } from './IconLingyiwanwu';
export { default as IconLmstudio } from './IconLmstudio'; export { default as IconLmstudio } from './IconLmstudio';
export { default as IconLogoGroq } from './IconLogoGroq'; export { default as IconLogoGroq } from './IconLogoGroq';
@ -201,6 +203,7 @@ export { default as IconWeibo1 } from './IconWeibo1';
export { default as IconWeixingongzhonghao } from './IconWeixingongzhonghao'; export { default as IconWeixingongzhonghao } from './IconWeixingongzhonghao';
export { default as IconWeixingongzhonghaoDaiyanse } from './IconWeixingongzhonghaoDaiyanse'; export { default as IconWeixingongzhonghaoDaiyanse } from './IconWeixingongzhonghaoDaiyanse';
export { default as IconWendajiqiren } from './IconWendajiqiren'; export { default as IconWendajiqiren } from './IconWendajiqiren';
export { default as IconWenhao } from './IconWenhao';
export { default as IconWenjian } from './IconWenjian'; export { default as IconWenjian } from './IconWenjian';
export { default as IconWenjianjia } from './IconWenjianjia'; export { default as IconWenjianjia } from './IconWenjianjia';
export { default as IconWenjianjiaKai } from './IconWenjianjiaKai'; export { default as IconWenjianjiaKai } from './IconWenjianjiaKai';

View File

@ -46,11 +46,11 @@ export const THEME_LIST = [
value: 'darkDeepForest', value: 'darkDeepForest',
palette: darkDeepForestPalette, palette: darkDeepForestPalette,
}, },
{ // {
label: '深邃黑', // label: '深邃黑',
value: 'white', // value: 'white',
palette: whitePalette, // palette: whitePalette,
}, // },
{ {
label: '电光蓝', label: '电光蓝',
value: 'electricBlue', value: 'electricBlue',

View File

@ -132,7 +132,6 @@ interface BannerProps {
onSearch?: (value: string, type?: 'search' | 'chat') => void; onSearch?: (value: string, type?: 'search' | 'chat') => void;
onSearchSuggestions?: (query: string) => Promise<SearchSuggestion[]>; onSearchSuggestions?: (query: string) => Promise<SearchSuggestion[]>;
baseUrl?: string; baseUrl?: string;
onQaClick?: () => void;
} }
const Banner = React.memo( const Banner = React.memo(
@ -145,7 +144,6 @@ const Banner = React.memo(
onSearch, onSearch,
onSearchSuggestions, onSearchSuggestions,
baseUrl = '', baseUrl = '',
onQaClick,
}: BannerProps) => { }: BannerProps) => {
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]); const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);

View File

@ -4,7 +4,10 @@ import React from 'react';
import { styled, Grid, Box, alpha } from '@mui/material'; import { styled, Grid, Box, alpha } from '@mui/material';
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon'; import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
import IconWenjian from '@panda-wiki/icons/IconWenjian'; import IconWenjian from '@panda-wiki/icons/IconWenjian';
import { useFadeInText, useCardAnimation } from '../hooks/useGsapAnimation'; import {
useFadeInText,
useCardFadeInAnimation,
} from '../hooks/useGsapAnimation';
interface BasicDocProps { interface BasicDocProps {
mobile?: boolean; mobile?: boolean;
@ -33,9 +36,13 @@ const StyledBasicDocItem = styled('div')(({ theme }) => ({
transform: 'translateY(-5px)', transform: 'translateY(-5px)',
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`, boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
borderColor: theme.palette.primary.main, borderColor: theme.palette.primary.main,
'.basic-doc-item-title': {
color: theme.palette.primary.main,
},
}, },
width: '100%', width: '100%',
cursor: 'pointer', cursor: 'pointer',
opacity: 0,
})); }));
const StyledBasicDocItemTitle = styled('h3')(({ theme }) => ({ const StyledBasicDocItemTitle = styled('h3')(({ theme }) => ({
@ -74,7 +81,7 @@ const BasicDocItem: React.FC<{
baseUrl: string; baseUrl: string;
size: any; size: any;
}> = React.memo(({ item, index, baseUrl, size }) => { }> = React.memo(({ item, index, baseUrl, size }) => {
const cardRef = useCardAnimation(0.2 + index * 0.1, 0.1); const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
return ( return (
<Grid size={size} key={index}> <Grid size={size} key={index}>
@ -84,7 +91,7 @@ const BasicDocItem: React.FC<{
window.open(`${baseUrl}/node/${item.id}`, '_blank'); window.open(`${baseUrl}/node/${item.id}`, '_blank');
}} }}
> >
<StyledBasicDocItemTitle> <StyledBasicDocItemTitle className='basic-doc-item-title'>
{item.emoji ? ( {item.emoji ? (
<Box>{item.emoji}</Box> <Box>{item.emoji}</Box>
) : ( ) : (
@ -93,16 +100,6 @@ const BasicDocItem: React.FC<{
<StyledBasicDocItemName>{item.name}</StyledBasicDocItemName> <StyledBasicDocItemName>{item.name}</StyledBasicDocItemName>
</StyledBasicDocItemTitle> </StyledBasicDocItemTitle>
<StyledBasicDocItemSummary>{item.summary}</StyledBasicDocItemSummary> <StyledBasicDocItemSummary>{item.summary}</StyledBasicDocItemSummary>
<Box
sx={{
color: 'primary.main',
fontSize: 14,
fontWeight: 400,
alignSelf: 'flex-end',
}}
>
</Box>
</StyledBasicDocItem> </StyledBasicDocItem>
</Grid> </Grid>
); );

View File

@ -0,0 +1,113 @@
'use client';
import React from 'react';
import { styled, Grid, alpha, Stack } from '@mui/material';
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
import {
useFadeInText,
useCardFadeInAnimation,
} from '../hooks/useGsapAnimation';
interface BlockGridProps {
mobile?: boolean;
title?: string;
items?: {
name: string;
url: string;
}[];
}
const StyledBlockGridItem = styled(Stack)(({ theme }) => ({
aspectRatio: '1 / 1',
position: 'relative',
border: `1px solid ${alpha(theme.palette.text.primary, 0.15)}`,
borderRadius: '10px',
padding: theme.spacing(1),
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
transition: 'all 0.2s ease',
'&:hover': {
color: theme.palette.primary.main,
borderColor: theme.palette.primary.main,
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
},
opacity: 0,
}));
export const StyledBlockGridItemImgBox = styled('div')(({ theme }) => ({
flex: 1,
overflow: 'hidden',
}));
export const StyledBlockGridItemImg = styled('img')(({ theme }) => ({
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: '10px',
}));
const StyledBlockGridItemTitle = styled('div')(({ theme }) => ({
position: 'absolute',
bottom: '24px',
left: '50%',
maxWidth: 'calc(100% - 24px)',
transform: 'translateX(-50%)',
padding: theme.spacing(0.5, 1),
overflow: 'hidden',
textOverflow: 'ellipsis',
flexShrink: 0,
whiteSpace: 'nowrap',
fontSize: 14,
textAlign: 'center',
fontWeight: 700,
color: theme.palette.background.default,
backgroundColor: alpha(theme.palette.text.primary, 0.5),
borderRadius: '6px',
}));
// 单个卡片组件,带动画效果
const BlockGridItem: React.FC<{
item: {
name: string;
url: string;
};
index: number;
}> = React.memo(({ item, index }) => {
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
return (
<StyledBlockGridItem ref={cardRef as React.Ref<HTMLDivElement>} gap={2}>
<StyledBlockGridItemImgBox>
<StyledBlockGridItemImg src={item.url} />
</StyledBlockGridItemImgBox>
<StyledBlockGridItemTitle>{item.name}</StyledBlockGridItemTitle>
</StyledBlockGridItem>
);
});
const BlockGrid: React.FC<BlockGridProps> = React.memo(
({ title, items = [], mobile }) => {
const size =
typeof mobile === 'boolean'
? mobile
? 12
: { xs: 12, md: 4 }
: { xs: 12, md: 4 };
// 添加标题淡入动画
const titleRef = useFadeInText(0.2, 0.1);
return (
<StyledTopicBox>
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
<Grid container spacing={3} sx={{ width: '100%' }}>
{items.map((item, index) => (
<Grid size={size} key={index}>
<BlockGridItem item={item} index={index} />
</Grid>
))}
</Grid>
</StyledTopicBox>
);
},
);
export default BlockGrid;

View File

@ -1,6 +1,5 @@
.swiper { .swiper {
width: 100%; width: 100%;
height: 100%;
} }
.swiper-slide { .swiper-slide {
@ -9,26 +8,11 @@
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
padding-bottom: 40px;
} }
/* .swiper-slide-prev {
transform-origin: center right;
opacity: 0.4;
} */
/* .swiper-slide-next {
opacity: 0.4;
} */
.swiper-slide img { .swiper-slide img {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
/* 居中激活项保持原尺寸 */
/* .swiper-slide-active {
transform: scale(1) translateZ(0) !important;
z-index: 1;
} */

View File

@ -1,9 +1,17 @@
import { CSSProperties, memo, useRef, useCallback, useState } from 'react'; import {
import { styled, alpha, Tabs, Tab, Box } from '@mui/material'; CSSProperties,
memo,
useRef,
useCallback,
useState,
useEffect,
} from 'react';
import { styled, alpha, Tabs, Tab, Box, useTheme } from '@mui/material';
import { StyledTopicTitle, StyledTopicBox } from '../component/styledCommon'; import { StyledTopicTitle, StyledTopicBox } from '../component/styledCommon';
import { Swiper, SwiperSlide } from 'swiper/react'; import { Swiper, SwiperSlide } from 'swiper/react';
import { useFadeInText } from '../hooks/useGsapAnimation'; import { useFadeInText } from '../hooks/useGsapAnimation';
import { Swiper as SwiperType } from 'swiper'; import { Swiper as SwiperType } from 'swiper';
import { gsap } from 'gsap';
import 'swiper/css'; import 'swiper/css';
import 'swiper/css/pagination'; import 'swiper/css/pagination';
@ -51,6 +59,33 @@ const StyledSwiperSlideImg = styled('img')(({ theme }) => ({
borderRadius: '10px', borderRadius: '10px',
})); }));
const StyledSwiperSlideDesc = styled('div')(({ theme }) => ({
position: 'absolute',
bottom: '24px',
left: '50%',
transform: 'translateX(-50%)',
padding: theme.spacing(0.5, 1),
fontSize: 14,
fontWeight: 400,
color: theme.palette.background.default,
borderRadius: '6px',
overflow: 'hidden',
whiteSpace: 'nowrap',
zIndex: 0,
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: alpha(theme.palette.text.primary, 0.5),
filter: 'blur(6px)',
borderRadius: '12px',
zIndex: -1,
},
}));
// 样式化的 Tabs 容器 - 浅灰色背景,圆角,阴影 // 样式化的 Tabs 容器 - 浅灰色背景,圆角,阴影
const StyledTabsContainer = styled(Box)(({ theme }) => ({ const StyledTabsContainer = styled(Box)(({ theme }) => ({
maxWidth: '100%', maxWidth: '100%',
@ -78,7 +113,7 @@ const StyledTabs = styled(Tabs)(({ theme }) => ({
const StyledTab = styled(Tab)(({ theme }) => ({ const StyledTab = styled(Tab)(({ theme }) => ({
minHeight: 'auto', minHeight: 'auto',
padding: theme.spacing(1, 2), padding: theme.spacing(1, 2),
borderRadius: '10px', borderRadius: '6px',
backgroundColor: theme.palette.background.default, backgroundColor: theme.palette.background.default,
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
fontSize: 14, fontSize: 14,
@ -96,11 +131,16 @@ const StyledTab = styled(Tab)(({ theme }) => ({
})); }));
const Carousel = ({ title, items }: CarouselProps) => { const Carousel = ({ title, items }: CarouselProps) => {
const theme = useTheme();
// 添加标题淡入动画 // 添加标题淡入动画
const titleRef = useFadeInText(0.2, 0.1); const titleRef = useFadeInText(0.2, 0.1);
// 添加Swiper ref // 添加Swiper ref
const swiperRef = useRef<SwiperType | null>(null); const swiperRef = useRef<SwiperType | null>(null);
const [activeTab, setActiveTab] = useState<string>(items[0]?.id || ''); const [activeTab, setActiveTab] = useState<string>(items[0]?.id || '');
// 存储所有描述元素的 ref
const descRefs = useRef<(HTMLDivElement | null)[]>([]);
// 存储动画时间线,用于清理
const animationTimelines = useRef<gsap.core.Timeline[]>([]);
// 导航函数 // 导航函数
const handlePrev = useCallback(() => { const handlePrev = useCallback(() => {
@ -115,7 +155,117 @@ const Carousel = ({ title, items }: CarouselProps) => {
} }
}, []); }, []);
// 监听 Swiper 切换,更新 activeTab // 触发从左到右的文字出现动画(逐字符显示,容器逐渐撑大)
const animateTextFromLeft = useCallback(
(index: number) => {
const descElement = descRefs.current[index];
if (!descElement) return;
// 清理之前的动画
animationTimelines.current.forEach(tl => tl.kill());
animationTimelines.current = [];
const originalText = descElement.textContent || '';
if (!originalText) return;
// 获取容器的 padding 值
const computedStyle = window.getComputedStyle(descElement);
const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;
const paddingRight = parseFloat(computedStyle.paddingRight) || 0;
const padding = paddingLeft + paddingRight;
// 将文字分割成字符
const chars = Array.from(originalText);
const charElements: HTMLSpanElement[] = [];
// 清空容器并创建字符元素(初始都隐藏)
descElement.innerHTML = '';
chars.forEach(char => {
const span = document.createElement('span');
span.textContent = char === ' ' ? '\u00A0' : char; // 空格用非断行空格
span.style.opacity = '0';
span.style.display = 'inline-block';
descElement.appendChild(span);
charElements.push(span);
});
// 创建一个隐藏的测量容器来准确测量每个字符的宽度
const measureContainer = document.createElement('div');
measureContainer.style.position = 'absolute';
measureContainer.style.visibility = 'hidden';
measureContainer.style.whiteSpace = 'nowrap';
measureContainer.style.fontSize = computedStyle.fontSize;
measureContainer.style.fontWeight = computedStyle.fontWeight;
measureContainer.style.fontFamily = computedStyle.fontFamily;
document.body.appendChild(measureContainer);
// 测量每个字符的宽度
const charWidths: number[] = [];
charElements.forEach(span => {
measureContainer.textContent = span.textContent;
const charWidth = measureContainer.offsetWidth;
charWidths.push(charWidth);
});
document.body.removeChild(measureContainer);
// 设置容器初始状态(只有 padding背景色透明
gsap.set(descElement, {
width: padding,
minWidth: padding,
});
// 创建动画时间线,延迟 0.5 秒开始
const tl = gsap.timeline({ delay: 0.5 });
let currentWidth = padding;
// 背景色从透明逐渐加深(与第一个字符同时开始)
tl.to(
descElement,
{
duration: 0.4, // 背景色变化稍快一些,在文字显示过程中完成
ease: 'power2.out',
},
0,
);
// 逐个显示字符,同时增加容器宽度
// 第一个字符在延迟后立即开始显示(时间位置 0
charElements.forEach((span, i) => {
const charWidth = charWidths[i];
currentWidth += charWidth;
// 同时显示字符和增加容器宽度
// 第一个字符立即显示i=0 时时间为 0后续字符依次延迟
tl.to(
span,
{
opacity: 1,
duration: 0.08,
ease: 'none',
},
i * 0.08,
);
// 同时更新容器宽度
tl.to(
descElement,
{
width: currentWidth,
duration: 0.08,
ease: 'none',
},
i * 0.08,
);
});
// 保存动画时间线
animationTimelines.current.push(tl);
},
[theme],
);
// 监听 Swiper 切换,更新 activeTab 并触发动画
const handleSlideChange = useCallback( const handleSlideChange = useCallback(
(swiper: SwiperType) => { (swiper: SwiperType) => {
const activeIndex = swiper.activeIndex; const activeIndex = swiper.activeIndex;
@ -123,9 +273,11 @@ const Carousel = ({ title, items }: CarouselProps) => {
const activeItem = items[activeIndex]; const activeItem = items[activeIndex];
if (activeItem) { if (activeItem) {
setActiveTab(activeItem.id); setActiveTab(activeItem.id);
// 触发当前幻灯片的文字动画
animateTextFromLeft(activeIndex);
} }
}, },
[items], [items, animateTextFromLeft],
); );
// 当 activeTab 改变时,切换对应的 Swiper 卡片 // 当 activeTab 改变时,切换对应的 Swiper 卡片
@ -135,11 +287,34 @@ const Carousel = ({ title, items }: CarouselProps) => {
const targetIndex = items.findIndex(item => item.id === value); const targetIndex = items.findIndex(item => item.id === value);
if (targetIndex !== -1 && swiperRef.current) { if (targetIndex !== -1 && swiperRef.current) {
swiperRef.current.slideTo(targetIndex); swiperRef.current.slideTo(targetIndex);
// 触发切换后的文字动画
setTimeout(() => {
animateTextFromLeft(targetIndex);
}, 300); // 等待切换动画完成
} }
}, },
[items], [items, animateTextFromLeft],
); );
// 初始加载时触发第一个幻灯片的动画
useEffect(() => {
if (items.length > 0 && descRefs.current[0]) {
// 延迟执行,确保元素已经渲染
const timer = setTimeout(() => {
animateTextFromLeft(0);
}, 100);
return () => clearTimeout(timer);
}
}, [items.length, animateTextFromLeft]);
// 组件卸载时清理所有动画
useEffect(() => {
return () => {
animationTimelines.current.forEach(tl => tl.kill());
animationTimelines.current = [];
};
}, []);
// 使用事件委托的方式处理点击事件 // 使用事件委托的方式处理点击事件
const handleSlideClick = useCallback( const handleSlideClick = useCallback(
(swiper: SwiperType, event: MouseEvent | TouchEvent | PointerEvent) => { (swiper: SwiperType, event: MouseEvent | TouchEvent | PointerEvent) => {
@ -213,9 +388,16 @@ const Carousel = ({ title, items }: CarouselProps) => {
modules={[Pagination, Autoplay]} modules={[Pagination, Autoplay]}
className='mySwiper' className='mySwiper'
> >
{items?.map(item => ( {items?.map((item, index) => (
<SwiperSlide key={item.id}> <SwiperSlide key={item.id} style={{ position: 'relative' }}>
<StyledSwiperSlideImg src={item.url} alt={item.title} /> <StyledSwiperSlideImg src={item.url} alt={item.title} />
<StyledSwiperSlideDesc
ref={el => {
descRefs.current[index] = el;
}}
>
{item.desc}
</StyledSwiperSlideDesc>
</SwiperSlide> </SwiperSlide>
))} ))}
</Swiper> </Swiper>

View File

@ -3,7 +3,10 @@
import React from 'react'; import React from 'react';
import { styled, Grid, alpha, Stack } from '@mui/material'; import { styled, Grid, alpha, Stack } from '@mui/material';
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon'; import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
import { useFadeInText, useCardAnimation } from '../hooks/useGsapAnimation'; import {
useFadeInText,
useCardScaleAnimation,
} from '../hooks/useGsapAnimation';
interface CaseProps { interface CaseProps {
mobile?: boolean; mobile?: boolean;
@ -27,6 +30,8 @@ const StyledCaseItem = styled('a')(({ theme }) => ({
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`, boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
}, },
cursor: 'pointer', cursor: 'pointer',
opacity: 0,
scale: 0,
})); }));
const StyledCaseItemTitle = styled('span')(({ theme }) => ({ const StyledCaseItemTitle = styled('span')(({ theme }) => ({
@ -40,7 +45,10 @@ const CaseItem: React.FC<{
item: any; item: any;
index: number; index: number;
}> = React.memo(({ item, index }) => { }> = React.memo(({ item, index }) => {
const cardRef = useCardAnimation(0.2 + index * 0.1, 0.1); const rand = Math.random();
const cardRef = useCardScaleAnimation({
duration: rand < 0.5 ? rand + 0.5 : rand,
});
return ( return (
<StyledCaseItem <StyledCaseItem
ref={cardRef as React.Ref<HTMLAnchorElement>} ref={cardRef as React.Ref<HTMLAnchorElement>}

View File

@ -3,7 +3,10 @@
import React from 'react'; import React from 'react';
import { styled, Grid, alpha, Stack, Rating } from '@mui/material'; import { styled, Grid, alpha, Stack, Rating } from '@mui/material';
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon'; import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
import { useFadeInText, useCardAnimation } from '../hooks/useGsapAnimation'; import {
useFadeInText,
useCardFadeInAnimation,
} from '../hooks/useGsapAnimation';
interface Props { interface Props {
mobile?: boolean; mobile?: boolean;
@ -22,6 +25,13 @@ const StyledItem = styled(Stack)(({ theme }) => ({
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`, boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
height: '100%', height: '100%',
justifyContent: 'space-between', justifyContent: 'space-between',
transition: 'all 0.2s ease',
'&:hover': {
color: theme.palette.primary.main,
borderColor: theme.palette.primary.main,
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
},
opacity: 0,
})); }));
const StyledItemSummary = styled('div')(({ theme }) => ({ const StyledItemSummary = styled('div')(({ theme }) => ({
@ -58,7 +68,7 @@ const Item: React.FC<{
}; };
index: number; index: number;
}> = React.memo(({ item, index }) => { }> = React.memo(({ item, index }) => {
const cardRef = useCardAnimation(0.2 + index * 0.1, 0.1); const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
return ( return (
<StyledItem ref={cardRef as React.Ref<HTMLDivElement>} gap={3}> <StyledItem ref={cardRef as React.Ref<HTMLDivElement>} gap={3}>
<StyledItemSummary>{item.comment}</StyledItemSummary> <StyledItemSummary>{item.comment}</StyledItemSummary>

View File

@ -1,3 +1,4 @@
import { decodeBase64 } from '../utils';
export const DocWidth = { export const DocWidth = {
full: { full: {
label: '全屏', label: '全屏',
@ -12,3 +13,6 @@ export const DocWidth = {
value: 720, value: 720,
}, },
}; };
export const PROJECT_NAME =
'5pys572R56uZ55SxIFBhbmRhV2lraSDmj5DkvpvmioDmnK/mlK/mjIE=';

View File

@ -11,7 +11,10 @@ import {
} from '../component/styledCommon'; } from '../component/styledCommon';
import { IconWenjianjia, IconWenjian } from '@panda-wiki/icons'; import { IconWenjianjia, IconWenjian } from '@panda-wiki/icons';
import ArrowForwardRoundedIcon from '@mui/icons-material/ArrowForwardRounded'; import ArrowForwardRoundedIcon from '@mui/icons-material/ArrowForwardRounded';
import { useFadeInText, useCardAnimation } from '../hooks/useGsapAnimation'; import {
useFadeInText,
useCardFadeInAnimation,
} from '../hooks/useGsapAnimation';
interface DirDocProps { interface DirDocProps {
mobile?: boolean; mobile?: boolean;
title?: string; title?: string;
@ -93,7 +96,7 @@ const DirDocItem: React.FC<{
baseUrl: string; baseUrl: string;
size: any; size: any;
}> = React.memo(({ item, index, baseUrl, size }) => { }> = React.memo(({ item, index, baseUrl, size }) => {
const cardRef = useCardAnimation(0.2 + index * 0.1, 0.1); const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
return ( return (
<Grid size={size} key={index}> <Grid size={size} key={index}>

View File

@ -4,7 +4,10 @@ import React from 'react';
import { styled, Grid, alpha } from '@mui/material'; import { styled, Grid, alpha } from '@mui/material';
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon'; import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
import { IconLianjiezu } from '@panda-wiki/icons'; import { IconLianjiezu } from '@panda-wiki/icons';
import { useFadeInText, useCardAnimation } from '../hooks/useGsapAnimation'; import {
useFadeInText,
useCardFadeInAnimation,
} from '../hooks/useGsapAnimation';
interface FaqProps { interface FaqProps {
mobile?: boolean; mobile?: boolean;
@ -35,6 +38,7 @@ const StyledFaqItem = styled('a')(({ theme }) => ({
}, },
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
opacity: 0,
})); }));
const StyledFaqItemTitle = styled('span')(({ theme }) => ({ const StyledFaqItemTitle = styled('span')(({ theme }) => ({
@ -48,7 +52,7 @@ const FaqItem: React.FC<{
index: number; index: number;
size: any; size: any;
}> = React.memo(({ item, index, size }) => { }> = React.memo(({ item, index, size }) => {
const cardRef = useCardAnimation(0.2 + index * 0.1, 0.1); const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
return ( return (
<Grid size={size} key={index}> <Grid size={size} key={index}>

View File

@ -3,7 +3,10 @@
import React from 'react'; import React from 'react';
import { styled, Grid, alpha, Stack } from '@mui/material'; import { styled, Grid, alpha, Stack } from '@mui/material';
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon'; import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
import { useFadeInText, useCardAnimation } from '../hooks/useGsapAnimation'; import {
useFadeInText,
useCardFadeInAnimation,
} from '../hooks/useGsapAnimation';
import { IconTips } from '@panda-wiki/icons'; import { IconTips } from '@panda-wiki/icons';
interface FeatureProps { interface FeatureProps {
@ -11,7 +14,7 @@ interface FeatureProps {
title?: string; title?: string;
items?: { items?: {
name: string; name: string;
link: string; desc: string;
}[]; }[];
} }
const StyledFeatureItem = styled(Stack)(({ theme }) => ({ const StyledFeatureItem = styled(Stack)(({ theme }) => ({
@ -20,12 +23,12 @@ const StyledFeatureItem = styled(Stack)(({ theme }) => ({
padding: theme.spacing(2.5), padding: theme.spacing(2.5),
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`, boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
// '&:hover': { '&:hover': {
// color: theme.palette.primary.main, color: theme.palette.primary.main,
// borderColor: theme.palette.primary.main, borderColor: theme.palette.primary.main,
// boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`, boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
// }, },
// cursor: 'pointer', opacity: 0,
})); }));
export const StyledFeatureItemIcon = styled('div')(({ theme }) => ({ export const StyledFeatureItemIcon = styled('div')(({ theme }) => ({
@ -62,10 +65,13 @@ const StyledFeatureItemSummary = styled('div')(({ theme }) => ({
// 单个卡片组件,带动画效果 // 单个卡片组件,带动画效果
const FeatureItem: React.FC<{ const FeatureItem: React.FC<{
item: any; item: {
name: string;
desc: string;
};
index: number; index: number;
}> = React.memo(({ item, index }) => { }> = React.memo(({ item, index }) => {
const cardRef = useCardAnimation(0.2 + index * 0.1, 0.1); const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
return ( return (
<StyledFeatureItem <StyledFeatureItem
ref={cardRef as React.Ref<HTMLDivElement>} ref={cardRef as React.Ref<HTMLDivElement>}

View File

@ -5,6 +5,8 @@ import { useState } from 'react';
import { IconDianhua, IconWeixingongzhonghao } from '@panda-wiki/icons'; import { IconDianhua, IconWeixingongzhonghao } from '@panda-wiki/icons';
import Overlay from './Overlay'; import Overlay from './Overlay';
import { DocWidth } from '../constants'; import { DocWidth } from '../constants';
import { PROJECT_NAME } from '../constants';
import { decodeBase64 } from '../utils';
interface DomainSocialMediaAccount { interface DomainSocialMediaAccount {
channel?: string; channel?: string;
@ -340,7 +342,7 @@ const Footer = React.memo(
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
<Box> PandaWiki </Box> <Box>{decodeBase64(PROJECT_NAME)}</Box>
<img src={logo} alt='PandaWiki' width={16} height={16} /> <img src={logo} alt='PandaWiki' width={16} height={16} />
</Stack> </Stack>
</Link> </Link>
@ -777,7 +779,7 @@ const Footer = React.memo(
}, },
}} }}
> >
<Box> PandaWiki </Box> <Box>{decodeBase64(PROJECT_NAME)}</Box>
<img <img
src={logo} src={logo}
alt='PandaWiki' alt='PandaWiki'

View File

@ -263,7 +263,7 @@ export const useTypewriterText = (
}; };
// 卡片渐入动画 hook // 卡片渐入动画 hook
export const useCardAnimation = ( export const useCardFadeInAnimation = (
delay: number = 0, delay: number = 0,
threshold: number = 0.1, threshold: number = 0.1,
) => { ) => {
@ -308,7 +308,6 @@ export const useCardAnimation = (
gsap.set(card, { gsap.set(card, {
opacity: 0, opacity: 0,
y: 50, y: 50,
// scale: 0.9,
}); });
// 创建动画 // 创建动画
@ -317,7 +316,6 @@ export const useCardAnimation = (
tl.to(card, { tl.to(card, {
opacity: 1, opacity: 1,
y: 0, y: 0,
scale: 1,
duration: 0.4, duration: 0.4,
ease: 'back.out(1.4)', ease: 'back.out(1.4)',
}); });
@ -329,3 +327,136 @@ export const useCardAnimation = (
return cardRef; return cardRef;
}; };
export const useCardScaleAnimation = ({
delay = 0,
threshold = 0.1,
duration = 0.4,
}: {
delay?: number;
threshold?: number;
duration?: number;
}) => {
const cardRef = useRef<HTMLElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [hasAnimated, setHasAnimated] = useState(false);
useEffect(() => {
if (!cardRef.current || hasAnimated) return;
const card = cardRef.current;
// 创建 Intersection Observer
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated) {
setIsVisible(true);
setHasAnimated(true);
}
});
},
{
threshold,
rootMargin: '0px 0px -50px 0px',
},
);
observer.observe(card);
return () => {
observer.disconnect();
};
}, [threshold, hasAnimated]);
useEffect(() => {
if (!cardRef.current || !isVisible) return;
const card = cardRef.current;
// 设置初始状态
gsap.set(card, {
opacity: 0,
scale: 0,
});
// 创建动画
const tl = gsap.timeline({ delay });
tl.to(card, {
opacity: 1,
scale: 1,
duration,
});
return () => {
tl.kill();
};
}, [isVisible, delay]);
return cardRef;
};
export const useCardAnimation = ({
delay = 0,
threshold = 0.1,
initial,
to,
}: {
delay?: number;
threshold?: number;
initial: GSAPTweenVars;
to: GSAPTweenVars;
}) => {
const cardRef = useRef<HTMLElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [hasAnimated, setHasAnimated] = useState(false);
useEffect(() => {
if (!cardRef.current || hasAnimated) return;
const card = cardRef.current;
// 创建 Intersection Observer
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated) {
setIsVisible(true);
setHasAnimated(true);
}
});
},
{
threshold,
rootMargin: '0px 0px -50px 0px',
},
);
observer.observe(card);
return () => {
observer.disconnect();
};
}, [threshold, hasAnimated]);
useEffect(() => {
if (!cardRef.current || !isVisible) return;
const card = cardRef.current;
// 设置初始状态
gsap.set(card, initial);
// 创建动画
const tl = gsap.timeline({ delay });
tl.to(card, to);
return () => {
tl.kill();
};
}, [isVisible, delay]);
return cardRef;
};

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React, { useMemo } from 'react';
import { styled, Grid, alpha, Stack, Box } from '@mui/material'; import { styled, alpha, Stack, Box } from '@mui/material';
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon'; import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
import { useFadeInText, useCardAnimation } from '../hooks/useGsapAnimation'; import { useFadeInText, useCardAnimation } from '../hooks/useGsapAnimation';
@ -18,8 +18,8 @@ interface ImgTextProps {
const StyledImgTextItem = styled(Stack)(({ theme }) => ({})); const StyledImgTextItem = styled(Stack)(({ theme }) => ({}));
export const StyledImgTextItemImg = styled('img')(({ theme }) => ({ export const StyledImgTextItemImg = styled('img')(({ theme }) => ({
maxWidth: 350, maxWidth: '100%',
maxHeight: 350, maxHeight: '100%',
width: '100%', width: '100%',
height: '100%', height: '100%',
objectFit: 'cover', objectFit: 'cover',
@ -28,7 +28,7 @@ export const StyledImgTextItemImg = styled('img')(({ theme }) => ({
})); }));
const StyledImgTextItemTitle = styled('h3')(({ theme }) => ({ const StyledImgTextItemTitle = styled('h3')(({ theme }) => ({
fontSize: 20, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: theme.palette.text.primary, color: theme.palette.text.primary,
})); }));
@ -49,14 +49,31 @@ const ImgText: React.FC<ImgTextProps> = React.memo(
: { xs: 12, md: 6 }; : { xs: 12, md: 6 };
const titleRef = useFadeInText(0.2, 0.1); const titleRef = useFadeInText(0.2, 0.1);
const cardRef = useCardAnimation(0.2, 0.1);
const cardLeftAnimation = useMemo(
() => ({
initial: { opacity: 0, x: -250 },
to: { opacity: 1, x: 0, duration: 0.6, ease: 'power2.out' },
}),
[],
);
const cardRightAnimation = useMemo(
() => ({
initial: { opacity: 0, x: 250 },
to: { opacity: 1, x: 0, duration: 0.6, ease: 'power2.out' },
}),
[],
);
const cardLeftRef = useCardAnimation(cardLeftAnimation);
const cardRightRef = useCardAnimation(cardRightAnimation);
return ( return (
<StyledTopicBox> <StyledTopicBox>
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle> <StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
<StyledImgTextItem <StyledImgTextItem
ref={cardRef as React.Ref<HTMLDivElement>} gap={mobile ? 4 : { xs: 4, sm: 6, md: 16 }}
gap={mobile ? 4 : { xs: 4, sm: 6, md: 38 }}
direction={ direction={
mobile mobile
? 'column-reverse' ? 'column-reverse'
@ -69,12 +86,46 @@ const ImgText: React.FC<ImgTextProps> = React.memo(
justifyContent='center' justifyContent='center'
sx={{ width: '100%' }} sx={{ width: '100%' }}
> >
<Box sx={{ width: '100%' }}> <Box
sx={{ width: '100%' }}
ref={cardLeftRef as React.Ref<HTMLDivElement>}
>
<StyledImgTextItemImg src={item.url} alt={item.name} /> <StyledImgTextItemImg src={item.url} alt={item.name} />
</Box> </Box>
<Stack gap={1} sx={{ width: '100%' }}> <Stack
<StyledImgTextItemTitle>{item.name}</StyledImgTextItemTitle> gap={1}
<StyledImgTextItemSummary>{item.desc}</StyledImgTextItemSummary> sx={{ width: '100%' }}
ref={cardRightRef as React.Ref<HTMLDivElement>}
alignItems={
mobile
? 'flex-start'
: direction === 'row'
? 'flex-start'
: 'flex-end'
}
>
<StyledImgTextItemTitle
sx={{
textAlign: mobile
? 'left'
: direction === 'row'
? 'left'
: 'right',
}}
>
{item.name}
</StyledImgTextItemTitle>
<StyledImgTextItemSummary
sx={{
textAlign: mobile
? 'left'
: direction === 'row'
? 'left'
: 'right',
}}
>
{item.desc}
</StyledImgTextItemSummary>
</Stack> </Stack>
</StyledImgTextItem> </StyledImgTextItem>
</StyledTopicBox> </StyledTopicBox>

View File

@ -14,11 +14,14 @@ export { default as Case } from './case';
export { default as ImgText } from './imgText'; export { default as ImgText } from './imgText';
export { default as Feature } from './feature'; export { default as Feature } from './feature';
export { default as Comment } from './comment'; export { default as Comment } from './comment';
export { default as Question } from './question';
export { default as BlockGrid } from './blockGrid';
// 导出动画 hooks // 导出动画 hooks
export { export {
useTextAnimation, useTextAnimation,
useFadeInText, useFadeInText,
useTypewriterText, useTypewriterText,
useCardFadeInAnimation,
useCardAnimation, useCardAnimation,
} from './hooks/useGsapAnimation'; } from './hooks/useGsapAnimation';

View File

@ -3,7 +3,10 @@
import React from 'react'; import React from 'react';
import { styled, Grid, alpha, Stack } from '@mui/material'; import { styled, Grid, alpha, Stack } from '@mui/material';
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon'; import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
import { useFadeInText, useCardAnimation } from '../hooks/useGsapAnimation'; import {
useFadeInText,
useCardFadeInAnimation,
} from '../hooks/useGsapAnimation';
interface MetricsProps { interface MetricsProps {
mobile?: boolean; mobile?: boolean;
@ -48,7 +51,7 @@ const MetricsItem: React.FC<{
index: number; index: number;
size: any; size: any;
}> = React.memo(({ item, index, size }) => { }> = React.memo(({ item, index, size }) => {
const cardRef = useCardAnimation(0.2 + index * 0.1, 0.1); const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
return ( return (
<Grid size={size} key={index}> <Grid size={size} key={index}>
@ -56,6 +59,7 @@ const MetricsItem: React.FC<{
ref={cardRef as React.Ref<HTMLDivElement>} ref={cardRef as React.Ref<HTMLDivElement>}
gap={1} gap={1}
alignItems='center' alignItems='center'
sx={{ opacity: 0 }}
> >
<StyledMetricsItemNumber className='metrics-item-number'> <StyledMetricsItemNumber className='metrics-item-number'>
{item.number} {item.number}

View File

@ -0,0 +1,89 @@
'use client';
import React from 'react';
import { styled, Stack, alpha } from '@mui/material';
import { StyledTopicBox, StyledTopicTitle } from '../component/styledCommon';
import { IconWenhao } from '@panda-wiki/icons';
import {
useFadeInText,
useCardFadeInAnimation,
} from '../hooks/useGsapAnimation';
interface QuestionProps {
mobile?: boolean;
title?: string;
onSearch: (question: string) => void;
items?: {
question: string;
}[];
}
const StyledItem = styled('div')(({ theme }) => ({
position: 'relative',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(2),
color: theme.palette.text.primary,
borderRadius: '10px',
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
boxShadow: `0px 5px 20px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
padding: theme.spacing(3, 4),
transition: 'all 0.2s ease',
'&:hover': {
transform: 'translateY(-5px)',
color: theme.palette.primary.main,
border: `1px solid ${alpha(theme.palette.primary.main, 0.5)}`,
boxShadow: `0px 10px 20px 0px ${alpha(theme.palette.text.primary, 0.1)}`,
},
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
cursor: 'pointer',
opacity: 0,
}));
const StyledItemTitle = styled('span')(({ theme }) => ({
fontSize: 20,
fontWeight: 400,
}));
// 单个卡片组件,带动画效果
const Item: React.FC<{
item: {
question: string;
};
onSearch: (question: string) => void;
index: number;
}> = React.memo(({ item, index, onSearch }) => {
const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
return (
<StyledItem
ref={cardRef as React.Ref<HTMLDivElement>}
onClick={() => onSearch(item.question)}
>
<IconWenhao sx={{ color: 'primary.main', fontSize: 20 }} />
<StyledItemTitle>{item.question}</StyledItemTitle>
</StyledItem>
);
});
const Question: React.FC<QuestionProps> = React.memo(
({ title, items = [], onSearch }) => {
// 添加标题淡入动画
const titleRef = useFadeInText(0.2, 0.1);
return (
<StyledTopicBox>
<StyledTopicTitle ref={titleRef}>{title}</StyledTopicTitle>
<Stack gap={3} sx={{ width: '100%' }}>
{items.map((item, index) => (
<Item key={index} item={item} index={index} onSearch={onSearch} />
))}
</Stack>
</StyledTopicBox>
);
},
);
export default Question;

View File

@ -11,7 +11,10 @@ import {
} from '../component/styledCommon'; } from '../component/styledCommon';
import IconWenjian from '@panda-wiki/icons/IconWenjian'; import IconWenjian from '@panda-wiki/icons/IconWenjian';
import ArrowForwardIosRoundedIcon from '@mui/icons-material/ArrowForwardIosRounded'; import ArrowForwardIosRoundedIcon from '@mui/icons-material/ArrowForwardIosRounded';
import { useFadeInText, useCardAnimation } from '../hooks/useGsapAnimation'; import {
useFadeInText,
useCardFadeInAnimation,
} from '../hooks/useGsapAnimation';
interface SimpleDocProps { interface SimpleDocProps {
mobile?: boolean; mobile?: boolean;
@ -62,7 +65,7 @@ const SimpleDocItem: React.FC<{
baseUrl: string; baseUrl: string;
size: any; size: any;
}> = React.memo(({ item, index, baseUrl, size }) => { }> = React.memo(({ item, index, baseUrl, size }) => {
const cardRef = useCardAnimation(0.2 + index * 0.1, 0.1); const cardRef = useCardFadeInAnimation(0.2 + index * 0.1, 0.1);
return ( return (
<Grid size={size} key={index}> <Grid size={size} key={index}>

View File

@ -0,0 +1,14 @@
export const decodeBase64 = (text: string) => {
try {
const buff = Buffer.from(text, 'base64');
return buff.toString('utf-8');
} catch (e) {
// 客户端如果报错,退回到 atob
if (typeof window !== 'undefined' && window.atob) {
return window.atob(text);
}
// 处理解码失败的情况
console.error('Base64 decoding failed:', e);
return '';
}
};

View File

@ -4,7 +4,8 @@ import { Box, Divider, Stack, Link, alpha } from '@mui/material';
import { useState } from 'react'; import { useState } from 'react';
import { IconDianhua, IconWeixingongzhonghao } from '@panda-wiki/icons'; import { IconDianhua, IconWeixingongzhonghao } from '@panda-wiki/icons';
import Overlay from './Overlay'; import Overlay from './Overlay';
import { DocWidth } from '../constants'; import { decodeBase64 } from '../utils';
import { PROJECT_NAME } from '../constants';
interface DomainSocialMediaAccount { interface DomainSocialMediaAccount {
channel?: string; channel?: string;
@ -343,7 +344,7 @@ const Footer = React.memo(
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
<Box> PandaWiki </Box> <Box>{decodeBase64(PROJECT_NAME)}</Box>
<img src={logo} alt='PandaWiki' width={16} height={16} /> <img src={logo} alt='PandaWiki' width={16} height={16} />
</Stack> </Stack>
</Link> </Link>
@ -773,7 +774,7 @@ const Footer = React.memo(
}, },
}} }}
> >
<Box> PandaWiki </Box> <Box>{decodeBase64(PROJECT_NAME)}</Box>
<img src={logo} alt='PandaWiki' width={0} /> <img src={logo} alt='PandaWiki' width={0} />
</Stack> </Stack>
</Link> </Link>

File diff suppressed because it is too large Load Diff