feat: contribute

This commit is contained in:
Gavan 2025-09-05 17:28:20 +08:00
parent 8af576080d
commit 5a64e3f021
58 changed files with 5435 additions and 5689 deletions

View File

@ -0,0 +1,29 @@
{
"search": "搜索",
"search_no_results_1": "哦不!",
"search_no_results_2": "没有找到相关表情",
"pick": "选择一个表情…",
"add_custom": "添加自定义表情",
"categories": {
"activity": "活动",
"custom": "自定义",
"flags": "旗帜",
"foods": "食物与饮品",
"frequent": "最近使用",
"nature": "动物与自然",
"objects": "物品",
"people": "表情与角色",
"places": "旅行与景点",
"search": "搜索结果",
"symbols": "符号"
},
"skins": {
"choose": "选择默认肤色",
"1": "默认",
"2": "白色",
"3": "偏白",
"4": "中等",
"5": "偏黑",
"6": "黑色"
}
}

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,7 @@ import Picker from '@emoji-mart/react';
import { Box, IconButton, Popover, SxProps } from '@mui/material';
import { Icon } from '@ctzhian/ui';
import React, { useCallback } from 'react';
import zh from '../../../public/emoji-data/zh.json';
import zh from '../../assets/emoji-data/zh.json';
interface EmojiPickerProps {
type: 1 | 2;
@ -39,7 +39,7 @@ const EmojiPicker: React.FC<EmojiPickerProps> = ({
};
const handleSelect = useCallback(
(emoji: any) => {
(emoji: { native: string }) => {
onChange?.(emoji.native);
handleClose();
},

View File

@ -13,6 +13,7 @@ const OtherBread = {
feedback: { title: '反馈', to: '/feedback' },
application: { title: '设置', to: '/setting' },
release: { title: '发布', to: '/release' },
contribution: { title: '贡献', to: '/contribution' },
};
const Bread = () => {

View File

@ -33,6 +33,14 @@ const MENUS = [
ConstsUserKBPermission.UserKBPermissionDataOperate,
],
},
{
label: '贡献',
value: '/contribution',
pathname: 'contribution',
icon: 'icon-gongxian',
show: true,
perms: [ConstsUserKBPermission.UserKBPermissionFullControl],
},
{
label: '问答',
value: '/conversation',

View File

@ -0,0 +1,237 @@
import { Box, Button, Stack, Typography, Divider } from '@mui/material';
import dayjs from 'dayjs';
import { Modal } from '@ctzhian/ui';
import type { GithubComChaitinPandaWikiProApiContributeV1ContributeItem } from '@/request/pro/types';
import {
ConstsContributeStatus,
GithubComChaitinPandaWikiProApiContributeV1ContributeDetailResp,
ConstsContributeType,
} from '@/request/pro/types';
import { getApiProV1ContributeDetail } from '@/request/pro/Contribute';
import { useAppSelector } from '@/store';
import { useEffect, useState } from 'react';
import { EditorDiff, Editor, useTiptap } from '@ctzhian/tiptap';
import { IconWenjian } from '@panda-wiki/icons';
type ContributePreviewModalProps = {
open: boolean;
row: GithubComChaitinPandaWikiProApiContributeV1ContributeItem | null;
onClose: () => void;
onAccept: () => void;
onReject: () => void;
};
export default function ContributePreviewModal(
props: ContributePreviewModalProps,
) {
const [activeTab, setActiveTab] = useState('diff');
const { open, row, onClose, onAccept, onReject } = props;
const { kb_id = '' } = useAppSelector(state => state.config);
const [data, setData] =
useState<GithubComChaitinPandaWikiProApiContributeV1ContributeDetailResp | null>(
null,
);
const editorRef = useTiptap({
content: '',
editable: false,
immediatelyRender: true,
});
useEffect(() => {
if (open && row) {
getApiProV1ContributeDetail({ id: row.id!, kb_id }).then(res => {
setData(res);
});
}
}, [open, row, kb_id]);
const handleTabChange = (value: string) => {
setActiveTab(value);
if (value === 'content') {
editorRef.editor.commands.setContent(data?.content || '');
} else if (value === 'old_content') {
editorRef.editor.commands.setContent(data?.original_node?.content || '');
} else if (value === 'diff') {
editorRef.editor.commands.setContent('');
}
};
useEffect(() => {
if (open) {
handleTabChange('diff');
setData(null);
}
}, [open]);
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={null}
>
<Stack direction='row' sx={{ overflow: 'hidden', height: '100%' }}>
<Stack
spacing={2}
sx={{
overflow: 'auto',
flex: 1,
pr: 2,
borderRight: '1px solid',
borderColor: 'divider',
}}
>
<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={{ display: activeTab === 'diff' ? 'none' : 'block' }}>
<Editor editor={editorRef.editor} />
</Box>
{(data?.content || data?.original_node?.content) &&
activeTab === 'diff' && (
<EditorDiff
oldHtml={data?.original_node?.content || ''}
newHtml={data?.content || ''}
/>
)}
</Stack>
<Stack justifyContent='space-between' sx={{ width: 220, pl: 2 }}>
<Box>
<Typography sx={{ fontSize: 16, fontWeight: 600, pb: 2 }}>
</Typography>
<Stack gap={2}>
<Button
size='large'
variant={activeTab === 'diff' ? 'contained' : 'outlined'}
fullWidth
onClick={() => handleTabChange('diff')}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
borderRadius: '10px',
py: 1.5,
}}
>
<Stack alignItems='flex-start' spacing={0.25}>
<Box></Box>
<Typography variant='caption' sx={{ opacity: 0.8 }}>
</Typography>
</Stack>
</Button>
<Button
size='large'
variant={activeTab === 'content' ? 'contained' : 'outlined'}
fullWidth
onClick={() => handleTabChange('content')}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
borderRadius: '10px',
py: 1.5,
}}
>
<Stack alignItems='flex-start' spacing={0.25}>
<Box></Box>
<Typography variant='caption' sx={{ opacity: 0.8 }}>
</Typography>
</Stack>
</Button>
<Button
size='large'
variant={activeTab === 'old_content' ? 'contained' : 'outlined'}
fullWidth
onClick={() => handleTabChange('old_content')}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
borderRadius: '10px',
py: 1.5,
}}
>
<Stack alignItems='flex-start' spacing={0.25}>
<Box></Box>
<Typography variant='caption' sx={{ opacity: 0.8 }}>
</Typography>
</Stack>
</Button>
</Stack>
</Box>
<Box>
<Divider sx={{ my: 3 }} />
<Stack direction='row' gap={1} justifyContent='flex-end'>
{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>
</Box>
</Stack>
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,73 @@
import { ITreeItem } from '@/api';
import Card from '@/components/Card';
import DragTree from '@/components/Drag/DragTree';
import { useAppSelector } from '@/store';
import { convertToTree } from '@/utils/drag';
import { Box, Checkbox, Stack } from '@mui/material';
import { Icon, Modal } from '@ctzhian/ui';
import { useEffect, useState } from 'react';
import { getApiV1NodeList } from '@/request/Node';
interface DocDeleteProps {
open: boolean;
onClose: () => void;
onOk: (id: string) => void;
}
const DocModal = ({ open, onClose, onOk }: DocDeleteProps) => {
const { kb_id } = useAppSelector(state => state.config);
const [tree, setTree] = useState<ITreeItem[]>([]);
const [folderIds, setFolderIds] = useState<string[]>([]);
const handleOk = () => {
onOk(folderIds.includes('root') ? '' : folderIds[0]);
};
useEffect(() => {
if (open) {
getApiV1NodeList({ kb_id }).then(res => {
const folder = res.filter(it => it.type === 1);
setTree(convertToTree(folder));
});
}
}, [open]);
return (
<Modal title='选择目录' open={open} onCancel={onClose} onOk={handleOk}>
<Card sx={{ bgcolor: 'background.paper3', p: 1 }}>
<Stack
direction={'row'}
alignItems={'center'}
gap={1}
sx={{ fontSize: 14, cursor: 'pointer' }}
>
<Checkbox
sx={{ color: 'text.disabled', width: '35px', height: '35px' }}
checked={folderIds.includes('root')}
onChange={() => {
setFolderIds(folderIds.includes('root') ? [] : ['root']);
}}
/>
<Icon type={'icon-wenjianjia-kai'} />
<Box></Box>
</Stack>
<DragTree
ui='select'
selected={folderIds}
data={tree}
readOnly={true}
relativeSelect={false}
onSelectChange={(ids, id = '') => {
if (folderIds.includes(id)) {
setFolderIds([]);
} else {
setFolderIds([id]);
}
}}
/>
</Card>
</Modal>
);
};
export default DocModal;

View File

@ -0,0 +1,359 @@
import { useState, useEffect } from 'react';
import { styled } from '@mui/material/styles';
import Logo from '@/assets/images/logo.png';
import { Box, Chip, Stack, TextField } from '@mui/material';
import Card from '@/components/Card';
import { tableSx } from '@/constant/styles';
import dayjs from 'dayjs';
import { Table, Ellipsis, message, Modal } from '@ctzhian/ui';
import type { ColumnType } from '@ctzhian/ui/dist/Table';
import DocModal from './DocModal';
import {
getApiProV1ContributeList,
postApiProV1ContributeAudit,
} from '@/request/pro/Contribute';
import {
GithubComChaitinPandaWikiProApiContributeV1ContributeItem,
ConstsContributeStatus,
ConstsContributeType,
} from '@/request/pro/types';
import { useURLSearchParams } from '@/hooks';
import { useAppSelector } from '@/store';
import ContributePreviewModal from './ContributePreviewModal';
const StyledSearchRow = styled(Stack)(({ theme }) => ({
padding: theme.spacing(2),
gap: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
}));
const statusColorMap = {
[ConstsContributeStatus.ContributeStatusApproved]: {
label: '已采纳',
color: 'success',
},
[ConstsContributeStatus.ContributeStatusRejected]: {
label: '已拒绝',
color: 'error',
},
[ConstsContributeStatus.ContributeStatusPending]: {
label: '等待处理',
color: 'warning',
},
} as const;
export default function ContributionPage() {
const { kb_id = '', kbDetail } = useAppSelector(state => state.config);
const [searchParams, setSearchParams] = useURLSearchParams();
const page = Number(searchParams.get('page') || '1');
const pageSize = Number(searchParams.get('page_size') || '20');
const nodeNameParam = searchParams.get('node_name') || '';
const authNameParam = searchParams.get('auth_name') || '';
const [searchDoc, setSearchDoc] = useState(nodeNameParam);
const [searchUser, setSearchUser] = useState(authNameParam);
const [data, setData] = useState<
GithubComChaitinPandaWikiProApiContributeV1ContributeItem[]
>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [docModalOpen, setDocModalOpen] = useState(false);
const [previewRow, setPreviewRow] =
useState<GithubComChaitinPandaWikiProApiContributeV1ContributeItem | null>(
null,
);
const [open, setOpen] = useState(false);
const closeDialog = () => {
setOpen(false);
setPreviewRow(null);
};
const handleDocModalOk = (id: string) => {
setDocModalOpen(false);
setPreviewRow(null);
postApiProV1ContributeAudit({
id: previewRow!.id!,
kb_id,
parent_id: id,
status: 'approved',
}).then(() => {
getData();
closeDialog();
message.success('采纳成功');
});
};
const handleAccept = () => {
if (previewRow?.type === ConstsContributeType.ContributeTypeAdd) {
setDocModalOpen(true);
} else {
Modal.confirm({
title: '采纳',
content: '确定要采纳该修改吗?',
okText: '采纳',
onOk: () => {
postApiProV1ContributeAudit({
id: previewRow!.id!,
kb_id,
status: 'approved',
}).then(() => {
getData();
closeDialog();
message.success('采纳成功');
});
},
});
}
};
const handleReject = () => {
Modal.confirm({
title: '拒绝',
content: '确定要拒绝该修改吗?',
okText: '拒绝',
onOk: () => {
postApiProV1ContributeAudit({
id: previewRow!.id!,
kb_id,
status: 'rejected',
}).then(() => {
getData();
closeDialog();
message.success('拒绝成功');
});
},
});
};
const columns: ColumnType<GithubComChaitinPandaWikiProApiContributeV1ContributeItem>[] =
[
{
dataIndex: 'node_name',
title: '文档',
width: 280,
render: (text: string, record) => {
return (
<Stack direction='row' alignItems='center' gap={1}>
{record.type === ConstsContributeType.ContributeTypeAdd && (
<Box
sx={{
transform: 'scale(0.8)',
fontSize: 12,
color: 'light.main',
px: 0.5,
borderRadius: 1,
bgcolor: 'error.main',
}}
>
new
</Box>
)}
<Ellipsis
className='primary-color'
sx={{ cursor: 'pointer' }}
onClick={() => {
setPreviewRow(record);
setOpen(true);
}}
>
{text || record.node_name || ''}
</Ellipsis>
</Stack>
);
},
},
{
dataIndex: 'reason',
title: '更新说明',
render: (text: string) => {
return <>{text || '-'}</>;
},
},
{
dataIndex: 'auth_name',
title: '用户',
width: 160,
render: (text: string, record) => {
return (
<Box sx={{ fontSize: 12 }}>
<Stack
direction={'row'}
alignItems={'center'}
gap={0.5}
sx={{ cursor: 'pointer' }}
>
{/* @ts-expect-error 类型不匹配 */}
<img src={record?.avatar || Logo} width={16} />
<Box sx={{ fontSize: 14 }}>{text || '匿名用户'}</Box>
</Stack>
</Box>
);
},
},
{
dataIndex: 'created_at',
title: '时间',
width: 180,
render: (text: string, record) => {
return (
<Stack>
<Box>{dayjs(text).fromNow()}</Box>
<Box sx={{ fontSize: 12, color: 'text.tertiary' }}>
{dayjs(text).format('YYYY-MM-DD HH:mm:ss')}
</Box>
</Stack>
);
},
},
{
dataIndex: 'status',
title: '操作选项',
width: 120,
render: (text, record) => {
const s =
statusColorMap[record.status as keyof typeof statusColorMap];
return record.status !==
ConstsContributeStatus.ContributeStatusPending ? (
<Chip
label={s.label}
color={s.color}
variant='outlined'
onClick={() => {
setPreviewRow(record);
setOpen(true);
}}
sx={{ cursor: 'pointer' }}
/>
) : (
<Box
sx={{ color: 'info.main', cursor: 'pointer' }}
onClick={() => {
setPreviewRow(record);
setOpen(true);
}}
>
{s.label}
</Box>
);
},
},
];
const getData = () => {
setLoading(true);
getApiProV1ContributeList({
page,
per_page: pageSize,
kb_id,
node_name: nodeNameParam,
auth_name: authNameParam,
})
.then(res => {
setData(res.list || []);
setTotal(res.total || 0);
})
.finally(() => setLoading(false));
};
useEffect(() => {
if (kb_id) getData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, nodeNameParam, authNameParam, kb_id]);
return (
<Card>
<Stack
direction='row'
alignItems={'center'}
justifyContent={'space-between'}
sx={{ p: 2 }}
>
<StyledSearchRow direction='row' sx={{ p: 0, flex: 1 }}>
<TextField
fullWidth
size='small'
label='文档'
value={searchDoc}
onKeyUp={e => {
if (e.key === 'Enter') {
setSearchParams({ node_name: searchDoc || '', page: '1' });
}
}}
onBlur={e => {
setSearchParams({ node_name: e.target.value, page: '1' });
}}
onChange={e => setSearchDoc(e.target.value)}
sx={{ width: 200 }}
/>
<TextField
fullWidth
size='small'
label='用户'
value={searchUser}
onKeyUp={e => {
if (e.key === 'Enter') {
setSearchParams({ auth_name: searchUser || '', page: '1' });
}
}}
onBlur={e => {
setSearchParams({ auth_name: e.target.value, page: '1' });
}}
onChange={e => setSearchUser(e.target.value)}
sx={{ width: 200 }}
/>
</StyledSearchRow>
</Stack>
<Table
columns={columns}
dataSource={data}
rowKey='id'
height='calc(100vh - 148px)'
size='small'
sx={{
overflow: 'hidden',
...tableSx,
'.MuiTableContainer-root': {
height: 'calc(100vh - 148px - 70px)',
},
}}
pagination={{
total,
page,
pageSize,
onChange: (page, pageSize) => {
setSearchParams({
page: String(page),
page_size: String(pageSize),
});
},
}}
PaginationProps={{
sx: {
borderTop: '1px solid',
borderColor: 'divider',
p: 2,
'.MuiSelect-root': {
width: 100,
},
},
}}
/>
<ContributePreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
/>
<DocModal
open={docModalOpen}
onClose={() => setDocModalOpen(false)}
onOk={handleDocModalOk}
/>
</Card>
);
}

View File

@ -20,11 +20,7 @@ import { putApiV1KnowledgeBaseDetail } from '@/request/KnowledgeBase';
import { DomainKnowledgeBaseDetail } from '@/request/types';
import { GithubComChaitinPandaWikiProApiAuthV1AuthItem } from '@/request/pro/types';
import UserGroup from './UserGroup';
import {
getApiProV1AuthGet,
postApiProV1AuthSet,
deleteApiProV1AuthDelete,
} from '@/request/pro/Auth';
import { getApiProV1AuthGet, postApiProV1AuthSet } from '@/request/pro/Auth';
import { getApiV1AuthGet, postApiV1AuthSet } from '@/request/Auth';
@ -242,24 +238,6 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
getAuth();
}, [kb_id, isPro, source_type, enabled]);
const onDeleteUser = (id: number) => {
Modal.confirm({
title: '删除用户',
content: '确定要删除该用户吗?',
okButtonProps: {
color: 'error',
},
onOk: () => {
deleteApiProV1AuthDelete({
id,
}).then(() => {
message.success('删除成功');
setMemberList(memberList.filter(item => item.id !== id));
});
},
});
};
const columns: ColumnType<GithubComChaitinPandaWikiProApiAuthV1AuthItem>[] = [
{
title: '用户名',

View File

@ -1,4 +1,3 @@
import { putApiV1App } from '@/request/App';
import { useAppSelector } from '@/store';
import InfoIcon from '@mui/icons-material/Info';
import {
@ -20,7 +19,7 @@ import { useEffect, useState } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import { message } from '@ctzhian/ui';
import { FormItem, SettingCard, SettingCardItem } from './Common';
import { getApiV1AppDetail } from '@/request/App';
import { getApiV1AppDetail, putApiV1App } from '@/request/App';
interface CardCommentProps {
kb: DomainKnowledgeBaseDetail;
@ -344,6 +343,94 @@ const DocumentCorrection = ({
);
};
const DocumentContribution = ({
data,
refresh,
}: {
data: DomainAppDetailResp;
refresh: () => void;
}) => {
const [isEdit, setIsEdit] = useState(false);
const { license, kb_id } = useAppSelector(state => state.config);
const { control, handleSubmit, setValue } = useForm({
defaultValues: {
is_enable: false,
},
});
const onSubmit = handleSubmit(formData => {
putApiV1App(
{ id: data.id! },
{
kb_id,
settings: {
...data.settings,
contribute_settings: {
is_enable: formData.is_enable,
},
},
},
).then(() => {
message.success('保存成功');
setIsEdit(false);
refresh();
});
});
const isPro = license.edition === 1 || license.edition === 2;
useEffect(() => {
setValue(
'is_enable',
// @ts-expect-error 忽略类型错误
data?.settings?.contribute_settings?.is_enable,
);
}, [data]);
return (
<SettingCardItem
title={
<>
{!isPro && (
<Tooltip title='联创版和企业版可用' placement='top' arrow>
<InfoIcon sx={{ color: 'text.secondary', fontSize: 14 }} />
</Tooltip>
)}
</>
}
isEdit={isEdit}
onSubmit={onSubmit}
>
<Controller
control={control}
name='is_enable'
render={({ field }) => (
<RadioGroup
row
{...field}
value={isPro ? field.value : undefined}
onChange={e => {
setIsEdit(true);
field.onChange(e.target.value === 'true');
}}
>
<FormControlLabel
value={true}
control={<Radio size='small' disabled={!isPro} />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
<FormControlLabel
value={false}
control={<Radio size='small' disabled={!isPro} />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
</RadioGroup>
)}
/>
</SettingCardItem>
);
};
const CardFeedback = ({ kb }: CardCommentProps) => {
const [info, setInfo] = useState<DomainAppDetailResp | null>(null);
@ -363,6 +450,7 @@ const CardFeedback = ({ kb }: CardCommentProps) => {
<AIQuestion data={info} refresh={getInfo} />
<DocumentComments data={info} refresh={getInfo} />
<DocumentCorrection data={info} refresh={getInfo} />
<DocumentContribution data={info} refresh={getInfo} />
</SettingCard>
);
};

View File

@ -10,7 +10,7 @@
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import httpRequest, { ContentType, RequestParams } from './httpClient';
import {
DeleteApiV1AppParams,
DomainAppDetailResp,
@ -19,7 +19,7 @@ import {
DomainUpdateAppReq,
GetApiV1AppDetailParams,
PutApiV1AppParams,
} from "./types";
} from './types';
/**
* @description Update app
@ -39,12 +39,12 @@ export const putApiV1App = (
) =>
httpRequest<DomainResponse>({
path: `/api/v1/app`,
method: "PUT",
method: 'PUT',
query: query,
body: app,
secure: true,
type: ContentType.Json,
format: "json",
format: 'json',
...params,
});
@ -65,7 +65,7 @@ export const deleteApiV1App = (
) =>
httpRequest<DomainResponse>({
path: `/api/v1/app`,
method: "DELETE",
method: 'DELETE',
query: query,
secure: true,
type: ContentType.Json,
@ -96,10 +96,10 @@ export const getApiV1AppDetail = (
}
>({
path: `/api/v1/app/detail`,
method: "GET",
method: 'GET',
query: query,
secure: true,
type: ContentType.Json,
format: "json",
format: 'json',
...params,
});

View File

@ -0,0 +1,118 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainResponse,
GetApiProV1ContributeDetailParams,
GetApiProV1ContributeListParams,
GithubComChaitinPandaWikiProApiContributeV1ContributeAuditReq,
GithubComChaitinPandaWikiProApiContributeV1ContributeAuditResp,
GithubComChaitinPandaWikiProApiContributeV1ContributeDetailResp,
GithubComChaitinPandaWikiProApiContributeV1ContributeListResp,
} from "./types";
/**
* @description
*
* @tags Contribute
* @name PostApiProV1ContributeAudit
* @summary
* @request POST:/api/pro/v1/contribute/audit
* @secure
* @response `200` `(DomainResponse & {
data?: GithubComChaitinPandaWikiProApiContributeV1ContributeAuditResp,
})` OK
*/
export const postApiProV1ContributeAudit = (
param: GithubComChaitinPandaWikiProApiContributeV1ContributeAuditReq,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: GithubComChaitinPandaWikiProApiContributeV1ContributeAuditResp;
}
>({
path: `/api/pro/v1/contribute/audit`,
method: "POST",
body: param,
secure: true,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description ID获取文档贡献详情
*
* @tags Contribute
* @name GetApiProV1ContributeDetail
* @summary
* @request GET:/api/pro/v1/contribute/detail
* @secure
* @response `200` `(DomainResponse & {
data?: GithubComChaitinPandaWikiProApiContributeV1ContributeDetailResp,
})` OK
*/
export const getApiProV1ContributeDetail = (
query: GetApiProV1ContributeDetailParams,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: GithubComChaitinPandaWikiProApiContributeV1ContributeDetailResp;
}
>({
path: `/api/pro/v1/contribute/detail`,
method: "GET",
query: query,
secure: true,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description
*
* @tags Contribute
* @name GetApiProV1ContributeList
* @summary
* @request GET:/api/pro/v1/contribute/list
* @secure
* @response `200` `(DomainResponse & {
data?: GithubComChaitinPandaWikiProApiContributeV1ContributeListResp,
})` OK
*/
export const getApiProV1ContributeList = (
query: GetApiProV1ContributeListParams,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: GithubComChaitinPandaWikiProApiContributeV1ContributeListResp;
}
>({
path: `/api/pro/v1/contribute/list`,
method: "GET",
query: query,
secure: true,
type: ContentType.Json,
format: "json",
...params,
});

View File

@ -10,14 +10,14 @@
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import httpRequest, { ContentType, RequestParams } from './httpClient';
import {
DomainGetNodeReleaseDetailResp,
DomainNodeReleaseListItem,
DomainPWResponse,
GetApiProV1NodeReleaseDetailParams,
GetApiProV1NodeReleaseListParams,
} from "./types";
} from './types';
/**
* @description Get Node Release Detail
@ -42,10 +42,10 @@ export const getApiProV1NodeReleaseDetail = (
}
>({
path: `/api/pro/v1/node/release/detail`,
method: "GET",
method: 'GET',
query: query,
type: ContentType.Json,
format: "json",
format: 'json',
...params,
});
@ -72,9 +72,9 @@ export const getApiProV1NodeReleaseList = (
}
>({
path: `/api/pro/v1/node/release/list`,
method: "GET",
method: 'GET',
query: query,
type: ContentType.Json,
format: "json",
format: 'json',
...params,
});

View File

@ -0,0 +1,48 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainResponse,
GithubComChaitinPandaWikiProApiShareV1SubmitContributeReq,
GithubComChaitinPandaWikiProApiShareV1SubmitContributeResp,
} from "./types";
/**
* @description
*
* @tags ShareContribute
* @name PostShareProV1ContributeSubmit
* @summary
* @request POST:/share/pro/v1/contribute/submit
* @response `200` `(DomainResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1SubmitContributeResp,
})` OK
*/
export const postShareProV1ContributeSubmit = (
param: GithubComChaitinPandaWikiProApiShareV1SubmitContributeReq,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1SubmitContributeResp;
}
>({
path: `/share/pro/v1/contribute/submit`,
method: "POST",
body: param,
type: ContentType.Json,
format: "json",
...params,
});

View File

@ -0,0 +1,48 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainResponse,
GithubComChaitinPandaWikiProApiShareV1FileUploadResp,
PostShareProV1FileUploadPayload,
} from "./types";
/**
* @description
*
* @tags ShareFile
* @name PostShareProV1FileUpload
* @summary
* @request POST:/share/pro/v1/file/upload
* @response `200` `(DomainResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1FileUploadResp,
})` OK
*/
export const postShareProV1FileUpload = (
data: PostShareProV1FileUploadPayload,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1FileUploadResp;
}
>({
path: `/share/pro/v1/file/upload`,
method: "POST",
body: data,
type: ContentType.FormData,
format: "json",
...params,
});

View File

@ -4,10 +4,13 @@ export * from './AuthGroup';
export * from './AuthOrg';
export * from './Block';
export * from './Comment';
export * from './Contribute';
export * from './DocumentFeedback';
export * from './License';
export * from './Node';
export * from './Prompt';
export * from './ShareAuth';
export * from './ShareContribute';
export * from './ShareFile';
export * from './ShareOpenapi';
export * from './types';

View File

@ -56,6 +56,17 @@ export enum ConstsLicenseEdition {
LicenseEditionEnterprise = 2,
}
export enum ConstsContributeType {
ContributeTypeAdd = 'add',
ContributeTypeEdit = 'edit',
}
export enum ConstsContributeStatus {
ContributeStatusPending = 'pending',
ContributeStatusApproved = 'approved',
ContributeStatusRejected = 'rejected',
}
export interface DomainCommentModerateListReq {
ids: string[];
status: DomainCommentStatus;
@ -309,6 +320,72 @@ export interface GithubComChaitinPandaWikiProApiAuthV1AuthSetReq {
user_info_url?: string;
}
export interface GithubComChaitinPandaWikiProApiContributeV1ContributeAuditReq {
id: string;
kb_id: string;
parent_id?: string;
position?: number;
status: 'approved' | 'rejected';
}
export interface GithubComChaitinPandaWikiProApiContributeV1ContributeAuditResp {
message?: string;
}
export interface GithubComChaitinPandaWikiProApiContributeV1ContributeDetailResp {
audit_time?: string;
audit_user_id?: string;
auth_id?: number;
auth_name?: string;
content?: string;
created_at?: string;
id?: string;
kb_id?: string;
meta?: GithubComChaitinPandaWikiProApiContributeV1NodeMeta;
node_id?: string;
node_name?: string;
/** edit类型时返回原始node信息 */
original_node?: GithubComChaitinPandaWikiProApiContributeV1OriginalNodeInfo;
reason?: string;
status?: ConstsContributeStatus;
type?: ConstsContributeType;
updated_at?: string;
}
export interface GithubComChaitinPandaWikiProApiContributeV1ContributeItem {
audit_time?: string;
audit_user_id?: string;
auth_id?: number;
auth_name?: string;
created_at?: string;
id?: string;
kb_id?: string;
meta?: GithubComChaitinPandaWikiProApiContributeV1NodeMeta;
node_id?: string;
node_name?: string;
reason?: string;
status?: ConstsContributeStatus;
type?: ConstsContributeType;
updated_at?: string;
}
export interface GithubComChaitinPandaWikiProApiContributeV1ContributeListResp {
list?: GithubComChaitinPandaWikiProApiContributeV1ContributeItem[];
total?: number;
}
export interface GithubComChaitinPandaWikiProApiContributeV1NodeMeta {
doc_width?: string;
emoji?: string;
}
export interface GithubComChaitinPandaWikiProApiContributeV1OriginalNodeInfo {
content?: string;
id?: string;
meta?: GithubComChaitinPandaWikiProApiContributeV1NodeMeta;
name?: string;
}
export interface GithubComChaitinPandaWikiProApiShareV1AuthCASReq {
kb_id?: string;
redirect_url?: string;
@ -397,6 +474,10 @@ export type GithubComChaitinPandaWikiProApiShareV1FeishuCallbackResp = Record<
any
>;
export interface GithubComChaitinPandaWikiProApiShareV1FileUploadResp {
key?: string;
}
export type GithubComChaitinPandaWikiProApiShareV1GitHubCallbackResp = Record<
string,
any
@ -407,6 +488,22 @@ export type GithubComChaitinPandaWikiProApiShareV1OAuthCallbackResp = Record<
any
>;
export interface GithubComChaitinPandaWikiProApiShareV1SubmitContributeReq {
content?: string;
doc_width?: string;
emoji?: string;
kb_id?: string;
name?: string;
node_id?: string;
reason?: string;
type: 'add' | 'edit';
}
export type GithubComChaitinPandaWikiProApiShareV1SubmitContributeResp = Record<
string,
any
>;
export type GithubComChaitinPandaWikiProApiShareV1WecomCallbackResp = Record<
string,
any
@ -505,6 +602,22 @@ export interface GetApiProV1BlockParams {
kb_id: string;
}
export interface GetApiProV1ContributeDetailParams {
id: string;
kb_id: string;
}
export interface GetApiProV1ContributeListParams {
auth_name?: string;
kb_id?: string;
node_name?: string;
/** @min 1 */
page: number;
/** @min 1 */
per_page: number;
status?: 'pending' | 'approved' | 'rejected';
}
export interface GetApiProV1DocumentListParams {
kb_id: string;
/** @min 1 */
@ -560,6 +673,11 @@ export interface PostShareProV1DocumentFeedbackPayload {
image?: File;
}
export interface PostShareProV1FileUploadPayload {
/** File */
file: File;
}
export interface GetShareProV1OpenapiCasCallbackParams {
state?: string;
ticket?: string;

View File

@ -242,6 +242,7 @@ export interface DomainAppSettings {
captcha_settings?: ConstsCaptchaSettings;
/** catalog settings */
catalog_settings?: DomainCatalogSettings;
contribute_settings?: DomainContributeSettings;
copy_setting?: '' | 'append' | 'disabled';
/** seo */
desc?: string;
@ -316,6 +317,7 @@ export interface DomainAppSettingsResp {
captcha_settings?: ConstsCaptchaSettings;
/** catalog settings */
catalog_settings?: DomainCatalogSettings;
contribute_settings?: DomainContributeSettings;
copy_setting?: ConstsCopySetting;
/** seo */
desc?: string;
@ -453,6 +455,10 @@ export interface DomainCommentReq {
user_name?: string;
}
export interface DomainContributeSettings {
is_enable?: boolean;
}
export interface DomainConversationDetailResp {
app_id?: string;
created_at?: string;

View File

@ -51,6 +51,12 @@ const router = [
LazyLoadable(lazy(() => import('./pages/setting'))),
),
},
{
path: '/contribution',
element: createElement(
LazyLoadable(lazy(() => import('./pages/contribution'))),
),
},
{
path: '/release',
element: createElement(

View File

@ -17,5 +17,5 @@ image: build
.
save: image
docker save -o ./panda-wiki-app_frontend.tar panda-wiki-app/frontend:main
docker save -o /tmp/panda-wiki-app_frontend.tar panda-wiki-app/frontend:main

View File

@ -12,8 +12,9 @@
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,css,scss,md,mdx,json,yml,yaml,mjs,cjs}\""
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@cap.js/widget": "^0.1.26",
"@ctzhian/tiptap": "1.3.1",
"@emotion/cache": "^11.14.0",
"@mui/material-nextjs": "^7.1.0",
"@sentry/nextjs": "^10.8.0",
@ -24,6 +25,7 @@
"highlight.js": "^11.11.1",
"html-react-parser": "^5.2.5",
"html-to-image": "^1.11.13",
"import-in-the-middle": "^1.14.2",
"katex": "^0.16.22",
"markdown-it": "13.0.1",
"markdown-it-highlightjs": "^4.2.0",
@ -38,6 +40,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"require-in-the-middle": "^7.5.2",
"uuid": "^11.1.0"
},
"devDependencies": {
@ -50,4 +53,4 @@
"eslint-config-prettier": "^9.1.2"
},
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
}
}

View File

@ -0,0 +1,5 @@
import DocEditor from '@/views/editor';
export default function EditorPage() {
return <DocEditor />;
}

View File

@ -18,8 +18,6 @@ const PCLayout = ({ children }: { children: React.ReactNode }) => {
alignItems='flex-start'
gap={'96px'}
sx={{
position: 'relative',
zIndex: 1,
pt: '50px',
pb: 10,
px: 5,

View File

@ -80,6 +80,9 @@ export interface KBDetail {
social_media_accounts?: DomainSocialMediaAccount[];
footer_show_intro?: boolean;
};
contribute_settings?: {
is_enable: boolean;
};
};
}
export interface DomainSocialMediaAccount {

View File

@ -0,0 +1,29 @@
{
"search": "搜索",
"search_no_results_1": "哦不!",
"search_no_results_2": "没有找到相关表情",
"pick": "选择一个表情…",
"add_custom": "添加自定义表情",
"categories": {
"activity": "活动",
"custom": "自定义",
"flags": "旗帜",
"foods": "食物与饮品",
"frequent": "最近使用",
"nature": "动物与自然",
"objects": "物品",
"people": "表情与角色",
"places": "旅行与景点",
"search": "搜索结果",
"symbols": "符号"
},
"skins": {
"choose": "选择默认肤色",
"1": "默认",
"2": "白色",
"3": "偏白",
"4": "中等",
"5": "偏黑",
"6": "黑色"
}
}

View File

@ -0,0 +1,117 @@
'use client';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import { Box, IconButton, Popover, SxProps } from '@mui/material';
import React, { useCallback } from 'react';
import zh from './emoji-data/zh.json';
import {
IconWenjianjia,
IconWenjianjiaKai,
IconWenjian,
} from '@panda-wiki/icons';
interface EmojiPickerProps {
type: 1 | 2;
readOnly?: boolean;
value?: string;
collapsed?: boolean;
onChange?: (emoji: string) => void;
sx?: SxProps;
iconSx?: SxProps;
}
const EmojiPicker: React.FC<EmojiPickerProps> = ({
type,
readOnly,
value,
onChange,
collapsed,
sx,
iconSx,
}) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(
null,
);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (readOnly) return;
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleSelect = useCallback(
(emoji: any) => {
onChange?.(emoji.native);
handleClose();
},
[onChange],
);
const open = Boolean(anchorEl);
const id = open ? 'emoji-picker' : undefined;
return (
<>
<IconButton
size='small'
aria-describedby={id}
disabled={readOnly}
onClick={handleClick}
sx={{
cursor: 'pointer',
height: 28,
color: 'text.primary',
...sx,
}}
>
{value ? (
<Box component='span' sx={{ fontSize: 14, ...iconSx }}>
{value}
</Box>
) : (
<>
{type === 1 ? (
collapsed ? (
<IconWenjianjia sx={{ fontSize: 16, ...iconSx }} />
) : (
<IconWenjianjiaKai sx={{ fontSize: 16, ...iconSx }} />
)
) : (
<IconWenjian sx={{ fontSize: 16, ...iconSx }} />
)}
</>
)}
</IconButton>
<Popover
id={id}
open={open}
onClose={handleClose}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Picker
data={data}
set='native'
theme='light'
locale='zh'
i18n={zh}
onEmojiSelect={handleSelect}
previewPosition='none'
searchPosition='sticky'
skinTonePosition='none'
perLine={9}
emojiSize={24}
/>
</Popover>
</>
);
};
export default EmojiPicker;

View File

@ -0,0 +1,196 @@
import { Box, Popover, Stack, SxProps, Theme, Typography } from '@mui/material';
import React from 'react';
interface Item {
label: React.ReactNode;
icon?: React.ReactNode;
extra?: React.ReactNode;
selected?: boolean;
children?: Item[];
show?: boolean;
textSx?: SxProps<Theme>;
key: number | string;
onClick?: () => void;
}
interface MenuSelectProps {
id?: string;
arrowIcon?: React.ReactNode;
list: Item[];
context?: React.ReactElement<{ onClick?: any; 'aria-describedby'?: any }>;
anchorOrigin?: {
vertical: 'top' | 'bottom' | 'center';
horizontal: 'left' | 'right' | 'center';
};
transformOrigin?: {
vertical: 'top' | 'bottom' | 'center';
horizontal: 'left' | 'right' | 'center';
};
childrenProps?: {
anchorOrigin?: {
vertical: 'top' | 'bottom' | 'center';
horizontal: 'left' | 'right' | 'center';
};
transformOrigin?: {
vertical: 'top' | 'bottom' | 'center';
horizontal: 'left' | 'right' | 'center';
};
};
}
const MenuSelect: React.FC<MenuSelectProps> = ({
id = 'menu-select',
arrowIcon,
list,
context,
anchorOrigin = {
vertical: 'bottom',
horizontal: 'right',
},
transformOrigin = {
vertical: 'top',
horizontal: 'right',
},
childrenProps = {
anchorOrigin: {
vertical: 'top',
horizontal: 'right',
},
transformOrigin: {
vertical: 'top',
horizontal: 'left',
},
},
}) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(
null,
);
const [hoveredItem, setHoveredItem] = React.useState<Item | null>(null);
const [subMenuAnchor, setSubMenuAnchor] = React.useState<HTMLElement | null>(
null,
);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
setHoveredItem(null);
setSubMenuAnchor(null);
};
const handleItemHover = (
event: React.MouseEvent<HTMLElement>,
item: Item,
) => {
if (item.children?.length) {
setHoveredItem(item);
setSubMenuAnchor(event.currentTarget);
}
};
const handleItemLeave = () => {
setHoveredItem(null);
setSubMenuAnchor(null);
};
const handleItemClick = (item: Item) => {
if (item.onClick) {
item.onClick();
}
handleClose();
};
const open = Boolean(anchorEl);
const curId = open ? id : undefined;
return (
<>
{context &&
React.cloneElement(context, {
onClick: handleClick,
'aria-describedby': curId,
})}
<Popover
id={curId}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={anchorOrigin}
transformOrigin={transformOrigin}
>
<Box className='menu-select-list' sx={{ p: 0.5 }}>
{list.map(item =>
item.show === false ? null : (
<Box
className='menu-select-item'
key={item.key}
onMouseEnter={e => handleItemHover(e, item)}
onMouseLeave={handleItemLeave}
onClick={() => handleItemClick(item)}
sx={{
position: 'relative',
cursor: 'pointer',
}}
>
<Stack alignItems='center' gap={1} direction='row'>
{item.icon}
<Typography
variant='body1'
sx={{ flexShrink: 0, ...item.textSx }}
>
{item.label}
</Typography>
{item.extra}
{item.children?.length ? arrowIcon : null}
</Stack>
{hoveredItem === item && item.children && (
<Popover
open={Boolean(subMenuAnchor)}
anchorEl={subMenuAnchor}
onClose={handleItemLeave}
sx={{ pointerEvents: 'none' }}
{...childrenProps}
>
<Box
className='menu-select-sub-list'
sx={{
pointerEvents: 'auto',
p: 0.5,
}}
>
{item.children.map(child =>
child.show === false ? null : (
<Box
key={child.key}
className='menu-select-sub-item'
onClick={() => handleItemClick(child)}
sx={{
cursor: 'pointer',
}}
>
<Stack alignItems='center' gap={1} direction='row'>
{child.icon}
<Typography
sx={{ flexShrink: 0, ...child.textSx }}
>
{child.label}
</Typography>
{child.extra}
</Stack>
</Box>
),
)}
</Box>
</Popover>
)}
</Box>
),
)}
</Box>
</Popover>
</>
);
};
export default MenuSelect;

View File

@ -0,0 +1,48 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from './httpClient';
import {
DomainPWResponse,
GetShareV1OpenapiGithubCallbackParams,
GithubComChaitinPandaWikiApiShareV1GitHubCallbackResp,
} from './types';
/**
* @description GitHub回调
*
* @tags ShareOpenapi
* @name GetShareV1OpenapiGithubCallback
* @summary GitHub回调
* @request GET:/share/v1/openapi/github/callback
* @response `200` `(DomainPWResponse & {
data?: GithubComChaitinPandaWikiApiShareV1GitHubCallbackResp,
})` OK
*/
export const getShareV1OpenapiGithubCallback = (
query: GetShareV1OpenapiGithubCallbackParams,
params: RequestParams = {},
) =>
httpRequest<
DomainPWResponse & {
data?: GithubComChaitinPandaWikiApiShareV1GitHubCallbackResp;
}
>({
path: `/share/v1/openapi/github/callback`,
method: 'GET',
query: query,
type: ContentType.Json,
format: 'json',
...params,
});

View File

@ -1,11 +1,11 @@
export * from './ShareApp'
export * from './ShareAuth'
export * from './ShareCaptcha'
export * from './ShareChat'
export * from './ShareComment'
export * from './ShareConversation'
export * from './ShareNode'
export * from './ShareStat'
export * from './Wechat'
export * from './types'
export * from './ShareApp';
export * from './ShareAuth';
export * from './ShareCaptcha';
export * from './ShareChat';
export * from './ShareComment';
export * from './ShareConversation';
export * from './ShareNode';
export * from './ShareOpenapi';
export * from './ShareStat';
export * from './Wechat';
export * from './types';

View File

@ -10,14 +10,14 @@
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import httpRequest, { ContentType, RequestParams } from './httpClient';
import {
DomainGetNodeReleaseDetailResp,
DomainNodeReleaseListItem,
DomainPWResponse,
GetApiProV1NodeReleaseDetailParams,
GetApiProV1NodeReleaseListParams,
} from "./types";
} from './types';
/**
* @description Get Node Release Detail
@ -42,10 +42,10 @@ export const getApiProV1NodeReleaseDetail = (
}
>({
path: `/api/pro/v1/node/release/detail`,
method: "GET",
method: 'GET',
query: query,
type: ContentType.Json,
format: "json",
format: 'json',
...params,
});
@ -72,9 +72,9 @@ export const getApiProV1NodeReleaseList = (
}
>({
path: `/api/pro/v1/node/release/list`,
method: "GET",
method: 'GET',
query: query,
type: ContentType.Json,
format: "json",
format: 'json',
...params,
});

View File

@ -13,4 +13,5 @@ export * from './ShareAuth';
export * from './ShareContribute';
export * from './ShareFile';
export * from './ShareOpenapi';
export * from './otherCustomer';
export * from './types';

View File

@ -0,0 +1,103 @@
import { RequestParams } from "./httpClient";
import {
GithubComChaitinPandaWikiProApiShareV1FileUploadResp,
PostShareProV1FileUploadPayload,
} from "./types";
/**
* 使 XMLHttpRequest
*/
export const postShareProV1FileUploadWithProgress = (
data: PostShareProV1FileUploadPayload,
params: RequestParams & {
onprogress?: (progress: { progress: number }) => void;
abortSignal?: AbortSignal;
} = {},
): Promise<GithubComChaitinPandaWikiProApiShareV1FileUploadResp> => {
return new Promise((resolve, reject) => {
const { onprogress, abortSignal, ...requestParams } = params;
// 创建 FormData
const formData = new FormData();
Object.keys(data).forEach(key => {
const value = data[key as keyof PostShareProV1FileUploadPayload];
if (value instanceof File) {
formData.append(key, value);
} else if (value !== null && value !== undefined) {
formData.append(key, String(value));
}
});
const xhr = new XMLHttpRequest();
// 设置上传进度监听
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && onprogress) {
const progress = (event.loaded / event.total) * 100;
onprogress({ progress });
}
});
// 设置响应处理
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
if (response.code === 0 || response.code === undefined) {
resolve(response.data);
} else {
reject(new Error(response.message || '上传失败'));
}
} catch (error) {
reject(new Error('响应解析失败'));
}
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
}
});
// 设置错误处理
xhr.addEventListener('error', () => {
reject(new Error('网络错误'));
});
// 设置中止处理
xhr.addEventListener('abort', () => {
reject(new Error('请求被中止'));
});
// 监听中止信号
if (abortSignal) {
abortSignal.addEventListener('abort', () => {
xhr.abort();
});
}
// 构建请求 URL
const baseUrl = process.env.TARGET || '';
const url = `${baseUrl}/share/pro/v1/file/upload`;
// 发送请求
xhr.open('POST', url);
// 设置请求头
if (requestParams.headers) {
Object.entries(requestParams.headers).forEach(([key, value]) => {
if (typeof value === 'string') {
xhr.setRequestHeader(key, value);
}
});
}
// 设置凭据
if (requestParams.credentials) {
xhr.withCredentials = requestParams.credentials === 'include';
}
xhr.send(formData);
});
};

View File

@ -56,6 +56,17 @@ export enum ConstsLicenseEdition {
LicenseEditionEnterprise = 2,
}
export enum ConstsContributeType {
ContributeTypeAdd = 'add',
ContributeTypeEdit = 'edit',
}
export enum ConstsContributeStatus {
ContributeStatusPending = 'pending',
ContributeStatusApproved = 'approved',
ContributeStatusRejected = 'rejected',
}
export interface DomainCommentModerateListReq {
ids: string[];
status: DomainCommentStatus;
@ -169,6 +180,7 @@ export interface GithubComChaitinPandaWikiProApiAuthV1AuthGetResp {
/** LDAP特定配置 */
ldap_server_url?: string;
name_field?: string;
proxy?: string;
scopes?: string[];
source_type?: ConstsSourceType;
token_url?: string;
@ -297,6 +309,7 @@ export interface GithubComChaitinPandaWikiProApiAuthV1AuthSetReq {
/** LDAP特定配置 */
ldap_server_url?: string;
name_field?: string;
proxy?: string;
scopes?: string[];
source_type?: ConstsSourceType;
token_url?: string;
@ -307,6 +320,72 @@ export interface GithubComChaitinPandaWikiProApiAuthV1AuthSetReq {
user_info_url?: string;
}
export interface GithubComChaitinPandaWikiProApiContributeV1ContributeAuditReq {
id: string;
kb_id: string;
parent_id?: string;
position?: number;
status: 'approved' | 'rejected';
}
export interface GithubComChaitinPandaWikiProApiContributeV1ContributeAuditResp {
message?: string;
}
export interface GithubComChaitinPandaWikiProApiContributeV1ContributeDetailResp {
audit_time?: string;
audit_user_id?: string;
auth_id?: number;
auth_name?: string;
content?: string;
created_at?: string;
id?: string;
kb_id?: string;
meta?: GithubComChaitinPandaWikiProApiContributeV1NodeMeta;
node_id?: string;
node_name?: string;
/** edit类型时返回原始node信息 */
original_node?: GithubComChaitinPandaWikiProApiContributeV1OriginalNodeInfo;
reason?: string;
status?: ConstsContributeStatus;
type?: ConstsContributeType;
updated_at?: string;
}
export interface GithubComChaitinPandaWikiProApiContributeV1ContributeItem {
audit_time?: string;
audit_user_id?: string;
auth_id?: number;
auth_name?: string;
created_at?: string;
id?: string;
kb_id?: string;
meta?: GithubComChaitinPandaWikiProApiContributeV1NodeMeta;
node_id?: string;
node_name?: string;
reason?: string;
status?: ConstsContributeStatus;
type?: ConstsContributeType;
updated_at?: string;
}
export interface GithubComChaitinPandaWikiProApiContributeV1ContributeListResp {
list?: GithubComChaitinPandaWikiProApiContributeV1ContributeItem[];
total?: number;
}
export interface GithubComChaitinPandaWikiProApiContributeV1NodeMeta {
doc_width?: string;
emoji?: string;
}
export interface GithubComChaitinPandaWikiProApiContributeV1OriginalNodeInfo {
content?: string;
id?: string;
meta?: GithubComChaitinPandaWikiProApiContributeV1NodeMeta;
name?: string;
}
export interface GithubComChaitinPandaWikiProApiShareV1AuthCASReq {
kb_id?: string;
redirect_url?: string;
@ -395,6 +474,10 @@ export type GithubComChaitinPandaWikiProApiShareV1FeishuCallbackResp = Record<
any
>;
export interface GithubComChaitinPandaWikiProApiShareV1FileUploadResp {
key?: string;
}
export type GithubComChaitinPandaWikiProApiShareV1GitHubCallbackResp = Record<
string,
any
@ -405,6 +488,22 @@ export type GithubComChaitinPandaWikiProApiShareV1OAuthCallbackResp = Record<
any
>;
export interface GithubComChaitinPandaWikiProApiShareV1SubmitContributeReq {
content?: string;
doc_width?: string;
emoji?: string;
kb_id?: string;
name?: string;
node_id?: string;
reason?: string;
type: 'add' | 'edit';
}
export type GithubComChaitinPandaWikiProApiShareV1SubmitContributeResp = Record<
string,
any
>;
export type GithubComChaitinPandaWikiProApiShareV1WecomCallbackResp = Record<
string,
any
@ -503,6 +602,22 @@ export interface GetApiProV1BlockParams {
kb_id: string;
}
export interface GetApiProV1ContributeDetailParams {
id: string;
kb_id: string;
}
export interface GetApiProV1ContributeListParams {
auth_name?: string;
kb_id?: string;
node_name?: string;
/** @min 1 */
page: number;
/** @min 1 */
per_page: number;
status?: 'pending' | 'approved' | 'rejected';
}
export interface GetApiProV1DocumentListParams {
kb_id: string;
/** @min 1 */
@ -558,6 +673,11 @@ export interface PostShareProV1DocumentFeedbackPayload {
image?: File;
}
export interface PostShareProV1FileUploadPayload {
/** File */
file: File;
}
export interface GetShareProV1OpenapiCasCallbackParams {
state?: string;
ticket?: string;

View File

@ -242,6 +242,7 @@ export interface DomainAppSettings {
captcha_settings?: ConstsCaptchaSettings;
/** catalog settings */
catalog_settings?: DomainCatalogSettings;
contribute_settings?: DomainContributeSettings;
copy_setting?: '' | 'append' | 'disabled';
/** seo */
desc?: string;
@ -316,6 +317,7 @@ export interface DomainAppSettingsResp {
captcha_settings?: ConstsCaptchaSettings;
/** catalog settings */
catalog_settings?: DomainCatalogSettings;
contribute_settings?: DomainContributeSettings;
copy_setting?: ConstsCopySetting;
/** seo */
desc?: string;
@ -453,6 +455,10 @@ export interface DomainCommentReq {
user_name?: string;
}
export interface DomainContributeSettings {
is_enable?: boolean;
}
export interface DomainConversationDetailResp {
app_id?: string;
created_at?: string;
@ -1157,6 +1163,7 @@ export interface GithubComChaitinPandaWikiApiAuthV1AuthGetResp {
auths?: V1AuthItem[];
client_id?: string;
client_secret?: string;
proxy?: string;
source_type?: ConstsSourceType;
}
@ -1166,6 +1173,11 @@ export interface GithubComChaitinPandaWikiApiShareV1AuthGetResp {
source_type?: ConstsSourceType;
}
export type GithubComChaitinPandaWikiApiShareV1GitHubCallbackResp = Record<
string,
any
>;
export interface GithubComChaitinPandaWikiDomainCheckModelReq {
api_header?: string;
api_key?: string;
@ -1257,6 +1269,7 @@ export interface V1AuthSetReq {
client_id?: string;
client_secret?: string;
kb_id?: string;
proxy?: string;
source_type: 'github';
}
@ -1658,3 +1671,8 @@ export interface GetShareV1NodeDetailParams {
/** format */
format: string;
}
export interface GetShareV1OpenapiGithubCallbackParams {
code?: string;
state?: string;
}

View File

@ -9,16 +9,16 @@ const createComponentStyleOverrides = (
): CssVarsThemeOptions['components'] => ({
MuiInputBase: {
styleOverrides: {
root: {
root: ({ theme }) => ({
borderRadius: '10px !important',
'.MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent',
borderColor: theme.palette.divider,
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'var(--mui-palette-text-primary) !important',
borderWidth: '1px !important',
},
},
}),
},
},
MuiSvgIcon: {

View File

@ -265,3 +265,21 @@ export const getRedirectUrl = () => {
: new URL('/', location.origin);
return redirectUrl as URL;
};
export const MAC_SYMBOLS = {
ctrl: '⌘',
alt: '⌥',
shift: '⇧',
};
export const isMac = () =>
typeof navigator !== 'undefined' &&
navigator.platform.toLowerCase().includes('mac');
export const getShortcutKeyText = (shortcutKey: string[]) => {
return shortcutKey
?.map(it =>
isMac() ? MAC_SYMBOLS[it as keyof typeof MAC_SYMBOLS] || it : it,
)
.join('+');
};

View File

@ -0,0 +1,174 @@
import SSEClient from '@/utils/fetch';
import { Box, Divider, Stack } from '@mui/material';
import { Editor, useTiptap, UseTiptapReturn } from '@ctzhian/tiptap';
import { Modal } from '@ctzhian/ui';
import { useCallback, useEffect, useRef, useState } from 'react';
interface AIGenerateProps {
open: boolean;
selectText: string;
onClose: () => void;
editorRef: UseTiptapReturn;
}
const AIGenerate = ({
open,
selectText,
onClose,
editorRef,
}: AIGenerateProps) => {
const sseClientRef = useRef<SSEClient<string> | null>(null);
const [loading, setLoading] = useState(false);
const [content, setContent] = useState('');
const defaultEditor = useTiptap({
editable: false,
immediatelyRender: false,
});
const readEditor = useTiptap({
editable: false,
immediatelyRender: false,
});
const onGenerate = useCallback(() => {
if (sseClientRef.current) {
setLoading(true);
sseClientRef.current.subscribe(
JSON.stringify({
text: selectText,
action: 'rephrase',
stream: true,
}),
data => {
setContent(prev => {
const newContent = prev + data;
readEditor.editor?.commands.setContent(newContent);
return newContent;
});
},
);
}
}, [selectText, sseClientRef.current, readEditor.editor]);
const onCancel = () => {
sseClientRef.current?.unsubscribe();
defaultEditor.editor.commands.setContent('');
readEditor.editor.commands.setContent('');
setContent('');
onClose();
};
const onSubmit = () => {
const { from, to } = editorRef.editor.state.selection;
editorRef.editor.commands.insertContentAt({ from, to }, content);
onCancel();
};
useEffect(() => {
if (!open) return;
sseClientRef.current = new SSEClient<string>({
url: '/api/v1/creation/text',
headers: {
'Content-Type': 'application/json',
},
onComplete: () => setLoading(false),
onError: () => setLoading(false),
});
if (selectText) {
defaultEditor.editor.commands.setContent(selectText);
setTimeout(() => {
onGenerate();
}, 60);
}
}, [selectText, open]);
useEffect(() => {
if (!defaultEditor.editor || !readEditor.editor) return;
return () => {
defaultEditor.editor.destroy();
readEditor.editor.destroy();
sseClientRef.current?.unsubscribe();
};
}, [defaultEditor, readEditor]);
return (
<Modal
open={open}
onCancel={onCancel}
title={'文本润色'}
okText='替换'
width={1000}
onOk={onSubmit}
okButtonProps={{
loading,
disabled: content.length === 0,
}}
>
<Stack
direction={'row'}
sx={{
'.tiptap.ProseMirror': {
padding: '0px',
},
}}
>
<Stack
sx={{
width: '50%',
flex: 1,
}}
>
<Box
sx={{
mb: 0.5,
ml: 1,
fontSize: 14,
fontWeight: 'bold',
color: 'text.auxiliary',
}}
>
</Box>
<Box
sx={{
borderRadius: '10px',
p: 2,
bgcolor: 'background.paper2',
flex: 1,
}}
>
<Editor editor={defaultEditor.editor} />
</Box>
</Stack>
<Divider orientation='vertical' flexItem sx={{ mx: 2 }} />
<Stack sx={{ width: '50%', flex: 1 }}>
<Box
sx={{
mb: 0.5,
ml: 1,
fontSize: 14,
fontWeight: 'bold',
color: 'text.auxiliary',
}}
>
</Box>
<Box
sx={{
borderRadius: '10px',
p: 2,
bgcolor: 'background.paper2',
flex: 1,
}}
>
<Editor editor={readEditor.editor} />
</Box>
</Stack>
</Stack>
</Modal>
);
};
export default AIGenerate;

View File

@ -0,0 +1,102 @@
import React, { useEffect, useState } from 'react';
import { Modal } from '@ctzhian/ui';
import { Box, TextField, Typography, styled } from '@mui/material';
import { IconErrorCorrection } from '@/components/icons';
interface ConfirmModalProps {
open: boolean;
onCancel: () => void;
onOk: (reason: string) => Promise<void>;
}
const StyledInfoBox = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'flex-start',
gap: theme.spacing(2),
padding: theme.spacing(2),
backgroundColor: theme.palette.background.paper2,
borderRadius: theme.spacing(1.5),
border: `1px solid ${theme.palette.divider}`,
marginBottom: theme.spacing(2),
}));
const StyledIconBox = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: '50%',
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
flexShrink: 0,
}));
const StyledContentBox = styled(Box)(({ theme }) => ({
flex: 1,
'& .title': {
fontSize: 16,
fontWeight: 600,
color: theme.palette.text.primary,
marginBottom: theme.spacing(0.5),
},
'& .description': {
fontSize: 14,
lineHeight: 1.5,
color: theme.palette.text.secondary,
},
}));
const StyledLabel = styled(Typography)(({ theme }) => ({
fontSize: 14,
fontWeight: 500,
color: theme.palette.text.primary,
marginBottom: theme.spacing(1),
}));
const ConfirmModal = ({ open, onCancel, onOk }: ConfirmModalProps) => {
const [reason, setReason] = useState('');
useEffect(() => {
setReason('');
}, [open]);
return (
<Modal
open={open}
onClose={onCancel}
title='确认提交'
okText='提交审核'
onOk={() => onOk(reason)}
>
<StyledInfoBox>
<StyledIconBox>
<IconErrorCorrection sx={{ fontSize: 20 }} />
</StyledIconBox>
<StyledContentBox>
<Typography className='title'></Typography>
<Typography className='description'>
</Typography>
</StyledContentBox>
</StyledInfoBox>
<StyledLabel></StyledLabel>
<TextField
fullWidth
multiline
rows={3}
placeholder='请输入备注信息,帮助审核人员更好地理解您的修改...'
value={reason}
onChange={e => setReason(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
},
}}
/>
</Modal>
);
};
export default ConfirmModal;

View File

@ -0,0 +1,117 @@
'use client';
import { V1NodeDetailResp } from '@/request/types';
import { IconBaocun } from '@panda-wiki/icons';
import { Box, Button, IconButton, Skeleton, Stack } from '@mui/material';
import { Ellipsis, Icon, message } from '@ctzhian/ui';
import dayjs from 'dayjs';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useWrapContext } from '..';
interface HeaderProps {
edit: boolean;
collaborativeUsers?: Array<{
id: string;
name: string;
color: string;
}>;
isSyncing?: boolean;
detail: V1NodeDetailResp;
updateDetail: (detail: V1NodeDetailResp) => void;
handleSave: () => void;
handleExport: (type: string) => void;
}
const Header = ({ edit, detail, handleSave }: HeaderProps) => {
const firstLoad = useRef(true);
const { catalogOpen, nodeDetail, setCatalogOpen } = useWrapContext();
const [showSaveTip, setShowSaveTip] = useState(false);
useEffect(() => {
if (nodeDetail?.updated_at && !firstLoad.current) {
setShowSaveTip(true);
setTimeout(() => {
setShowSaveTip(false);
}, 1500);
}
firstLoad.current = false;
}, [nodeDetail?.updated_at]);
return (
<Box sx={{ p: 1 }}>
<Stack
direction={'row'}
alignItems={'center'}
gap={1}
justifyContent={'space-between'}
sx={{ height: '40px' }}
>
{!catalogOpen && (
<Stack
alignItems='center'
justifyContent='space-between'
onClick={() => setCatalogOpen(true)}
sx={{
cursor: 'pointer',
color: 'text.auxiliary',
':hover': {
color: 'text.primary',
},
}}
>
<Icon
type='icon-muluzhankai'
sx={{
fontSize: 24,
}}
/>
</Stack>
)}
<Stack sx={{ width: 0, flex: 1 }}>
{detail?.name ? (
<Ellipsis sx={{ fontSize: 14, fontWeight: 'bold' }}>
<Box
component='span'
sx={{ cursor: 'pointer' }}
// onClick={() => setRenameOpen(true)}
>
{detail.name}
</Box>
</Ellipsis>
) : // <Skeleton variant='text' width={300} height={24} />
null}
{nodeDetail?.updated_at && (
<Stack
direction={'row'}
alignItems={'center'}
gap={0.5}
sx={{ fontSize: 12, color: 'text.auxiliary' }}
>
<IconBaocun sx={{ fontSize: 12 }} />
{nodeDetail?.updated_at ? (
dayjs(nodeDetail.updated_at).format('YYYY-MM-DD HH:mm:ss')
) : (
<Skeleton variant='text' width={100} height={24} />
)}
</Stack>
)}
</Stack>
<Stack direction={'row'} gap={4}>
<Button
size='small'
variant='contained'
disabled={!detail.name}
startIcon={<IconBaocun />}
onClick={handleSave}
>
</Button>
</Stack>
</Stack>
</Box>
);
};
export default Header;

View File

@ -0,0 +1,91 @@
'use client';
import { Box, Skeleton, Stack } from '@mui/material';
import { useTiptap } from '@ctzhian/tiptap';
import { Icon } from '@ctzhian/ui';
import { useState } from 'react';
import Header from './Header';
import Toolbar from './Toolbar';
const LoadingEditorWrap = () => {
const [isSyncing] = useState(false);
const [collaborativeUsers] = useState<
Array<{
id: string;
name: string;
color: string;
}>
>([]);
const editorRef = useTiptap({
editable: false,
content: '',
exclude: ['invisibleCharacters', 'youtube', 'mention'],
immediatelyRender: false,
});
return (
<Box>
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 2,
transition: 'left 0.3s ease-in-out',
}}
>
<Header
edit={false}
isSyncing={isSyncing}
collaborativeUsers={collaborativeUsers}
detail={{}}
updateDetail={() => {}}
handleSave={() => {}}
handleExport={() => {}}
/>
{editorRef.editor && <Toolbar editorRef={editorRef} />}
</Box>
<Box>
<Box
sx={{
p: '72px 72px 150px',
mt: '102px',
mx: 'auto',
maxWidth: 892,
minWidth: '386px',
}}
>
<Stack direction={'row'} alignItems={'center'} gap={1} sx={{ mb: 2 }}>
<Skeleton variant='text' width={36} height={36} />
<Skeleton variant='text' width={300} height={36} />
</Stack>
<Stack direction={'row'} alignItems={'center'} gap={2} sx={{ mb: 4 }}>
<Stack direction={'row'} alignItems={'center'} gap={0.5}>
<Icon type='icon-a-shijian2' sx={{ color: 'text.auxiliary' }} />
<Skeleton variant='text' width={130} height={24} />
</Stack>
<Stack direction={'row'} alignItems={'center'} gap={0.5}>
<Icon type='icon-ziti' sx={{ color: 'text.auxiliary' }} />
<Skeleton variant='text' width={80} height={24} />
</Stack>
</Stack>
<Stack
gap={1}
sx={{
minHeight: 'calc(100vh - 432px)',
}}
>
<Skeleton variant='text' height={24} />
<Skeleton variant='text' width={300} height={24} />
<Skeleton variant='text' height={24} />
<Skeleton variant='text' height={24} />
<Skeleton variant='text' width={600} height={24} />
</Stack>
</Box>
</Box>
</Box>
);
};
export default LoadingEditorWrap;

View File

@ -0,0 +1,102 @@
import { V1NodeDetailResp } from '@/request';
import { Button, CircularProgress, Stack, TextField } from '@mui/material';
import { Icon, message, Modal } from '@ctzhian/ui';
import { useEffect, useState } from 'react';
import { useWrapContext } from '..';
interface SummaryProps {
open: boolean;
onClose: () => void;
updateDetail: (detail: V1NodeDetailResp) => void;
}
const Summary = ({ open, onClose, updateDetail }: SummaryProps) => {
const { nodeDetail } = useWrapContext();
const [summary, setSummary] = useState(nodeDetail?.meta?.summary || '');
const [loading, setLoading] = useState(false);
const [edit, setEdit] = useState(false);
const handleClose = () => {
setEdit(false);
setSummary('');
onClose();
};
const createSummary = () => {
if (!nodeDetail) return;
setLoading(true);
// postApiV1NodeSummary({ kb_id, ids: [nodeDetail.id!] })
// .then(res => {
// // @ts-expect-error 类型错误
// setSummary(res.summary);
// setEdit(true);
// })
// .finally(() => {
// setLoading(false);
// });
};
useEffect(() => {
if (open) {
setSummary(nodeDetail?.meta?.summary || '');
}
}, [open, nodeDetail]);
return (
<Modal
open={open}
onCancel={handleClose}
title='智能摘要'
okText='保存'
okButtonProps={{
disabled: loading || !edit,
}}
onOk={() => {
if (!nodeDetail) return;
updateDetail({
meta: {
...nodeDetail?.meta,
summary,
},
});
// putApiV1NodeDetail({ id: nodeDetail.id!, kb_id, summary }).then(() => {
// message.success('保存成功');
// });
handleClose();
}}
>
<Stack gap={2}>
<TextField
autoFocus
multiline
disabled={loading}
rows={10}
fullWidth
value={summary}
onChange={e => {
setSummary(e.target.value);
setEdit(true);
}}
placeholder='请输入摘要'
/>
<Button
fullWidth
variant='outlined'
onClick={createSummary}
disabled={loading}
startIcon={
loading ? (
<CircularProgress size={16} />
) : (
<Icon type='icon-DJzhinengzhaiyao' sx={{ fontSize: 16 }} />
)
}
>
AI
</Button>
</Stack>
</Modal>
);
};
export default Summary;

View File

@ -0,0 +1,200 @@
import { Box, Drawer, IconButton, Stack } from '@mui/material';
import {
H1Icon,
H2Icon,
H3Icon,
H4Icon,
H5Icon,
H6Icon,
TocList,
} from '@ctzhian/tiptap';
import { Ellipsis, Icon } from '@ctzhian/ui';
import { useState } from 'react';
interface TocProps {
headings: TocList;
fixed: boolean;
setFixed: (fixed: boolean) => void;
}
const HeadingIcon = [
<H1Icon sx={{ fontSize: 12 }} key='h1' />,
<H2Icon sx={{ fontSize: 12 }} key='h2' />,
<H3Icon sx={{ fontSize: 12 }} key='h3' />,
<H4Icon sx={{ fontSize: 12 }} key='h4' />,
<H5Icon sx={{ fontSize: 12 }} key='h5' />,
<H6Icon sx={{ fontSize: 12 }} key='h6' />,
];
const HeadingSx = [
{ fontSize: 14, fontWeight: 700, color: 'text.secondary' },
{ fontSize: 14, fontWeight: 400, color: 'text.auxiliary' },
{ fontSize: 14, fontWeight: 400, color: 'text.disabled' },
];
const Toc = ({ headings, fixed, setFixed }: TocProps) => {
const [open, setOpen] = useState(false);
const levels = Array.from(
new Set(headings.map(it => it.level).sort((a, b) => a - b)),
).slice(0, 3);
return (
<>
{!open && (
<Stack
sx={{
position: 'fixed',
top: 110,
right: 0,
width: 56,
pr: 1,
}}
>
<Stack
gap={1.5}
alignItems={'flex-end'}
sx={{ mt: 10 }}
onMouseEnter={() => setOpen(true)}
>
{headings
.filter(it => levels.includes(it.level))
.map(it => {
return (
<Box
key={it.id}
sx={{
width: 25 - (it.level - 1) * 5,
height: 4,
borderRadius: '2px',
bgcolor: it.isActive
? 'action.active'
: it.isScrolledOver
? 'action.selected'
: 'action.hover',
}}
/>
);
})}
</Stack>
</Stack>
)}
<Drawer
variant={'persistent'}
open={open}
onClose={() => setOpen(false)}
onMouseLeave={() => {
if (!fixed) setOpen(false);
}}
anchor='right'
sx={{
position: 'sticky',
zIndex: 2,
top: 110,
width: 292,
flexShrink: 0,
'& .MuiDrawer-paper': {
p: 1,
boxShadow: 'none !important',
mt: '102px',
bgcolor: 'background.default',
width: 292,
boxSizing: 'border-box',
},
}}
>
<Stack
direction={'row'}
justifyContent={'space-between'}
alignItems={'center'}
sx={{
fontSize: 14,
fontWeight: 'bold',
color: 'text.auxiliary',
mb: 1,
p: 1,
pb: 0,
}}
>
<Box></Box>
<IconButton
size='small'
onClick={() => {
if (fixed) {
setOpen(false);
}
setFixed(!fixed);
}}
>
<Icon
type={!fixed ? 'icon-dingzi' : 'icon-icon_tool_close'}
sx={{ fontSize: 18 }}
/>
</IconButton>
</Stack>
<Stack
gap={1}
sx={{
height: 'calc(100% - 146px)',
overflowY: 'auto',
p: 1,
pt: 0,
}}
>
{headings
.filter(it => levels.includes(it.level))
.map(it => {
const idx = levels.indexOf(it.level);
return (
<Stack
key={it.id}
direction={'row'}
alignItems={'center'}
gap={1}
sx={{
cursor: 'pointer',
':hover': {
color: 'primary.main',
},
ml: idx * 2,
...HeadingSx[idx],
color: it.isActive
? 'primary.main'
: (HeadingSx[idx]?.color ?? 'inherit'),
}}
onClick={() => {
const element = document.getElementById(it.id);
if (element) {
const offset = 100;
const elementPosition =
element.getBoundingClientRect().top;
const offsetPosition =
elementPosition + window.pageYOffset - offset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
});
}
}}
>
<Box
sx={{
color: 'text.disabled',
flexShrink: 0,
lineHeight: 1,
}}
>
{HeadingIcon[it.level]}
</Box>
<Ellipsis arrow sx={{ flex: 1, width: 0 }}>
{it.textContent}
</Ellipsis>
</Stack>
);
})}
</Stack>
</Drawer>
</>
);
};
export default Toc;

View File

@ -0,0 +1,44 @@
'use client';
import { Box } from '@mui/material';
import {
AiGenerate2Icon,
EditorToolbar,
UseTiptapReturn,
} from '@ctzhian/tiptap';
interface ToolbarProps {
editorRef: UseTiptapReturn;
handleAiGenerate?: () => void;
}
const Toolbar = ({ editorRef, handleAiGenerate }: ToolbarProps) => {
return (
<Box
sx={{
width: 'auto',
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
bgcolor: 'background.default',
px: 0.5,
mx: 1,
}}
>
{editorRef.editor && (
<EditorToolbar
editor={editorRef.editor}
menuInToolbarMore={[
{
id: 'ai',
label: '文本润色',
icon: <AiGenerate2Icon sx={{ fontSize: '1rem' }} />,
onClick: handleAiGenerate,
},
]}
/>
)}
</Box>
);
};
export default Toolbar;

View File

@ -0,0 +1,327 @@
'use client';
import { postShareProV1FileUploadWithProgress } from '@/request/pro/otherCustomer';
import Emoji from '@/components/emoji';
import { V1NodeDetailResp } from '@/request/types';
import { Box, Stack, TextField } from '@mui/material';
import { Editor, TocList, useTiptap, UseTiptapReturn } from '@ctzhian/tiptap';
import { message } from '@ctzhian/ui';
import { IconZiti, IconAShijian2 } from '@panda-wiki/icons';
import dayjs from 'dayjs';
import { useCallback, useEffect, useState } from 'react';
import { useWrapContext } from '..';
import AIGenerate from './AIGenerate';
import Header from './Header';
import Toc from './Toc';
import Toolbar from './Toolbar';
import ConfirmModal from './ConfirmModal';
import { useParams } from 'next/navigation';
interface WrapProps {
detail: V1NodeDetailResp;
}
const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
const { catalogOpen, nodeDetail, setNodeDetail, onSave } = useWrapContext();
const { id } = useParams();
const [characterCount, setCharacterCount] = useState(0);
const [headings, setHeadings] = useState<TocList>([]);
const [fixedToc, setFixedToc] = useState(false);
const [selectionText, setSelectionText] = useState('');
const [aiGenerateOpen, setAiGenerateOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
const updateDetail = (value: V1NodeDetailResp) => {
setNodeDetail({
...nodeDetail,
updated_at: dayjs().format('YYYY-MM-DD HH:mm:ss'),
status: 1,
...value,
});
};
const handleExport = async (type: string) => {
if (type === 'html') {
const html = editorRef.getHTML();
if (!html) return;
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${nodeDetail?.name}.html`;
a.click();
URL.revokeObjectURL(url);
message.success('导出成功');
}
if (type === 'md') {
const markdown = editorRef.getMarkdownByJSON();
if (!markdown) return;
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${nodeDetail?.name}.md`;
a.click();
URL.revokeObjectURL(url);
message.success('导出成功');
}
};
const handleUpload = async (
file: File,
onProgress?: (progress: { progress: number }) => void,
abortSignal?: AbortSignal,
) => {
const { key } = await postShareProV1FileUploadWithProgress(
{ file },
{
onprogress: ({ progress }) => {
onProgress?.({ progress: progress / 100 });
},
abortSignal,
},
);
return Promise.resolve('/static-file/' + key);
};
const handleTocUpdate = (toc: TocList) => {
setHeadings(toc);
};
const handleError = (error: Error) => {
if (error.message) {
message.error(error.message);
}
};
const handleUpdate = ({ editor }: { editor: UseTiptapReturn['editor'] }) => {
setIsEditing(true);
setCharacterCount((editor.storage as any).characterCount.characters());
};
const editorRef = useTiptap({
editable: true,
immediatelyRender: false,
content: defaultDetail?.content || '',
exclude: ['invisibleCharacters', 'youtube', 'mention'],
onCreate: ({ editor: tiptapEditor }) => {
setCharacterCount(
(tiptapEditor.storage as any).characterCount.characters(),
);
},
onError: handleError,
onUpload: handleUpload,
onUpdate: handleUpdate,
onTocUpdate: handleTocUpdate,
});
const handleAiGenerate = useCallback(() => {
if (editorRef.editor) {
const { from, to } = editorRef.editor.state.selection;
const text = editorRef.editor.state.doc.textBetween(from, to, '\n');
if (!text) {
message.error('请先选择文本');
return;
}
setSelectionText(text);
setAiGenerateOpen(true);
}
}, [editorRef.editor]);
const handleGlobalSave = useCallback(
(event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
if (editorRef && editorRef.editor) {
const html = editorRef.getHTML();
updateDetail({
content: html,
});
setConfirmModalOpen(true);
}
}
},
[editorRef, onSave],
);
useEffect(() => {
document.addEventListener('keydown', handleGlobalSave);
return () => {
document.removeEventListener('keydown', handleGlobalSave);
};
}, [handleGlobalSave]);
useEffect(() => {
return () => {
if (editorRef) editorRef.editor?.destroy();
};
}, []);
return (
<>
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 10,
bgcolor: 'background.default',
transition: 'left 0.3s ease-in-out',
}}
>
<Header
edit={isEditing}
detail={nodeDetail!}
updateDetail={updateDetail}
handleSave={async () => {
setConfirmModalOpen(true);
}}
handleExport={handleExport}
/>
<Toolbar editorRef={editorRef} handleAiGenerate={handleAiGenerate} />
</Box>
<Box
sx={{
...(fixedToc && {
display: 'flex',
}),
}}
>
<Box
sx={{
width: `calc(100vw - 160px - ${fixedToc ? 292 : 0}px)`,
p: '72px 80px 150px',
mt: '102px',
mx: 'auto',
}}
>
<Stack
direction={'row'}
alignItems={'center'}
gap={1}
sx={{ mb: 2, position: 'relative' }}
>
<Emoji
type={2}
sx={{ flexShrink: 0, width: 36, height: 36 }}
iconSx={{ fontSize: 28 }}
value={nodeDetail?.meta?.emoji}
readOnly={!!id}
onChange={value => {
setNodeDetail({
...nodeDetail,
meta: {
...nodeDetail?.meta,
emoji: value,
},
});
}}
/>
<TextField
sx={{ flex: 1 }}
value={nodeDetail?.name}
placeholder='请输入文档名称'
slotProps={{
input: {
readOnly: !!id,
sx: {
fontSize: 28,
fontWeight: 'bold',
bgcolor: 'background.default',
'& input': {
p: 0,
lineHeight: '36px',
height: '36px',
},
'& fieldset': {
border: 'none !important',
},
},
},
}}
onChange={e => {
setNodeDetail({
...nodeDetail,
name: e.target.value,
});
}}
/>
</Stack>
<Stack direction={'row'} alignItems={'center'} gap={2} sx={{ mb: 4 }}>
{defaultDetail?.created_at && (
<Stack
direction={'row'}
alignItems={'center'}
gap={0.5}
sx={{
fontSize: 12,
color: 'text.auxiliary',
cursor: 'text',
':hover': {
color: 'text.auxiliary',
},
}}
>
<IconAShijian2 />
{dayjs(defaultDetail?.created_at).format(
'YYYY-MM-DD HH:mm:ss',
)}{' '}
</Stack>
)}
<Stack
direction={'row'}
alignItems={'center'}
gap={0.5}
sx={{ fontSize: 12, color: 'text.auxiliary' }}
>
<IconZiti />
{characterCount}
</Stack>
</Stack>
<Box
sx={{
wordBreak: 'break-all',
'.tiptap.ProseMirror': {
overflowX: 'hidden',
minHeight: 'calc(100vh - 102px - 48px)',
},
'.tableWrapper': {
maxWidth: `calc(100vw - 160px - ${catalogOpen ? 292 : 0}px - ${fixedToc ? 292 : 0}px)`,
overflowX: 'auto',
},
}}
>
{editorRef.editor && <Editor editor={editorRef.editor} />}
</Box>
</Box>
<Toc headings={headings} fixed={fixedToc} setFixed={setFixedToc} />
</Box>
<AIGenerate
open={aiGenerateOpen}
selectText={selectionText}
onClose={() => setAiGenerateOpen(false)}
editorRef={editorRef}
/>
<ConfirmModal
open={confirmModalOpen}
onCancel={() => setConfirmModalOpen(false)}
onOk={async (reason: string) => {
const value = editorRef.getHTML();
updateDetail({
content: value,
});
await onSave(value, reason);
setConfirmModalOpen(false);
}}
/>
</>
);
};
export default Wrap;

