init push

This commit is contained in:
2026-05-21 19:52:45 +08:00
commit e3f75311ab
1280 changed files with 179173 additions and 0 deletions

44
web/admin/src/App.tsx Normal file
View File

@@ -0,0 +1,44 @@
import router from '@/router';
import { useAppDispatch } from '@/store';
import { theme } from '@/themes';
import { ThemeProvider } from '@ctzhian/ui';
import { useEffect } from 'react';
import { useLocation, useRoutes } from 'react-router-dom';
import { getApiV1License } from './request/pro/License';
import { setLicense } from './store/slices/config';
import '@ctzhian/tiptap/dist/index.css';
function App() {
const location = useLocation();
const { pathname } = location;
const dispatch = useAppDispatch();
const routerView = useRoutes(router);
const loginPage = pathname.includes('/login');
const onlyAllowShareApi = loginPage;
const token = localStorage.getItem('panda_wiki_token') || '';
useEffect(() => {
if (token) {
getApiV1License().then(res => {
dispatch(setLicense(res));
});
}
}, [token]);
if (!token && !onlyAllowShareApi) {
window.location.href = window.__BASENAME__ + '/login';
return null;
}
return (
<ThemeProvider theme={theme} defaultMode='light' storageManager={null}>
{routerView}
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,69 @@
/**
* @deprecated This file is deprecated and will be removed in a future version.
* Do not import from this file. Use 'src/request' instead.
*/
import request from './request';
import {
CheckModelData,
CreateModelData,
GetModelNameData,
ModelListItem,
UpdateKnowledgeBaseData,
UpdateModelData,
} from './type';
export type * from './type';
// =============================================》knowledge base
export const updateKnowledgeBase = (
data: Partial<UpdateKnowledgeBaseData>,
): Promise<void> =>
request({ url: 'api/v1/knowledge_base/detail', method: 'put', data });
// =============================================》file
export const uploadFile = (
data: FormData,
config?: {
onUploadProgress?: (event: { progress: number }) => void;
abortSignal?: AbortSignal;
},
): Promise<{ key: string; filename: string }> =>
request({
url: '/api/v1/file/upload',
method: 'post',
data,
onUploadProgress: config?.onUploadProgress
? progressEvent => {
const progress = Math.round(
(progressEvent.loaded * 100) / (progressEvent.total || 1),
);
config.onUploadProgress?.({ progress });
}
: undefined,
signal: config?.abortSignal,
headers: { 'Content-Type': 'multipart/form-data' },
});
// =============================================》model
export const getModelNameList = (
data: GetModelNameData,
): Promise<{ models: { model: string }[]; error: string }> =>
request({ url: 'api/v1/model/provider/supported', method: 'post', data });
export const testModel = (data: CheckModelData): Promise<{ error: string }> =>
request({ url: 'api/v1/model/check', method: 'post', data });
export const getModelList = (): Promise<ModelListItem[]> =>
request({ url: 'api/v1/model/list', method: 'get' });
export const createModel = (data: CreateModelData): Promise<{ id: string }> =>
request({ url: 'api/v1/model', method: 'post', data });
export const deleteModel = (params: { id: string }): Promise<void> =>
request({ url: 'api/v1/model', method: 'delete', params });
export const updateModel = (data: UpdateModelData): Promise<void> =>
request({ url: 'api/v1/model', method: 'put', data });

View File

@@ -0,0 +1,62 @@
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from 'axios';
import { message } from '@ctzhian/ui';
type BasicResponse<T> = {
data: T;
success: boolean;
message: string;
};
type ErrorResponse = {
data: unknown;
success: boolean;
message: string;
};
type Response<T> = BasicResponse<T> | ErrorResponse;
const request = <T>(options: AxiosRequestConfig): Promise<T> => {
const token = localStorage.getItem('panda_wiki_token') || '';
const config = {
baseURL: window.__BASENAME__ || '/',
timeout: 0,
withCredentials: true,
headers: {
Authorization: `Bearer ${token}`,
},
};
const service: AxiosInstance = axios.create(config);
service.interceptors.response.use(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(response: AxiosResponse<Response<T>>) => {
if (response.status === 200) {
const res = response.data;
if (res.success) {
return res.data;
}
message.error(res.message || '网络异常');
return Promise.reject(res);
}
message.error(response.statusText);
return Promise.reject(response);
},
(error: AxiosError) => {
if (error.response?.status === 401) {
window.location.href = window.__BASENAME__ + '/login';
localStorage.removeItem('panda_wiki_token');
}
message.error(error.response?.statusText || '网络异常');
return Promise.reject(error.response);
},
);
return service(options);
};
export default request;

648
web/admin/src/api/type.ts Normal file
View File

@@ -0,0 +1,648 @@
import { AppType, IconMap, ModelProvider } from '@/constant/enums';
import {
ConstsNodeRagInfoStatus,
DomainNodePermissions,
} from '@/request/types';
export type Paging = {
page?: number;
per_page?: number;
};
export type ResposeList<T> = {
total: number;
data: T[];
};
export interface BaseItem {
id: string;
}
export type TrendData = { count: number; name: string; color?: string };
// =============================================》user
export type UserForm = {
account: string;
password: string;
};
export type UserInfo = {
id: string;
account: string;
last_access?: string;
created_at?: string;
};
export type UpdateUserInfo = {
id: string;
new_password: string;
};
// =============================================》knowledge base
export type UpdateKnowledgeBaseData = {
id: string;
name: string;
access_settings: {
hosts?: string[] | null;
ports?: number[] | null;
ssl_ports?: number[] | null;
private_key?: string;
public_key?: string;
base_url?: string;
simple_auth?: AuthSetting | null;
trusted_proxies?: string[] | null;
};
};
export interface KnowledgeBaseFormData {
name: string;
domain: string;
http: boolean;
https: boolean;
port: number;
ssl_port: number;
httpsCert: string;
httpsKey: string;
}
export type KnowledgeBaseAccessSettings = {
hosts: string[] | null;
ports: number[] | null;
private_key: string;
public_key: string;
base_url: string;
ssl_ports: number[] | null;
trusted_proxies: string[] | null;
simple_auth?: AuthSetting | null;
};
export type KnowledgeBaseStats = {
doc_count: number;
chunk_count: number;
word_count: number;
};
export type KnowledgeBaseListItem = Pick<
UpdateKnowledgeBaseData,
'id' | 'name'
> & {
created_at: string;
updated_at: string;
access_settings: KnowledgeBaseAccessSettings;
stats: KnowledgeBaseStats;
};
export interface CardWebHeaderBtn {
id: string;
url: string;
variant: 'contained' | 'outlined' | 'text';
showIcon: boolean;
icon: string;
text: string;
target: '_blank' | '_self';
}
export type ReleaseListItem = {
created_at: string;
id: string;
kb_id: string;
message: string;
tag: string;
};
export type AuthSetting = {
enabled?: boolean;
password?: string;
};
// =============================================》node
export type NodeListItem = {
id: string;
name: string;
type: 1 | 2;
emoji: string;
position: number;
parent_id: string;
summary: string;
created_at: string;
updated_at: string;
status: 1 | 2; // 1 草稿 2 发布
};
export type GetNodeRecommendData = {
kb_id: string;
node_ids: string[];
};
export type CreateNodeSummaryData = {
kb_id: string;
ids: string[];
};
export type NodeDetail = {
id: string;
name: string;
type: 1 | 2;
content: string;
kb_id: string;
status: 1 | 2;
parent_id: string | null;
meta: {
emoji?: string;
summary?: string;
};
created_at: string;
updated_at: string;
};
export type CreateNodeData = {
kb_id: string;
content?: string;
name?: string;
parent_id?: string | null;
type: 1 | 2;
emoji?: string;
};
export type NodeListFilterData = {
kb_id: string;
search?: string;
};
export type NodeAction = 'delete' | 'public' | 'private';
export type UpdateNodeActionData = {
ids: string[];
kb_id: string;
action: NodeAction;
};
export type UpdateNodeData = {
kb_id: string;
content?: string;
id: string;
name?: string;
emoji?: string;
status?: 1 | 2;
summary?: string;
};
export interface ITreeItem {
id: string;
name: string;
level: number;
order?: number;
emoji?: string;
parentId?: string;
content_type?: string;
summary?: string;
rag_status?: ConstsNodeRagInfoStatus;
rag_message?: string;
children?: ITreeItem[];
type: 1 | 2;
isEditting?: boolean;
canHaveChildren?: boolean;
updated_at?: string;
status?: 0 | 1 | 2;
permissions?: DomainNodePermissions;
collapsed?: boolean;
}
export interface NodeReleaseItem {
id: string;
name: string;
node_id: string;
updated_at: string;
release_id: string;
release_name: string;
release_message: string;
meta: {
emoji?: string;
summary?: string;
};
}
export interface NodeReleaseDetail {
content: string;
name: string;
meta: {
emoji?: string;
summary?: string;
};
}
// =============================================》crawler
export type ScrapeRSSItem = {
desc: string;
published: string;
title: string;
url: string;
};
// =============================================》app
export type AppCommonInfo = {
name: string;
type: keyof typeof AppType;
};
export type AppStats = {
day_counts: TrendData[];
last_24h_count: number;
last_24h_ip_count: number;
};
export type AppListItem = {
id: string;
link: string;
stats: AppStats | null;
settings: {
icon: string;
};
} & AppCommonInfo;
export type DingBotSetting = {
dingtalk_bot_is_enabled: boolean;
dingtalk_bot_client_id: string;
dingtalk_bot_client_secret: string;
dingtalk_bot_welcome_str: string;
dingtalk_bot_template_id: string;
};
export type WechatOfficeAccountSetting = {
wechat_official_account_is_enabled: boolean;
wechat_official_account_app_id: string;
wechat_official_account_app_secret: string;
wechat_official_account_token: string;
wechat_official_account_encodingaeskey: string;
};
export type WecomBotSetting = {
wechat_app_is_enabled: boolean;
wechat_app_agent_id: string;
wechat_app_secret: string;
wechat_app_token: string;
wechat_app_encodingaeskey: string;
wechat_app_corpid: string;
};
export type WecomBotServiceSetting = {
wechat_service_is_enabled: boolean;
wechat_service_secret: string;
wechat_service_token: string;
wechat_service_encodingaeskey: string;
wechat_service_corpid: string;
};
export type FeishuBotSetting = {
feishu_bot_is_enabled: boolean;
feishu_bot_app_id: string;
feishu_bot_app_secret: string;
feishu_bot_welcome_str: string;
};
export type DiscordBotSetting = {
discord_bot_is_enabled: boolean;
discord_bot_token: string;
};
export type HeaderSetting = {
title: string;
icon: string;
btns: CardWebHeaderBtn[];
};
export type WelcomeSetting = {
welcome_str: string;
search_placeholder: string;
recommend_questions: string[];
recommend_node_ids: string[];
};
export type SEOSetting = {
keyword: string;
desc: string;
};
export type CustomCodeSetting = {
head_code: string;
body_code: string;
};
export type ThemeAndStyleSetting = {
bg_image: string;
doc_width?: string;
};
export type ThemeMode = {
theme_mode: 'light' | 'dark';
};
export type FooterSetting = {
footer_style: 'simple' | 'complex';
corp_name: string;
icp: string;
brand_name: string;
brand_desc: string;
brand_logo: string;
brand_groups: {
name: string;
links: {
name: string;
url: string;
}[];
}[];
};
export type CatalogSetting = {
catalog_visible: 1 | 2;
catalog_folder: 1 | 2;
catalog_width: number;
};
export type WebComponentSetting = {
is_open: boolean | 1 | 0;
theme_mode: 'light' | 'dark';
btn_text: string;
btn_logo: string;
};
export type OtherSetting = {
widget_bot_settings: WebComponentSetting;
theme_and_style: ThemeAndStyleSetting;
footer_settings: FooterSetting;
catalog_settings: CatalogSetting;
base_url: string;
};
export type CustomSetting = {
web_app_custom_style: {
allow_theme_switching?: boolean;
header_search_placeholder?: string;
show_brand_info?: boolean;
social_media_accounts?: DomainSocialMediaAccount[];
footer_show_intro?: boolean;
};
};
export interface DomainSocialMediaAccount {
channel?: string;
icon?: string;
link?: string;
text?: string;
phone?: string;
}
export type AppSetting = HeaderSetting &
WelcomeSetting &
SEOSetting &
CustomCodeSetting &
DingBotSetting &
WechatOfficeAccountSetting &
WecomBotSetting &
WecomBotServiceSetting &
FeishuBotSetting &
DiscordBotSetting &
ThemeMode &
OtherSetting &
CustomSetting;
export type RecommendNode = {
id: string;
name: string;
type: 1 | 2;
parent_id: string;
summary: string;
position: number;
recommend_nodes?: RecommendNode[];
};
export type AppDetail = {
id: string;
link: string;
stats: AppStats | null;
settings: AppSetting;
kb_id: string;
recommend_nodes: RecommendNode[];
} & AppCommonInfo;
export type UpdateAppDetailData = {
name?: string;
settings?: Partial<AppSetting>;
};
export type AppConfigEditData = {
link: string;
name: string;
recommend_questions: string[];
search_placeholder: string;
icon: string;
desc: string;
position: number[];
plugin_ids: string[];
associated_kb_ids: string[];
};
// =============================================》model
export type GetModelNameData = {
type: 'chat' | 'embedding' | 'rerank' | 'analysis' | 'analysis-vl';
provider: keyof typeof ModelProvider | '';
api_header: string;
api_key: string;
base_url: string;
is_active?: boolean;
};
export type CreateModelData = {
model: string;
parameters?: DomainModelParam;
} & GetModelNameData;
export type CheckModelData = {
api_version: string;
} & CreateModelData;
export type UpdateModelData = {
id: string;
param?: DomainModelParam;
} & CheckModelData;
export interface DomainModelParam {
context_window?: number;
max_tokens?: number;
r1_enabled?: boolean;
support_computer_use?: boolean;
support_images?: boolean;
support_prompt_cache?: boolean;
temperature?: number | null;
}
export type ModelListItem = {
completion_tokens: number;
id: string;
model: keyof typeof IconMap;
type: 'chat' | 'embedding' | 'rerank' | 'analysis';
api_version: string;
prompt_tokens: number;
total_tokens: number;
parameters?: DomainModelParam;
} & GetModelNameData;
// =============================================》conversation
export type GetConversationListData = {
kb_id?: string;
remote_ip?: string;
subject?: string;
} & Paging;
export type ConversationListItem = {
app_name: string;
app_type: keyof typeof AppType;
created_at: string;
id: string;
model: string;
feedback_info: FeedbackInfo;
ip_address: {
city: string;
country: string;
ip: string;
province: string;
};
remote_ip: string;
subject: string;
info?: {
user_info?: {
from?: 0 | 1; // 1群聊2私聊
name?: string;
email?: string;
avatar?: string;
user_id?: string;
real_name?: string;
};
};
};
export type FeedbackListItem = {
node_id: string;
id: string;
created_at: string;
content: string;
node_name: string;
info: {
user_name: string;
remote_ip: string;
};
ip_address: {
ip: string;
country: string;
province: string;
city: string;
};
node_type: number;
};
export type FeedbackInfo = {
feedback_content: string;
feedback_type: number;
score: number;
};
export type ConversationDetail = {
app_id: string;
created_at: string;
id: string;
remote_ip: string;
subject: string;
messages: {
app_id: string;
completion_tokens: number;
content: string;
conversation_id: string;
created_at: string;
id: string;
model: string;
prompt_tokens: number;
provider: keyof typeof ModelProvider;
remote_ip: string;
role: 'assistant' | 'user';
total_tokens: number;
info: FeedbackInfo;
}[];
references: {
app_id: string;
conversation_id: string;
doc_id: string;
favicon: string;
title: string;
url: string;
}[];
};
export type ChatConversationItem = {
role: 'assistant' | 'user';
content: string;
};
export type ChatConversationPair = {
user: string;
image_paths: string[];
assistant: string;
thinking_content: string;
created_at: string;
info: {
feedback_content: string;
feedback_type: number;
score: number;
};
};
// ============================================》stat
export type StatInstantPageItme = {
ip: string;
created_at: string;
ip_address: {
ip: string;
city: string;
country: string;
province: string;
};
node_id: string;
node_name: string;
info?: {
username: string;
avatar_url: string;
email: string;
};
};
export type RefererHostItem = {
referer_host: string;
count: number;
};
export type HotDocsItem = {
node_id: string;
count: number;
node_name: string;
};
export type StatTypeItem = {
ip_count: number;
page_visit_count: number;
session_count: number;
};
export type ConversationDistributionItem = {
app_id: string;
app_type: keyof typeof AppType;
count: number;
};
// ============================================》license
export type LicenseInfo = {
edition: 0 | 1 | 2;
expired_at: number;
started_at: number;
};

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,20 @@
@font-face {
font-family: 'G';
src: url('./gilroy-bold.otf');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'G';
src: url('./gilroy-medium.otf');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'G';
src: url('./gilroy-regular.otf');
font-weight: normal;
font-style: normal;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,233 @@
/*
1. Use a more-intuitive box-sizing model.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
/*
2. Remove default margin
*/
* {
margin: 0;
}
/*
3. Allow percentage-based heights in the application
*/
html,
body {
height: 100%;
font-family: 'G';
}
/*
Typographic tweaks!
4. Add accessible line-height
5. Improve text rendering
*/
body {
line-height: 1.5;
}
/*
6. Improve media defaults
*/
/* img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
} */
/*
7. Remove built-in form typography styles
*/
input,
button,
textarea,
select {
font: inherit;
}
/*
8. Avoid text overflows
*/
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
/*
9. Create a root stacking context
*/
#root,
#__next {
isolation: isolate;
}
input:-webkit-autofill,
textarea:-webkit-autofill,
select:-webkit-autofill {
background-color: transparent !important;
background-image: none !important;
box-shadow: none !important;
-webkit-text-fill-color: var(--mui-palette-text-primary) !important;
transition: background-color 5000s ease-in-out 0s !important;
}
body {
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code,
.code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
a {
text-decoration: none;
}
::-webkit-scrollbar {
width: 4px;
/* 纵向滚动条*/
height: 0;
/* 横向滚动条隐藏 */
border-radius: 10px;
}
/*定义滚动条轨道 内阴影*/
::-webkit-scrollbar-track {
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: #fff;
border-radius: 10px;
}
/*定义滑块 内阴影*/
::-webkit-scrollbar-thumb {
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: #ccc;
border-radius: 10px;
}
.dark ::-webkit-scrollbar {
width: 5px;
/* 纵向滚动条*/
height: 0;
/* 横向滚动条隐藏 */
background-color: #363636;
border-radius: 10px;
}
/*定义滚动条轨道 内阴影*/
.dark ::-webkit-scrollbar-track {
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: #363636;
border-radius: 10px;
}
/*定义滑块 内阴影*/
.dark ::-webkit-scrollbar-thumb {
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0);
background-color: #9b9b9b;
border-radius: 10px;
}
@keyframes loadingRotate {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@keyframes panda-wiki-scale {
0% {
transform: scale(0);
}
50% {
transform: scale(1);
}
51% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@keyframes panda-wiki-rotate {
0% {
transform: rotate(0deg);
}
50% {
transform: rotate(360deg);
}
51% {
transform: rotate(360deg);
}
100% {
transform: rotate(360deg);
}
}
/* 适用于Chrome, Safari, Edge等Webkit浏览器 */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* 适用于Firefox */
input {
-moz-appearance: textfield;
appearance: textfield;
}
[class^='ellipsis-'] {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.ellipsis-1 {
-webkit-line-clamp: 1;
}
.ellipsis-2 {
-webkit-line-clamp: 2;
}
.ellipsis-3 {
-webkit-line-clamp: 3;
}
.ellipsis-5 {
-webkit-line-clamp: 4;
}
.ellipsis-5 {
-webkit-line-clamp: 5;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
import Logo from '@/assets/images/logo.png';
import { Avatar as MuiAvatar, SxProps } from '@mui/material';
import { IconDandulogo } from '@panda-wiki/icons';
import { ReactNode } from 'react';
interface AvatarProps {
src?: string;
className?: string;
sx?: SxProps;
errorIcon?: ReactNode;
errorImg?: ReactNode;
}
const Avatar = (props: AvatarProps) => {
const src = props.src;
const LogoIcon = (
<IconDandulogo
sx={{ width: '100%', height: '100%', color: 'text.primary' }}
/>
);
const errorNode = props.errorIcon || props.errorImg || LogoIcon;
if (props.errorIcon || props.errorImg) {
return (
<MuiAvatar
sx={{
img: { objectFit: 'contain' },
bgcolor: 'transparent',
...props.sx,
}}
src={src}
variant='square'
>
{errorNode}
</MuiAvatar>
);
}
return (
<MuiAvatar
sx={{
img: { objectFit: 'contain' },
bgcolor: 'transparent',
...props.sx,
}}
src={src || Logo}
variant='square'
>
{errorNode}
</MuiAvatar>
);
};
export default Avatar;

View File

@@ -0,0 +1,131 @@
import { TrendData } from '@/api';
import * as echarts from 'echarts';
import { useEffect, useRef, useState } from 'react';
type ECharts = ReturnType<typeof echarts.init>;
export interface PropsData {
height: number;
text: string;
chartData: TrendData[];
}
const BarTrend = ({ chartData, height, text }: PropsData) => {
const domWrapRef = useRef<HTMLDivElement>(null!);
const echartRef = useRef<ECharts>(null!);
const [loading, setLoading] = useState(true);
const [data, setData] = useState<TrendData[]>([]);
useEffect(() => {
if (domWrapRef.current && !echartRef.current && chartData.length > 0) {
echartRef.current = echarts.init(domWrapRef.current, null, {
renderer: 'svg',
});
}
setData(chartData);
}, [chartData]);
useEffect(() => {
const option = {
grid: {
left: 0,
right: 0,
bottom: 10,
top: 10,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
formatter: (
params: { seriesName: string; name: string; value: number }[],
) => {
if (params[0]) {
const { name, seriesName, value } = params[0];
return `<div style="font-family: G;min-width: 80px">
${name || '-'}
<div>${seriesName} <span style='font-weight: 700'>${value || 0}</span></div>
</div>`;
}
return '';
},
},
xAxis: {
type: 'category',
data: data.map(it => it.name),
splitLine: {
show: false,
},
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
show: false,
},
},
yAxis: {
type: 'value',
splitNumber: 4,
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
show: false,
},
splitLine: {
lineStyle: {
type: 'dashed',
color: '#F2F3F5',
},
},
},
series: {
name: text,
type: 'bar',
barGap: 0,
barMinHeight: 4,
data: data.map(it => ({
value: it.count,
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#3248F2' },
{ offset: 1, color: '#9E68FC' },
],
},
borderRadius: [4, 4, 0, 0],
},
})),
},
};
if (domWrapRef.current && echartRef.current && data.length > 0) {
echartRef.current.setOption(option);
setLoading(false);
}
const resize = () => {
if (echartRef.current) {
echartRef.current.resize();
}
};
window.addEventListener('resize', resize);
return () => {
window.removeEventListener('resize', resize);
};
}, [data]);
if (data.length === 0 && !loading)
return <div style={{ width: '100%', height }} />;
return <div ref={domWrapRef} style={{ width: '100%', height }} />;
};
export default BarTrend;

View File

@@ -0,0 +1,27 @@
import { Paper, SxProps } from '@mui/material';
interface CardProps {
sx?: SxProps;
children: React.ReactNode;
onClick?: () => void;
className?: string;
}
const Card = ({ sx, children, onClick, className }: CardProps) => {
return (
<Paper
className={`paper-item ${className}`}
sx={{
borderRadius: '10px',
boxShadow: 'none',
border: 'none',
overflow: 'hidden',
...sx,
}}
onClick={onClick ? onClick : undefined}
>
{children}
</Paper>
);
};
export default Card;

View File

@@ -0,0 +1,189 @@
import { Box, Popover, Stack, SxProps, Theme } 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;
}
export interface CascaderProps {
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 Cascader: React.FC<CascaderProps> = ({
id = 'cascader',
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='cascader-list' sx={{ p: 0.5 }}>
{list.map(item =>
item.show === false ? null : (
<Box
className='cascader-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}
<Box sx={{ flexShrink: 0, ...item.textSx }}>{item.label}</Box>
{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='cascader-sub-list'
sx={{
pointerEvents: 'auto',
p: 0.5,
}}
>
{item.children.map(child =>
child.show === false ? null : (
<Box
key={child.key}
className='cascader-sub-item'
onClick={() => handleItemClick(child)}
sx={{
cursor: 'pointer',
}}
>
<Stack alignItems='center' gap={1} direction='row'>
{child.icon}
<Box sx={{ flexShrink: 0, ...child.textSx }}>
{child.label}
</Box>
{child.extra}
</Stack>
</Box>
),
)}
</Box>
</Popover>
)}
</Box>
),
)}
</Box>
</Popover>
</>
);
};
export default Cascader;

