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,67 @@
/*
* 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 { Modal, Form, Input, type ModalProps } from '@coze-arch/coze-design';
export interface DeleteProjectBaseProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
}
export interface DeleteProjectModalProps
extends Omit<
ModalProps,
'size' | 'footer' | 'header' | 'okButtonColor' | 'okText' | 'cancelText'
>,
DeleteProjectBaseProps {}
const DeleteProjectContent: React.FC<DeleteProjectBaseProps> = ({
value,
onChange,
placeholder,
}) => (
<>
<div className="coz-fg-secondary leading-20px text-[14px] font-normal mb-16px">
{I18n.t('project_ide_delete_confirm_describe')}
</div>
<Form.Label required>{I18n.t('project_ide_project_name')}</Form.Label>
<Input value={value} onChange={onChange} placeholder={placeholder} />
</>
);
export const DeleteProjectModal: React.FC<DeleteProjectModalProps> = ({
value,
onChange,
placeholder,
...restModalProps
}) => (
<Modal
size="default"
header={I18n.t('project_ide_delete_confirm')}
okButtonColor="red"
okText={I18n.t('project_ide_delete')}
cancelText={I18n.t('Cancel')}
{...restModalProps}
>
<DeleteProjectContent
value={value}
onChange={onChange}
placeholder={placeholder}
/>
</Modal>
);

View File

@@ -0,0 +1,37 @@
/* stylelint-disable declaration-no-important */
.guide-modal {
: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;
}
:global(.semi-modal-footer) {
display: none;
}
}
.guide-button {
box-shadow: -8px -18px 30px 0 #DAE6F7 inset;
}
.guide-button-hover {
&:hover {
.guide-img-bg {
background: linear-gradient(29deg, rgba(79, 62, 255, 17%) 15.59%, rgba(242, 146, 255, 14%) 42.22%, rgba(242, 146, 255, 0%) 72.41%), var(--coz-bg-primary, #F8F8FC);
}
.guide-desc-hover {
opacity: 0;
}
.create-button-hover {
opacity: 1;
}
}
}

View File

@@ -0,0 +1,182 @@
/*
* 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 } from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozCross } from '@coze-arch/coze-design/icons';
import {
Badge,
Button,
Avatar,
Modal,
type ModalProps,
} from '@coze-arch/coze-design';
import { useHiddenSession } from '../../hooks/use-hidden-session';
import ProjectImg from '../../assets/project-img.png';
import ProjectImgOversea from '../../assets/project-img-oversea.png';
import AgentImg from '../../assets/agent-img.png';
import AgentImgOversea from '../../assets/agent-img-oversea.png';
import styles from './index.module.less';
export type CreateType = 'project' | 'agent';
export interface GuideModalProps
extends Omit<ModalProps, 'size' | 'footer' | 'header' | 'onCancel'> {
onCancel: () => void;
onChange: (type: CreateType) => void;
extraButtonConfigs?: GuideButtonProps[];
}
interface GuideButtonProps {
onClick: () => void;
assetSrc: string;
title: ReactNode;
description: ReactNode;
tip?: ReactNode;
}
export const GuideButton: React.FC<GuideButtonProps> = ({
onClick,
assetSrc,
title,
description,
tip,
}) => {
const { isSessionHidden, hideSession } = useHiddenSession(
tip ? 'guideTip' : '',
);
const showTip = !isSessionHidden && Boolean(tip);
return (
<div
onClick={onClick}
className={classNames(
'relative cursor-pointer p-8px pb-16px hover:coz-shadow-default coz-bg-max coz-stroke-primary border-[1px] border-solid rounded-[12px] flex flex-col items-center',
styles['guide-button-hover'],
)}
>
<Avatar
src={assetSrc}
className={classNames(
'w-[314px] h-[240px] rounded-[8px] coz-bg-secondary',
styles['guide-img-bg'],
styles['guide-button'],
{
'!mb-[-28px]': showTip,
},
)}
imgCls="w-full h-full"
bottomSlot={{
render: () =>
showTip ? (
<div className="z-10 px-2 coz-fg-hglt text-[12px] font-medium w-full flex justify-center items-center h-[28px] rounded-[4px] rounded-t-none bg-[#DEDBFF]">
<div className="mx-auto">{tip}</div>
<IconCozCross
className="w-[12px] h-[12px]"
onClick={e => {
e.stopPropagation();
hideSession();
}}
/>
</div>
) : null,
text: tip,
textColor: '',
bgColor: '#DEDBFF',
className: '',
}}
/>
<div className="mb-[4px] mt-[20px] coz-fg-plus text-[20px] font-medium leading-[28px]">
{title}
</div>
<div
className={classNames(
'mb-[8px] coz-fg-secondary text-[14px] font-normal leading-[20px] opacity-100',
styles['guide-desc-hover'],
)}
>
{description}
</div>
<div
className={classNames(
'absolute w-full flex justify-center left-0 bottom-[12px] opacity-0',
styles['create-button-hover'],
)}
>
<Button>{I18n.t('create_title')}</Button>
</div>
</div>
);
};
const ProjectAsset = IS_OVERSEA ? ProjectImgOversea : ProjectImg;
const AgentAsset = IS_OVERSEA ? AgentImgOversea : AgentImg;
export const GuideModal: React.FC<GuideModalProps> = ({
onChange,
extraButtonConfigs = [],
...modalProps
}) => (
<Modal
// 清除 modal 自带边距 由内部 padding 撑开 展示按钮阴影
className={styles['guide-modal']}
size="xl"
title={I18n.t('create_title')}
width={'fit-content'}
{...modalProps}
>
<div className="flex justify-between pl-24px pb-24px pr-24px gap-[8px]">
<GuideButton
onClick={() => onChange('agent')}
assetSrc={AgentAsset}
title={I18n.t('creat_project_creat_agent')}
description={I18n.t('creat_project_agent_describe')}
tip={!IS_OPEN_SOURCE ? I18n.t('agent_creat_tips') : null}
/>
<GuideButton
onClick={() => onChange('project')}
assetSrc={ProjectAsset}
title={
<span className="flex gap-x-4px items-center">
{I18n.t('creat_project_creat_project')}
<Badge count="Beta" type="alt" />
</span>
}
description={
IS_OPEN_SOURCE
? I18n.t('creat_project_describe_open')
: I18n.t('creat_project_describe')
}
/>
{extraButtonConfigs.map(({ onClick, ...config }, index) => (
<GuideButton
key={index}
onClick={() => {
modalProps.onCancel();
onClick();
}}
{...config}
/>
))}
</div>
</Modal>
);
GuideModal.displayName = 'GuideModal';

View File

@@ -0,0 +1,135 @@
/*
* 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 { useRef, useState, Suspense, lazy } from 'react';
import { SpaceFormSelect } from '@coze-studio/components';
import { type AuditData } from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { type RenderAutoGenerateParams } from '@coze-common/biz-components/picture-upload';
import { type FormApi, Modal, type ModalProps } from '@coze-arch/coze-design';
import {
filedKeyMap,
ProjectForm,
type ProjectFormProps,
type ProjectFormValues,
ProjectInfoFieldFragment,
} from '../project-form';
import { useFormSubmitState } from '../../hooks/use-project-form-submit-state';
const LazyReactMarkdown = lazy(() => import('react-markdown'));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ReactMarkdown = (props: any) => (
<Suspense fallback={null}>
<LazyReactMarkdown {...props} />
</Suspense>
);
interface ProjectFormModalProps
extends Omit<
ModalProps,
'size' | 'okText' | 'cancelText' | 'okButtonProps' | 'onOk'
> {
/** @default false */
showMonetizeConfig?: boolean;
selectSpace?: boolean;
formProps?: Omit<ProjectFormProps, 'getFormApi' | 'onValueChange'>;
request: (param: ProjectFormValues) => Promise<AuditData>;
isFormValid: (values: ProjectFormValues) => boolean;
}
export type BizProjectFormModalProps = ProjectFormModalProps & {
renderAutoGenerate?: (params: RenderAutoGenerateParams) => React.ReactNode;
};
export const ProjectFormModal: React.FC<BizProjectFormModalProps> = ({
selectSpace,
formProps = {},
isFormValid,
request,
showMonetizeConfig,
renderAutoGenerate,
...restModalProps
}) => {
const [loading, setLoading] = useState(false);
const [auditResult, setAuditResult] = useState<AuditData>({
check_not_pass: false,
});
const {
bizCallback: { onAfterUpload, onBeforeUpload, onValuesChange },
isSubmitDisabled,
} = useFormSubmitState<ProjectFormValues>({
initialValues: formProps.initValues,
getIsFormValid: isFormValid,
});
const formApi = useRef<FormApi<ProjectFormValues>>();
const onFormSubmit: ModalProps['onOk'] = async () => {
if (!formApi.current) {
return;
}
try {
setLoading(true);
const auditData = await request(formApi.current.getValues());
setAuditResult(auditData);
// 没有通过校验就不关闭弹窗
if (auditData.check_not_pass) {
return;
}
} finally {
setLoading(false);
}
};
return (
<Modal
size="default"
okText={I18n.t('Confirm')}
cancelText={I18n.t('Cancel')}
okButtonProps={{
disabled: isSubmitDisabled,
loading,
}}
onOk={onFormSubmit}
{...restModalProps}
>
<ProjectForm
{...formProps}
getFormApi={api => {
formApi.current = api;
}}
onValueChange={onValuesChange}
>
{selectSpace ? <SpaceFormSelect field={filedKeyMap.space_id} /> : null}
<ProjectInfoFieldFragment
showMonetizeConfig={showMonetizeConfig}
onBeforeUpload={onBeforeUpload}
onAfterUpload={onAfterUpload}
renderAutoGenerate={renderAutoGenerate}
/>
</ProjectForm>
{auditResult.check_not_pass ? (
<div className="coz-fg-hglt-red mt-[-8px]">
<ReactMarkdown skipHtml={true} linkTarget="_blank">
{/* 注意使用 || msg undefined 或者空字符串都走兜底 */}
{auditResult.check_not_pass_msg || I18n.t('publish_audit_pop7')}
</ReactMarkdown>
</div>
) : null}
</Modal>
);
};