View File

@ -0,0 +1,14 @@
export const DocWidth = {
full: {
label: '全宽',
value: 0,
},
wide: {
label: '超宽',
value: 1127,
},
normal: {
label: '常规',
value: 892,
},
};

View File

@ -0,0 +1,80 @@
'use client';
import { getShareV1NodeDetail } from '@/request/ShareNode';
import { V1NodeDetailResp } from '@/request/types';
import { Box } from '@mui/material';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useWrapContext } from '..';
import LoadingEditorWrap from './Loading';
import EditorWrap from './Wrap';
const Edit = () => {
const { id = '' } = useParams();
const { setNodeDetail, nodeDetail } = useWrapContext();
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<V1NodeDetailResp | null>(nodeDetail);
const getDetail = () => {
setLoading(true);
// @ts-expect-error 类型错误
getShareV1NodeDetail({
id: id[0] as string,
})
.then(res => {
setDetail(res as V1NodeDetailResp);
setNodeDetail(res as V1NodeDetailResp);
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 0);
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
if (id) {
getDetail();
} else {
setLoading(false);
}
}, [id]);
return (
<Box
sx={{
position: 'relative',
flexGrow: 1,
/* Give a remote user a caret */
'& .collaboration-carets__caret': {
borderLeft: '1px solid #fff',
borderRight: '1px solid #fff',
marginLeft: '-1px',
marginRight: '-1px',
pointerEvents: 'none',
position: 'relative',
wordBreak: 'normal',
},
/* Render the username above the caret */
'& .collaboration-carets__label': {
borderRadius: '0 3px 3px 3px',
color: '#fff',
fontSize: '12px',
fontStyle: 'normal',
fontWeight: '600',
left: '-1px',
lineHeight: 'normal',
padding: '0.1rem 0.3rem',
position: 'absolute',
top: '1.4em',
userSelect: 'none',
whiteSpace: 'nowrap',
},
}}
>
{loading ? <LoadingEditorWrap /> : <EditorWrap detail={detail!} />}
</Box>
);
};
export default Edit;