View File

@@ -0,0 +1,257 @@
import { postApiV1KnowledgeBaseRelease } from '@/request/KnowledgeBase';
import { useAppDispatch, useAppSelector } from '@/store';
import {
setIsCreateWikiModalOpen,
setIsRefreshDocList,
setKbC,
} from '@/store/slices/config';
import { Modal, message } from '@ctzhian/ui';
import { Box, Step, StepLabel, Stepper } from '@mui/material';
import dayjs from 'dayjs';
import { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import {
Step1Model,
Step2Config,
Step3Import,
Step4Publish,
Step5Test,
Step6Decorate,
Step7Complete,
} from './steps';
// Remove interface as we're using Redux state
const steps = [
'模型配置',
'配置监听',
'录入文档',
'发布内容',
'问答测试',
'装饰页面',
'完成配置',
];
const CreateWikiModal = () => {
const { kb_c, kb_id, kbList } = useAppSelector(state => state.config);
const dispatch = useAppDispatch();
const location = useLocation();
const [open, setOpen] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [nodeIds, setNodeIds] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const Step1ModelRef = useRef<{ onSubmit: () => Promise<void> }>(null);
const step2ConfigRef = useRef<{ onSubmit: () => Promise<void> }>(null);
const step3ImportRef = useRef<{
onSubmit: () => Promise<Record<'id', string>[]>;
}>(null);
const step6DecorateRef = useRef<{ onSubmit: () => Promise<void> }>(null);
const onCancel = () => {
dispatch(setKbC(false));
setOpen(false);
if (location.pathname === '/') {
dispatch(setIsRefreshDocList(true));
}
};
const onPublish = () => {
return postApiV1KnowledgeBaseRelease({
kb_id,
message: '创建 Wiki 站点',
tag: `${dayjs().format('YYYYMMDD')}-${Math.random().toString(36).substring(2, 8)}`,
node_ids: nodeIds,
});
};
const handleNext = () => {
if (activeStep === 0) {
setLoading(true);
Step1ModelRef.current
?.onSubmit?.()
.then(() => {
setActiveStep(prev => prev + 1);
})
.finally(() => {
setLoading(false);
});
} else if (activeStep === 1) {
setLoading(true);
step2ConfigRef.current
?.onSubmit?.()
.then(() => {
setActiveStep(prev => prev + 1);
})
.finally(() => {
setLoading(false);
});
} else if (activeStep === 2) {
setLoading(true);
step3ImportRef.current
?.onSubmit?.()
.then(res => {
setNodeIds(res.map(item => item.id));
setActiveStep(prev => prev + 1);
})
.finally(() => {
setLoading(false);
});
} else if (activeStep === 3) {
setLoading(true);
onPublish().finally(() => {
setActiveStep(prev => prev + 1);
setLoading(false);
});
} else if (activeStep === 4) {
setActiveStep(prev => prev + 1);
} else if (activeStep === 5) {
setLoading(true);
step6DecorateRef.current
?.onSubmit?.()
.then(() => {
setActiveStep(prev => prev + 1);
})
.finally(() => {
setLoading(false);
});
} else if (activeStep === 6) {
onCancel();
}
};
const handleBack = () => {
if (activeStep > 0) {
setActiveStep(prev => prev - 1);
}
};
const renderStepContent = () => {
switch (activeStep) {
case 0:
return <Step1Model ref={Step1ModelRef} />;
case 1:
return <Step2Config ref={step2ConfigRef} />;
case 2:
return <Step3Import ref={step3ImportRef} />;
case 3:
return <Step4Publish />;
case 4:
return <Step5Test />;
case 5:
return <Step6Decorate ref={step6DecorateRef} nodeIds={nodeIds} />;
case 6:
return <Step7Complete />;
default:
return null;
}
};
useEffect(() => {
if (!open) {
setTimeout(() => {
setNodeIds([]);
setActiveStep(0);
}, 300);
}
dispatch(setIsCreateWikiModalOpen(open));
}, [open]);
useEffect(() => {
setOpen(kb_c);
}, [kb_c]);
useEffect(() => {
if (kbList?.length === 0) setOpen(true);
}, [kbList]);
useEffect(() => {
if (kbList && kbList.length > 0 && activeStep === 0) setActiveStep(1);
}, [activeStep, kbList]);
return (
<Modal
open={open}
onCancel={onCancel}
title='创建 Wiki 站点'
width={880}
closable={activeStep === 1 && (kbList || []).length > 0}
showCancel={false}
okText={activeStep === steps.length - 1 ? '关闭' : '下一步'}
// cancelText='上一步'
okButtonProps={{ loading }}
onOk={handleNext}
keyboard={activeStep === 1 && (kbList || []).length > 0}
>
<Box sx={{ display: 'flex', minHeight: 300 }}>
<Box
sx={{
width: '140px',
borderRight: '1px solid',
borderColor: 'divider',
pl: '16px',
pr: 5,
flexShrink: 0,
}}
>
<Stepper
activeStep={activeStep}
orientation='vertical'
sx={{
'& .MuiStepLabel-root': {
padding: '2px 0',
},
'& .MuiStepLabel-label': {
fontSize: '14px',
ml: 1,
},
'.MuiStepLabel-iconContainer': {
'.Mui-completed ': {
fontSize: 0,
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: 'primary.main',
},
},
'.MuiStepConnector-root': {
ml: '5px',
},
'.MuiStepIcon-root': {
fontSize: '10px',
color: 'rgba(23,28,25,0.3)',
'&.Mui-active': {
color: 'primary.main',
},
'.MuiStepIcon-text': {
fontSize: 0,
},
},
'& .MuiStepConnector-line': {
borderColor: 'divider',
},
}}
>
{steps.map((label, index) => (
<Step key={label}>
<StepLabel
sx={{
'& .MuiStepLabel-label': {
color: index === activeStep ? 'text.primary' : '#717572',
},
}}
>
{label}
</StepLabel>
</Step>
))}
</Stepper>
</Box>
<Box sx={{ flex: 1, pl: 5 }}>{renderStepContent()}</Box>
</Box>
</Modal>
);
};
export default CreateWikiModal;

View File

@@ -0,0 +1,125 @@
import React, {
useState,
useImperativeHandle,
Ref,
useEffect,
useRef,
} from 'react';
import { Box } from '@mui/material';
import { useAppSelector, useAppDispatch } from '@/store';
import { setModelList } from '@/store/slices/config';
import { getApiV1ModelList, getApiV1ModelModeSetting } from '@/request/Model';
import { GithubComChaitinPandaWikiDomainModelListItem } from '@/request/types';
import ModelConfig, {
ModelConfigRef,
} from '@/components/System/component/ModelConfig';
interface Step1ModelProps {
ref: Ref<{ onSubmit: () => Promise<void> }>;
}
const Step1Model: React.FC<Step1ModelProps> = ({ ref }) => {
const { modelList } = useAppSelector(state => state.config);
const dispatch = useAppDispatch();
const modelConfigRef = useRef<ModelConfigRef>(null);
const [chatModelData, setChatModelData] =
useState<GithubComChaitinPandaWikiDomainModelListItem | null>(null);
const [embeddingModelData, setEmbeddingModelData] =
useState<GithubComChaitinPandaWikiDomainModelListItem | null>(null);
const [rerankModelData, setRerankModelData] =
useState<GithubComChaitinPandaWikiDomainModelListItem | null>(null);
const [analysisModelData, setAnalysisModelData] =
useState<GithubComChaitinPandaWikiDomainModelListItem | null>(null);
const [analysisVLModelData, setAnalysisVLModelData] =
useState<GithubComChaitinPandaWikiDomainModelListItem | null>(null);
const getModelList = () => {
return getApiV1ModelList().then(res => {
dispatch(
setModelList(res as GithubComChaitinPandaWikiDomainModelListItem[]),
);
return res;
});
};
const handleModelList = (
list: GithubComChaitinPandaWikiDomainModelListItem[],
) => {
const chat = list.find(it => it.type === 'chat') || null;
const embedding = list.find(it => it.type === 'embedding') || null;
const rerank = list.find(it => it.type === 'rerank') || null;
const analysis = list.find(it => it.type === 'analysis') || null;
const analysisVL = list.find(it => it.type === 'analysis-vl') || null;
setChatModelData(chat);
setEmbeddingModelData(embedding);
setRerankModelData(rerank);
setAnalysisModelData(analysis);
setAnalysisVLModelData(analysisVL);
};
useEffect(() => {
if (modelList) {
handleModelList(modelList);
}
}, [modelList]);
const onSubmit = async () => {
await modelConfigRef.current?.onSubmit?.();
// 检查模型模式设置
try {
const modeSetting = await getApiV1ModelModeSetting();
// 如果是 auto 模式,检查是否配置了 API key
if (modeSetting?.mode === 'auto') {
if (!modeSetting.auto_mode_api_key) {
return Promise.reject(new Error('请点击应用完成模型配置'));
}
} else {
getModelList().then(res => {
const list = res as GithubComChaitinPandaWikiDomainModelListItem[];
const chat = list.find(it => it.type === 'chat') || null;
const embedding = list.find(it => it.type === 'embedding') || null;
const rerank = list.find(it => it.type === 'rerank') || null;
const analysis = list.find(it => it.type === 'analysis') || null;
// 手动模式检查
if (!chat || !embedding || !rerank || !analysis) {
return Promise.reject(new Error('请配置必要的模型后点击应用'));
}
});
}
} catch (error) {
if (error instanceof Error) {
return Promise.reject(error);
}
return Promise.reject(new Error('配置模型失败'));
}
return Promise.resolve();
};
useImperativeHandle(ref, () => ({
onSubmit,
}));
return (
<Box>
<ModelConfig
ref={modelConfigRef}
onCloseModal={() => {}}
chatModelData={chatModelData}
embeddingModelData={embeddingModelData}
rerankModelData={rerankModelData}
analysisModelData={analysisModelData}
analysisVLModelData={analysisVLModelData}
getModelList={getModelList}
hideDocumentationHint={true}
showTip={true}
showSaveBtn={false}
/>
</Box>
);
};
export default Step1Model;

View File

@@ -0,0 +1,361 @@
import React, { useState, useImperativeHandle, Ref, useEffect } from 'react';
import {
Checkbox,
FormControlLabel,
TextField,
Typography,
Stack,
FormControl,
FormHelperText,
} from '@mui/material';
import {
getApiV1KnowledgeBaseList,
getApiV1KnowledgeBaseDetail,
postApiV1KnowledgeBase,
} from '@/request/KnowledgeBase';
import { DomainCreateKnowledgeBaseReq } from '@/request/types';
import { setKbId, setKbList, setKbDetail } from '@/store/slices/config';
import { SettingCardItem, FormItem } from '@/pages/setting/component/Common';
import { Controller, useForm } from 'react-hook-form';
import FileText from '@/components/UploadFile/FileText';
import { message } from '@ctzhian/ui';
import { useAppDispatch } from '@/store';
const VALIDATION_RULES = {
name: {
required: {
value: true,
message: 'Wiki 站名称不能为空',
},
},
port: {
required: {
value: true,
message: '端口不能为空',
},
min: {
value: 1,
message: '端口号不能小于1',
},
max: {
value: 65535,
message: '端口号不能大于65535',
},
},
domain: {
pattern: {
value:
/^(localhost|((([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+[a-zA-Z]{2,})|(\d{1,3}(?:\.\d{1,3}){3})|(\[[0-9a-fA-F:]+\]))$/,
message: '请输入有效的域名、IP 或 localhost',
},
},
http: {
validate: (
value: boolean,
formValues: { http: boolean; https: boolean },
) => {
if (!value && !formValues.https) {
return 'HTTP 端口和 HTTPS 端口必须有一个启用';
}
return true;
},
},
https: {
validate: (
value: boolean,
formValues: { http: boolean; https: boolean },
) => {
if (!value && !formValues.http) {
return 'HTTP 端口和 HTTPS 端口必须有一个启用';
}
return true;
},
},
};
interface Step2ConfigProps {
ref: Ref<{ onSubmit: () => Promise<unknown> }>;
}
const Step2Config: React.FC<Step2ConfigProps> = ({ ref }) => {
const {
control,
formState: { errors },
trigger,
watch,
reset,
getValues,
} = useForm({
defaultValues: {
name: '',
domain: window.location.hostname,
port: 80,
ssl_port: 443,
httpsCert: '',
httpsKey: '',
http: true,
https: false,
},
});
const { http, https } = watch();
useEffect(() => {
return () => {
reset();
};
}, []);
const dispatch = useAppDispatch();
const getKb = (id?: string) => {
const kb_id = id || localStorage.getItem('kb_id') || '';
return Promise.all([
getApiV1KnowledgeBaseList().then(res => {
dispatch(setKbList(res));
if (res.find(item => item.id === kb_id)) {
dispatch(setKbId(kb_id));
} else {
dispatch(setKbId(res[0]?.id || ''));
}
}),
getApiV1KnowledgeBaseDetail({ id: kb_id }).then(res => {
dispatch(setKbDetail(res));
}),
]);
};
const onSubmit = async () => {
const isRHFValid = await trigger();
if (!isRHFValid) {
return Promise.reject();
} else {
const value = getValues();
if (!value.http && !value.https) {
message.error('HTTP 和 HTTPS 至少需要启用一种服务');
return Promise.reject(new Error('HTTP 和 HTTPS 至少需要启用一种服务'));
}
const formData: DomainCreateKnowledgeBaseReq = { name: value.name };
if (value.domain) formData.hosts = [value.domain];
if (value.http) formData.ports = [+value.port];
if (value.https) {
formData.ssl_ports = [+value.ssl_port];
if (value.httpsCert) formData.public_key = value.httpsCert;
if (value.httpsKey) formData.private_key = value.httpsKey;
}
return (
postApiV1KnowledgeBase(formData)
// @ts-expect-error 类型错误
.then(({ id }) => {
return getKb(id).then(() => {
// message.success('创建成功');
});
})
);
}
};
useImperativeHandle(ref, () => ({
onSubmit,
}));
return (
<>
<SettingCardItem title='WIKI 站'>
{/* Knowledge Base Name Section */}
<FormItem
label='名称'
required
labelWidth={100}
sx={{ alignItems: 'flex-start' }}
labelSx={{ height: 52 }}
>
<Controller
control={control}
name='name'
rules={VALIDATION_RULES.name}
render={({ field }) => (
<TextField
{...field}
autoFocus
placeholder='请输入'
fullWidth
error={!!errors.name}
helperText={errors.name?.message}
/>
)}
/>
</FormItem>
</SettingCardItem>
<SettingCardItem title='服务监听方式'>
<FormItem
label='域名或 IP'
labelWidth={100}
sx={{ alignItems: 'flex-start' }}
labelSx={{ height: 52 }}
>
<Controller
control={control}
name='domain'
rules={VALIDATION_RULES.domain}
render={({ field }) => (
<TextField
{...field}
fullWidth
placeholder='请输入'
error={!!errors.domain}
helperText={errors.domain?.message}
/>
)}
/>
</FormItem>
<FormItem
label='HTTP 端口'
labelWidth={100}
sx={{ alignItems: 'flex-start' }}
labelSx={{ height: 52 }}
>
<Stack direction='row' gap={2} sx={{ flex: 1 }}>
<FormControl error={!!errors.http}>
<Controller
control={control}
name='http'
// rules={VALIDATION_RULES.http}
render={({ field }) => (
<FormControlLabel
sx={{ mr: 0, height: 52 }}
control={
<Checkbox
checked={field.value}
onChange={e => field.onChange(e.target.checked)}
sx={{ padding: '4px' }}
/>
}
label={
<Typography sx={{ fontSize: '14px', minWidth: '30px' }}>
</Typography>
}
/>
)}
/>
{/* {errors.http && (
<FormHelperText>{errors.http.message}</FormHelperText>
)} */}
</FormControl>
<Controller
control={control}
name='port'
rules={VALIDATION_RULES.port}
render={({ field }) => (
<TextField
{...field}
placeholder='HTTP 端口'
disabled={!http}
fullWidth
error={!!errors.port}
helperText={errors.port?.message}
/>
)}
/>
</Stack>
</FormItem>
<FormItem
label='HTTPS 端口'
labelWidth={100}
sx={{ alignItems: 'flex-start' }}
labelSx={{ height: 52 }}
>
<Stack direction='row' gap={2} sx={{ flex: 1 }}>
<FormControl error={!!errors.https}>
<Controller
control={control}
name='https'
// rules={VALIDATION_RULES.https}
render={({ field }) => (
<FormControlLabel
sx={{ mr: 0, height: 52 }}
control={
<Checkbox
checked={field.value}
onChange={e => field.onChange(e.target.checked)}
sx={{ padding: '4px' }}
/>
}
label={
<Typography sx={{ fontSize: '14px', minWidth: '30px' }}>
</Typography>
}
/>
)}
/>
{/* {errors.https && (
<FormHelperText>{errors.https.message}</FormHelperText>
)} */}
</FormControl>
<Controller
control={control}
name='ssl_port'
rules={VALIDATION_RULES.port}
render={({ field }) => (
<TextField
{...field}
placeholder='HTTPS 端口'
disabled={!https}
sx={{ width: 137 }}
error={!!errors.ssl_port}
helperText={errors.ssl_port?.message}
/>
)}
/>
<FormControl error={!!errors.httpsCert} sx={{ width: 137 }}>
<Controller
control={control}
name='httpsCert'
rules={{ required: https ? '请上传' : false }}
render={({ field }) => (
<FileText
{...field}
sx={{ width: 137 }}
textSx={{ fontSize: 14 }}
tip={'证书文件'}
disabled={!https}
/>
)}
/>
{errors.httpsCert && (
<FormHelperText>{errors.httpsCert.message}</FormHelperText>
)}
</FormControl>
<FormControl error={!!errors.httpsKey} sx={{ width: 137 }}>
<Controller
control={control}
name='httpsKey'
rules={{ required: https ? '请上传' : false }}
render={({ field }) => (
<FileText
{...field}
sx={{ width: 137 }}
textSx={{ fontSize: 14 }}
tip={'私钥文件'}
disabled={!https}
/>
)}
/>
{errors.httpsKey && (
<FormHelperText>{errors.httpsKey.message}</FormHelperText>
)}
</FormControl>
</Stack>
</FormItem>
</SettingCardItem>
</>
);
};
export default Step2Config;

View File

@@ -0,0 +1,52 @@
import React, { useImperativeHandle, Ref } from 'react';
import { Box, Stack, FormControlLabel, Checkbox } from '@mui/material';
import importDoc from '@/assets/images/init/import.png';
import { getApiV1NodeListGroupNav, postApiV1Node } from '@/request/Node';
import { INIT_DOC_DATA } from './initData';
import { useAppSelector } from '@/store';
interface Step3ImportProps {
ref: Ref<{ onSubmit: () => Promise<Record<'id', string>[]> }>;
}
const Step3Import: React.FC<Step3ImportProps> = ({ ref }) => {
const { kb_id } = useAppSelector(state => state.config);
const onSubmit = async () => {
let nav_id = '';
if (kb_id) {
const res = await getApiV1NodeListGroupNav({ kb_id });
const list = (res || []) as Array<{ nav_id?: string }>;
nav_id = list?.[0]?.nav_id || '';
}
return Promise.all(
INIT_DOC_DATA.map(item => {
return postApiV1Node({
...item,
kb_id,
nav_id: nav_id || '',
});
}),
);
};
useImperativeHandle(ref, () => ({
onSubmit,
}));
return (
<Stack gap={2} sx={{ textAlign: 'center', py: 4 }}>
<Box component='img' src={importDoc} sx={{ width: '100%' }}></Box>
<FormControlLabel
control={
<Checkbox
checked
sx={{ m: 1, color: 'rgba(50, 72, 242, 0.6) !important' }}
/>
}
label='导入样例文档'
/>
</Stack>
);
};
export default Step3Import;

View File

@@ -0,0 +1,21 @@
import { Box, Stack, FormControlLabel, Checkbox } from '@mui/material';
import publish from '@/assets/images/init/publish.png';
const Step4Publish = () => {
return (
<Stack gap={2} sx={{ textAlign: 'center', py: 4 }}>
<Box component='img' src={publish} sx={{ width: '100%' }}></Box>
<FormControlLabel
control={
<Checkbox
checked
sx={{ m: 1, color: 'rgba(50, 72, 242, 0.6) !important' }}
/>
}
label='发布内容'
/>
</Stack>
);
};
export default Step4Publish;

View File

@@ -0,0 +1,12 @@
import { Box, Stack } from '@mui/material';
import test from '@/assets/images/init/test.png';
const Step5Test = () => {
return (
<Stack gap={2} sx={{ textAlign: 'center', py: 4 }}>
<Box component='img' src={test} sx={{ width: '100%' }}></Box>
</Stack>
);
};
export default Step5Test;

View File

@@ -0,0 +1,63 @@
import React, { useImperativeHandle, Ref } from 'react';
import { Box, Stack, FormControlLabel, Checkbox } from '@mui/material';
import decorate from '@/assets/images/init/decorate.png';
import { INIT_LADING_DATA } from './initData';
import { getApiV1AppDetail, putApiV1App } from '@/request/App';
import { useAppSelector } from '@/store';
interface Step6DecorateProps {
ref: Ref<{ onSubmit: () => void }>;
nodeIds: string[];
}
const Step6Decorate: React.FC<Step6DecorateProps> = ({ ref, nodeIds }) => {
const { kb_id } = useAppSelector(state => state.config);
const onSubmit = () => {
return getApiV1AppDetail({
kb_id: kb_id,
type: '1',
}).then(res => {
return putApiV1App(
{ id: res.id! },
{
kb_id,
settings: {
...res.settings,
...INIT_LADING_DATA,
web_app_landing_configs:
INIT_LADING_DATA.web_app_landing_configs.map(item => {
if (item.type === 'basic_doc') {
return {
...item,
node_ids: nodeIds,
};
}
return item;
}),
},
},
);
});
};
useImperativeHandle(ref, () => ({
onSubmit,
}));
return (
<Stack gap={2} sx={{ textAlign: 'center', py: 4 }}>
<Box component='img' src={decorate} sx={{ width: '100%' }}></Box>
<FormControlLabel
control={
<Checkbox
checked
sx={{ m: 1, color: 'rgba(50, 72, 242, 0.6) !important' }}
/>
}
label='使用样例装扮'
/>
</Stack>
);
};
export default Step6Decorate;

View File

@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import { Box, Stack, Button } from '@mui/material';
import complete from '@/assets/images/init/complete.png';
import { useAppSelector } from '@/store';
const Step7Complete = () => {
const { kbDetail } = useAppSelector(state => state.config);
const wikiUrl = useMemo(() => {
if (!kbDetail) return '';
if (kbDetail.access_settings?.base_url) {
return kbDetail.access_settings.base_url;
} else {
let defaultUrl: string = '';
const host = kbDetail.access_settings?.hosts?.[0] || '';
if (!host) return '';
if (
kbDetail.access_settings?.ssl_ports &&
kbDetail.access_settings?.ssl_ports.length > 0
) {
defaultUrl = kbDetail.access_settings.ssl_ports.includes(443)
? `https://${host}`
: `https://${host}:${kbDetail.access_settings.ssl_ports[0]}`;
} else if (
kbDetail.access_settings?.ports &&
kbDetail.access_settings?.ports.length > 0
) {
defaultUrl = kbDetail.access_settings.ports.includes(80)
? `http://${host}`
: `http://${host}:${kbDetail.access_settings.ports[0]}`;
}
return defaultUrl;
}
}, [kbDetail]);
return (
<Stack
gap={2}
alignItems='center'
justifyContent='center'
sx={{ height: '100%' }}
>
<Box component='img' src={complete} sx={{ width: 274 }}></Box>
<Box sx={{ fontSize: 14, color: 'text.tertiary' }}></Box>
<Button
variant='contained'
onClick={() => {
if (wikiUrl) {
window.open(wikiUrl, '_blank');
}
}}
>
访 WIKI
</Button>
</Stack>
);
};
export default Step7Complete;

View File

@@ -0,0 +1,7 @@
export { default as Step1Model } from './Step1Model';
export { default as Step2Config } from './Step2Config';
export { default as Step3Import } from './Step3Import';
export { default as Step4Publish } from './Step4Publish';
export { default as Step5Test } from './Step5Test';
export { default as Step6Decorate } from './Step6Decorate';
export { default as Step7Complete } from './Step7Complete';

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,91 @@
import { addOpacityToColor } from '@/utils';
import CloseIcon from '@mui/icons-material/Close';
import { Box, IconButton, Modal, SxProps, useTheme } from '@mui/material';
import { useState } from 'react';
interface ImageProps {
src: string;
alt?: string;
width: number | string;
preview?: boolean;
sx?: SxProps;
}
const CustomImage = ({
src,
alt = '',
width,
preview = true,
sx,
}: ImageProps) => {
const [open, setOpen] = useState(false);
const theme = useTheme();
const handleOpen = () => {
if (preview) {
setOpen(true);
}
};
const handleClose = () => {
if (preview) {
setOpen(false);
}
};
return (
<>
<Box
component='img'
src={src}
alt={alt}
width={width}
onClick={handleOpen}
sx={sx}
/>
<Modal
open={open}
onClose={handleClose}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box sx={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
<IconButton
onClick={handleClose}
sx={{
position: 'absolute',
top: -40,
right: -40,
color: 'white',
bgcolor: addOpacityToColor(theme.palette.common.black, 0.5),
'&:hover': {
bgcolor: addOpacityToColor(theme.palette.common.black, 0.7),
},
}}
>
<CloseIcon />
</IconButton>
<Box
component='img'
src={src}
alt={alt}
sx={{
minWidth: '1200px',
minHeight: '80vh',
maxWidth: '90vw',
maxHeight: '90vh',
objectFit: 'contain',
borderRadius: 1,
boxShadow: `0 8px 16px ${addOpacityToColor(theme.palette.common.black, 0.2)}`,
}}
/>
</Box>
</Modal>
</>
);
};
export default CustomImage;

View File

@@ -0,0 +1,416 @@
import { useAppSelector } from '@/store';
import { Box, Stack, useColorScheme, createTheme } from '@mui/material';
import { ThemeProvider } from '@ctzhian/ui';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
memo,
useRef,
useState,
} from 'react';
import { handleComponentProps } from '../utils';
import { themeOptions } from '@/themes';
import { IconShanchu } from '@panda-wiki/icons';
import { Component } from '..';
import {
DndContext,
DragEndEvent,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
arrayMove,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { CSSProperties, MouseEvent } from 'react';
import { THEME_TO_PALETTE } from '@panda-wiki/themes/constants';
interface ShowContentProps {
curComponent: Component;
setCurComponent: Dispatch<SetStateAction<Component>>;
renderMode: 'pc' | 'mobile';
scale: number;
components: Component[];
setComponents: Dispatch<SetStateAction<Component[]>>;
setIsEdit?: Dispatch<SetStateAction<boolean>>;
baseUrl: string;
}
interface SortableItemProps {
item: Component;
renderMode: 'pc' | 'mobile';
// 预先缓存好的渲染 props避免父组件每次重新计算
cachedProps?: Record<string, unknown>;
isHighlighted: boolean;
onSelect: (item: Component) => void;
onDelete?: (item: Component) => void;
baseUrl: string;
}
const SortableItem = memo(
({
item,
renderMode,
cachedProps,
isHighlighted,
onSelect,
onDelete,
baseUrl,
}: SortableItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id, disabled: !!item.fixed });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.9 : 1,
cursor: isDragging ? 'move' : undefined,
};
return (
<Box
sx={{
position: 'relative',
border: isHighlighted ? '2px solid #5F58FE' : '2px solid transparent',
borderRadius: '0px',
padding: '2px',
cursor: item.fixed ? 'default' : 'move',
'&:hover': {
border: isHighlighted ? '2px solid #5F58FE' : '2px dashed #5F58FE',
},
}}
data-component={item.id}
ref={setNodeRef}
style={style}
{...(!item.fixed ? { ...attributes, ...listeners } : {})}
onClick={() => onSelect(item)}
>
<item.component
mobile={renderMode === 'mobile'}
docWidth={renderMode === 'pc' ? 'full' : 'normal'}
{...(cachedProps || {})}
basePath={baseUrl}
/>
{isHighlighted && (
<Stack
direction={'row'}
alignItems={'center'}
gap={2}
sx={{
position: 'absolute',
left: '-2px',
...(item?.name === 'footer'
? { top: '-24px' }
: { bottom: '-24px' }),
fontWeight: 400,
color: '#FFFFFF',
fontSize: '14px',
zIndex: 20,
}}
>
<Box sx={{ bgcolor: '#5F58FE', padding: '1px 16px', height: 24 }}>
{item?.title}
</Box>
{!item.fixed && (
<Stack
justifyContent='center'
alignItems='center'
sx={{ bgcolor: '#5F58FE', height: 24, px: 0.5 }}
onClick={(e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
onDelete?.(item);
}}
>
<IconShanchu sx={{ fontSize: '16px' }} />
</Stack>
)}
</Stack>
)}
</Box>
);
},
// (prev, next) => {
// if (!isSameItemShallow(prev.item, next.item)) return false;
// if (prev.isHighlighted !== next.isHighlighted) return false;
// if (prev.renderMode !== next.renderMode) return false;
// // 仅当缓存 props 引用变化时重渲染
// if (prev.cachedProps !== next.cachedProps) return false;
// return true;
// },
);
const ShowContent = ({
setCurComponent,
curComponent,
renderMode,
scale,
components,
setComponents,
setIsEdit,
baseUrl,
}: ShowContentProps) => {
const { appPreviewData } = useAppSelector(state => state.config);
const { setMode } = useColorScheme();
const containerRef = useRef<HTMLDivElement>(null);
const isComponentClickRef = useRef(false);
useEffect(() => {
setMode(appPreviewData?.settings?.theme_mode as 'light' | 'dark');
}, [appPreviewData?.settings?.theme_mode, setMode]);
const handleScroll = () => {
const targetElement = containerRef.current?.querySelector(
`[data-component="${curComponent.id}"]`,
);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest',
});
}
if (!targetElement) {
setTimeout(() => {
handleScroll();
}, 100);
}
};
// 滚动到当前选中的组件(仅在组件真正改变时)
useEffect(() => {
if (
!curComponent?.id ||
!containerRef.current ||
isComponentClickRef.current
) {
isComponentClickRef.current = false;
return;
}
handleScroll();
}, [curComponent]);
const handleSelect = useCallback(
(item: Component) => {
if (item.disabled) return;
setCurComponent(item);
isComponentClickRef.current = true;
},
[setCurComponent],
);
const handleDelete = useCallback(
(item: Component) => {
const filterComponents = components.filter(c => c.id !== item.id);
if (curComponent?.id === item.id) {
setCurComponent(
filterComponents.find(c => !c.disabled && !c.hidden) ||
filterComponents[0],
);
}
setComponents(filterComponents);
setIsEdit?.(true);
},
[components, curComponent?.id, setComponents, setCurComponent, setIsEdit],
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const nonFixedIds = useMemo(
() => components.filter(c => !c.fixed).map(c => c.id),
[components],
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
if (active.id === over.id) return;
const nonFixedItems = components.filter(c => !c.fixed);
const fromIdx = nonFixedItems.findIndex(c => c.id === active.id);
const toIdx = nonFixedItems.findIndex(c => c.id === over.id);
if (fromIdx === -1 || toIdx === -1) return;
const newNonFixed = arrayMove(nonFixedItems, fromIdx, toIdx);
const result: Component[] = [];
let cursor = 0;
for (let i = 0; i < components.length; i++) {
const cur = components[i];
if (cur.fixed) {
result.push(cur);
} else {
result.push(newNonFixed[cursor]);
cursor += 1;
}
}
setComponents(result);
const newCur = result.find(c => c.id === curComponent.id);
if (newCur) setCurComponent(newCur);
if (setIsEdit) setIsEdit(true);
};
// app settings 引用:作为传递给子组件的 props 变化依据
const appSettings = appPreviewData?.settings;
// 每个组件项的 props 缓存,仅在必要时更新
const propsCacheRef = useRef<
Record<string, Record<string, unknown> | undefined>
>({});
const [cacheTick, setCacheTick] = useState(0);
// 初始化/同步缓存(新增、删除)
useEffect(() => {
const nextKeys = new Set(components.map(c => c.id));
// 新增项:补齐缓存
components.forEach(c => {
if (!propsCacheRef.current[c.id]) {
propsCacheRef.current[c.id] =
handleComponentProps(c.name, c.id, appSettings) || {};
}
});
// 移除项:清理缓存
Object.keys(propsCacheRef.current).forEach(k => {
if (!nextKeys.has(k)) delete propsCacheRef.current[k];
});
setCacheTick(t => t + 1);
}, [appSettings, components]);
// appSettings 变化时,只更新当前高亮组件的缓存,其他组件沿用旧 props
useEffect(() => {
if (!curComponent?.id) return;
propsCacheRef.current[curComponent.id] =
handleComponentProps(curComponent.name, curComponent.id, appSettings) ||
{};
setCacheTick(t => t + 1);
}, [appSettings, curComponent?.id]);
// 渲染项缓存:仅在关键签名或必要依赖变更时重建
const renderedItems = useMemo(() => {
return components
.filter(item => !item.hidden)
.map(item =>
propsCacheRef.current[item.id] ? (
<SortableItem
key={item.id}
item={item}
renderMode={renderMode}
cachedProps={propsCacheRef.current[item.id]}
isHighlighted={curComponent?.id === item.id}
onSelect={handleSelect}
onDelete={handleDelete}
baseUrl={baseUrl}
/>
) : null,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
renderMode,
curComponent?.id,
handleSelect,
handleDelete,
cacheTick,
baseUrl,
]);
return (
<Stack
ref={containerRef}
className='show-content-container'
sx={{
flex: 1,
flexShrink: 0,
my: '20px',
border: '1px solid #ECEEF1',
'&::-webkit-scrollbar': {
height: '8px', // 滚动条高度
},
overflow: 'auto',
'&::-webkit-scrollbar-thumb': {
background: '#888', // 滑块颜色
borderRadius: '4px',
},
}}
>
<Stack
sx={{
minWidth: renderMode === 'pc' ? `1200px` : '375px',
width: renderMode === 'pc' ? `100%` : '375px',
margin: '0 auto',
boxShadow:
renderMode === 'pc' ? null : '0 10px 15px -3px rgb(0 0 0 / 0.1)',
// minHeight: '800px',
// height: '100%',
bgcolor: 'background.default',
position: 'relative',
transform: `scale(${scale})`,
transformOrigin: 'center center',
transition: 'transform 0.2s ease',
}}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={nonFixedIds}
strategy={verticalListSortingStrategy}
>
{renderedItems}
</SortableContext>
</DndContext>
</Stack>
</Stack>
);
};
const ThemeWrapper = ({ children }: { children: React.ReactNode }) => {
const { appPreviewData } = useAppSelector(state => state.config);
const theme = useMemo(() => {
const themeName =
appPreviewData?.settings?.web_app_landing_theme?.name || 'blue';
return createTheme(
// @ts-expect-error themeOptions is not typed
{
...themeOptions[0],
palette:
THEME_TO_PALETTE[themeName]?.palette || THEME_TO_PALETTE.blue.palette,
},
...themeOptions.slice(1),
);
}, [appPreviewData?.settings?.web_app_landing_theme?.name]);
return (
<ThemeProvider theme={theme} storageManager={null}>
{children}
</ThemeProvider>
);
};
const Content = (props: ShowContentProps) => {
return (
<ThemeWrapper>
<ShowContent {...props} />
</ThemeWrapper>
);
};
export default Content;

View File

@@ -0,0 +1,537 @@
import { FooterSetting } from '@/api/type';
import { IconShanchu2, IconDrag, IconTianjia } from '@panda-wiki/icons';
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Box, IconButton, Stack, TextField } from '@mui/material';
import {
CSSProperties,
forwardRef,
HTMLAttributes,
useCallback,
useState,
} from 'react';
import { Control, Controller, FieldErrors } from 'react-hook-form';
import { BrandGroup } from '.';
export type ItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
groupIndex: number;
withOpacity?: boolean;
isDragging?: boolean;
dragHandleProps?: any;
setIsEdit: (value: boolean) => void;
handleRemove?: () => void;
item: BrandGroup;
data: BrandGroup[];
onChange: (value: BrandGroup[]) => void;
control: Control<FooterSetting>;
errors: FieldErrors<FooterSetting>;
};
interface LinkItemProps extends HTMLAttributes<HTMLDivElement> {
linkId: string;
linkIndex: number;
groupIndex: number;
control: Control<FooterSetting>;
errors: FieldErrors<FooterSetting>;
setIsEdit: (value: boolean) => void;
onRemove: () => void;
withOpacity?: boolean;
isDragging?: boolean;
dragHandleProps?: any;
data: BrandGroup[];
}
const LinkItem = forwardRef<HTMLDivElement, LinkItemProps>(
(
{
linkIndex,
groupIndex,
control,
errors,
setIsEdit,
onRemove,
withOpacity,
isDragging,
dragHandleProps,
style,
data,
...props
},
ref,
) => {
const inlineStyles: CSSProperties = {
opacity: withOpacity ? '0.5' : '1',
cursor: isDragging ? 'grabbing' : 'grab',
...style,
};
return (
<Box ref={ref} style={inlineStyles} {...props}>
<Stack gap={1} direction='column'>
<Stack
direction={'row'}
justifyContent={'flex-start'}
alignItems={'center'}
>
<IconButton
size='small'
sx={{
cursor: 'grab',
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
flexShrink: 0,
width: '28px',
height: '28px',
}}
{...dragHandleProps}
>
<IconDrag sx={{ fontSize: '18px' }} />
</IconButton>
<Box
sx={{
fontSize: '12px',
lineHeight: '20px',
fontWeight: '600',
}}
>
{linkIndex + 1}
</Box>
<IconButton
size='small'
onClick={onRemove}
sx={{
color: 'text.tertiary',
':hover': { color: 'error.main' },
flexShrink: 0,
width: '28px',
height: '28px',
ml: 'auto',
}}
>
<IconShanchu2 sx={{ fontSize: '12px' }} />
</IconButton>
</Stack>
<Controller
control={control}
name={`brand_groups`}
rules={{ required: '请输入链接文字' }}
render={({ field }) => (
<TextField
{...field}
slotProps={{
inputLabel: {
shrink: true,
},
}}
value={field.value[groupIndex].links[linkIndex]?.name}
sx={{
height: '36px',
bgcolor: '#ffffff',
'& .MuiOutlinedInput-root': {
height: '36px',
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
fullWidth
label='文字'
placeholder='文字'
onChange={e => {
const newGroups = [...data];
newGroups[groupIndex] = {
...newGroups[groupIndex],
links: [...newGroups[groupIndex].links],
};
newGroups[groupIndex].links[linkIndex] = {
...newGroups[groupIndex].links[linkIndex],
name: e.target.value,
};
field.onChange(newGroups);
setIsEdit(true);
}}
error={
!!errors.brand_groups?.[groupIndex]?.links?.[linkIndex]?.name
}
helperText={
errors.brand_groups?.[groupIndex]?.links?.[linkIndex]?.name
?.message
}
/>
)}
/>
<Controller
control={control}
name={`brand_groups`}
rules={{ required: '请输入链接地址' }}
render={({ field }) => (
<TextField
{...field}
slotProps={{
inputLabel: {
shrink: true,
},
}}
value={field.value[groupIndex].links[linkIndex]?.url}
sx={{
height: '36px',
bgcolor: '#ffffff',
'& .MuiOutlinedInput-root': {
height: '36px',
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
fullWidth
label='链接'
placeholder='链接'
onChange={e => {
const newGroups = [...data];
newGroups[groupIndex] = {
...newGroups[groupIndex],
links: [...newGroups[groupIndex].links],
};
newGroups[groupIndex].links[linkIndex] = {
...newGroups[groupIndex].links[linkIndex],
url: e.target.value,
};
field.onChange(newGroups);
setIsEdit(true);
}}
error={
!!errors.brand_groups?.[groupIndex]?.links?.[linkIndex]?.url
}
helperText={
errors.brand_groups?.[groupIndex]?.links?.[linkIndex]?.url
?.message
}
/>
)}
/>
</Stack>
</Box>
);
},
);
const SortableLinkItem: React.FC<LinkItemProps> = ({ linkId, ...rest }) => {
const {
isDragging,
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: linkId });
const style = {
transform: CSS.Transform.toString(transform),
transition: transition || undefined,
};
return (
<LinkItem
ref={setNodeRef}
style={style}
withOpacity={isDragging}
dragHandleProps={{
...attributes,
...listeners,
}}
linkId={linkId}
{...rest}
/>
);
};
const Item = forwardRef<HTMLDivElement, ItemProps>(
(
{
groupIndex,
withOpacity,
isDragging,
style,
dragHandleProps,
handleRemove,
setIsEdit,
item,
data,
onChange,
errors,
control,
...props
},
ref,
) => {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
const inlineStyles: CSSProperties = {
opacity: withOpacity ? '0.5' : '1',
borderRadius: '10px',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: '#ffffff',
width: '100%',
...style,
};
const handleLinkDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
const handleLinkDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id && data) {
const oldIndex = data.findIndex(
(_, index) => `link-${groupIndex}-${index}` === active.id,
);
const newIndex = data.findIndex(
(_, index) => `link-${groupIndex}-${index}` === over!.id,
);
const newData = arrayMove(data[groupIndex].links, oldIndex, newIndex);
const newGroups = [...data];
newGroups[groupIndex] = {
...newGroups[groupIndex],
links: newData,
};
onChange(newGroups);
}
setActiveId(null);
},
[data, data[groupIndex].links, setIsEdit, groupIndex],
);
const handleLinkDragCancel = useCallback(() => {
setActiveId(null);
}, []);
const handleAddLink = () => {
const newGroups = [...data];
newGroups[groupIndex] = {
...newGroups[groupIndex],
links: [...newGroups[groupIndex].links, { name: '', url: '' }],
};
onChange(newGroups);
};
const handleRemoveLink = (linkIndex: number) => {
const newGroups = [...data];
newGroups[groupIndex] = {
...newGroups[groupIndex],
links: newGroups[groupIndex].links.filter(
(_, index) => index !== linkIndex,
),
};
onChange(newGroups);
};
return (
<Box ref={ref} style={inlineStyles} {...props}>
<Box
sx={{
p: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
width: '346px',
}}
>
<Stack direction={'column'} gap={1}>
<Stack direction='column' gap={1}>
<Stack
direction={'row'}
justifyContent={'flex-start'}
alignItems={'center'}
>
<IconButton
size='small'
sx={{
cursor: 'grab',
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
flexShrink: 0,
width: '28px',
height: '28px',
}}
{...dragHandleProps}
>
<IconDrag sx={{ fontSize: '18px' }} />
</IconButton>
<Box
sx={{
fontSize: '12px',
lineHeight: '20px',
fontWeight: '600',
}}
>
{groupIndex + 1}
</Box>
<IconButton
size='small'
onClick={handleRemove}
sx={{
color: 'text.tertiary',
':hover': { color: 'error.main' },
flexShrink: 0,
width: '28px',
height: '28px',
ml: 'auto',
}}
>
<IconShanchu2 sx={{ fontSize: '12px' }} />
</IconButton>
</Stack>
<Controller
control={control}
name={`brand_groups`}
rules={{ required: '请输入链接组名称' }}
render={({ field }) => (
<TextField
{...field}
slotProps={{
inputLabel: {
shrink: true,
},
}}
value={field.value[groupIndex].name}
sx={{
height: '36px',
bgcolor: '#ffffff',
'& .MuiOutlinedInput-root': {
height: '36px',
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
fullWidth
placeholder='输入链接组名称'
label='链接组名称'
onChange={e => {
const newGroups = [...data];
newGroups[groupIndex] = {
...newGroups[groupIndex],
name: e.target.value,
};
field.onChange(newGroups);
setIsEdit(true);
}}
error={!!errors.brand_groups?.[groupIndex]?.name}
helperText={
errors.brand_groups?.[groupIndex]?.name?.message
}
/>
)}
/>
</Stack>
{/* 链接拖拽区域 */}
{item.links && item.links.length > 0 && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleLinkDragStart}
onDragEnd={handleLinkDragEnd}
onDragCancel={handleLinkDragCancel}
>
<SortableContext
items={item.links.map(
(_, index) => `link-${groupIndex}-${index}`,
)}
strategy={rectSortingStrategy}
>
<Stack gap={1}>
{item.links.map((link, linkIndex) => (
<Box
sx={{
width: '100%',
border: '1px dashed',
borderColor: 'divider',
borderRadius: '10px',
p: 1,
}}
>
<SortableLinkItem
key={linkIndex}
linkId={`link-${groupIndex}-${linkIndex}`}
linkIndex={linkIndex}
groupIndex={groupIndex}
control={control}
errors={errors}
setIsEdit={setIsEdit}
onRemove={() => handleRemoveLink(linkIndex)}
data={data}
/>
</Box>
))}
</Stack>
</SortableContext>
<DragOverlay adjustScale style={{ transformOrigin: '0 0' }}>
{activeId ? (
<LinkItem
isDragging
linkId={activeId}
linkIndex={parseInt(activeId.split('-')[2])}
groupIndex={groupIndex}
control={control}
errors={errors}
setIsEdit={setIsEdit}
onRemove={() => {}}
data={data}
/>
) : null}
</DragOverlay>
</DndContext>
)}
<Stack
direction={'row'}
sx={{
alignItems: 'center',
marginLeft: 'auto',
cursor: 'pointer',
ml: 1,
mt: 1,
}}
onClick={handleAddLink}
>
<IconTianjia
sx={{ fontSize: '10px !important', color: '#5F58FE' }}
/>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
marginLeft: 0.5,
color: '#5F58FE',
}}
>
</Box>
</Stack>
</Stack>
</Box>
</Box>
);
},
);
export default Item;

View File

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

View File

@@ -0,0 +1,165 @@
import { FooterSetting } from '@/api/type';
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { Box } from '@mui/material';
import { FC, useCallback, useEffect, useState } from 'react';
import { Control, FieldErrors } from 'react-hook-form';
import Item from './Item';
import SortableItem from './SortableItem';
import { useAppDispatch, useAppSelector } from '@/store';
import { setAppPreviewData } from '@/store/slices/config';
export interface BrandGroup {
name: string;
links: {
name: string;
url: string;
}[];
}
interface DragBrandProps {
onChange: (data: BrandGroup[]) => void;
setIsEdit: (value: boolean) => void;
data: {
name: string;
links: {
name: string;
url: string;
}[];
}[];
control: Control<FooterSetting>;
errors: FieldErrors<FooterSetting>;
}
const DragBrand: FC<DragBrandProps> = ({
setIsEdit,
data = [],
onChange,
control,
errors,
}) => {
const dispatch = useAppDispatch();
const { appPreviewData } = useAppSelector(state => state.config);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id && data) {
const oldIndex = data?.findIndex(
(_, index) => `group-${index}` === active.id,
);
const newIndex = data?.findIndex(
(_, index) => `group-${index}` === over!.id,
);
const newData = arrayMove(data, oldIndex, newIndex);
onChange(newData);
}
setActiveId(null);
},
[data, setIsEdit],
);
const handleDragCancel = useCallback(() => {
setActiveId(null);
}, []);
const handleRemove = useCallback(
(index: number) => {
if (data) {
const newData = data.filter((_, i) => i !== index);
onChange(newData);
}
},
[data, setIsEdit],
);
useEffect(() => {
if (data) {
if (!appPreviewData) return;
const previewData = {
...appPreviewData,
settings: {
...appPreviewData.settings,
footer_settings: {
...appPreviewData?.settings?.footer_settings,
data,
},
},
};
dispatch(setAppPreviewData(previewData));
}
}, [data]);
return (
<>
{data && (
<>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext
items={data?.map((_, index) => `group-${index}`)}
strategy={rectSortingStrategy}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{data?.map((group, groupIndex) => (
<SortableItem
key={groupIndex}
id={`group-${groupIndex}`}
groupIndex={groupIndex}
setIsEdit={setIsEdit}
handleRemove={() => handleRemove(groupIndex)}
item={group}
data={data}
onChange={onChange}
control={control}
errors={errors}
/>
))}
</Box>
</SortableContext>
<DragOverlay adjustScale style={{ transformOrigin: '0 0' }}>
{activeId ? (
<Item
isDragging
groupIndex={parseInt(activeId.split('-')[1])}
setIsEdit={setIsEdit}
item={data[parseInt(activeId.split('-')[1])]}
data={data}
onChange={onChange}
control={control}
errors={errors}
/>
) : null}
</DragOverlay>
</DndContext>
</>
)}
</>
);
};
export default DragBrand;