View File

@@ -0,0 +1,143 @@
/*
* 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 PropsWithChildren } from 'react';
import {
type DraftProjectCopyRequest,
type DraftProjectUpdateRequest,
type DraftProjectCreateRequest,
} from '@coze-arch/idl/intelligence_api';
import { I18n } from '@coze-arch/i18n';
import { FileBizType, IconType } from '@coze-arch/bot-api/developer_api';
import {
PictureUpload,
type RenderAutoGenerateParams,
} from '@coze-common/biz-components/picture-upload';
import { botInputLengthService } from '@coze-agent-ide/bot-input-length-limit';
import { IconCozUpload } from '@coze-arch/coze-design/icons';
import {
type BaseFormProps,
Form,
FormInput,
FormTextArea,
useFormApi,
withField,
} from '@coze-arch/coze-design';
import { SwitchWithDesc } from '../switch-with-desc';
import { type ModifyUploadValueType } from '../../type';
export type ProjectFormValues = ModifyUploadValueType<
Omit<DraftProjectCreateRequest, 'monetization_conf' | 'create_from'> &
DraftProjectCopyRequest &
DraftProjectUpdateRequest & {
enableMonetize?: boolean;
}
>;
export type ProjectFormSubmitValues = DraftProjectCreateRequest;
export type ProjectFormProps = BaseFormProps<ProjectFormValues>;
export interface ProjectInfoFieldProps {
/** @default false */
showMonetizeConfig?: boolean;
onBeforeUpload?: () => void;
onAfterUpload?: () => void;
renderAutoGenerate?: (params: RenderAutoGenerateParams) => React.ReactNode;
}
export const ProjectForm: React.FC<PropsWithChildren<ProjectFormProps>> = ({
children,
...formProps
}) => <Form<ProjectFormValues> {...formProps}>{children}</Form>;
export const filedKeyMap: Record<
keyof ProjectFormValues,
keyof ProjectFormValues
> = {
name: 'name',
enableMonetize: 'enableMonetize',
description: 'description',
icon_uri: 'icon_uri',
space_id: 'space_id',
project_id: 'project_id',
to_space_id: 'to_space_id',
} as const;
export const ProjectInfoFieldFragment: React.FC<ProjectInfoFieldProps> = ({
showMonetizeConfig,
onAfterUpload,
onBeforeUpload,
renderAutoGenerate,
}) => {
const formApi = useFormApi<ProjectFormValues>();
return (
<>
<FormInput
label={I18n.t('creat_project_project_name')}
rules={[{ required: true }]}
field={filedKeyMap.name}
maxLength={botInputLengthService.getInputLengthLimit('projectName')}
getValueLength={botInputLengthService.getValueLength}
noErrorMessage
/>
{showMonetizeConfig ? (
<FormSwitch
field={filedKeyMap.enableMonetize}
label={I18n.t('monetization')}
desc={I18n.t('monetization_des')}
initValue={true}
rules={[{ required: true }]}
/>
) : null}
<FormTextArea
label={I18n.t('creat_project_project_describe')}
field={filedKeyMap.description}
maxCount={botInputLengthService.getInputLengthLimit(
'projectDescription',
)}
maxLength={botInputLengthService.getInputLengthLimit(
'projectDescription',
)}
getValueLength={botInputLengthService.getValueLength}
/>
<PictureUpload
accept=".jpeg,.jpg,.png,.gif"
label={I18n.t('bot_edit_profile_pircture')}
field={filedKeyMap.icon_uri}
rules={[{ required: true }]}
fileBizType={FileBizType.BIZ_BOT_ICON}
iconType={IconType.Bot}
maskIcon={<IconCozUpload />}
withAutoGenerate
renderAutoGenerate={renderAutoGenerate}
generateInfo={() => {
const values = formApi.getValues();
return {
name: values?.name,
desc: values?.description,
};
}}
beforeUploadCustom={onBeforeUpload}
afterUploadCustom={onAfterUpload}
/>
</>
);
};
const FormSwitch = withField(SwitchWithDesc);

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

View File

@@ -0,0 +1,46 @@
/*
* 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 cls from 'classnames';
import { Switch, type SwitchProps } from '@coze-arch/coze-design';
export function SwitchWithDesc({
value,
onChange,
className,
desc,
descClassName,
switchClassName,
...rest
}: Omit<SwitchProps, 'checked'> & {
value?: boolean;
desc: string;
descClassName?: string;
switchClassName?: string;
}) {
return (
<div className={cls('flex items-center justify-between', className)}>
<span className={cls('coz-fg-primary', descClassName)}>{desc}</span>
<Switch
size="small"
{...rest}
checked={value}
onChange={onChange}
className={cls('shrink-0', switchClassName)}
/>
</div>
);
}