View File

@ -0,0 +1,107 @@
'use client';
import { createContext, useContext } from 'react';
import { postShareProV1ContributeSubmit } from '@/request/pro/ShareContribute';
import { V1NodeDetailResp } from '@/request/types';
import { useParams, useRouter } from 'next/navigation';
import { Box, Stack, useMediaQuery } from '@mui/material';
import { message } from '@ctzhian/ui';
import { useEffect, useState } from 'react';
import Edit from './edit';
export interface WrapContext {
catalogOpen: boolean;
setCatalogOpen: (open: boolean) => void;
nodeDetail: V1NodeDetailResp | null;
setNodeDetail: (detail: V1NodeDetailResp) => void;
onSave: (content: string, reason: string) => void;
}
const WrapContext = createContext<WrapContext | undefined>(undefined);
export const useWrapContext = () => {
const context = useContext(WrapContext);
if (!context) {
throw new Error('useWrapContext must be used within a WrapContextProvider');
}
return context;
};
const DocEditor = () => {
const { id } = useParams();
const isWideScreen = useMediaQuery('(min-width:1400px)');
const [nodeDetail, setNodeDetail] = useState<V1NodeDetailResp | null>(
id
? null
: {
name: '',
content: '',
},
);
const [catalogOpen, setCatalogOpen] = useState(true);
const onSave = (content: string, reason: string) => {
return postShareProV1ContributeSubmit({
node_id: id ? id[0] : undefined,
name: nodeDetail!.name,
content,
type: id ? 'edit' : 'add',
reason,
emoji: nodeDetail?.meta?.emoji,
}).then(() => {
message.success('保存成功, 即将关闭页面');
setTimeout(() => {
try {
// 优先尝试直接关闭当前窗口
window.close();
// 若浏览器阻止关闭,则尽量离开当前页
setTimeout(() => {
// 仍未关闭时,尝试回退;若无历史则跳首页
if (history.length > 1) {
history.back();
} else {
// 某些浏览器可通过替换为 _self 再关闭
try {
window.open('', '_self');
window.close();
} catch {}
// 最终兜底:跳转到首页
setTimeout(() => {
if (!document.hidden) {
window.location.href = '/';
}
}, 50);
}
}, 0);
} catch {}
}, 3000);
});
};
useEffect(() => {
setCatalogOpen(isWideScreen);
}, [isWideScreen]);
return (
<Stack
direction='row'
sx={{ color: 'text.primary', bgcolor: 'background.default' }}
>
<Box sx={{ flexGrow: 1 }}>
<WrapContext.Provider
value={{
catalogOpen,
setCatalogOpen,
nodeDetail,
setNodeDetail,
onSave,
}}
>
<Edit />
</WrapContext.Provider>
</Box>
</Stack>
);
};
export default DocEditor;