View File

@@ -0,0 +1,322 @@
import { CardWebHeaderBtn } from '@/api';
import UploadFile from '@/components/UploadFile';
import { useAppDispatch, useAppSelector } from '@/store';
import {
Box,
Checkbox,
FormControl,
IconButton,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
} from '@mui/material';
import { IconShanchu2, IconDrag } from '@panda-wiki/icons';
import {
CSSProperties,
Dispatch,
forwardRef,
HTMLAttributes,
SetStateAction,
} from 'react';
import { Control, Controller } from 'react-hook-form';
export type ItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
item: CardWebHeaderBtn;
withOpacity?: boolean;
isDragging?: boolean;
dragHandleProps?: any;
handleRemove?: (id: string) => void;
setIsEdit: Dispatch<SetStateAction<boolean>>;
data: CardWebHeaderBtn[];
control: Control<any>;
};
const Item = forwardRef<HTMLDivElement, ItemProps>(
(
{
item,
withOpacity,
isDragging,
style,
dragHandleProps,
handleRemove,
setIsEdit,
data: btns,
control,
...props
},
ref,
) => {
const dispatch = useAppDispatch();
const { appPreviewData } = useAppSelector(state => state.config);
const inlineStyles: CSSProperties = {
opacity: withOpacity ? '0.5' : '1',
borderRadius: '10px',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: '#ffffff',
...style,
};
return (
<Box ref={ref} style={inlineStyles} {...props}>
<Stack
direction={'row'}
alignItems={'center'}
justifyContent={'space-between'}
gap={0.5}
sx={{
px: 1,
py: '20px',
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
height: '234px',
width: '346px',
}}
>
<Controller
control={control}
name='btns'
render={({ field }) => {
const curBtn = btns.find(btn => btn.id === item.id);
if (!curBtn) return <></>;
return (
<Stack direction={'column'} gap={'20px'}>
<Stack direction={'row'} gap={1}>
<FormControl>
<InputLabel
id={curBtn.id + '_button_style'}
sx={{
'&.Mui-focused': {
color: 'black',
},
}}
>
</InputLabel>
<Select
labelId={curBtn.id + '_button_style'}
id={curBtn.id + '_button_style'}
value={curBtn.variant}
label='按钮样式'
onChange={e => {
const newBtns = [
...(appPreviewData?.settings?.btns || []),
];
const index = newBtns.findIndex(
(btn: any) => btn.id === curBtn.id,
);
newBtns[index] = {
...curBtn,
variant: e.target.value as 'contained' | 'outlined',
};
field.onChange(newBtns);
setIsEdit(true);
}}
sx={{
width: '144px',
height: '36px',
}}
>
<MenuItem value={'contained'}></MenuItem>
<MenuItem value={'outlined'}></MenuItem>
<MenuItem value={'text'}></MenuItem>
</Select>
</FormControl>
<FormControl>
<InputLabel
id={curBtn.id + '_button_target'}
sx={{
'&.Mui-focused': {
color: 'black',
},
}}
>
</InputLabel>
<Select
labelId={curBtn.id + '_button_target'}
id={curBtn.id + '_button_target'}
value={curBtn.target}
label='打开方式'
onChange={e => {
const newBtns = [
...(appPreviewData?.settings?.btns || []),
];
const index = newBtns.findIndex(
(btn: any) => btn.id === curBtn.id,
);
newBtns[index] = {
...curBtn,
target: e.target.value as '_blank' | '_self',
};
field.onChange(newBtns);
setIsEdit(true);
}}
sx={{
width: '144px',
height: '36px',
}}
>
<MenuItem value={'_self'}></MenuItem>
<MenuItem value={'_blank'}></MenuItem>
</Select>
</FormControl>
</Stack>
<Stack direction={'row'} gap={2}>
<Stack direction={'row'} alignItems={'center'} gap={1}>
<Checkbox
size='small'
sx={{ p: 0, m: 0 }}
checked={curBtn.showIcon}
onChange={e => {
const newBtns = [
...(appPreviewData?.settings?.btns || []),
];
const index = newBtns.findIndex(
(btn: any) => btn.id === curBtn.id,
);
newBtns[index] = {
...curBtn,
showIcon: e.target.checked,
};
field.onChange(newBtns);
setIsEdit(true);
}}
/>
<Box sx={{ fontSize: 14, lineHeight: '32px' }}>
</Box>
</Stack>
<UploadFile
name='icon'
id={`${curBtn.id}_icon`}
type='url'
disabled={false}
accept='image/*'
width={36}
value={curBtn.icon}
onChange={(url: string) => {
const newBtns = [
...(appPreviewData?.settings?.btns || []),
];
const index = newBtns.findIndex(
(btn: any) => btn.id === curBtn.id,
);
newBtns[index] = { ...curBtn, icon: url };
field.onChange(newBtns);
setIsEdit(true);
}}
/>
</Stack>
<TextField
label='按钮文本'
slotProps={{
inputLabel: {
shrink: true,
},
}}
sx={{
height: '36px',
'& .MuiOutlinedInput-root': {
height: '36px',
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
fullWidth
placeholder='请输入文本'
variant='outlined'
value={curBtn.text}
onChange={e => {
const newBtns = [
...(appPreviewData?.settings?.btns || []),
];
const index = newBtns.findIndex(
(btn: any) => btn.id === curBtn.id,
);
newBtns[index] = { ...curBtn, text: e.target.value };
field.onChange(newBtns);
setIsEdit(true);
}}
/>
<TextField
label='按钮链接'
slotProps={{
inputLabel: {
shrink: true,
},
}}
sx={{
height: '36px',
'& .MuiOutlinedInput-root': {
height: '36px',
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
fullWidth
placeholder='请输入链接'
value={curBtn.url}
variant='outlined'
onChange={e => {
const newBtns = [
...(appPreviewData?.settings?.btns || []),
];
const index = newBtns.findIndex(
(btn: any) => btn.id === curBtn.id,
);
newBtns[index] = { ...curBtn, url: e.target.value };
field.onChange(newBtns);
setIsEdit(true);
}}
/>
</Stack>
);
}}
/>
<Stack
direction={'column'}
sx={{ justifyContent: 'space-between', height: '100%' }}
>
<IconButton
size='small'
onClick={e => {
e.stopPropagation();
handleRemove?.(item.id);
}}
sx={{
color: 'text.tertiary',
':hover': { color: 'error.main' },
width: '28px',
height: '28px',
}}
>
<IconShanchu2 sx={{ fontSize: '12px' }} />
</IconButton>
<IconButton
size='small'
sx={{
cursor: 'grab',
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
{...dragHandleProps}
>
<IconDrag sx={{ fontSize: '18px' }} />
</IconButton>
</Stack>
</Stack>
</Box>
);
},
);
export default Item;

View File

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

View File

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

View File

@@ -0,0 +1,314 @@
import UploadFile from '@/components/UploadFile';
import { DomainSocialMediaAccount } from '@/request/types';
import { IconShanchu2, IconDrag } from '@panda-wiki/icons';
import {
Box,
IconButton,
MenuItem,
Select,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
} from '@mui/material';
import {
CSSProperties,
Dispatch,
forwardRef,
HTMLAttributes,
SetStateAction,
} from 'react';
import { Control, Controller } from 'react-hook-form';
import { options } from '../../config/FooterConfig';
export interface SocialInfoProps extends HTMLAttributes<HTMLDivElement> {
item: DomainSocialMediaAccount;
data: DomainSocialMediaAccount[];
control: Control<any>;
withOpacity?: boolean;
isDragging?: boolean;
dragHandleProps?: any;
setIsEdit: Dispatch<SetStateAction<boolean>>;
index: number;
}
const Item = forwardRef<HTMLDivElement, SocialInfoProps>(
(
{
item,
data = [],
control,
setIsEdit,
index,
style,
withOpacity,
isDragging,
dragHandleProps,
...props
},
ref,
) => {
const inlineStyles: CSSProperties = {
opacity: withOpacity ? '0.5' : '1',
borderRadius: '10px',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: '#ffffff',
...style,
};
return (
<>
{item && (
<Box ref={ref} {...props} style={inlineStyles}>
<Controller
control={control}
name='social_media_accounts'
render={({ field }) => (
<Stack
direction={'column'}
sx={{
p: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
width: '346px',
}}
gap={1}
>
<Stack
direction={'row'}
justifyContent={'flex-start'}
alignItems={'center'}
>
<IconButton
size='small'
sx={{
cursor: 'grab',
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
flexShrink: 0,
width: '28px',
height: '28px',
}}
{...dragHandleProps}
>
<IconDrag sx={{ fontSize: '18px' }} />
</IconButton>
<Box
sx={{
fontSize: '12px',
lineHeight: '20px',
fontWeight: '600',
}}
>
{index + 1}
</Box>
<IconButton
size='small'
onClick={() => {
let newData = [...data];
newData = newData.filter((_, i) => i !== index);
field.onChange(newData);
setIsEdit(true);
}}
sx={{
color: 'text.tertiary',
':hover': { color: 'error.main' },
flexShrink: 0,
width: '28px',
height: '28px',
ml: 'auto',
}}
>
<IconShanchu2 sx={{ fontSize: '12px' }} />
</IconButton>
</Stack>
<Stack direction={'row'} gap={1}>
<Select
value={item?.channel || ''}
sx={{
bgcolor: '#fff',
padding: 0,
width: '60px',
minWidth: '60px',
}}
MenuProps={{
PaperProps: {
sx: {
maxHeight: 'none',
overflow: 'hidden',
},
},
}}
renderValue={selected => {
const option = options.find(i => i.key === selected);
const AppIcon = option?.config_type || option?.type;
return (
<Stack justifyContent={'center'} sx={{ mt: '2px' }}>
{AppIcon && <AppIcon sx={{ fontSize: '14px' }} />}
</Stack>
);
}}
>
<MenuItem
sx={{
bgcolor: '#fff',
padding: 0,
}}
disableRipple
onClick={e => e.preventDefault()}
>
<ToggleButtonGroup
value={item?.channel}
onChange={(e, newValue) => {
const newData = [...data];
newData[index] = {
...item,
channel: newValue,
};
field.onChange(newData);
setIsEdit(true);
}}
exclusive
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1,
backgroundColor: 'white',
p: 1,
borderRadius: 1,
}}
>
{options.map(item => {
const AppIcon = item?.config_type || item?.type;
return (
<ToggleButton
key={item.key}
value={item.key}
sx={{
p: 1,
height: 'auto',
border: '1px solid #ddd !important',
borderRadius: '0px',
}}
>
<Stack
direction='row'
gap={1}
alignItems='center'
>
{AppIcon && (
<AppIcon sx={{ fontSize: '16px' }} />
)}
</Stack>
</ToggleButton>
);
})}
</ToggleButtonGroup>
</MenuItem>
</Select>
<TextField
{...field}
slotProps={{
inputLabel: {
shrink: true,
},
}}
value={item?.text || ''}
sx={{
width: '100%',
height: '36px',
bgcolor: '#ffffff',
'& .MuiOutlinedInput-root': {
height: '36px',
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
placeholder={
options.find(i => i.key === item.channel)
?.text_placeholder || ''
}
label={
options.find(i => i.key === item.channel)?.text_label ||
''
}
onChange={e => {
const newData = [...data];
newData[index] = {
...item,
text: e.target.value,
};
field.onChange(newData);
setIsEdit(true);
}}
/>
</Stack>
<Stack
sx={{ width: '100%', bgcolor: '#ECEEF1', height: '1px' }}
></Stack>
{item.channel === 'wechat_oa' && (
<UploadFile
{...field}
id={`${item.link}-${Date.now()}`}
name={`${item.link}-${Date.now()}`}
type='url'
accept='image/*'
width={80}
value={item?.icon || ''}
onChange={(url: string) => {
const newData = [...data];
newData[index] = {
...item,
icon: url,
};
field.onChange(newData);
setIsEdit(true);
}}
/>
)}
{item.channel === 'phone' && (
<TextField
{...field}
slotProps={{
inputLabel: {
shrink: true,
},
}}
value={item?.phone || ''}
sx={{
mt: 1,
width: '100%',
height: '36px',
bgcolor: '#ffffff',
'& .MuiOutlinedInput-root': {
height: '36px',
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
placeholder={'请输入电话号码'}
label={'电话'}
onChange={e => {
const newData = [...data];
newData[index] = {
...item,
phone: e.target.value,
};
field.onChange(newData);
setIsEdit(true);
}}
/>
)}
</Stack>
)}
/>
</Box>
)}
</>
);
},
);
export default Item;

View File

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

View File

@@ -0,0 +1,115 @@
import { DomainSocialMediaAccount } from '@/api';
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { Stack } from '@mui/material';
import { Dispatch, FC, SetStateAction, useCallback, useState } from 'react';
import Item from './Item';
import SortableItem from './SortableItem';
import { Control } from 'react-hook-form';
interface DragSocialInfoProps {
data: DomainSocialMediaAccount[];
columns?: number;
onChange: (data: DomainSocialMediaAccount[]) => void;
setIsEdit: Dispatch<SetStateAction<boolean>>;
control: Control<any>;
}
const DragSocialInfo: FC<DragSocialInfoProps> = ({
data = [],
onChange,
setIsEdit,
control,
}) => {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id && data) {
const oldIndex = data.findIndex(
(_, index) => `social-${index}` === active?.id,
);
const newIndex = data.findIndex(
(_, index) => `social-${index}` === over?.id,
);
const newData = arrayMove(data, oldIndex, newIndex);
onChange(newData);
}
setActiveId(null);
},
[data, onChange],
);
const handleDragCancel = useCallback(() => {
setActiveId(null);
}, []);
if (!data || data.length === 0) return null;
return (
<>
{data && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext
items={data.map((_, index) => `social-${index}`)}
strategy={rectSortingStrategy}
>
<Stack direction={'column'} gap={2.5}>
{data.map((item, idx) => (
<SortableItem
key={`social-${idx}`}
id={`social-${idx}`}
item={item}
setIsEdit={setIsEdit}
data={data}
control={control}
index={idx}
/>
))}
</Stack>
</SortableContext>
<DragOverlay adjustScale style={{ transformOrigin: '0 0' }}>
{activeId && data ? (
<Item
isDragging
item={data.find((_, index) => `social-${index}` === activeId)!}
setIsEdit={setIsEdit}
data={data}
control={control}
index={data.findIndex(
(_, index) => `social-${index}` === activeId,
)}
/>
) : null}
</DragOverlay>
</DndContext>
)}
</>
);
};
export default DragSocialInfo;

View File

@@ -0,0 +1,63 @@
import { styled, SwitchProps, Switch } from '@mui/material';
const IOSSwitch = styled((props: SwitchProps) => (
<Switch focusVisibleClassName='.Mui-focusVisible' disableRipple {...props} />
))(({ theme }) => ({
width: 42,
height: 26,
padding: 0,
'& .MuiSwitch-switchBase': {
padding: 0,
margin: 2,
transitionDuration: '300ms',
'&.Mui-checked': {
transform: 'translateX(16px)',
color: '#fff',
'& + .MuiSwitch-track': {
backgroundColor: '#6E73FE',
opacity: 1,
border: 0,
...theme.applyStyles('dark', {
backgroundColor: '#6E73FE',
}),
},
'&.Mui-disabled + .MuiSwitch-track': {
opacity: 0.5,
},
},
'&.Mui-focusVisible .MuiSwitch-thumb': {
color: '#6E73FE',
border: '6px solid #fff',
},
'&.Mui-disabled .MuiSwitch-thumb': {
color: theme.palette.grey[100],
...theme.applyStyles('dark', {
color: theme.palette.grey[600],
}),
},
'&.Mui-disabled + .MuiSwitch-track': {
opacity: 0.7,
...theme.applyStyles('dark', {
opacity: 0.3,
}),
},
},
'& .MuiSwitch-thumb': {
boxSizing: 'border-box',
width: 22,
height: 22,
},
'& .MuiSwitch-track': {
borderRadius: 26 / 2,
backgroundColor: '#E9E9EA',
opacity: 1,
transition: theme.transitions.create(['background-color'], {
duration: 500,
}),
...theme.applyStyles('dark', {
backgroundColor: '#39393D',
}),
},
}));
export default IOSSwitch;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { Box, InputAdornment, Popover, TextField } from '@mui/material';
import type { SxProps } from '@mui/material/styles';
import { ColorPicker, useColor, ColorService } from 'react-color-palette';
// @ts-expect-error ignore
import 'react-color-palette/css';
type ColorPickerFieldProps = {
label?: string;
value?: string;
onChange?: (hex: string) => void;
width?: number;
placeholder?: string;
sx?: SxProps;
};
const ColorPickerField: React.FC<ColorPickerFieldProps> = ({
label,
value = '#000000',
onChange,
width = 320,
placeholder = '请输入',
sx,
}) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const [color, setColor] = useColor(value || '#000000');
React.useEffect(() => {
if (value && value !== color.hex) {
setColor(ColorService.convert('hex', value));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const open = Boolean(anchorEl);
const handleOpen = (e: React.MouseEvent<HTMLElement>) =>
setAnchorEl(e.currentTarget);
const handleClose = () => setAnchorEl(null);
return (
<>
<TextField
label={label}
onClick={handleOpen}
slotProps={{
inputLabel: {
shrink: true,
},
input: {
readOnly: true,
startAdornment: (
<InputAdornment position='start'>
<Box
sx={{
width: 20,
height: 20,
borderRadius: '4px',
backgroundColor: value,
}}
/>
</InputAdornment>
),
},
}}
sx={sx}
value={value}
placeholder={placeholder}
/>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
>
<Box sx={{ p: 1, width }}>
<ColorPicker
color={color}
onChange={c => {
setColor(c);
onChange?.(c.hex);
}}
/>
</Box>
</Popover>
</>
);
};
export default ColorPickerField;

View File

@@ -0,0 +1,483 @@
import React from 'react';
import {
Box,
IconButton,
MenuItem,
Popover,
Select,
Stack,
Typography,
alpha,
} from '@mui/material';
import { v4 as uuidv4 } from 'uuid';
import { IconWangyeguajian } from '@panda-wiki/icons';
import { Dispatch, SetStateAction, useMemo, useState } from 'react';
import type { CSSProperties } from 'react';
import { Component } from '../../index';
import {
DndContext,
DragEndEvent,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
arrayMove,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useAppDispatch, useAppSelector } from '@/store';
import { setAppPreviewData } from '@/store/slices/config';
import AddCircleRoundedIcon from '@mui/icons-material/AddCircleRounded';
import { IconShanchu } from '@panda-wiki/icons';
import { DEFAULT_DATA, COMPONENTS_MAP } from '../../constants';
import { THEME_LIST, THEME_TO_PALETTE } from '@panda-wiki/themes/constants';
interface ComponentBarProps {
components: Component[];
setComponents: Dispatch<SetStateAction<Component[]>>;
curComponent: Component;
setCurComponent: Dispatch<SetStateAction<Component>>;
setIsEdit: Dispatch<SetStateAction<boolean>>;
allowAdd?: boolean;
}
const ThemeCard = ({ palette, label }: any) => {
return (
<Box
sx={{
boxShadow: '0px 0px 10px 0px rgba(0, 0, 0, 0.1)',
bgcolor: palette.background.default,
my: 0.5,
}}
>
<Stack
sx={{
p: 1,
width: '150px',
height: '50px',
bgcolor: alpha(palette.primary.main, 0.3),
}}
>
<Box
sx={{
fontSize: 12,
height: 20,
color: palette.primary.main,
}}
>
{label}
</Box>
<Box
sx={{
height: '120px',
bgcolor: palette.background.default,
}}
></Box>
</Stack>
<Box sx={{ height: '30px', bgcolor: palette.background.default }}></Box>
</Box>
);
};
const ComponentBar = ({
components,
setComponents,
curComponent,
setCurComponent,
setIsEdit,
allowAdd = true,
}: ComponentBarProps) => {
const dispatch = useAppDispatch();
const appPreviewData = useAppSelector(state => state.config.appPreviewData);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const popoverOpen = Boolean(anchorEl);
const options = useMemo(
() => Object.values(COMPONENTS_MAP).filter(item => !item.fixed),
[],
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
const nonFixedIds = useMemo(
() => components.filter(c => !c.fixed).map(c => c.id),
[components],
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
if (active.id === over.id) return;
// 仅对非 fixed 项进行重排fixed 保持原位置
const nonFixedItems = components.filter(c => !c.fixed);
const fromIdx = nonFixedItems.findIndex(c => c.id === active.id);
const toIdx = nonFixedItems.findIndex(c => c.id === over.id);
if (fromIdx === -1 || toIdx === -1) return;
const newNonFixed = arrayMove(nonFixedItems, fromIdx, toIdx);
const result: Component[] = [];
let cursor = 0;
for (let i = 0; i < components.length; i++) {
const cur = components[i];
if (cur.fixed) {
result.push(cur);
} else {
result.push(newNonFixed[cursor]);
cursor += 1;
}
}
setComponents(result);
setIsEdit(true);
};
const SortableItem = ({ item }: { item: Component }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id, disabled: !!item.fixed });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
cursor: isDragging ? 'move' : item.disabled ? 'not-allowed' : 'pointer',
};
return (
<Stack
ref={setNodeRef}
direction={'row'}
sx={{
flexShrink: 0,
cursor: 'not-allowed',
height: '40px',
borderRadius: '6px',
bgcolor:
item.id === curComponent.id
? '#F2F8FF'
: item.disabled
? 'var(--mui-palette-action-disabledBackground)'
: '',
pl: '12px',
alignItems: 'center',
mb: '10px',
transition: 'all .15s ease',
'&:hover': {
'.icon-shanchu': {
display: item.fixed ? 'none' : 'block',
},
},
}}
style={style}
key={item.id}
onClick={() => {
if (item.disabled) return;
setCurComponent(item);
}}
{...(!item.fixed ? { ...attributes, ...listeners } : {})}
>
<IconWangyeguajian
sx={{
color:
item.id === curComponent.id
? 'primary.main'
: item.disabled
? 'var(--mui-palette-action-disabled)'
: 'text.secondary',
fontSize: '14px',
}}
></IconWangyeguajian>
<Typography
sx={{
marginLeft: '8px',
fontSize: '14px',
color:
item.id === curComponent.id
? 'primary.main'
: item.disabled
? 'var(--mui-palette-action-disabled)'
: 'text.secondary',
fontWeight: 500,
}}
>
{item.title}
</Typography>
<IconShanchu
className='icon-shanchu'
sx={{ fontSize: '14px', ml: 'auto', mr: 1, display: 'none' }}
onClick={e => {
e.stopPropagation();
if (item.fixed) return;
const filterComponents = components.filter(c => c.id !== item.id);
if (curComponent.id === item.id) {
setCurComponent(
filterComponents.find(c => !c.disabled && !c.hidden) ||
filterComponents[0],
);
}
setComponents(filterComponents);
setIsEdit(true);
}}
/>
</Stack>
);
};
return (
<Stack
sx={{
width: '20px',
minWidth: '200px',
bgcolor: '#FFFFFF',
borderRight: '1px solid #ECEEF1',
height: '100%',
overflow: 'hidden',
flexShrink: 0,
}}
direction={'column'}
>
{appPreviewData && (
<>
<Stack
direction={'row'}
sx={{
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '19px',
}}
>
<Typography
sx={{
fontSize: '16px',
lineHeight: '30px',
fontWeight: 600,
}}
>
</Typography>
</Stack>
<Stack sx={{ pr: '20px', marginTop: '15px' }}>
<Select
value={
THEME_TO_PALETTE[
appPreviewData.settings?.web_app_landing_theme?.name || 'blue'
]?.value || 'blue'
}
renderValue={value => {
return THEME_TO_PALETTE[value]?.label;
}}
sx={{
width: '100%',
height: '40px',
bgcolor: '#F2F8FF',
border: '1px solid #5F58FE',
color: '#5F58FE',
'&:focus': {
border: '1px solid #5F58FE',
},
'&:hover': {
border: '1px solid #5F58FE',
},
'&.Mui-focused': {
border: '1px solid #5F58FE',
},
'& .MuiOutlinedInput-notchedOutline': {
border: 'none',
},
}}
onChange={e => {
if (!appPreviewData) return;
const newInfo = {
...appPreviewData,
settings: {
...appPreviewData.settings,
web_app_landing_theme: {
name: e.target.value,
},
},
};
setIsEdit(true);
dispatch(setAppPreviewData(newInfo));
}}
>
{THEME_LIST.map(item => (
<MenuItem key={item.value} value={item.value}>
<ThemeCard palette={item.palette} label={item.label} />
</MenuItem>
))}
</Select>
</Stack>
</>
)}
{allowAdd && (
<Stack
direction={'row'}
sx={{
justifyContent: 'space-between',
alignItems: 'center',
pr: '20px',
paddingTop: '19px',
}}
>
<Typography
sx={{
fontSize: '16px',
lineHeight: '30px',
fontWeight: 600,
}}
>
</Typography>
<IconButton
size='small'
onClick={e => {
setAnchorEl(e.currentTarget);
}}
>
<AddCircleRoundedIcon
sx={{ fontSize: '16px', color: 'primary.main' }}
/>
</IconButton>
</Stack>
)}
<Popover
open={popoverOpen}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{
paper: {
sx: {
p: '12px',
width: '282px',
},
},
}}
>
<Stack
sx={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: '12px',
}}
>
{options.map(item => (
<Stack
key={item.name}
direction={'column'}
alignItems={'center'}
gap={1}
sx={{
cursor: 'pointer',
transition: 'all .15s ease',
color: 'text.secondary',
'&:hover': {
color: 'primary.main',
},
}}
onClick={() => {
const addComponent = {
id: uuidv4(),
name: item.name,
title: item.title,
component: item.component,
config: item.config,
fixed: false,
};
// if (components.find(c => c.name === item.name)) return;
const newInfo = {
...appPreviewData,
settings: {
...(appPreviewData?.settings || {}),
web_app_landing_configs: [
...(appPreviewData?.settings?.web_app_landing_configs ||
[]),
{
type: item.name,
id: addComponent.id,
...DEFAULT_DATA[item.name as keyof typeof DEFAULT_DATA],
},
],
},
};
dispatch(setAppPreviewData(newInfo));
setCurComponent(addComponent);
setAnchorEl(null);
setComponents([
...components.slice(0, -1),
addComponent,
...components.slice(-1),
]);
setIsEdit(true);
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '10px',
width: '60px',
border: '1px solid',
borderColor: 'divider',
height: '60px',
'&:hover': {
borderColor: 'primary.main',
bgcolor: '#F8FAFF',
},
}}
>
{'icon' in item &&
item.icon &&
(() => {
const IconComponent = item.icon;
return <IconComponent sx={{ fontSize: '24px' }} />;
})()}
</Box>
<Typography sx={{ fontSize: '12px' }}>{item.title}</Typography>
</Stack>
))}
</Stack>
</Popover>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={nonFixedIds}
strategy={verticalListSortingStrategy}
>
<Stack
direction={'column'}
sx={{
marginTop: '15px',
overflowY: 'auto',
flex: 1,
minHeight: 0,
pr: '20px',
paddingBottom: '20px',
}}
>
{components.map(item => (
<SortableItem key={item.id} item={item} />
))}
</Stack>
</SortableContext>
</DndContext>
</Stack>
);
};
export default ComponentBar;

View File

@@ -0,0 +1,167 @@
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
SortingStrategy,
} from '@dnd-kit/sortable';
import { Stack, SxProps, Theme } from '@mui/material';
import {
ComponentType,
CSSProperties,
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
export interface DragListProps<T extends { id?: string | null }> {
data: T[];
onChange: (data: T[]) => void;
setIsEdit: Dispatch<SetStateAction<boolean>>;
SortableItemComponent: ComponentType<{
id: string;
item: T;
handleRemove: (id: string) => void;
handleUpdateItem: (item: T) => void;
setIsEdit: Dispatch<SetStateAction<boolean>>;
}>;
ItemComponent: ComponentType<{
isDragging?: boolean;
item: T;
style?: CSSProperties;
setIsEdit: Dispatch<SetStateAction<boolean>>;
handleUpdateItem?: (item: T) => void;
}>;
containerSx?: SxProps<Theme>;
sortingStrategy?: SortingStrategy;
direction?: 'row' | 'column';
gap?: number;
}
function DragList<T extends { id?: string | null }>({
data,
onChange,
setIsEdit,
SortableItemComponent,
ItemComponent,
containerSx,
sortingStrategy = rectSortingStrategy,
direction = 'row',
gap = 2,
}: DragListProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
const dataRef = useRef(data);
// 保持 ref 与 data 同步
useEffect(() => {
dataRef.current = data;
}, [data]);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
const currentData = dataRef.current;
const oldIndex = currentData.findIndex(
item => (item.id || '') === active.id,
);
const newIndex = currentData.findIndex(
item => (item.id || '') === over!.id,
);
const newData = arrayMove(currentData, oldIndex, newIndex);
onChange(newData);
}
setActiveId(null);
},
[onChange],
);
const handleDragCancel = useCallback(() => {
setActiveId(null);
}, []);
const handleRemove = useCallback(
(id: string) => {
const currentData = dataRef.current;
const newData = currentData.filter(item => (item.id || '') !== id);
onChange(newData);
},
[onChange],
);
const handleUpdateItem = useCallback(
(updatedItem: T) => {
const currentData = dataRef.current;
const newData = currentData.map(item =>
(item.id || '') === (updatedItem.id || '') ? updatedItem : item,
);
onChange(newData);
},
[onChange],
);
if (data.length === 0) return null;
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext
items={data.map(item => item.id || '')}
strategy={sortingStrategy}
>
<Stack
direction={direction}
flexWrap={'wrap'}
gap={gap}
sx={containerSx}
>
{data.map(item => (
<SortableItemComponent
key={item.id || ''}
id={item.id || ''}
item={item}
handleRemove={handleRemove}
handleUpdateItem={handleUpdateItem}
setIsEdit={setIsEdit}
/>
))}
</Stack>
</SortableContext>
<DragOverlay adjustScale style={{ transformOrigin: '0 0' }}>
{activeId ? (
<ItemComponent
isDragging
item={data.find(item => (item.id || '') === activeId)!}
setIsEdit={setIsEdit}
handleUpdateItem={handleUpdateItem}
/>
) : null}
</DragOverlay>
</DndContext>
);
}
export default DragList;

View File

@@ -0,0 +1,49 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ComponentType } from 'react';
export interface SortableItemProps<T extends { id?: string | null }> {
id: string;
item: T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ItemComponent: ComponentType<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
function SortableItem<T extends { id?: string | null }>({
id,
item,
ItemComponent,
...rest
}: SortableItemProps<T>) {
const {
isDragging,
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition: transition || undefined,
};
return (
<ItemComponent
ref={setNodeRef}
style={style}
withOpacity={isDragging}
dragHandleProps={{
...attributes,
...listeners,
}}
item={item}
{...rest}
/>
);
}
export default SortableItem;

View File

@@ -0,0 +1,84 @@
import { styled, Stack } from '@mui/material';
import { IconTianjia } from '@panda-wiki/icons';
export const StyledCommonWrapper = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
}));
export const StyledCommonItemTitle = styled('div')(({ theme }) => ({
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
fontWeight: 600,
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
backgroundColor: theme.palette.primary.main,
borderRadius: '2px',
marginRight: theme.spacing(1),
},
}));
const StyledCommonItemTitleDesc = styled('div')(({ theme }) => ({
fontSize: 12,
fontWeight: 400,
color: theme.palette.text.tertiary,
}));
export const StyledCommonItemTitleAdd = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
marginLeft: 'auto',
cursor: 'pointer',
gap: theme.spacing(0.5),
}));
export const StyledCommonItemTitleAddText = styled('div')(({ theme }) => ({
fontSize: 14,
lineHeight: '22px',
marginLeft: 0.5,
fontWeight: 400,
color: theme.palette.text.secondary,
}));
export const CommonItem = ({
children,
title,
onAdd,
desc,
}: {
children?: React.ReactNode;
title?: string;
desc?: string;
onAdd?: () => void;
}) => {
return (
<Stack direction={'column'} gap={2}>
<StyledCommonItemTitle>
<Stack direction={'row'} alignItems={'center'} gap={1}>
{title}
{desc && (
<StyledCommonItemTitleDesc>{desc}</StyledCommonItemTitleDesc>
)}
</Stack>
{onAdd && (
<StyledCommonItemTitleAdd onClick={onAdd}>
<IconTianjia
sx={{ fontSize: '10px !important', color: 'primary.main' }}
/>
<StyledCommonItemTitleAddText></StyledCommonItemTitleAddText>
</StyledCommonItemTitleAdd>
)}
</StyledCommonItemTitle>
{children}
</Stack>
);
};

