init push
44
web/admin/src/App.tsx
Normal 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;
|
||||
69
web/admin/src/api/index.tsx
Normal 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 });
|
||||
62
web/admin/src/api/request.ts
Normal 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
@@ -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;
|
||||
};
|
||||
29
web/admin/src/assets/emoji-data/zh.json
Normal 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": "黑色"
|
||||
}
|
||||
}
|
||||
20
web/admin/src/assets/fonts/font.css
Normal 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;
|
||||
}
|
||||
BIN
web/admin/src/assets/fonts/gilroy-bold.otf
Normal file
BIN
web/admin/src/assets/fonts/gilroy-medium.otf
Normal file
BIN
web/admin/src/assets/fonts/gilroy-regular.otf
Normal file
BIN
web/admin/src/assets/images/blueCard.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
web/admin/src/assets/images/business-version.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
web/admin/src/assets/images/clock.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
web/admin/src/assets/images/document.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
web/admin/src/assets/images/enterprise-version.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
web/admin/src/assets/images/free-version.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
web/admin/src/assets/images/full.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
web/admin/src/assets/images/init/complete.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
web/admin/src/assets/images/init/decorate.png
Normal file
|
After Width: | Height: | Size: 526 KiB |
BIN
web/admin/src/assets/images/init/import.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
web/admin/src/assets/images/init/publish.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
web/admin/src/assets/images/init/test.png
Normal file
|
After Width: | Height: | Size: 555 KiB |
BIN
web/admin/src/assets/images/login-bgi.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
web/admin/src/assets/images/login-logo.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
web/admin/src/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
web/admin/src/assets/images/no-permission.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
web/admin/src/assets/images/nodata.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
web/admin/src/assets/images/normal.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
web/admin/src/assets/images/pro-version.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
web/admin/src/assets/images/purpleCard.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
web/admin/src/assets/images/qrcode.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
web/admin/src/assets/images/welcome.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
web/admin/src/assets/images/wide.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
1
web/admin/src/assets/json/coin.json
Normal file
1
web/admin/src/assets/json/error.json
Normal file
1
web/admin/src/assets/json/help-center.json
Normal file
1
web/admin/src/assets/json/takeoff.json
Normal file
1
web/admin/src/assets/json/upgrade.json
Normal file
233
web/admin/src/assets/styles/index.css
Normal 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;
|
||||
}
|
||||
1225
web/admin/src/assets/styles/markdown.css
Normal file
56
web/admin/src/components/Avatar/index.tsx
Normal 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;
|
||||
131
web/admin/src/components/BarTrend/index.tsx
Normal 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;
|
||||
27
web/admin/src/components/Card/index.tsx
Normal 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;
|
||||
189
web/admin/src/components/Cascader/index.tsx
Normal 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;
|
||||
257
web/admin/src/components/CreateWikiModal/index.tsx
Normal 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;
|
||||
125
web/admin/src/components/CreateWikiModal/steps/Step1Model.tsx
Normal 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;
|
||||
361
web/admin/src/components/CreateWikiModal/steps/Step2Config.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
12
web/admin/src/components/CreateWikiModal/steps/Step5Test.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
7
web/admin/src/components/CreateWikiModal/steps/index.ts
Normal 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';
|
||||
250
web/admin/src/components/CreateWikiModal/steps/initData.ts
Normal file
91
web/admin/src/components/CustomImage/index.tsx
Normal 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;
|
||||
416
web/admin/src/components/CustomModal/components/ShowContent.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as DragList } from './DragList';
|
||||
export type { DragListProps } from './DragList';
|
||||
|
||||
export type { SortableItemProps } from './SortableItem';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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*495,16:9 )'
|
||||
onAdd={handleAddQuestion}
|
||||
>
|
||||
{list.length === 0 ? (
|
||||
<Empty />
|
||||
) : (
|
||||
<DragList
|
||||
data={list}
|
||||
onChange={handleListChange}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={ItemSortableComponent}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
)}
|
||||
</CommonItem>
|
||||
</StyledCommonWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Config;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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*350,1 : 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||