feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -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;
}
}
}

View File

@@ -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>
);

View File

@@ -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% 会根据父容器宽度计算
}

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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>
);