View File

@@ -0,0 +1,4 @@
export { default as DragList } from './DragList';
export type { DragListProps } from './DragList';
export type { SortableItemProps } from './SortableItem';

View File

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

View File

@@ -0,0 +1,182 @@
import {
Box,
IconButton,
Stack,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
} from '@mui/material';
import { IconShanchu2, IconDrag } from '@panda-wiki/icons';
import {
CSSProperties,
Dispatch,
forwardRef,
HTMLAttributes,
SetStateAction,
} from 'react';
type Item = {
id: string;
text: string;
type: 'contained' | 'outlined' | 'text';
href: string;
};
export type ItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
item: Item;
withOpacity?: boolean;
isDragging?: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
handleRemove?: (id: string) => void;
handleUpdateItem?: (item: Item) => void;
setIsEdit: Dispatch<SetStateAction<boolean>>;
};
const Item = forwardRef<HTMLDivElement, ItemProps>(
(
{
item,
withOpacity,
isDragging,
style,
dragHandleProps,
handleRemove,
handleUpdateItem,
setIsEdit,
...props
},
ref,
) => {
const inlineStyles: CSSProperties = {
opacity: withOpacity ? '0.5' : '1',
borderRadius: '10px',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: '#ffffff',
width: '100%',
...style,
};
return (
<Box ref={ref} style={inlineStyles} {...props}>
<Stack
direction={'row'}
alignItems={'center'}
justifyContent={'space-between'}
gap={0.5}
sx={{
py: 1.5,
px: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
}}
>
<Stack
direction={'column'}
gap={'20px'}
sx={{
flex: 1,
p: 1.5,
}}
>
<TextField
label='文字'
slotProps={{
inputLabel: {
shrink: true,
},
}}
size='small'
fullWidth
placeholder='请输入'
variant='outlined'
value={item.text}
onChange={e => {
const updatedItem = { ...item, text: e.target.value };
handleUpdateItem?.(updatedItem);
setIsEdit(true);
}}
/>
<TextField
label='按钮链接'
slotProps={{
inputLabel: {
shrink: true,
},
}}
size='small'
fullWidth
placeholder='请输入'
variant='outlined'
value={item.href}
onChange={e => {
const updatedItem = { ...item, href: e.target.value };
handleUpdateItem?.(updatedItem);
setIsEdit(true);
}}
/>
<FormControl>
<InputLabel
sx={{
'&.Mui-focused': {
color: 'black',
},
}}
>
</InputLabel>
<Select
value={item.type}
label='按钮样式'
onChange={e => {
const updatedItem = { ...item, type: e.target.value };
handleUpdateItem?.(updatedItem);
setIsEdit(true);
}}
>
<MenuItem value={'contained'}></MenuItem>
<MenuItem value={'outlined'}></MenuItem>
<MenuItem value={'text'}></MenuItem>
</Select>
</FormControl>
</Stack>
<Stack
direction={'column'}
sx={{ justifyContent: 'space-between', alignSelf: 'stretch' }}
>
<IconButton
size='small'
onClick={e => {
e.stopPropagation();
handleRemove?.(item.id);
}}
sx={{
color: 'text.tertiary',
':hover': { color: 'error.main' },
width: '28px',
height: '28px',
}}
>
<IconShanchu2 sx={{ fontSize: '12px' }} />
</IconButton>
<IconButton
size='small'
sx={{
cursor: 'grab',
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
{...(dragHandleProps as any)}
>
<IconDrag sx={{ fontSize: '18px' }} />
</IconButton>
</Stack>
</Stack>
</Box>
);
},
);
export default Item;

View File

@@ -0,0 +1,249 @@
import React, { useEffect, useRef, useMemo } from 'react';
import { TextField } from '@mui/material';
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
import type { ConfigProps } from '../type';
import { useForm, Controller } from 'react-hook-form';
import { useAppSelector } from '@/store';
import DragList from '../../components/DragList';
import SortableItem from '../../components/SortableItem';
import Item from './Item';
import HotSearchItem from './HotSearchItem';
import UploadFile from '@/components/UploadFile';
import { DEFAULT_DATA } from '../../../constants';
import useDebounceAppPreviewData from '@/hooks/useDebounceAppPreviewData';
import { handleLandingConfigs, findConfigById } from '../../../utils';
import { Empty } from '@ctzhian/ui';
const Config: React.FC<ConfigProps> = ({ setIsEdit, id }) => {
const { appPreviewData } = useAppSelector(state => state.config);
const { control, watch, setValue, subscribe } = useForm<
typeof DEFAULT_DATA.banner
>({
defaultValues: findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
});
const debouncedDispatch = useDebounceAppPreviewData();
const btns = watch('btns') || [];
const hotSearch = watch('hot_search') || [];
// 使用 ref 来维护稳定的 ID 映射
const idMapRef = useRef<Map<number, string>>(new Map());
// 将string[]转换为对象数组用于显示,保持 ID 稳定
const hotSearchList = Array.isArray(hotSearch)
? hotSearch.map((text, index) => {
// 如果该索引没有 ID生成一个新的
if (!idMapRef.current.has(index)) {
idMapRef.current.set(
index,
`${Date.now()}-${index}-${Math.random()}`,
);
}
return {
id: idMapRef.current.get(index)!,
text: String(text),
};
})
: [];
// 清理不再使用的 ID并确保所有索引都有 ID
useEffect(() => {
const currentIndexes = new Set(hotSearch.map((_, index) => index));
// 清理不存在的索引
const keysToDelete: number[] = [];
idMapRef.current.forEach((_, key) => {
if (!currentIndexes.has(key)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach(key => idMapRef.current.delete(key));
// 确保每个索引都有 ID
hotSearch.forEach((_, index) => {
if (!idMapRef.current.has(index)) {
idMapRef.current.set(index, `${Date.now()}-${index}-${Math.random()}`);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hotSearch.length]);
const handleAddButton = () => {
const nextId = `${Date.now()}`;
setValue('btns', [
...(btns || []),
{ id: nextId, text: '', type: 'contained', href: '' },
]);
};
const handleAddHotSearch = () => {
const newIndex = hotSearch.length;
const nextId = `${Date.now()}-${newIndex}-${Math.random()}`;
idMapRef.current.set(newIndex, nextId);
// 转换回string[]格式
setValue('hot_search', [...hotSearch, '']);
setIsEdit(true);
};
const handleHotSearchChange = (newList: { id: string; text: string }[]) => {
// 重建 ID 映射关系
const newIdMap = new Map<number, string>();
newList.forEach((item, index) => {
newIdMap.set(index, item.id);
});
idMapRef.current = newIdMap;
// 转换回string[]格式
setValue(
'hot_search',
newList.map(item => item.text),
);
setIsEdit(true);
};
// 稳定的 SortableItemComponent 引用
const HotSearchSortableItem = useMemo(
() => (props: any) => (
<SortableItem {...props} ItemComponent={HotSearchItem} />
),
[],
);
const ButtonSortableItem = useMemo(
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
[],
);
useEffect(() => {
const callback = subscribe({
formState: {
values: true,
},
callback: ({ values }) => {
const previewData = {
...appPreviewData,
settings: {
...appPreviewData?.settings,
web_app_landing_configs: handleLandingConfigs({
id,
config: appPreviewData?.settings?.web_app_landing_configs || [],
values: {
...values,
},
}),
},
};
setIsEdit(true);
debouncedDispatch(previewData);
},
});
return () => {
callback();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe, appPreviewData, id]);
return (
<StyledCommonWrapper>
<CommonItem title='主标题'>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField
label='文字'
slotProps={{
inputLabel: {
shrink: true,
},
}}
fullWidth
{...field}
placeholder='请输入'
/>
)}
/>
</CommonItem>
<CommonItem title='副标题'>
<Controller
control={control}
name='subtitle'
render={({ field }) => (
<TextField
label='文字'
slotProps={{
inputLabel: {
shrink: true,
},
}}
multiline
minRows={2}
fullWidth
{...field}
placeholder='请输入'
/>
)}
/>
</CommonItem>
<CommonItem title='背景图' desc='(推荐 1920*720)'>
<Controller
control={control}
name='bg_url'
render={({ field }) => (
<UploadFile
name='bg_url'
id='bannerconfig_bgUrl'
type='url'
disabled={false}
accept='image/*'
width={354}
height={129}
value={field.value || ''}
onChange={(url: string) => {
field.onChange(url);
setIsEdit(true);
}}
/>
)}
/>
</CommonItem>
<CommonItem title='搜索框'>
<Controller
control={control}
name='placeholder'
render={({ field }) => <TextField {...field} placeholder='请输入' />}
/>
</CommonItem>
<CommonItem title='热门搜索' onAdd={handleAddHotSearch}>
{hotSearchList.length === 0 ? (
<Empty />
) : (
<DragList
data={hotSearchList}
onChange={handleHotSearchChange}
setIsEdit={setIsEdit}
SortableItemComponent={HotSearchSortableItem}
ItemComponent={HotSearchItem}
/>
)}
</CommonItem>
<CommonItem title='主按钮' onAdd={handleAddButton}>
<DragList
data={btns as Required<(typeof btns)[0]>[]}
onChange={btns => {
setValue('btns', btns);
setIsEdit(true);
}}
setIsEdit={setIsEdit}
SortableItemComponent={ButtonSortableItem}
ItemComponent={Item}
/>
</CommonItem>
</StyledCommonWrapper>
);
};
export default Config;

View File

@@ -0,0 +1,213 @@
import {
createNodeSummaryStream,
subscribeNodeSummaryStream,
type StreamSummaryEvent,
} from '@/request/nodeStream';
import { DomainRecommendNodeListResp } from '@/request/types';
import { useAppSelector } from '@/store';
import { Box, IconButton, Stack } from '@mui/material';
import { Ellipsis, message } from '@ctzhian/ui';
import {
CSSProperties,
forwardRef,
HTMLAttributes,
useRef,
useState,
} from 'react';
import {
IconShanchu2,
IconDrag,
IconWenjianjia,
IconWenjian,
} from '@panda-wiki/icons';
import SSEClient from '@/utils/fetch';
export type ItemProps = HTMLAttributes<HTMLDivElement> & {
item: DomainRecommendNodeListResp;
withOpacity?: boolean;
isDragging?: boolean;
dragHandleProps?: any;
handleRemove?: (id: string) => void;
refresh?: () => void;
};
const Item = forwardRef<HTMLDivElement, ItemProps>(
(
{
item,
withOpacity,
isDragging,
style,
dragHandleProps,
handleRemove,
refresh,
...props
},
ref,
) => {
const { kb_id } = useAppSelector(state => state.config);
const inlineStyles: CSSProperties = {
opacity: withOpacity ? '0.5' : '1',
borderRadius: '10px',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: '#ffffff',
width: '100%',
minWidth: '0px',
...style,
};
const [loading, setLoading] = useState(false);
const sseClientRef = useRef<SSEClient<StreamSummaryEvent> | null>(null);
const handleCreateSummary = () => {
setLoading(true);
sseClientRef.current?.unsubscribe();
sseClientRef.current = createNodeSummaryStream({
onComplete: () => setLoading(false),
onError: error => {
setLoading(false);
message.error(error.message || '生成摘要失败');
},
});
subscribeNodeSummaryStream(
sseClientRef.current,
{ ids: [item.id!], kb_id },
event => {
if (event.type === 'done') {
setLoading(false);
message.success('生成摘要成功');
refresh?.();
return;
}
if (event.type === 'error') {
setLoading(false);
message.error(event.content || event.error || '生成摘要失败');
sseClientRef.current?.unsubscribe();
}
},
);
};
return (
<Box ref={ref} style={inlineStyles} {...props}>
<Stack
direction={'row'}
gap={1}
sx={{
p: 1,
height: '100%',
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
}}
>
<Box
sx={{
px: 2,
py: 1,
flexGrow: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
}}
>
<Stack direction={'row'} alignItems={'center'} gap={1}>
{item.emoji ? (
<Box sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}>
{item.emoji}
</Box>
) : item.type === 1 ? (
<IconWenjianjia
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
/>
) : (
<IconWenjian
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
/>
)}
<Ellipsis sx={{ flex: 1, width: 0, lineHeight: '32px' }}>
{item.name}
</Ellipsis>
</Stack>
{item.summary ? (
<Box
className='ellipsis-5'
sx={{
fontSize: 14,
color: 'text.tertiary',
lineHeight: '21px',
}}
>
{item.summary}
</Box>
) : item.type === 2 ? (
<Box
sx={{ color: 'warning.main', fontSize: 12, lineHeight: '21px' }}
>
</Box>
) : null}
{/* : item.type === 2 ? <Button size='small' loading={loading} sx={{
height: '21px',
px: 0,
ml: '18px',
}} onClick={handleCreateSummary}>生成摘要</Button> : null} */}
{item.recommend_nodes && item.recommend_nodes.length > 0 && (
<Stack sx={{ fontSize: 14, color: 'text.tertiary', pl: '20px' }}>
{item.recommend_nodes
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
.slice(0, 4)
.map(it => (
<Stack direction={'row'} alignItems={'center'} gap={1}>
{it.type === 1 ? (
<IconWenjianjia
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
/>
) : (
<IconWenjian
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
/>
)}
<Ellipsis sx={{ flex: 1, width: 0 }}>{it.name}</Ellipsis>
</Stack>
))}
</Stack>
)}
</Box>
<Stack justifyContent={'space-between'} sx={{ flexShrink: 0 }}>
<IconButton
size='small'
onClick={e => {
e.stopPropagation();
handleRemove?.(item.id!);
}}
sx={{
color: 'text.tertiary',
':hover': { color: 'error.main' },
width: '28px',
height: '28px',
}}
>
<IconShanchu2 sx={{ fontSize: '12px' }} />
</IconButton>
<IconButton
size='small'
sx={{
cursor: 'grab',
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
{...dragHandleProps}
>
<IconDrag sx={{ fontSize: '18px' }} />
</IconButton>
</Stack>
</Stack>
</Box>
);
},
);
export default Item;

View File

@@ -0,0 +1,150 @@
import React, { useEffect, useState, useMemo } from 'react';
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
import { TextField } from '@mui/material';
import { Controller, useForm } from 'react-hook-form';
import DragList from '../../components/DragList';
import SortableItem from '../../components/SortableItem';
import Item from './Item';
import type { ConfigProps } from '../type';
import { Empty } from '@ctzhian/ui';
import { useAppSelector } from '@/store';
import AddRecommendContent from '@/pages/setting/component/AddRecommendContent';
import { getApiV1NodeRecommendNodes } from '@/request/Node';
import useDebounceAppPreviewData from '@/hooks/useDebounceAppPreviewData';
import ColorPickerField from '../../components/ColorPickerField';
import { handleLandingConfigs, findConfigById } from '../../../utils';
import { DEFAULT_DATA } from '../../../constants';
const BasicDocConfig = ({ setIsEdit, id }: ConfigProps) => {
const { kb_id } = useAppSelector(state => state.config);
const { appPreviewData } = useAppSelector(state => state.config);
const debouncedDispatch = useDebounceAppPreviewData();
const { control, watch, setValue, subscribe, reset } = useForm<
typeof DEFAULT_DATA.basic_doc
>({
defaultValues: findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
});
const nodes = watch('nodes') || [];
const [open, setOpen] = useState(false);
const nodeRec = (ids: string[]) => {
getApiV1NodeRecommendNodes({ kb_id, node_ids: ids }).then(res => {
setValue('nodes', res as []);
});
};
const handleListChange = (newList: string[]) => {
nodeRec(newList);
setIsEdit(true);
};
// 稳定的 SortableItemComponent 引用
const ItemSortableComponent = useMemo(
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
[],
);
useEffect(() => {
reset(
findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
{ keepDefaultValues: true },
);
}, [appPreviewData, id]);
useEffect(() => {
const callback = subscribe({
formState: {
values: true,
},
callback: ({ values }) => {
const previewData = {
...appPreviewData,
settings: {
...appPreviewData?.settings,
web_app_landing_configs: handleLandingConfigs({
id,
config: appPreviewData?.settings?.web_app_landing_configs || [],
values,
}),
},
};
setIsEdit(true);
debouncedDispatch(previewData);
},
});
return () => {
callback();
};
}, [subscribe, appPreviewData, id]);
return (
<StyledCommonWrapper>
<CommonItem title='标题'>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField label='文字' {...field} placeholder='请输入' />
)}
/>
{/* <Controller
control={control}
name='title_color'
render={({ field }) => (
<ColorPickerField
label='标题颜色'
value={field.value}
onChange={field.onChange}
sx={{ flex: 1 }}
/>
)}
/> */}
</CommonItem>
{/* <CommonItem title='背景颜色'>
<Controller
control={control}
name='bg_color'
render={({ field }) => (
<ColorPickerField
value={field.value}
onChange={field.onChange}
sx={{ flex: 1 }}
/>
)}
/>
</CommonItem> */}
<CommonItem title='推荐文档' onAdd={() => setOpen(true)}>
{nodes.length === 0 ? (
<Empty />
) : (
<DragList
data={nodes}
onChange={value => {
setIsEdit(true);
setValue('nodes', value);
}}
setIsEdit={setIsEdit}
SortableItemComponent={ItemSortableComponent}
ItemComponent={Item}
/>
)}
</CommonItem>
<AddRecommendContent
open={open}
selected={nodes.map(item => item.id!)}
onChange={handleListChange}
onClose={() => setOpen(false)}
disabled={item => item.type === 1}
/>
</StyledCommonWrapper>
);
};
export default BasicDocConfig;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,141 @@
import React, { useEffect, useMemo } from 'react';
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
import { TextField } from '@mui/material';
import { Controller, useForm } from 'react-hook-form';
import DragList from '../../components/DragList';
import SortableItem from '../../components/SortableItem';
import Item from './Item';
import type { ConfigProps } from '../type';
import { useAppSelector } from '@/store';
import useDebounceAppPreviewData from '@/hooks/useDebounceAppPreviewData';
import { DEFAULT_DATA } from '../../../constants';
import { Empty } from '@ctzhian/ui';
import ColorPickerField from '../../components/ColorPickerField';
import { findConfigById, handleLandingConfigs } from '../../../utils';
const Config = ({ setIsEdit, id }: ConfigProps) => {
const { appPreviewData } = useAppSelector(state => state.config);
const debouncedDispatch = useDebounceAppPreviewData();
const { control, setValue, watch, subscribe, reset } = useForm<
typeof DEFAULT_DATA.carousel
>({
defaultValues: findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
});
const list = watch('list') || [];
const handleAddQuestion = () => {
const nextId = `${Date.now()}`;
setValue('list', [...list, { id: nextId, title: '', url: '', desc: '' }]);
};
const handleListChange = (
newList: (typeof DEFAULT_DATA.carousel)['list'],
) => {
setValue('list', newList);
setIsEdit(true);
};
// 稳定的 SortableItemComponent 引用
const ItemSortableComponent = useMemo(
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
[],
);
useEffect(() => {
reset(
findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
{ keepDefaultValues: true },
);
}, [appPreviewData, id]);
useEffect(() => {
const callback = subscribe({
formState: {
values: true,
},
callback: ({ values }) => {
const previewData = {
...appPreviewData,
settings: {
...appPreviewData?.settings,
web_app_landing_configs: handleLandingConfigs({
id,
config: appPreviewData?.settings?.web_app_landing_configs || [],
values,
}),
},
};
setIsEdit(true);
debouncedDispatch(previewData);
},
});
return () => {
callback();
};
}, [subscribe, id, appPreviewData]);
return (
<StyledCommonWrapper>
<CommonItem title='标题'>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField label='文字' {...field} placeholder='请输入' />
)}
/>
{/* <Controller
control={control}
name='title_color'
render={({ field }) => (
<ColorPickerField
label='标题颜色'
value={field.value}
onChange={field.onChange}
sx={{ flex: 1 }}
/>
)}
/> */}
</CommonItem>
{/* <CommonItem title='背景颜色'>
<Controller
control={control}
name='bg_color'
render={({ field }) => (
<ColorPickerField
value={field.value}
onChange={field.onChange}
sx={{ flex: 1 }}
/>
)}
/>
</CommonItem> */}
<CommonItem
title='图片'
desc='(推荐 880*49516:9 )'
onAdd={handleAddQuestion}
>
{list.length === 0 ? (
<Empty />
) : (
<DragList
data={list}
onChange={handleListChange}
setIsEdit={setIsEdit}
SortableItemComponent={ItemSortableComponent}
ItemComponent={Item}
/>
)}
</CommonItem>
</StyledCommonWrapper>
);
};
export default Config;

View File

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

View File

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

View File

@@ -0,0 +1,208 @@
import { Box, IconButton, Stack, TextField } from '@mui/material';
import { IconShanchu2, IconDrag } from '@panda-wiki/icons';
import {
CSSProperties,
Dispatch,
forwardRef,
HTMLAttributes,
SetStateAction,
} from 'react';
import UploadFile from '@/components/UploadFile';
export type ItemType = {
user_name: string;
avatar: string;
profession: string;
comment: string;
id: string;
};
export type ItemTypeProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
item: ItemType;
withOpacity?: boolean;
isDragging?: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>;
handleRemove?: (id: string) => void;
handleUpdateItem?: (item: ItemType) => void;
setIsEdit: Dispatch<SetStateAction<boolean>>;
};
const ItemType = forwardRef<HTMLDivElement, ItemTypeProps>(
(
{
item,
withOpacity,
isDragging,
style,
dragHandleProps,
handleRemove,
handleUpdateItem,
setIsEdit,
...props
},
ref,
) => {
const inlineStyles: CSSProperties = {
opacity: withOpacity ? '0.5' : '1',
borderRadius: '10px',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: '#ffffff',
width: '100%',
...style,
};
return (
<Box ref={ref} style={inlineStyles} {...props}>
<Stack
direction={'row'}
alignItems={'center'}
justifyContent={'space-between'}
gap={0.5}
sx={{
py: 1.5,
px: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
}}
>
<Stack
direction={'column'}
gap={'20px'}
sx={{
flex: 1,
p: 1.5,
}}
>
<TextField
label='评论'
slotProps={{
inputLabel: {
shrink: true,
},
}}
sx={{
'& .MuiOutlinedInput-root': {
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
fullWidth
multiline
minRows={2}
placeholder='请输入评论'
variant='outlined'
value={item.comment}
onChange={e => {
const updatedItem = { ...item, comment: e.target.value };
handleUpdateItem?.(updatedItem);
setIsEdit(true);
}}
/>
<TextField
label='用户名'
slotProps={{
inputLabel: {
shrink: true,
},
}}
sx={{
'& .MuiOutlinedInput-root': {
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
fullWidth
placeholder='请输入用户名'
variant='outlined'
value={item.user_name}
onChange={e => {
const updatedItem = { ...item, user_name: e.target.value };
handleUpdateItem?.(updatedItem);
setIsEdit(true);
}}
/>
<TextField
label='职业'
slotProps={{
inputLabel: {
shrink: true,
},
}}
sx={{
'& .MuiOutlinedInput-root': {
padding: '0 12px',
'& .MuiOutlinedInput-input': {
padding: '8px 0',
},
},
}}
fullWidth
placeholder='请输入职业'
variant='outlined'
value={item.profession}
onChange={e => {
const updatedItem = { ...item, profession: e.target.value };
handleUpdateItem?.(updatedItem);
setIsEdit(true);
}}
/>
<UploadFile
name='url'
id={`${item.id}_icon`}
type='url'
disabled={false}
accept='image/*'
width={80}
height={80}
value={item.avatar}
label='上传头像'
onChange={(url: string) => {
const updatedItem = { ...item, avatar: url };
handleUpdateItem?.(updatedItem);
setIsEdit(true);
}}
/>
</Stack>
<Stack
direction={'column'}
sx={{ justifyContent: 'space-between', alignSelf: 'stretch' }}
>
<IconButton
size='small'
onClick={e => {
e.stopPropagation();
handleRemove?.(item.id);
}}
sx={{
color: 'text.tertiary',
':hover': { color: 'error.main' },
width: '28px',
height: '28px',
}}
>
<IconShanchu2 sx={{ fontSize: '12px' }} />
</IconButton>
<IconButton
size='small'
sx={{
cursor: 'grab',
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
{...(dragHandleProps as any)}
>
<IconDrag sx={{ fontSize: '18px' }} />
</IconButton>
</Stack>
</Stack>
</Box>
);
},
);
export default ItemType;

View File

@@ -0,0 +1,113 @@
import React, { useEffect, useMemo } from 'react';
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
import { TextField } from '@mui/material';
import { Controller, useForm } from 'react-hook-form';
import DragList from '../../components/DragList';
import SortableItem from '../../components/SortableItem';
import Item from './Item';
import type { ConfigProps } from '../type';
import { useAppSelector } from '@/store';
import useDebounceAppPreviewData from '@/hooks/useDebounceAppPreviewData';
import { Empty } from '@ctzhian/ui';
import { DEFAULT_DATA } from '../../../constants';
import { findConfigById, handleLandingConfigs } from '../../../utils';
const Config = ({ setIsEdit, id }: ConfigProps) => {
const { appPreviewData } = useAppSelector(state => state.config);
const debouncedDispatch = useDebounceAppPreviewData();
const { control, setValue, watch, reset, subscribe } = useForm<
typeof DEFAULT_DATA.comment
>({
defaultValues: findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
});
const list = watch('list') || [];
const handleAddQuestion = () => {
const nextId = `${Date.now()}`;
setValue('list', [
...list,
{ id: nextId, avatar: '', user_name: '', profession: '', comment: '' },
]);
};
const handleListChange = (newList: (typeof DEFAULT_DATA.comment)['list']) => {
setValue('list', newList);
setIsEdit(true);
};
// 稳定的 SortableItemComponent 引用
const ItemSortableComponent = useMemo(
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
[],
);
useEffect(() => {
reset(
findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
{ keepDefaultValues: true },
);
}, [id, appPreviewData]);
useEffect(() => {
const callback = subscribe({
formState: {
values: true,
},
callback: ({ values }) => {
const previewData = {
...appPreviewData,
settings: {
...appPreviewData?.settings,
web_app_landing_configs: handleLandingConfigs({
id,
config: appPreviewData?.settings?.web_app_landing_configs || [],
values,
}),
},
};
setIsEdit(true);
debouncedDispatch(previewData);
},
});
return () => {
callback();
};
}, [subscribe, id, appPreviewData]);
return (
<StyledCommonWrapper>
<CommonItem title='标题'>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField label='文字' {...field} placeholder='请输入' />
)}
/>
</CommonItem>
<CommonItem title='评论列表' onAdd={handleAddQuestion}>
{list.length === 0 ? (
<Empty />
) : (
<DragList
data={list}
onChange={handleListChange}
setIsEdit={setIsEdit}
SortableItemComponent={ItemSortableComponent}
ItemComponent={Item}
/>
)}
</CommonItem>
</StyledCommonWrapper>
);
};
export default Config;

View File

@@ -0,0 +1,57 @@
import { Stack } from '@mui/material';
import { Dispatch, SetStateAction } from 'react';
import { Component } from '../../index';
import { DomainAppDetailResp } from '@/request/types';
interface ConfigBarProps {
curComponent: Component;
components: Component[];
setIsEdit: Dispatch<SetStateAction<boolean>>;
data: DomainAppDetailResp | null | undefined;
isEdit: boolean;
}
const ConfigBar = ({
curComponent,
components,
setIsEdit,
data,
isEdit,
}: ConfigBarProps) => {
const curConfig = components.find(c => c.name === curComponent.name);
return (
<Stack
sx={{
width: '400px',
minWidth: '400px',
bgcolor: '#FFFFFF',
borderLeft: '1px solid #ECEEF1',
paddingTop: '19px',
paddingX: '20px',
height: '100%',
overflow: 'hidden',
flexShrink: 0,
}}
direction={'column'}
>
{curConfig ? (
<Stack
sx={{
flex: 1,
overflowY: 'auto',
minHeight: 0,
pb: 4,
}}
>
<curConfig.config
setIsEdit={setIsEdit}
data={data}
isEdit={isEdit}
id={curComponent.id}
/>
</Stack>
) : null}
</Stack>
);
};
export default ConfigBar;

View File

@@ -0,0 +1,225 @@
import {
createNodeSummaryStream,
subscribeNodeSummaryStream,
type StreamSummaryEvent,
} from '@/request/nodeStream';
import { DomainRecommendNodeListResp } from '@/request/types';
import { useAppSelector } from '@/store';
import { Box, IconButton, Stack } from '@mui/material';
import { Ellipsis, message } from '@ctzhian/ui';
import {
IconShanchu2,
IconDrag,
IconWenjianjia,
IconWenjian,
} from '@panda-wiki/icons';
import {
CSSProperties,
forwardRef,
HTMLAttributes,
useRef,
useState,
} from 'react';
import SSEClient from '@/utils/fetch';
export type ItemProps = HTMLAttributes<HTMLDivElement> & {
item: DomainRecommendNodeListResp;
withOpacity?: boolean;
isDragging?: boolean;
dragHandleProps?: any;
handleRemove?: (id: string) => void;
refresh?: () => void;
};
const Item = forwardRef<HTMLDivElement, ItemProps>(
(
{
item,
withOpacity,
isDragging,
style,
dragHandleProps,
handleRemove,
refresh,
...props
},
ref,
) => {
const { kb_id } = useAppSelector(state => state.config);
const inlineStyles: CSSProperties = {
opacity: withOpacity ? '0.5' : '1',
borderRadius: '10px',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: '#ffffff',
width: '100%',
minWidth: '0px',
...style,
};
const [loading, setLoading] = useState(false);
const sseClientRef = useRef<SSEClient<StreamSummaryEvent> | null>(null);
const handleCreateSummary = () => {
setLoading(true);
sseClientRef.current?.unsubscribe();
sseClientRef.current = createNodeSummaryStream({
onComplete: () => setLoading(false),
onError: error => {
setLoading(false);
message.error(error.message || '生成摘要失败');
},
});
subscribeNodeSummaryStream(
sseClientRef.current,
{ ids: [item.id!], kb_id },
event => {
if (event.type === 'done') {
setLoading(false);
message.success('生成摘要成功');
refresh?.();
return;
}
if (event.type === 'error') {
setLoading(false);
message.error(event.content || event.error || '生成摘要失败');
sseClientRef.current?.unsubscribe();
}
},
);
};
const recommend_nodes = [...(item.recommend_nodes || [])];
return (
<Box ref={ref} style={inlineStyles} {...props}>
<Stack
direction={'row'}
gap={1}
sx={{
p: 1,
height: '100%',
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
}}
>
<Box
sx={{
px: 2,
py: 1,
flexGrow: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
}}
>
<Stack direction={'row'} alignItems={'center'} gap={1}>
{item.emoji ? (
<Box sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}>
{item.emoji}
</Box>
) : item.type === 1 ? (
<IconWenjianjia
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
/>
) : (
<IconWenjian
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
/>
)}
<Ellipsis sx={{ flex: 1, width: 0, lineHeight: '32px' }}>
{item.name}
</Ellipsis>
</Stack>
{item.summary ? (
<Box
className='ellipsis-5'
sx={{
fontSize: 14,
color: 'text.tertiary',
lineHeight: '21px',
}}
>
{item.summary}
</Box>
) : item.type === 2 ? (
<Box
sx={{ color: 'warning.main', fontSize: 12, lineHeight: '21px' }}
>
</Box>
) : null}
{/* : item.type === 2 ? <Button size='small' loading={loading} sx={{
height: '21px',
px: 0,
ml: '18px',
}} onClick={handleCreateSummary}>生成摘要</Button> : null} */}
{recommend_nodes.length > 0 && (
<Stack sx={{ fontSize: 14, color: 'text.tertiary', pl: '20px' }}>
{recommend_nodes
?.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
.slice(0, 4)
.map(it => (
<Stack
direction={'row'}
alignItems={'center'}
gap={1}
key={it.id}
>
{it.emoji ? (
<Box
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
>
{it.emoji}
</Box>
) : it.type === 1 ? (
<IconWenjianjia
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
/>
) : (
<IconWenjian
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
/>
)}
<Ellipsis sx={{ flex: 1, width: 0 }}>{it.name}</Ellipsis>
</Stack>
))}
</Stack>
)}
</Box>
<Stack justifyContent={'space-between'} sx={{ flexShrink: 0 }}>
<IconButton
size='small'
onClick={e => {
e.stopPropagation();
handleRemove?.(item.id!);
}}
sx={{
color: 'text.tertiary',
':hover': { color: 'error.main' },
width: '28px',
height: '28px',
}}
>
<IconShanchu2 sx={{ fontSize: '12px' }} />
</IconButton>
<IconButton
size='small'
sx={{
cursor: 'grab',
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
{...dragHandleProps}
>
<IconDrag sx={{ fontSize: '18px' }} />
</IconButton>
</Stack>
</Stack>
</Box>
);
},
);
export default Item;

View File

@@ -0,0 +1,152 @@
import { useEffect, useState, useMemo } from 'react';
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
import { TextField } from '@mui/material';
import { Controller, useForm } from 'react-hook-form';
import DragList from '../../components/DragList';
import SortableItem from '../../components/SortableItem';
import Item from './Item';
import { Empty } from '@ctzhian/ui';
import { DomainNodeType } from '@/request/types';
import type { ConfigProps } from '../type';
import { useAppSelector } from '@/store';
import AddRecommendContent from '@/pages/setting/component/AddRecommendContent';
import { getApiV1NodeRecommendNodes } from '@/request/Node';
import useDebounceAppPreviewData from '@/hooks/useDebounceAppPreviewData';
import { DEFAULT_DATA } from '../../../constants';
import { findConfigById, handleLandingConfigs } from '../../../utils';
const DirDocConfig = ({ setIsEdit, id }: ConfigProps) => {
const { kb_id } = useAppSelector(state => state.config);
const { appPreviewData } = useAppSelector(state => state.config);
const debouncedDispatch = useDebounceAppPreviewData();
const { control, setValue, watch, subscribe, reset } = useForm<
typeof DEFAULT_DATA.dir_doc
>({
defaultValues: findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
});
const nodes = watch('nodes') || [];
const [open, setOpen] = useState(false);
const nodeRec = (ids: string[]) => {
getApiV1NodeRecommendNodes({ kb_id, node_ids: ids }).then(res => {
setValue('nodes', res);
});
};
const handleListChange = (newList: string[]) => {
setIsEdit(true);
nodeRec(newList);
};
// 稳定的 SortableItemComponent 引用
const ItemSortableComponent = useMemo(
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
[],
);
useEffect(() => {
reset(
findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
{ keepDefaultValues: true },
);
}, [appPreviewData, id]);
useEffect(() => {
const callback = subscribe({
formState: {
values: true,
},
callback: ({ values }) => {
const previewData = {
...appPreviewData,
settings: {
...appPreviewData?.settings,
web_app_landing_configs: handleLandingConfigs({
id,
config: appPreviewData?.settings?.web_app_landing_configs || [],
values,
}),
},
};
setIsEdit(true);
debouncedDispatch(previewData);
},
});
return () => {
callback();
};
}, [subscribe, id, appPreviewData]);
return (
<StyledCommonWrapper>
<CommonItem title='标题'>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField label='文字' {...field} placeholder='请输入' />
)}
/>
{/* <Controller
control={control}
name='title_color'
render={({ field }) => (
<ColorPickerField
label='标题颜色'
value={field.value}
onChange={field.onChange}
sx={{ flex: 1 }}
/>
)}
/> */}
</CommonItem>
{/* <CommonItem title='背景颜色'>
<Controller
control={control}
name='bg_color'
render={({ field }) => (
<ColorPickerField
value={field.value}
onChange={field.onChange}
sx={{ flex: 1 }}
/>
)}
/>
</CommonItem> */}
<CommonItem title='推荐文件夹' onAdd={() => setOpen(true)}>
{nodes.length === 0 ? (
<Empty />
) : (
<DragList
data={nodes}
onChange={value => {
setIsEdit(true);
setValue('nodes', value);
}}
setIsEdit={setIsEdit}
SortableItemComponent={ItemSortableComponent}
ItemComponent={Item}
/>
)}
</CommonItem>
<AddRecommendContent
open={open}
selected={nodes.map(item => item.id!)}
onChange={handleListChange}
onClose={() => setOpen(false)}
disabled={item => item.type === DomainNodeType.NodeTypeDocument}
nodeType={DomainNodeType.NodeTypeFolder}
/>
</StyledCommonWrapper>
);
};
export default DirDocConfig;

View File

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

View File

@@ -0,0 +1,134 @@
import React, { useEffect, useMemo } from 'react';
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
import { TextField } from '@mui/material';
import { Controller, useForm } from 'react-hook-form';
import DragList from '../../components/DragList';
import SortableItem from '../../components/SortableItem';
import FaqItem from './Item';
import type { ConfigProps } from '../type';
import { useAppSelector } from '@/store';
import useDebounceAppPreviewData from '@/hooks/useDebounceAppPreviewData';
import { Empty } from '@ctzhian/ui';
import { DEFAULT_DATA } from '../../../constants';
import { findConfigById, handleLandingConfigs } from '../../../utils';
const FaqConfig = ({ setIsEdit, id }: ConfigProps) => {
const { appPreviewData } = useAppSelector(state => state.config);
const debouncedDispatch = useDebounceAppPreviewData();
const { control, setValue, watch, reset, subscribe } = useForm<
typeof DEFAULT_DATA.faq
>({
defaultValues: findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
});
const list = watch('list') || [];
const handleAddQuestion = () => {
const nextId = `${Date.now()}`;
setValue('list', [...list, { id: nextId, question: '', link: '' }]);
};
const handleListChange = (newList: (typeof DEFAULT_DATA.faq)['list']) => {
setValue('list', newList);
setIsEdit(true);
};
// 稳定的 SortableItemComponent 引用
const FaqSortableComponent = useMemo(
() => (props: any) => <SortableItem {...props} ItemComponent={FaqItem} />,
[],
);
useEffect(() => {
reset(
findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
{ keepDefaultValues: true },
);
}, [id, appPreviewData]);
useEffect(() => {
const callback = subscribe({
formState: {
values: true,
},
callback: ({ values }) => {
const previewData = {
...appPreviewData,
settings: {
...appPreviewData?.settings,
web_app_landing_configs: handleLandingConfigs({
id,
config: appPreviewData?.settings?.web_app_landing_configs || [],
values,
}),
},
};
setIsEdit(true);
debouncedDispatch(previewData);
},
});
return () => {
callback();
};
}, [subscribe, id, appPreviewData]);
return (
<StyledCommonWrapper>
<CommonItem title='标题'>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField label='文字' {...field} placeholder='请输入' />
)}
/>
{/* <Controller
control={control}
name='title_color'
render={({ field }) => (
<ColorPickerField
label='标题颜色'
value={field.value}
onChange={field.onChange}
sx={{ flex: 1 }}
/>
)}
/> */}
</CommonItem>
{/* <CommonItem title='背景颜色'>
<Controller
control={control}
name='bg_color'
render={({ field }) => (
<ColorPickerField
value={field.value}
onChange={field.onChange}
sx={{ flex: 1 }}
/>
)}
/>
</CommonItem> */}
<CommonItem title='链接列表' onAdd={handleAddQuestion}>
{list.length === 0 ? (
<Empty />
) : (
<DragList
data={list}
onChange={handleListChange}
setIsEdit={setIsEdit}
SortableItemComponent={FaqSortableComponent}
ItemComponent={FaqItem}
/>
)}
</CommonItem>
</StyledCommonWrapper>
);
};
export default FaqConfig;

View File

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

View File

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

View File

@@ -0,0 +1,545 @@
import { AppDetail, HeaderSetting } from '@/api';
import UploadFile from '@/components/UploadFile';
import { Stack, Box, TextField, SvgIconProps } from '@mui/material';
import DragBrand from '../basicComponents/DragBrand';
import { Dispatch, SetStateAction, useEffect, useRef } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useAppDispatch, useAppSelector } from '@/store';
import { setAppPreviewData } from '@/store/slices/config';
import { DomainSocialMediaAccount } from '@/request/types';
import Switch from '../basicComponents/Switch';
import DragSocialInfo from '../basicComponents/DragSocialInfo';
import VersionMask from '@/components/VersionMask';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import { IconTianjia } from '@panda-wiki/icons';
import {
IconWeixingongzhonghao,
IconDianhua,
IconWeixingongzhonghaoDaiyanse,
IconDianhua1,
} from '@panda-wiki/icons';
interface FooterConfigProps {
data?: AppDetail | null;
setIsEdit: Dispatch<SetStateAction<boolean>>;
isEdit: boolean;
}
export interface Option {
key: string;
value: string;
type: React.ComponentType<SvgIconProps>;
config_type?: React.ComponentType<SvgIconProps>;
text_placeholder?: string;
text_label?: string;
}
export const options: Option[] = [
{
key: 'wechat_oa',
value: '微信公众号',
type: IconWeixingongzhonghao,
config_type: IconWeixingongzhonghaoDaiyanse,
text_placeholder: '请输入公众号名称',
text_label: '公众号名称',
},
{
key: 'phone',
value: '电话',
type: IconDianhua,
config_type: IconDianhua1,
text_placeholder: '请输入文字',
text_label: '文字',
},
];
const FooterConfig = ({ data, setIsEdit, isEdit }: FooterConfigProps) => {
const { appPreviewData, license } = useAppSelector(state => state.config);
const dispatch = useAppDispatch();
const {
control,
formState: { errors },
watch,
setValue,
reset,
} = useForm<HeaderSetting | any>({
defaultValues: {
corp_name: '',
icp: '',
brand_name: '',
brand_desc: '',
brand_logo: '',
show_brand_info: false,
social_media_accounts: [],
footer_show_intro: true,
brand_groups: [],
},
});
const corp_name = watch('corp_name');
const icp = watch('icp');
const brand_name = watch('brand_name');
const brand_desc = watch('brand_desc');
const brand_logo = watch('brand_logo');
const brand_groups = watch('brand_groups');
const show_brand_info = watch('show_brand_info');
const social_media_accounts: DomainSocialMediaAccount[] = watch(
'social_media_accounts',
);
const footer_show_intro = watch('footer_show_intro');
const isHydratingRef = useRef(true);
const latestAppPreviewDataRef = useRef(appPreviewData);
useEffect(() => {
latestAppPreviewDataRef.current = appPreviewData;
}, [appPreviewData]);
useEffect(() => {
const source =
isEdit && appPreviewData ? appPreviewData.settings : data?.settings;
if (!source) return;
isHydratingRef.current = true;
reset({
corp_name: source.footer_settings?.corp_name || '',
icp: source.footer_settings?.icp || '',
brand_name: source.footer_settings?.brand_name || '',
brand_desc: source.footer_settings?.brand_desc || '',
brand_logo: source.footer_settings?.brand_logo || '',
brand_groups: source.footer_settings?.brand_groups || [],
show_brand_info: source.web_app_custom_style?.show_brand_info || false,
social_media_accounts:
source.web_app_custom_style?.social_media_accounts || [],
footer_show_intro:
source.web_app_custom_style?.footer_show_intro === false ? false : true,
});
}, [appPreviewData?.id, data?.id, reset]);
useEffect(() => {
if (!latestAppPreviewDataRef.current) return;
if (isHydratingRef.current) {
isHydratingRef.current = false;
return;
}
const currentAppPreviewData = latestAppPreviewDataRef.current;
const previewData = {
...currentAppPreviewData,
settings: {
...currentAppPreviewData.settings,
footer_settings: {
...currentAppPreviewData.settings?.footer_settings,
corp_name,
icp,
brand_name,
brand_desc,
brand_logo,
brand_groups,
},
web_app_custom_style: {
...currentAppPreviewData.settings?.web_app_custom_style,
show_brand_info,
social_media_accounts,
footer_show_intro,
},
},
};
dispatch(setAppPreviewData(previewData));
}, [
corp_name,
icp,
brand_name,
brand_desc,
brand_logo,
brand_groups,
dispatch,
show_brand_info,
social_media_accounts,
footer_show_intro,
]);
return (
<>
<Stack gap={3}>
<Stack direction={'column'} gap={2}>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
fontWeight: 600,
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
bgcolor: '#3248F2',
borderRadius: '2px',
mr: 1,
},
}}
>
<Controller
control={control}
name='footer_show_intro'
render={({ field }) => (
<Switch
sx={{ marginLeft: 'auto', mr: 0.5 }}
{...field}
checked={field?.value === false ? false : true}
onChange={e => {
field.onChange(e.target.checked);
setIsEdit(true);
}}
></Switch>
)}
/>
</Box>
<Stack direction={'column'} spacing={3}>
<Stack direction={'column'} spacing={1}>
<Box
sx={{ fontWeight: 400, fontSize: '12px', lineHeight: '20px' }}
>
Logo
</Box>
<Controller
control={control}
name='brand_logo'
render={({ field }) => (
<UploadFile
{...field}
id='footerconfig_logo'
name='footerconfig_logo'
type='url'
accept='image/*'
width={80}
onChange={(url: string) => {
field.onChange(url);
setIsEdit(true);
}}
/>
)}
/>
</Stack>
<Stack direction={'column'} spacing={1}>
<Box
sx={{ fontWeight: 400, fontSize: '12px', lineHeight: '20px' }}
>
Logo
</Box>
<Controller
control={control}
name='brand_name'
render={({ field }) => (
<TextField
fullWidth
{...field}
placeholder='请输入'
error={!!errors.title}
helperText={errors.title?.message?.toString()}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
/>
)}
/>
</Stack>
<Stack direction={'column'} spacing={1}>
<Box
sx={{ fontWeight: 400, fontSize: '12px', lineHeight: '20px' }}
>
</Box>
<Controller
control={control}
name='brand_desc'
render={({ field }) => (
<TextField
fullWidth
{...field}
placeholder='请输入'
error={!!errors.title}
helperText={errors.title?.message?.toString()}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
multiline
sx={{
'& textarea': {
resize: 'vertical',
minHeight: '36px',
minWidth: '100%',
},
'& .MuiOutlinedInput-root': {
pb: '4px',
pr: '4px',
},
}}
/>
)}
/>
</Stack>
<Stack direction={'column'} spacing={1}>
<Stack
sx={{ fontWeight: 400, fontSize: '12px', lineHeight: '20px' }}
direction={'row'}
>
<Stack
direction={'row'}
sx={{
alignItems: 'center',
marginLeft: 'auto',
cursor: 'pointer',
}}
onClick={() => {
const newAccounts = [
...(social_media_accounts || []),
{
icon: '',
channel: '',
text: '',
link: '',
},
];
setValue('social_media_accounts', newAccounts);
setIsEdit(true);
}}
>
<IconTianjia
sx={{ fontSize: '10px !important', color: '#5F58FE' }}
/>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
marginLeft: 0.5,
color: '#5F58FE',
}}
>
</Box>
</Stack>
</Stack>
<DragSocialInfo
data={social_media_accounts || []}
control={control}
onChange={(data: DomainSocialMediaAccount[]) => {
setValue('social_media_accounts', data);
setIsEdit(true);
}}
setIsEdit={setIsEdit}
></DragSocialInfo>
</Stack>
</Stack>
</Stack>
<Stack direction={'column'} gap={2}>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
fontWeight: 600,
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
bgcolor: '#3248F2',
borderRadius: '2px',
mr: 1,
},
}}
>
<Stack
direction={'row'}
sx={{
alignItems: 'center',
marginLeft: 'auto',
cursor: 'pointer',
}}
onClick={() => {
const newGroups = [
...(brand_groups || []),
{ name: '', links: [{ name: '', url: '' }] },
];
setValue('brand_groups', newGroups);
setIsEdit(true);
}}
>
<IconTianjia
sx={{ fontSize: '10px !important', color: '#5F58FE' }}
/>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
marginLeft: 0.5,
fontWeight: 400,
color: '#5F58FE',
}}
>
</Box>
</Stack>
</Box>
<DragBrand
control={control}
data={brand_groups || []}
onChange={brand_groups => {
setValue('brand_groups', brand_groups);
setIsEdit(true);
}}
setIsEdit={setIsEdit}
errors={errors}
></DragBrand>
</Stack>
<Stack direction={'column'} gap={2}>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
fontWeight: 600,
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
bgcolor: '#3248F2',
borderRadius: '2px',
mr: 1,
},
}}
>
</Box>
<Controller
control={control}
name='corp_name'
render={({ field }) => (
<TextField
fullWidth
{...field}
placeholder='请输入'
error={!!errors.title}
helperText={errors.title?.message?.toString()}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
/>
)}
/>
</Stack>
<Stack direction={'column'} gap={2}>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
fontWeight: 600,
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
bgcolor: '#3248F2',
borderRadius: '2px',
mr: 1,
},
}}
>
ICP
</Box>
<Controller
control={control}
name='icp'
render={({ field }) => (
<TextField
fullWidth
{...field}
placeholder='请输入'
error={!!errors.placeholder}
helperText={errors.placeholder?.message?.toString()}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
/>
)}
/>
</Stack>
<Stack direction={'column'} gap={2}>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
fontWeight: 600,
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
bgcolor: '#3248F2',
borderRadius: '2px',
mr: 1,
},
}}
>
PandaWiki
</Box>
<VersionMask
permission={PROFESSION_VERSION_PERMISSION}
wrapperSx={{ px: 2 }}
sx={{ inset: '-8px 0' }}
>
<Controller
control={control}
name='show_brand_info'
render={({ field }) => (
<Stack direction={'row'}>
<Box
sx={{
fontSize: 12,
lineHeight: '20px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
}}
>
PandaWiki
</Box>
<Switch
sx={{ marginLeft: 'auto' }}
{...field}
checked={field?.value === false ? false : true}
onChange={e => {
field.onChange(e.target.checked);
setIsEdit(true);
}}
></Switch>
</Stack>
)}
/>
</VersionMask>
</Stack>
</Stack>
</>
);
};
export default FooterConfig;

