feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/* stylelint-disable declaration-no-important */
|
||||
.project-template-modal {
|
||||
:global(.semi-modal) {
|
||||
width: 960px !important;
|
||||
}
|
||||
|
||||
|
||||
:global(.semi-modal-content) {
|
||||
padding-right: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
:global(.semi-modal-header) {
|
||||
padding-right: 24px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.error-empty {
|
||||
padding: 124px 0;
|
||||
|
||||
:global {
|
||||
.semi-empty-content {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.semi-empty-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { groupBy, xorBy } from 'lodash-es';
|
||||
import { useRequest } from 'ahooks';
|
||||
import {
|
||||
ProductEntityType,
|
||||
ProductListSource,
|
||||
SortType,
|
||||
type ProductInfo,
|
||||
} from '@coze-arch/idl/product_api';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { ProductApi } from '@coze-arch/bot-api';
|
||||
import {
|
||||
IllustrationFailure,
|
||||
IllustrationFailureDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconCozRefresh } from '@coze-arch/coze-design/icons';
|
||||
import { Button, Empty, Modal, type ModalProps } from '@coze-arch/coze-design';
|
||||
|
||||
import {
|
||||
type BeforeProjectTemplateCopyCallback,
|
||||
type ProjectTemplateCopySuccessCallback,
|
||||
useProjectTemplateCopyModal,
|
||||
} from '../../hooks/use-project-template-copy-modal';
|
||||
import {
|
||||
CardSkeleton,
|
||||
TemplateGroupSkeleton,
|
||||
} from './template-components/skeleton';
|
||||
import { ProjectTemplateGroup } from './template-components/project-template-group';
|
||||
import {
|
||||
openTemplatePreview,
|
||||
ProjectTemplateCard,
|
||||
} from './template-components/project-template-card';
|
||||
import { CreateEmptyProjectUI } from './template-components/create-empty-project-ui';
|
||||
|
||||
import styles from './index.module.less';
|
||||
|
||||
const MAX_PAGE_SIZE = 50;
|
||||
|
||||
export interface ProjectTemplateBaseProps {
|
||||
spaceId?: string;
|
||||
isSelectSpaceOnCopy: boolean;
|
||||
onBeforeCopy: BeforeProjectTemplateCopyCallback | undefined;
|
||||
onCopyError: (() => void) | undefined;
|
||||
onCopyOk: ProjectTemplateCopySuccessCallback | undefined;
|
||||
onCreateProject?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要特别处理、放到「基础」类别中的模版
|
||||
* 本身业务中没有「基础」这个类别,但是在这个通过复制创建的场景下 pm 希望为用户提供一些具有基本功能有代表性的模版
|
||||
* 所以这个特殊处理的模版应运而生
|
||||
*
|
||||
* 迭代时需要注意,业务上需要保证这些模版都是被 recommend 才能复用 PublicGetProductList 这个接口
|
||||
*/
|
||||
const BASE_TEMPLATE_ID_LIST = ['7439261984903938074'];
|
||||
|
||||
const ProjectTemplateContent: React.FC<ProjectTemplateBaseProps> = ({
|
||||
spaceId,
|
||||
isSelectSpaceOnCopy,
|
||||
onCopyOk,
|
||||
onCreateProject,
|
||||
onBeforeCopy,
|
||||
onCopyError,
|
||||
}) => {
|
||||
const {
|
||||
data: categories,
|
||||
error: categoriesError,
|
||||
loading: isCategoryLoading,
|
||||
refresh: refreshCategoryRequest,
|
||||
} = useRequest(async () => {
|
||||
const response = await ProductApi.PublicGetProductCategoryList({
|
||||
entity_type: ProductEntityType.TemplateCommon,
|
||||
});
|
||||
return response.data?.categories;
|
||||
});
|
||||
|
||||
const {
|
||||
data: products,
|
||||
error: productsError,
|
||||
loading: isProductLoading,
|
||||
refresh: refreshProductRequest,
|
||||
} = useRequest(async () => {
|
||||
const response = await ProductApi.PublicGetProductList({
|
||||
entity_type: ProductEntityType.ProjectTemplate,
|
||||
page_num: 1,
|
||||
page_size: MAX_PAGE_SIZE,
|
||||
sort_type: SortType.Heat,
|
||||
source: ProductListSource.Recommend,
|
||||
is_free: true,
|
||||
});
|
||||
return response.data?.products;
|
||||
});
|
||||
|
||||
const { copyProject, modalContextHolder } = useProjectTemplateCopyModal({
|
||||
onSuccess: onCopyOk,
|
||||
source: spaceId ? 'space' : 'navi',
|
||||
onBefore: onBeforeCopy,
|
||||
onError: onCopyError,
|
||||
});
|
||||
const refreshRequest = () => {
|
||||
refreshCategoryRequest();
|
||||
refreshProductRequest();
|
||||
};
|
||||
const isRequestLoading = isCategoryLoading || isProductLoading;
|
||||
const isRequestError = Boolean(categoriesError || productsError);
|
||||
|
||||
if (!categories || !products) {
|
||||
return (
|
||||
<>
|
||||
{modalContextHolder}
|
||||
<div className="px-24px flex flex-col gap-y-[20px]">
|
||||
<ProjectTemplateGroup title="基础">
|
||||
<CreateEmptyProjectUI onClick={onCreateProject} />
|
||||
{isRequestLoading ? (
|
||||
<>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</>
|
||||
) : null}
|
||||
</ProjectTemplateGroup>
|
||||
{isRequestLoading ? (
|
||||
<>
|
||||
<TemplateGroupSkeleton />
|
||||
<TemplateGroupSkeleton />
|
||||
</>
|
||||
) : null}
|
||||
{!isRequestLoading && isRequestError ? (
|
||||
<Empty
|
||||
className={styles['error-empty']}
|
||||
image={<IllustrationFailure className="h-160px w-160px" />}
|
||||
darkModeImage={
|
||||
<IllustrationFailureDark className="h-160px w-160px" />
|
||||
}
|
||||
title={
|
||||
<span className="coz-fg-primary text-[14px] font-medium leading-20px">
|
||||
{I18n.t('creat_project_templates_load_failed')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button onClick={refreshRequest} icon={<IconCozRefresh />}>
|
||||
{I18n.t('Retry')}
|
||||
</Button>
|
||||
</Empty>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const baseTemplateList = products.filter(p =>
|
||||
BASE_TEMPLATE_ID_LIST.some(id => id === p.meta_info.id),
|
||||
);
|
||||
const recommendTemplateList = xorBy(
|
||||
products,
|
||||
baseTemplateList,
|
||||
p => p.meta_info.id,
|
||||
);
|
||||
const productGroupList = groupBy(
|
||||
recommendTemplateList,
|
||||
p => p.meta_info.category?.id,
|
||||
);
|
||||
|
||||
const renderTemplateList = (productList: ProductInfo[]) =>
|
||||
productList.map(product => (
|
||||
<ProjectTemplateCard
|
||||
viewSource={spaceId ? 'space' : 'navi'}
|
||||
onClick={() => {
|
||||
openTemplatePreview(product.meta_info.id ?? '');
|
||||
}}
|
||||
onCopyTemplate={param => {
|
||||
copyProject({
|
||||
spaceId,
|
||||
isSelectSpace: isSelectSpaceOnCopy,
|
||||
productId: param.id,
|
||||
name: param.name,
|
||||
sourceProduct: product,
|
||||
});
|
||||
}}
|
||||
key={product.meta_info.id}
|
||||
product={product}
|
||||
/>
|
||||
));
|
||||
return (
|
||||
<>
|
||||
{modalContextHolder}
|
||||
<div className="px-24px flex flex-col gap-y-[20px]">
|
||||
<ProjectTemplateGroup title="基础">
|
||||
<CreateEmptyProjectUI onClick={onCreateProject} />
|
||||
{renderTemplateList(baseTemplateList)}
|
||||
</ProjectTemplateGroup>
|
||||
{categories.map(category => {
|
||||
const productList = productGroupList[category.id ?? ''];
|
||||
if (!productList?.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ProjectTemplateGroup key={category.id} title={category.name}>
|
||||
{renderTemplateList(productGroupList[category.id ?? ''] ?? [])}
|
||||
</ProjectTemplateGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProjectTemplateModal: React.FC<
|
||||
Omit<ModalProps, 'size' | 'title' | 'className' | 'footer'> &
|
||||
ProjectTemplateBaseProps
|
||||
> = ({
|
||||
spaceId,
|
||||
isSelectSpaceOnCopy,
|
||||
onCopyOk,
|
||||
onCreateProject,
|
||||
onBeforeCopy,
|
||||
onCopyError,
|
||||
...props
|
||||
}) => (
|
||||
<Modal
|
||||
size="xxl"
|
||||
title={I18n.t('creat_project_templates')}
|
||||
className={styles['project-template-modal']}
|
||||
footer={null}
|
||||
{...props}
|
||||
>
|
||||
<ProjectTemplateContent
|
||||
spaceId={spaceId}
|
||||
isSelectSpaceOnCopy={isSelectSpaceOnCopy}
|
||||
onCopyOk={onCopyOk}
|
||||
onCreateProject={onCreateProject}
|
||||
onBeforeCopy={onBeforeCopy}
|
||||
onCopyError={onCopyError}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
.template-card-mask {
|
||||
--template-card-mask-start: rgba(var(--coze-bg-3), 0);
|
||||
--template-card-mask-end: rgba(var(--coze-bg-3), 0.96);
|
||||
|
||||
background: linear-gradient(180deg, var(--template-card-mask-start) 0%, var(--template-card-mask-end) 100%);
|
||||
}
|
||||
|
||||
.template-group {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px 1%;
|
||||
// 根据 gpt 设置 1% 会根据父容器宽度计算
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { IconCozPlusFill } from '@coze-arch/coze-design/icons';
|
||||
|
||||
import { ProjectTemplateCardUI } from './project-template-card';
|
||||
|
||||
export const CreateEmptyProjectUI: React.FC<{
|
||||
onClick: (() => void) | undefined;
|
||||
}> = ({ onClick }) => (
|
||||
<ProjectTemplateCardUI
|
||||
onClick={onClick}
|
||||
className="h-200px flex items-center justify-center flex-col coz-fg-primary"
|
||||
>
|
||||
<IconCozPlusFill />
|
||||
<div className="py-6px px-8px text-[14px] leading-[20px] font-medium">
|
||||
{I18n.t('creat_project_creat_new_project')}
|
||||
</div>
|
||||
</ProjectTemplateCardUI>
|
||||
);
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
type MouseEventHandler,
|
||||
type PropsWithChildren,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useHover } from 'ahooks';
|
||||
import { TeaExposure } from '@coze-studio/components';
|
||||
import { type ProductInfo } from '@coze-arch/idl/product_api';
|
||||
import { I18n } from '@coze-arch/i18n';
|
||||
import { openNewWindow } from '@coze-arch/bot-utils';
|
||||
import { extractTemplateActionCommonParams } from '@coze-arch/bot-tea/utils';
|
||||
import {
|
||||
EVENT_NAMES,
|
||||
type ParamsTypeDefine,
|
||||
sendTeaEvent,
|
||||
} from '@coze-arch/bot-tea';
|
||||
import { Button, Image } from '@coze-arch/coze-design';
|
||||
|
||||
import styles from './card.module.less';
|
||||
|
||||
export interface ProjectTemplateCardContentProps {
|
||||
/** 埋点参数 页面来源 */
|
||||
viewSource: ParamsTypeDefine[EVENT_NAMES.template_action_front]['source'];
|
||||
product: ProductInfo;
|
||||
onCopyTemplate?: (param: { name: string; id: string }) => void;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const openTemplatePreview = (templateId: string) => {
|
||||
const url = new URL(
|
||||
`/template/project/${templateId}`,
|
||||
window.location.origin,
|
||||
);
|
||||
openNewWindow(() => url.toString());
|
||||
};
|
||||
|
||||
const ActionButton: React.FC<ProjectTemplateCardContentProps> = ({
|
||||
viewSource,
|
||||
product,
|
||||
className,
|
||||
onCopyTemplate,
|
||||
}) => {
|
||||
const onPreview: MouseEventHandler<HTMLButtonElement> = e => {
|
||||
e.stopPropagation();
|
||||
sendTeaEvent(EVENT_NAMES.template_action_front, {
|
||||
action: 'click',
|
||||
source: viewSource,
|
||||
...extractTemplateActionCommonParams(product),
|
||||
});
|
||||
openTemplatePreview(product.meta_info.id ?? '');
|
||||
};
|
||||
const onCopy: MouseEventHandler<HTMLButtonElement> = e => {
|
||||
e.stopPropagation();
|
||||
onCopyTemplate?.({
|
||||
name: product.meta_info.name ?? '',
|
||||
id: product.meta_info.id ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
const isShowCopyActionButton = !product.meta_info.is_professional;
|
||||
|
||||
return (
|
||||
<div className={classNames('w-full px-12px', className)}>
|
||||
<div
|
||||
className={classNames('w-full h-24px', styles['template-card-mask'])}
|
||||
/>
|
||||
<div className="w-full flex justify-between pt-8px coz-bg-max gap-x-8px">
|
||||
<Button color="highlight" className="flex-[1]" onClick={onPreview}>
|
||||
{I18n.t('creat_project_use_template_preview')}
|
||||
</Button>
|
||||
{isShowCopyActionButton ? (
|
||||
<Button color="hgltplus" className="flex-[1]" onClick={onCopy}>
|
||||
{I18n.t('creat_project_use_template_use')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProjectTemplateCardUI = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<{ className?: string; onClick?: () => void }>
|
||||
>(({ className, children, onClick }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'cursor-pointer p-12px coz-bg-max coz-stroke-primary border-solid border-[1px] hover:coz-shadow-default rounded-[16px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
export const ProjectTemplateCard: React.FC<ProjectTemplateCardContentProps> = ({
|
||||
viewSource,
|
||||
product,
|
||||
onCopyTemplate,
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const isHover = useHover(divRef);
|
||||
return (
|
||||
<ProjectTemplateCardUI
|
||||
ref={divRef}
|
||||
className={classNames('relative', className)}
|
||||
onClick={() => {
|
||||
sendTeaEvent(EVENT_NAMES.template_action_front, {
|
||||
action: 'click',
|
||||
source: viewSource,
|
||||
...extractTemplateActionCommonParams(product),
|
||||
});
|
||||
onClick?.();
|
||||
}}
|
||||
>
|
||||
<TeaExposure
|
||||
once
|
||||
teaEvent={{
|
||||
name: EVENT_NAMES.template_action_front,
|
||||
params: {
|
||||
...extractTemplateActionCommonParams(product),
|
||||
action: 'card_view',
|
||||
source: viewSource,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="px-4px mb-8px overflow-hidden text-ellipsis coz-fg-primary text-[14px] font-medium leading-[20px]">
|
||||
{product.meta_info.name}
|
||||
</div>
|
||||
<Image
|
||||
preview={false}
|
||||
src={product.meta_info.covers?.at(0)?.url}
|
||||
className="rounded-[16px] block w-full"
|
||||
imgCls="object-cover object-center w-full"
|
||||
height={148}
|
||||
/>
|
||||
<ActionButton
|
||||
viewSource={viewSource}
|
||||
product={product}
|
||||
onCopyTemplate={onCopyTemplate}
|
||||
className={classNames(
|
||||
'absolute left-0 bottom-[8px]',
|
||||
!isHover && 'hidden',
|
||||
)}
|
||||
/>
|
||||
</TeaExposure>
|
||||
</ProjectTemplateCardUI>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { type ReactNode, type PropsWithChildren } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import styles from './card.module.less';
|
||||
|
||||
export interface ProjectTemplateGroupProps {
|
||||
title: ReactNode | undefined;
|
||||
groupChildrenClassName?: string;
|
||||
}
|
||||
|
||||
export const ProjectTemplateGroup: React.FC<
|
||||
PropsWithChildren<ProjectTemplateGroupProps>
|
||||
> = ({ title, groupChildrenClassName, children }) => (
|
||||
<div>
|
||||
<div className="mb-8px coz-fg-plus text-[16px] font-medium leading-[22px]">
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'grid',
|
||||
styles['template-group'],
|
||||
groupChildrenClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2025 coze-dev Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Skeleton } from '@coze-arch/coze-design';
|
||||
|
||||
import { ProjectTemplateGroup } from './project-template-group';
|
||||
|
||||
export const CardSkeleton: React.FC = () => (
|
||||
<Skeleton.Image className="rounded-xl" />
|
||||
);
|
||||
|
||||
export const TemplateGroupSkeleton: React.FC = () => (
|
||||
<ProjectTemplateGroup
|
||||
title={<Skeleton.Title className="w-120px" />}
|
||||
groupChildrenClassName="h-[200px]"
|
||||
>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</ProjectTemplateGroup>
|
||||
);
|
||||
Reference in New Issue
Block a user