View File

@ -0,0 +1,79 @@
import Emoji from '@/components/emoji';
import { postShareProV1ContributeSubmit } from '@/request/pro/ShareContribute';
import { Box, TextField } from '@mui/material';
import { message, Modal } from '@ctzhian/ui';
import { Controller, useForm } from 'react-hook-form';
interface AddNodeModalProps {
open: boolean;
onCancel: () => void;
}
const AddNodeModal = ({ open, onCancel }: AddNodeModalProps) => {
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<{ name: string; emoji: string }>({
defaultValues: {
name: '',
emoji: '',
},
});
const handleClose = () => {
reset();
onCancel();
};
const submit = (value: { name: string; emoji: string }) => {
postShareProV1ContributeSubmit({
name: value.name,
content: '',
reason: '',
type: 'add',
}).then(({ id }) => {
message.success('创建成功');
reset();
handleClose();
window.open(`/doc/editor/${id}`, '_blank');
});
};
return (
<Modal
title='创建文档'
open={open}
width={600}
okText='创建'
onCancel={handleClose}
onOk={handleSubmit(submit)}
>
<Box sx={{ fontSize: 14, lineHeight: '36px' }}></Box>
<Controller
control={control}
name='name'
rules={{ required: `请输入文档名称` }}
render={({ field }) => (
<TextField
{...field}
fullWidth
autoFocus
size='small'
placeholder={`请输入文档名称`}
error={!!errors.name}
helperText={errors.name?.message}
/>
)}
/>
<Box sx={{ fontSize: 14, lineHeight: '36px', mt: 1 }}></Box>
<Controller
control={control}
name='emoji'
render={({ field }) => <Emoji {...field} type={2} />}
/>
</Modal>
);
};
export default AddNodeModal;