View File

@@ -0,0 +1,294 @@
import { AppDetail, HeaderSetting } from '@/api';
import DragBtn from '../basicComponents/DragBtn';
import UploadFile from '@/components/UploadFile';
import { Stack, Box, TextField } from '@mui/material';
import { Dispatch, SetStateAction, useEffect, useRef } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useAppSelector } from '@/store';
import useDebounceAppPreviewData from '@/hooks/useDebounceAppPreviewData';
import { IconTianjia } from '@panda-wiki/icons';
interface CardWebHeaderProps {
data?: AppDetail | null;
setIsEdit: Dispatch<SetStateAction<boolean>>;
isEdit: boolean;
}
const HeaderConfig = ({ data, setIsEdit, isEdit }: CardWebHeaderProps) => {
const { appPreviewData } = useAppSelector(state => state.config);
const debouncedDispatch = useDebounceAppPreviewData();
const {
control,
formState: { errors },
watch,
setValue,
reset,
} = useForm<HeaderSetting | any>({
defaultValues: {
title: '',
icon: '',
btns: [],
header_search_placeholder: '',
allow_theme_switching: false,
},
});
const btns = watch('btns');
const title = watch('title');
const icon = watch('icon');
const header_search_placeholder = watch('header_search_placeholder');
const allow_theme_switching = watch('allow_theme_switching');
const isHydratingRef = useRef(true);
const latestAppPreviewDataRef = useRef(appPreviewData);
const handleAddButton = () => {
const id = Date.now().toString();
const newBtn = {
id,
url: '',
variant: 'outlined' as const,
showIcon: true,
icon: '',
text: '按钮' + (btns.length + 1),
target: '_self' as const,
};
const currentBtns = appPreviewData?.settings!.btns || [];
const newBtns = [...currentBtns, newBtn];
setValue('btns', newBtns);
setIsEdit(true);
};
useEffect(() => {
latestAppPreviewDataRef.current = appPreviewData;
}, [appPreviewData]);
useEffect(() => {
const source =
isEdit && appPreviewData ? appPreviewData.settings : data?.settings;
if (!source) return;
isHydratingRef.current = true;
reset({
title: source.title || '',
icon: source.icon || '',
btns: source.btns || [],
header_search_placeholder:
source.web_app_custom_style?.header_search_placeholder || '',
allow_theme_switching:
source.web_app_custom_style?.allow_theme_switching || false,
});
}, [appPreviewData?.id, data?.id, reset]);
useEffect(() => {
if (!latestAppPreviewDataRef.current) return;
if (isHydratingRef.current) {
isHydratingRef.current = false;
return;
}
const currentAppPreviewData = latestAppPreviewDataRef.current;
const previewData = {
...currentAppPreviewData,
settings: {
...currentAppPreviewData.settings,
title: title,
btns: btns,
icon: icon,
web_app_custom_style: {
...currentAppPreviewData.settings?.web_app_custom_style,
header_search_placeholder: header_search_placeholder,
allow_theme_switching: allow_theme_switching,
},
},
};
debouncedDispatch(previewData);
return () => {
debouncedDispatch.cancel();
};
}, [
allow_theme_switching,
btns,
debouncedDispatch,
header_search_placeholder,
icon,
title,
]);
return (
<>
<Stack gap={3}>
<Stack direction={'column'} gap={2}>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
bgcolor: '#3248F2',
borderRadius: '2px',
mr: 1,
},
}}
>
Logo
</Box>
<Controller
control={control}
name='icon'
render={({ field }) => (
<UploadFile
{...field}
id='headerconfig_logo'
name='headerconfig_logo'
type='url'
accept='image/*'
width={80}
onChange={(url: string) => {
field.onChange(url);
setIsEdit(true);
}}
/>
)}
/>
</Stack>
<Stack direction={'column'} gap={2}>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
bgcolor: '#3248F2',
borderRadius: '2px',
mr: 1,
},
}}
>
/ Logo文本
</Box>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField
fullWidth
{...field}
placeholder='请输入'
error={!!errors.title}
helperText={errors.title?.message?.toString()}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
/>
)}
/>
</Stack>
<Stack direction={'column'} gap={2}>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
bgcolor: '#3248F2',
borderRadius: '2px',
mr: 1,
},
}}
>
</Box>
<Controller
control={control}
name='header_search_placeholder'
render={({ field }) => (
<TextField
fullWidth
{...field}
placeholder='请输入'
error={!!errors.placeholder}
helperText={errors.placeholder?.message?.toString()}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
/>
)}
/>
</Stack>
<Stack direction={'column'} gap={2}>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
bgcolor: '#3248F2',
borderRadius: '2px',
mr: 1,
},
}}
>
<Stack
direction={'row'}
sx={{
alignItems: 'center',
marginLeft: 'auto',
cursor: 'pointer',
}}
onClick={handleAddButton}
>
<IconTianjia
sx={{ fontSize: '10px !important', color: '#5F58FE' }}
/>
<Box sx={{ fontSize: 14, lineHeight: '22px', marginLeft: 0.5 }}>
</Box>
</Stack>
</Box>
<Box>
<DragBtn
control={control}
data={btns}
onChange={btns => {
setValue('btns', btns);
setIsEdit(true);
}}
setIsEdit={setIsEdit}
/>
</Box>
</Stack>
</Stack>
</>
);
};
export default HeaderConfig;

View File

@@ -0,0 +1,129 @@
import React, { useEffect } from 'react';
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
import { Stack, TextField } from '@mui/material';
import { Controller, useForm } from 'react-hook-form';
import type { ConfigProps } from '../type';
import { useAppSelector } from '@/store';
import useDebounceAppPreviewData from '@/hooks/useDebounceAppPreviewData';
import { DEFAULT_DATA } from '../../../constants';
import { findConfigById, handleLandingConfigs } from '../../../utils';
import UploadFile from '@/components/UploadFile';
const Config = ({ setIsEdit, id }: ConfigProps) => {
const { appPreviewData } = useAppSelector(state => state.config);
const debouncedDispatch = useDebounceAppPreviewData();
const { control, setValue, watch, subscribe, reset } = useForm<
typeof DEFAULT_DATA.img_text
>({
defaultValues: findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
});
useEffect(() => {
reset(
findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
{ keepDefaultValues: true },
);
}, [appPreviewData, id]);
useEffect(() => {
const callback = subscribe({
formState: {
values: true,
},
callback: ({ values }) => {
const previewData = {
...appPreviewData,
settings: {
...appPreviewData?.settings,
web_app_landing_configs: handleLandingConfigs({
id,
config: appPreviewData?.settings?.web_app_landing_configs || [],
values,
}),
},
};
setIsEdit(true);
debouncedDispatch(previewData);
},
});
return () => {
callback();
};
}, [subscribe, id, appPreviewData]);
return (
<StyledCommonWrapper>
<CommonItem title='标题'>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField label='文字' {...field} placeholder='请输入' />
)}
/>
</CommonItem>
<CommonItem title='图片' desc='(推荐 350*3501 : 1 )'>
<Controller
control={control}
name='item.url'
render={({ field }) => (
<UploadFile
name='item.url'
id={`${id}_icon`}
type='url'
disabled={false}
accept='image/*'
width={110}
height={110}
value={field.value}
onChange={(url: string) => {
field.onChange(url);
setIsEdit(true);
}}
/>
)}
/>
</CommonItem>
<CommonItem title='内容'>
<Controller
control={control}
name='item.name'
render={({ field }) => (
<TextField
label='标题'
{...field}
placeholder='请输入'
onChange={e => {
setIsEdit(true);
field.onChange(e.target.value);
}}
/>
)}
/>
<Controller
control={control}
name='item.desc'
render={({ field }) => (
<TextField
label='描述'
{...field}
placeholder='请输入'
onChange={e => {
setIsEdit(true);
field.onChange(e.target.value);
}}
/>
)}
/>
</CommonItem>
</StyledCommonWrapper>
);
};
export default Config;