View File

@ -3,19 +3,24 @@
import { NodeDetail } from '@/assets/type';
import useCopy from '@/hooks/useCopy';
import { useStore } from '@/provider';
import { useParams } from 'next/navigation';
import { ConstsCopySetting } from '@/request/types';
import { TocList, useTiptap } from '@ctzhian/tiptap';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { Fab, Zoom } from '@mui/material';
import { useEffect, useMemo, useState } from 'react';
import { Fab, Zoom, Stack } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DocAnchor from './DocAnchor';
import DocContent from './DocContent';
import MenuIcon from '@mui/icons-material/Menu';
const Doc = ({ node }: { node?: NodeDetail }) => {
const { kbDetail, mobile } = useStore();
const [headings, setHeadings] = useState<TocList>([]);
const [characterCount, setCharacterCount] = useState(0);
const params = useParams() || {};
const docId = params.id as string;
const editorRef = useTiptap({
content: node?.content || '',
editable: false,
@ -33,6 +38,7 @@ const Doc = ({ node }: { node?: NodeDetail }) => {
}, [kbDetail]);
const [showScrollTop, setShowScrollTop] = useState(false);
const [showActions, setShowActions] = useState(false);
useCopy({
mode:
@ -47,16 +53,25 @@ const Doc = ({ node }: { node?: NodeDetail }) => {
});
const handleScroll = () => {
setShowScrollTop(window.scrollY > 300);
setShowScrollTop(
document.querySelector('#scroll-container')!.scrollTop > 300,
);
};
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
document
.querySelector('#scroll-container')!
.addEventListener('scroll', handleScroll);
return () =>
document
.querySelector('#scroll-container')!
.removeEventListener('scroll', handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
document
.querySelector('#scroll-container')!
.scrollTo({ top: 0, behavior: 'smooth' });
};
useEffect(() => {
@ -78,22 +93,78 @@ const Doc = ({ node }: { node?: NodeDetail }) => {
{!mobile && <DocAnchor headings={headings} />}
<Zoom in={showScrollTop}>
<Fab
size='small'
onClick={scrollToTop}
{!mobile && kbDetail?.settings.contribute_settings?.is_enable && (
<Stack
gap={1}
sx={{
backgroundColor: 'background.paper3',
color: 'text.primary',
position: 'fixed',
bottom: 66,
bottom: 20,
right: 16,
zIndex: 1000,
zIndex: 10000,
}}
onMouseLeave={() => setShowActions(false)}
>
<KeyboardArrowUpIcon sx={{ fontSize: 24 }} />
</Fab>
</Zoom>
<Zoom
in={showActions}
style={{ transitionDelay: showActions ? '100ms' : '0ms' }}
>
<Fab
color='primary'
size='small'
onClick={() => {
window.open(`/editor`, '_blank');
}}
>
<AddIcon />
</Fab>
</Zoom>
<Zoom
in={showActions}
style={{ transitionDelay: showActions ? '40ms' : '0ms' }}
>
<Fab
color='primary'
size='small'
onClick={() => {
window.open(`/editor/${docId}`, '_blank');
}}
>
<EditIcon />
</Fab>
</Zoom>
<Fab
size='small'
sx={{
backgroundColor: 'background.paper2',
color: 'text.secondary',
'&:hover': { backgroundColor: 'background.paper2' },
}}
onMouseEnter={() => setShowActions(true)}
>
<MenuIcon
sx={{
transition: 'transform 200ms',
transform: showActions ? 'rotate(90deg)' : 'rotate(0deg)',
}}
/>
</Fab>
<Zoom in={showScrollTop}>
<Fab
size='small'
onClick={scrollToTop}
sx={{
backgroundColor: 'background.paper3',
color: 'text.primary',
'&:hover': {
backgroundColor: 'background.paper2',
},
}}
>
<KeyboardArrowUpIcon sx={{ fontSize: 24 }} />
</Fab>
</Zoom>
</Stack>
)}
</>
);
};

View File

@ -19,6 +19,7 @@
"packageManager": "pnpm@10.12.1",
"dependencies": {
"@ctzhian/ui": "^7.0.5",
"@ctzhian/tiptap": "1.3.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.2",
@ -42,4 +43,4 @@
"prettier": "^3.6.2",
"typescript": "^5.9.2"
}
}
}

View File

@ -0,0 +1,17 @@
import React from 'react';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
const IconFuzhi1 = (props: SvgIconProps) => (
<SvgIcon
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1024 1024'
{...props}
>
<path d='M833.33 767.96h-91.9c-21.73 0-39.34-17.6-39.34-39.34s17.62-39.34 39.34-39.34h91.9c8.82 0 15.98-7.18 15.98-15.98V193.8c0-8.8-7.17-15.98-15.98-15.98H353.84c-8.82 0-15.98 7.18-15.98 15.98v90.86c0 21.75-17.62 39.34-39.34 39.34s-39.34-17.6-39.34-39.34V193.8c0-52.21 42.47-94.67 94.67-94.67h479.49c52.19 0 94.67 42.45 94.67 94.67v479.49c-0.01 52.21-42.49 94.67-94.68 94.67z'></path>
<path d='M675.96 925.33H196.47c-52.19 0-94.67-42.45-94.67-94.67V351.17c0-52.21 42.47-94.67 94.67-94.67h479.49c52.19 0 94.67 42.45 94.67 94.67v479.49c-0.01 52.22-42.48 94.67-94.67 94.67zM196.47 335.19c-8.82 0-15.98 7.18-15.98 15.98v479.49c0 8.8 7.17 15.98 15.98 15.98h479.49c8.82 0 15.98-7.18 15.98-15.98V351.17c0-8.8-7.17-15.98-15.98-15.98H196.47z'></path>
</SvgIcon>
);
IconFuzhi1.displayName = 'icon-fuzhi1';
export default IconFuzhi1;

View File

@ -0,0 +1,16 @@
import React from 'react';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
const IconGongxian = (props: SvgIconProps) => (
<SvgIcon
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1024 1024'
{...props}
>
<path d='M965.22509829 246.61344053c19.4864257 0 34.73090838 15.77472557 34.7309084 35.26115127 0 146.08191235-86.29702807 247.62342311-209.57849677 247.62342311-1.59072863 0-3.31401797-0.53024288-4.90474659-1.06048576-57.00110917 117.8464792-141.70740862 197.11778917-239.93490141 212.36227186 0 0.53024288 0.53024288 1.06048575 0.53024286 1.59072864v141.70740861H685.6545419c19.4864257 0 34.73090838 15.77472557 34.73090839 35.26115127 0 19.4864257-15.77472557 35.26115125-34.73090839 35.26115125H337.0198509c-18.95618282 0-34.73090838-15.77472557-34.73090837-35.26115125 0-19.4864257 15.77472557-35.26115125 34.73090837-35.26115127h139.58643712V742.39052965c0-0.53024288 0-1.06048575 0.53024288-1.59072864-98.22749279-15.24448269-182.93379223-94.51579266-239.40465853-212.36227186-1.59072863 0-3.31401797 1.06048575-4.90474661 1.06048576-123.81171155 0.66280359-210.10873964-101.54151077-210.10873964-246.96061952 0-19.4864257 15.77472557-35.26115125 34.73090839-35.26115125h108.03698599c-1.59072863-24.3911723-3.31401797-48.91490531-3.31401797-73.83632049 0-21.20971504 1.06048575-43.47991584 2.7837751-69.46181677 1.59072863-18.42593995 16.30496844-32.60993687 34.73090838-32.60993687h622.77025792c18.42593995 0 33.67042263 14.05143621 34.73090838 32.60993687 2.1209715 25.58421877 2.65121438 48.38466243 2.65121438 69.46181677 0 24.92141517-1.06048575 49.97539106-3.31401798 73.83632049l108.69978958-0.66280361z m-149.79361248 210.24130036c60.31512715-11.40022183 102.60199651-62.96634153 112.41148973-139.05619424h-78.74106709c-7.55596098 49.44514819-18.95618282 95.57627841-33.67042264 139.05619424z m-304.0942894-24.52373302l59.78488426 30.88664754c20.14922928 10.33973609 33.14017975 0.53024288 29.29591891-21.20971505l-11.40022184-65.21987375 48.38466245-46.13113022c16.30496844-15.77472557 11.40022183-30.88664752-10.86997896-34.20066551L559.72185885 286.64677769l-29.82616178-59.1220807c-10.33973609-20.14922928-26.64470452-20.14922928-36.32163702 0L463.74789827 286.64677769l-66.81060238 9.80949319c-22.2702008 3.31401797-27.17494739 18.42593995-10.86997895 34.20066551l48.38466244 46.13113022-11.40022183 65.21987375c-3.84426085 22.2702008 9.27925033 31.54945113 29.2959189 21.20971505l58.98951996-30.88664754zM94.96397799 317.79854665c9.80949321 75.957292 52.09636257 127.65597241 112.41148972 139.05619424-14.71423981-42.94967296-26.11446164-89.61104606-33.67042262-139.05619424H94.96397799z m0 0'></path>
</SvgIcon>
);
IconGongxian.displayName = 'icon-gongxian';
export default IconGongxian;

View File

@ -0,0 +1,16 @@
import React from 'react';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
const IconMulu = (props: SvgIconProps) => (
<SvgIcon
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1024 1024'
{...props}
>
<path d='M874.642286 761.929143a51.785143 51.785143 0 1 1 0 103.570286H149.357714a51.785143 51.785143 0 1 1 0-103.570286h725.284572z m4.681143-386.267429a31.085714 31.085714 0 0 1 47.177142 26.697143v200.996572a31.085714 31.085714 0 0 1-47.177142 26.697142L711.826286 529.554286a31.085714 31.085714 0 0 1 0-53.394286l167.497143-100.498286zM512 450.998857a51.785143 51.785143 0 0 1 0 103.643429H149.357714a51.785143 51.785143 0 1 1 0-103.570286H512z m362.642286-310.857143a51.785143 51.785143 0 1 1 0 103.643429H149.357714a51.785143 51.785143 0 1 1 0-103.570286h725.284572z'></path>
</SvgIcon>
);
IconMulu.displayName = 'icon-mulu';
export default IconMulu;

View File

@ -0,0 +1,16 @@
import React from 'react';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
const IconNeirongdagang = (props: SvgIconProps) => (
<SvgIcon
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1024 1024'
{...props}
>
<path d='M153.6 256h716.8a51.2 51.2 0 0 0 0-102.4H153.6a51.2 51.2 0 0 0 0 102.4z m512 204.8h-512a51.2 51.2 0 0 0 0 102.4h512a51.2 51.2 0 0 0 0-102.4z m-204.8 307.2H153.6a51.2 51.2 0 0 0 0 102.4h307.2a51.2 51.2 0 0 0 0-102.4z'></path>
</SvgIcon>
);
IconNeirongdagang.displayName = 'icon-neirongdagang';
export default IconNeirongdagang;

View File

@ -0,0 +1,16 @@
import React from 'react';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
const IconWenzi = (props: SvgIconProps) => (
<SvgIcon
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1024 1024'
{...props}
>
<path d='M563.2 281.6V870.4a51.2 51.2 0 0 1-102.4 0V281.6H179.2a51.2 51.2 0 1 1 0-102.4h665.6a51.2 51.2 0 0 1 0 102.4H563.2z'></path>
</SvgIcon>
);
IconWenzi.displayName = 'icon-wenzi';
export default IconWenzi;

View File

@ -0,0 +1,27 @@
import React from 'react';
import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon';
const IconXinference = (props: SvgIconProps) => (
<SvgIcon
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 1024 1024'
{...props}
>
<path
d='M0 0m146.285714 0l731.428572 0q146.285714 0 146.285714 146.285714l0 731.428572q0 146.285714-146.285714 146.285714l-731.428572 0q-146.285714 0-146.285714-146.285714l0-731.428572q0-146.285714 146.285714-146.285714Z'
fill='#9D62F7'
></path>
<path
d='M413.793524 519.94819a315.489524 315.489524 0 0 0 59.294476 51.2 324.900571 324.900571 0 0 0 59.587048 31.451429 450.681905 450.681905 0 0 0 92.867047-124.611048l137.167238-271.603809-239.177143 188.025905a451.34019 451.34019 0 0 0-109.714285 125.561904z m-19.504762 169.081905q-25.161143-16.847238-48.761905-35.961905l-82.895238 164.547048 149.138286-117.248a1104.847238 1104.847238 0 0 1-17.578667-11.239619z'
fill='#FFFFFF'
></path>
<path
d='M701.854476 424.764952c44.958476 59.587048 58.075429 125.805714 27.599238 171.398096-44.495238 66.56-165.13219 64-269.433904-5.705143s-152.795429-180.150857-108.300191-246.735238c30.47619-45.592381 96.670476-58.758095 168.96-40.082286-125.001143-53.077333-243.809524-47.835429-290.133333 21.040762-57.880381 86.649905 21.162667 241.371429 176.542476 344.941714s328.289524 117.735619 386.194286 31.134476c46.153143-68.973714 5.36381-180.833524-91.428572-275.992381z'
fill='#FFFFFF'
></path>
</SvgIcon>
);
IconXinference.displayName = 'icon-Xinference';
export default IconXinference;

View File

@ -76,6 +76,7 @@ export { default as IconFeishujiqiren } from './IconFeishujiqiren';
export { default as IconFenxi } from './IconFenxi';
export { default as IconFireworks } from './IconFireworks';
export { default as IconFuzhi } from './IconFuzhi';
export { default as IconFuzhi1 } from './IconFuzhi1';
export { default as IconGemini } from './IconGemini';
export { default as IconGeminiAi } from './IconGeminiAi';
export { default as IconGengduo } from './IconGengduo';
@ -84,6 +85,7 @@ export { default as IconGitHub1 } from './IconGitHub1';
export { default as IconGitee_ai } from './IconGitee_ai';
export { default as IconGithub } from './IconGithub';
export { default as IconGongjuTool } from './IconGongjuTool';
export { default as IconGongxian } from './IconGongxian';
export { default as IconGpustack } from './IconGpustack';
export { default as IconGraphRag } from './IconGraphRag';
export { default as IconGrok } from './IconGrok';
@ -122,9 +124,11 @@ export { default as IconMistral } from './IconMistral';
export { default as IconMixedbread } from './IconMixedbread';
export { default as IconModaGPT } from './IconModaGPT';
export { default as IconMoxing } from './IconMoxing';
export { default as IconMulu } from './IconMulu';
export { default as IconMulushouqi } from './IconMulushouqi';
export { default as IconMuluwendang } from './IconMuluwendang';
export { default as IconMuluzhankai } from './IconMuluzhankai';
export { default as IconNeirongdagang } from './IconNeirongdagang';
export { default as IconNeirongguanli } from './IconNeirongguanli';
export { default as IconNeteaseYoudao } from './IconNeteaseYoudao';
export { default as IconNewapi } from './IconNewapi';
@ -187,6 +191,7 @@ export { default as IconWeixingongzhonghaoDaiyanse } from './IconWeixingongzhong
export { default as IconWenjian } from './IconWenjian';
export { default as IconWenjianjia } from './IconWenjianjia';
export { default as IconWenjianjiaKai } from './IconWenjianjiaKai';
export { default as IconWenzi } from './IconWenzi';
export { default as IconWenzishuliang } from './IconWenzishuliang';
export { default as IconWord } from './IconWord';
export { default as IconXiajiantou } from './IconXiajiantou';
@ -195,6 +200,7 @@ export { default as IconXiala1 } from './IconXiala1';
export { default as IconXialaCopy } from './IconXialaCopy';
export { default as IconXiaohongshu } from './IconXiaohongshu';
export { default as IconXiaohongshuHui } from './IconXiaohongshuHui';
export { default as IconXinference } from './IconXinference';
export { default as IconYanzhengma } from './IconYanzhengma';
export { default as IconYidongduan } from './IconYidongduan';
export { default as IconYingweida } from './IconYingweida';

File diff suppressed because it is too large Load Diff