View File

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

View File

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

View File

@@ -0,0 +1,258 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ITreeItem } from '@/api';
import Nodata from '@/assets/images/nodata.png';
import Card from '@/components/Card';
import DragTree from '@/components/Drag/DragTree';
import { getApiV1NodeListGroupNav } from '@/request/Node';
import {
DomainNodeListItemResp,
GithubComChaitinPandaWikiApiNodeV1NodeListGroupNavResp,
} from '@/request/types';
import { useAppSelector } from '@/store';
import { convertToTree } from '@/utils/drag';
import { Modal } from '@ctzhian/ui';
import { Box, Checkbox, IconButton, Skeleton, Stack } from '@mui/material';
import { IconXiajiantou } from '@panda-wiki/icons';
import { memo } from 'react';
interface AddNavContentProps {
open: boolean;
selected: string[];
onChange: (ids: string[]) => void;
onClose: () => void;
}
/** 兼容不同版本 API 返回结构 */
function normalizeNavGroupResponse(
res: unknown,
): GithubComChaitinPandaWikiApiNodeV1NodeListGroupNavResp[] {
if (Array.isArray(res))
return res as GithubComChaitinPandaWikiApiNodeV1NodeListGroupNavResp[];
if (res && typeof res === 'object') {
const obj = res as Record<string, unknown>;
for (const key of ['list', 'data', 'groups', 'items']) {
if (Array.isArray(obj[key]))
return obj[
key
] as GithubComChaitinPandaWikiApiNodeV1NodeListGroupNavResp[];
}
}
return [];
}
/** 从单个导航分组中提取节点列表 */
function getNavNodeList(
nav:
| GithubComChaitinPandaWikiApiNodeV1NodeListGroupNavResp
| Record<string, unknown>,
): DomainNodeListItemResp[] {
const n = nav as Record<string, unknown>;
return (
(n['list'] as DomainNodeListItemResp[] | undefined) ||
(n['nodes'] as DomainNodeListItemResp[] | undefined) ||
(n['items'] as DomainNodeListItemResp[] | undefined) ||
[]
);
}
// ─── 单个导航分组 Card ──────────────────────────────────────────────────────────
interface NavGroupCardProps {
navId: string;
navName: string;
navTreeList: ITreeItem[];
isSelected: boolean;
isExpanded: boolean;
onToggleExpand: (navId: string) => void;
onToggleSelect: (navId: string) => void;
refresh: () => void;
}
const NavGroupCard = memo(
({
navId,
navName,
navTreeList,
isSelected,
isExpanded,
onToggleExpand,
onToggleSelect,
refresh,
}: NavGroupCardProps) => {
return (
<Card sx={{ bgcolor: 'background.paper3', overflow: 'hidden' }}>
<Stack
direction='row'
alignItems='center'
sx={{ py: 1, px: 1.5, cursor: 'pointer', fontSize: 14 }}
>
<Checkbox
size='small'
checked={isSelected}
onChange={() => onToggleSelect(navId)}
sx={{ p: 0, mr: 0.5 }}
/>
<IconButton
size='small'
onClick={() => onToggleExpand(navId)}
sx={{ p: 0.25, mr: 0.5 }}
>
<IconXiajiantou
sx={{
fontSize: 16,
color: 'text.disabled',
transform: isExpanded ? 'none' : 'rotate(-90deg)',
transition: 'transform 0.2s',
}}
/>
</IconButton>
<Box
sx={{ flex: 1, cursor: 'pointer' }}
onClick={() => onToggleExpand(navId)}
>
{navName}
</Box>
</Stack>
{isExpanded && (
<Stack gap={0.25} sx={{ fontSize: 14, px: 2, pb: 1 }}>
<DragTree
ui='select'
readOnly
selected={[]}
data={navTreeList}
refresh={refresh}
disabled={() => true}
/>
</Stack>
)}
</Card>
);
},
);
NavGroupCard.displayName = 'NavGroupCard';
// ─── 主组件 ───────────────────────────────────────────────────────────────────
const AddNavContent = ({
open,
selected,
onChange,
onClose,
}: AddNavContentProps) => {
const { kb_id } = useAppSelector(state => state.config);
const [loading, setLoading] = useState(false);
const [navList, setNavList] = useState<
GithubComChaitinPandaWikiApiNodeV1NodeListGroupNavResp[]
>([]);
const [selectedIds, setSelectedIds] = useState<string[]>(selected);
const [expandedNavIds, setExpandedNavIds] = useState<Set<string>>(new Set());
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const getData = useCallback(() => {
setLoading(true);
getApiV1NodeListGroupNav({ kb_id, status: 'released' })
.then(res => {
const navData = normalizeNavGroupResponse(res);
setNavList(navData);
// setExpandedNavIds(
// new Set(navData.map((nav, idx) => nav.nav_id || `nav-${idx}`)),
// );
})
.finally(() => {
setLoading(false);
});
}, [kb_id]);
useEffect(() => {
setSelectedIds(selected);
}, [selected]);
useEffect(() => {
if (open && kb_id) getData();
}, [open, kb_id, getData]);
const navGroups = useMemo(() => {
return navList
.filter(nav => nav.list && nav.list.length > 0)
.map(nav => {
const navId = nav.nav_id!;
const navNodes = getNavNodeList(nav);
const navTreeList = navNodes.length > 0 ? convertToTree(navNodes) : [];
return {
navId,
id: navId,
name: nav.nav_name || '未分类',
navName: nav.nav_name || '未分类',
navNodes,
navTreeList,
};
});
}, [navList]);
const isEmpty = !loading && navGroups.length === 0;
const handleToggleExpand = useCallback((navId: string) => {
setExpandedNavIds(prev => {
const next = new Set(prev);
if (next.has(navId)) next.delete(navId);
else next.add(navId);
return next;
});
}, []);
const handleToggleSelect = useCallback((navId: string) => {
setSelectedIds(prev =>
prev.includes(navId) ? prev.filter(id => id !== navId) : [...prev, navId],
);
}, []);
const handleConfirm = () => {
onChange(selectedIds);
onClose();
};
return (
<Modal title='选择目录' open={open} onOk={handleConfirm} onCancel={onClose}>
{loading ? (
<Stack gap={2}>
{new Array(5).fill(0).map((_, index) => (
<Skeleton variant='text' height={36} key={index} />
))}
</Stack>
) : isEmpty ? (
<Stack alignItems='center' justifyContent='center'>
<img src={Nodata} alt='empty' style={{ width: 100, height: 100 }} />
<Box
sx={{
fontSize: 12,
lineHeight: '20px',
color: 'text.tertiary',
mt: 1,
}}
>
</Box>
</Stack>
) : (
<Stack gap={1}>
{navGroups.map(group => (
<NavGroupCard
key={group.navId}
{...group}
isSelected={selectedSet.has(group.navId)}
isExpanded={expandedNavIds.has(group.navId)}
onToggleExpand={handleToggleExpand}
onToggleSelect={handleToggleSelect}
refresh={getData}
/>
))}
</Stack>
)}
</Modal>
);
};
export default AddNavContent;

View File

@@ -0,0 +1,148 @@
import { GithubComChaitinPandaWikiApiNodeV1NodeListGroupNavResp } from '@/request/types';
import { Box, IconButton, Stack } from '@mui/material';
import { Ellipsis } from '@ctzhian/ui';
import {
IconShanchu2,
IconDrag,
IconWenjianjia,
IconWenjian,
IconMulushu,
} from '@panda-wiki/icons';
import { CSSProperties, forwardRef, HTMLAttributes } from 'react';
export type ItemProps = HTMLAttributes<HTMLDivElement> & {
item: GithubComChaitinPandaWikiApiNodeV1NodeListGroupNavResp & { id: string };
withOpacity?: boolean;
isDragging?: boolean;
dragHandleProps?: any;
handleRemove?: (id: string) => void;
refresh?: () => void;
};
const Item = forwardRef<HTMLDivElement, ItemProps>(
(
{
item,
withOpacity,
isDragging,
style,
dragHandleProps,
handleRemove,
refresh,
...props
},
ref,
) => {
const inlineStyles: CSSProperties = {
opacity: withOpacity ? '0.5' : '1',
borderRadius: '10px',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: '#ffffff',
width: '100%',
minWidth: '0px',
...style,
};
const recommend_nodes = [...(item.list || [])];
return (
<Box ref={ref} style={inlineStyles} {...props}>
<Stack
direction={'row'}
gap={1}
sx={{
p: 1,
height: '100%',
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
}}
>
<Box
sx={{
px: 2,
py: 1,
flexGrow: 1,
border: '1px solid',
borderColor: 'divider',
borderRadius: '10px',
}}
>
<Stack direction={'row'} alignItems={'center'} gap={1}>
<IconMulushu
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
/>
<Ellipsis sx={{ flex: 1, width: 0, lineHeight: '32px' }}>
{item.nav_name}
</Ellipsis>
</Stack>
{recommend_nodes.length > 0 && (
<Stack sx={{ fontSize: 14, color: 'text.tertiary', pl: '20px' }}>
{recommend_nodes
?.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
.slice(0, 4)
.map(it => (
<Stack
direction={'row'}
alignItems={'center'}
gap={1}
key={item.nav_id}
>
{it.emoji ? (
<Box
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
>
{it.emoji}
</Box>
) : it.type === 1 ? (
<IconWenjianjia
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
/>
) : (
<IconWenjian
sx={{ fontSize: 14, color: '#2f80f7', flexShrink: 0 }}
/>
)}
<Ellipsis sx={{ flex: 1, width: 0 }}>{it.name}</Ellipsis>
</Stack>
))}
</Stack>
)}
</Box>
<Stack justifyContent={'space-between'} sx={{ flexShrink: 0 }}>
<IconButton
size='small'
onClick={e => {
e.stopPropagation();
handleRemove?.(item.nav_id!);
}}
sx={{
color: 'text.tertiary',
':hover': { color: 'error.main' },
width: '28px',
height: '28px',
}}
>
<IconShanchu2 sx={{ fontSize: '12px' }} />
</IconButton>
<IconButton
size='small'
sx={{
cursor: 'grab',
color: 'text.secondary',
'&:hover': { color: 'primary.main' },
}}
{...dragHandleProps}
>
<IconDrag sx={{ fontSize: '18px' }} />
</IconButton>
</Stack>
</Stack>
</Box>
);
},
);
export default Item;

View File

@@ -0,0 +1,141 @@
import { useEffect, useState, useMemo } from 'react';
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
import { TextField } from '@mui/material';
import { Controller, useForm } from 'react-hook-form';
import DragList from '../../components/DragList';
import SortableItem from '../../components/SortableItem';
import Item from './Item';
import { Empty } from '@ctzhian/ui';
import type { ConfigProps } from '../type';
import { useAppSelector } from '@/store';
import { getApiV1NodeListGroupNav } from '@/request/Node';
import { convertToTree } from '@/utils/drag';
import AddNavContent from './AddNavContent';
import useDebounceAppPreviewData from '@/hooks/useDebounceAppPreviewData';
import { DEFAULT_DATA } from '../../../constants';
import { findConfigById, handleLandingConfigs } from '../../../utils';
const NavDocConfig = ({ setIsEdit, id }: ConfigProps) => {
const { appPreviewData, kb_id } = useAppSelector(state => state.config);
const debouncedDispatch = useDebounceAppPreviewData();
const { control, setValue, watch, subscribe, reset } = useForm<
typeof DEFAULT_DATA.nav_doc
>({
defaultValues: findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
});
const nodes = watch('nodes') || [];
const [open, setOpen] = useState(false);
const nodeRec = (ids: string[]) => {
getApiV1NodeListGroupNav({ kb_id, nav_ids: ids, status: 'released' }).then(
res => {
setValue(
'nodes',
res.map(item => {
const navTreeList = item.list ? convertToTree(item.list || []) : [];
return {
...item,
id: item.nav_id!,
name: item.nav_name,
list: navTreeList,
};
}),
);
},
);
};
const handleListChange = (ids: string[]) => {
setIsEdit(true);
nodeRec(ids);
};
// 稳定的 SortableItemComponent 引用
const ItemSortableComponent = useMemo(
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
[],
);
useEffect(() => {
reset(
findConfigById(
appPreviewData?.settings?.web_app_landing_configs || [],
id,
),
{ keepDefaultValues: true },
);
}, [appPreviewData, id]);
useEffect(() => {
const callback = subscribe({
formState: {
values: true,
},
callback: ({ values }) => {
const previewData = {
...appPreviewData,
settings: {
...appPreviewData?.settings,
web_app_landing_configs: handleLandingConfigs({
id,
config: appPreviewData?.settings?.web_app_landing_configs || [],
values,
}),
},
};
setIsEdit(true);
debouncedDispatch(previewData);
},
});
return () => {
callback();
};
}, [subscribe, id, appPreviewData]);
return (
<StyledCommonWrapper>
{/* 标题配置 */}
<CommonItem title='标题'>
<Controller
control={control}
name='title'
render={({ field }) => (
<TextField label='文字' {...field} placeholder='请输入' />
)}
/>
</CommonItem>
{/* 推荐目录列表 */}
<CommonItem title='推荐目录' onAdd={() => setOpen(true)}>
{nodes.length === 0 ? (
<Empty />
) : (
<DragList
data={nodes}
onChange={value => {
setIsEdit(true);
setValue('nodes', value);
}}
setIsEdit={setIsEdit}
SortableItemComponent={ItemSortableComponent}
ItemComponent={Item}
/>
)}
</CommonItem>
{/* 添加目录弹窗:只选择导航目录名称 */}
<AddNavContent
open={open}
selected={nodes.map(item => item.nav_id!)}
onChange={handleListChange}
onClose={() => setOpen(false)}
/>
</StyledCommonWrapper>
);
};
export default NavDocConfig;

View File

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

Some files were not shown because too many files have changed in this diff Show More