🎨 chore(web): apply ESLint and Prettier auto-fixes (baseline)
- Ran: bun run eslint:fix && bun run lint:fix - Inserted AGPL license header via eslint-plugin-header - Enforced no-multiple-empty-lines and other lint rules - Formatted code using Prettier v3 (@so1ve/prettier-config) - No functional changes; formatting-only baseline across JS/JSX files
This commit is contained in:
@@ -160,7 +160,7 @@ export function PreCode(props) {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="copy-code-button"
|
||||
className='copy-code-button'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
@@ -174,14 +174,15 @@ export function PreCode(props) {
|
||||
>
|
||||
<Tooltip content={t('复制代码')}>
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
size='small'
|
||||
theme='borderless'
|
||||
icon={<IconCopy />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref.current) {
|
||||
const code = ref.current.querySelector('code')?.innerText ?? '';
|
||||
const code =
|
||||
ref.current.querySelector('code')?.innerText ?? '';
|
||||
copy(code).then((success) => {
|
||||
if (success) {
|
||||
Toast.success(t('代码已复制到剪贴板'));
|
||||
@@ -217,7 +218,13 @@ export function PreCode(props) {
|
||||
backgroundColor: 'var(--semi-color-bg-1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '8px', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
>
|
||||
HTML预览:
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: htmlCode }} />
|
||||
@@ -258,7 +265,7 @@ function CustomCode(props) {
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Button size="small" onClick={toggleCollapsed} theme="solid">
|
||||
<Button size='small' onClick={toggleCollapsed} theme='solid'>
|
||||
{t('显示更多')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -367,7 +374,16 @@ function _MarkdownContent(props) {
|
||||
components={{
|
||||
pre: PreCode,
|
||||
code: CustomCode,
|
||||
p: (pProps) => <p {...pProps} dir="auto" style={{ lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
p: (pProps) => (
|
||||
<p
|
||||
{...pProps}
|
||||
dir='auto'
|
||||
style={{
|
||||
lineHeight: '1.6',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
a: (aProps) => {
|
||||
const href = aProps.href || '';
|
||||
if (/\.(aac|mp3|opus|wav)$/.test(href)) {
|
||||
@@ -379,13 +395,16 @@ function _MarkdownContent(props) {
|
||||
}
|
||||
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
|
||||
return (
|
||||
<video controls style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}>
|
||||
<video
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}
|
||||
>
|
||||
<source src={href} />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
const isInternal = /^\/#/i.test(href);
|
||||
const target = isInternal ? '_self' : aProps.target ?? '_blank';
|
||||
const target = isInternal ? '_self' : (aProps.target ?? '_blank');
|
||||
return (
|
||||
<a
|
||||
{...aProps}
|
||||
@@ -403,20 +422,84 @@ function _MarkdownContent(props) {
|
||||
/>
|
||||
);
|
||||
},
|
||||
h1: (props) => <h1 {...props} style={{ fontSize: '24px', fontWeight: 'bold', margin: '20px 0 12px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h2: (props) => <h2 {...props} style={{ fontSize: '20px', fontWeight: 'bold', margin: '18px 0 10px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h3: (props) => <h3 {...props} style={{ fontSize: '18px', fontWeight: 'bold', margin: '16px 0 8px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h4: (props) => <h4 {...props} style={{ fontSize: '16px', fontWeight: 'bold', margin: '14px 0 6px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h5: (props) => <h5 {...props} style={{ fontSize: '14px', fontWeight: 'bold', margin: '12px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h6: (props) => <h6 {...props} style={{ fontSize: '13px', fontWeight: 'bold', margin: '10px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h1: (props) => (
|
||||
<h1
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '20px 0 12px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h2: (props) => (
|
||||
<h2
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
margin: '18px 0 10px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h3: (props) => (
|
||||
<h3
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
margin: '16px 0 8px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h4: (props) => (
|
||||
<h4
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
margin: '14px 0 6px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h5: (props) => (
|
||||
<h5
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
margin: '12px 0 4px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h6: (props) => (
|
||||
<h6
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
margin: '10px 0 4px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
blockquote: (props) => (
|
||||
<blockquote
|
||||
{...props}
|
||||
style={{
|
||||
borderLeft: isUserMessage ? '4px solid rgba(255, 255, 255, 0.5)' : '4px solid var(--semi-color-primary)',
|
||||
borderLeft: isUserMessage
|
||||
? '4px solid rgba(255, 255, 255, 0.5)'
|
||||
: '4px solid var(--semi-color-primary)',
|
||||
paddingLeft: '16px',
|
||||
margin: '12px 0',
|
||||
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.1)' : 'var(--semi-color-fill-0)',
|
||||
backgroundColor: isUserMessage
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'var(--semi-color-fill-0)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
fontStyle: 'italic',
|
||||
@@ -424,9 +507,36 @@ function _MarkdownContent(props) {
|
||||
}}
|
||||
/>
|
||||
),
|
||||
ul: (props) => <ul {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
ol: (props) => <ol {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
li: (props) => <li {...props} style={{ margin: '4px 0', lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
ul: (props) => (
|
||||
<ul
|
||||
{...props}
|
||||
style={{
|
||||
margin: '8px 0',
|
||||
paddingLeft: '20px',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
ol: (props) => (
|
||||
<ol
|
||||
{...props}
|
||||
style={{
|
||||
margin: '8px 0',
|
||||
paddingLeft: '20px',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
li: (props) => (
|
||||
<li
|
||||
{...props}
|
||||
style={{
|
||||
margin: '4px 0',
|
||||
lineHeight: '1.6',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
table: (props) => (
|
||||
<div style={{ overflow: 'auto', margin: '12px 0' }}>
|
||||
<table
|
||||
@@ -434,7 +544,9 @@ function _MarkdownContent(props) {
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
|
||||
border: isUserMessage
|
||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
@@ -446,8 +558,12 @@ function _MarkdownContent(props) {
|
||||
{...props}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.2)' : 'var(--semi-color-fill-1)',
|
||||
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
|
||||
backgroundColor: isUserMessage
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: 'var(--semi-color-fill-1)',
|
||||
border: isUserMessage
|
||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'left',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
@@ -459,7 +575,9 @@ function _MarkdownContent(props) {
|
||||
{...props}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
|
||||
border: isUserMessage
|
||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
@@ -496,25 +614,29 @@ export function MarkdownRenderer(props) {
|
||||
color: 'var(--semi-color-text-0)',
|
||||
...style,
|
||||
}}
|
||||
dir="auto"
|
||||
dir='auto'
|
||||
{...otherProps}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '16px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid var(--semi-color-border)',
|
||||
borderTop: '2px solid var(--semi-color-primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '16px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid var(--semi-color-border)',
|
||||
borderTop: '2px solid var(--semi-color-primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}}
|
||||
/>
|
||||
正在渲染...
|
||||
</div>
|
||||
) : (
|
||||
@@ -529,4 +651,4 @@ export function MarkdownRenderer(props) {
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownRenderer;
|
||||
export default MarkdownRenderer;
|
||||
|
||||
@@ -59,12 +59,12 @@
|
||||
}
|
||||
|
||||
.user-message a {
|
||||
color: #87CEEB !important;
|
||||
color: #87ceeb !important;
|
||||
/* 浅蓝色链接 */
|
||||
}
|
||||
|
||||
.user-message a:hover {
|
||||
color: #B0E0E6 !important;
|
||||
color: #b0e0e6 !important;
|
||||
/* hover时更浅的蓝色 */
|
||||
}
|
||||
|
||||
@@ -298,7 +298,12 @@ pre:hover .copy-code-button {
|
||||
.markdown-body hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--semi-color-border), transparent);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--semi-color-border),
|
||||
transparent
|
||||
);
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
@@ -332,7 +337,7 @@ pre:hover .copy-code-button {
|
||||
}
|
||||
|
||||
/* 任务列表样式 */
|
||||
.markdown-body input[type="checkbox"] {
|
||||
.markdown-body input[type='checkbox'] {
|
||||
margin-right: 8px;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
@@ -441,4 +446,4 @@ pre:hover .copy-code-button {
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const TwoFactorAuthModal = ({
|
||||
onCancel,
|
||||
title,
|
||||
description,
|
||||
placeholder
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -56,10 +56,18 @@ const TwoFactorAuthModal = ({
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3">
|
||||
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||
<div className='flex items-center'>
|
||||
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
|
||||
<svg
|
||||
className='w-4 h-4 text-blue-600 dark:text-blue-400'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{title || t('安全验证')}
|
||||
@@ -69,11 +77,9 @@ const TwoFactorAuthModal = ({
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button onClick={onCancel}>{t('取消')}</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
type='primary'
|
||||
loading={loading}
|
||||
disabled={!code || loading}
|
||||
onClick={onVerify}
|
||||
@@ -85,18 +91,29 @@ const TwoFactorAuthModal = ({
|
||||
width={500}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className='space-y-6'>
|
||||
{/* 安全提示 */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
|
||||
<div className='flex items-start'>
|
||||
<svg
|
||||
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<Typography.Text strong className="text-blue-800 dark:text-blue-200">
|
||||
<Typography.Text
|
||||
strong
|
||||
className='text-blue-800 dark:text-blue-200'
|
||||
>
|
||||
{t('安全验证')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="block text-blue-700 dark:text-blue-300 text-sm mt-1">
|
||||
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
|
||||
{description || t('为了保护账户安全,请验证您的两步验证码。')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -105,19 +122,19 @@ const TwoFactorAuthModal = ({
|
||||
|
||||
{/* 验证码输入 */}
|
||||
<div>
|
||||
<Typography.Text strong className="block mb-2">
|
||||
<Typography.Text strong className='block mb-2'>
|
||||
{t('验证身份')}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder={placeholder || t('请输入认证器验证码或备用码')}
|
||||
value={code}
|
||||
onChange={onCodeChange}
|
||||
size="large"
|
||||
size='large'
|
||||
maxLength={8}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<Typography.Text type="tertiary" size="small" className="mt-2 block">
|
||||
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||
{t('支持6位TOTP验证码或8位备用码')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -126,4 +143,4 @@ const TwoFactorAuthModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorAuthModal;
|
||||
export default TwoFactorAuthModal;
|
||||
|
||||
@@ -27,15 +27,15 @@ const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* CardPro 高级卡片组件
|
||||
*
|
||||
*
|
||||
* 布局分为6个区域:
|
||||
* 1. 统计信息区域 (statsArea)
|
||||
* 2. 描述信息区域 (descriptionArea)
|
||||
* 2. 描述信息区域 (descriptionArea)
|
||||
* 3. 类型切换/标签区域 (tabsArea)
|
||||
* 4. 操作按钮区域 (actionsArea)
|
||||
* 5. 搜索表单区域 (searchArea)
|
||||
* 6. 分页区域 (paginationArea) - 固定在卡片底部
|
||||
*
|
||||
*
|
||||
* 支持三种布局类型:
|
||||
* - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单
|
||||
* - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单
|
||||
@@ -71,47 +71,38 @@ const CardPro = ({
|
||||
const hasMobileHideableContent = actionsArea || searchArea;
|
||||
|
||||
const renderHeader = () => {
|
||||
const hasContent = statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
|
||||
const hasContent =
|
||||
statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
|
||||
if (!hasContent) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className='flex flex-col w-full'>
|
||||
{/* 统计信息区域 - 用于type2 */}
|
||||
{type === 'type2' && statsArea && (
|
||||
<>
|
||||
{statsArea}
|
||||
</>
|
||||
)}
|
||||
{type === 'type2' && statsArea && <>{statsArea}</>}
|
||||
|
||||
{/* 描述信息区域 - 用于type1和type3 */}
|
||||
{(type === 'type1' || type === 'type3') && descriptionArea && (
|
||||
<>
|
||||
{descriptionArea}
|
||||
</>
|
||||
<>{descriptionArea}</>
|
||||
)}
|
||||
|
||||
{/* 第一个分隔线 - 在描述信息或统计信息后面 */}
|
||||
{((type === 'type1' || type === 'type3') && descriptionArea) ||
|
||||
(type === 'type2' && statsArea) ? (
|
||||
<Divider margin="12px" />
|
||||
(type === 'type2' && statsArea) ? (
|
||||
<Divider margin='12px' />
|
||||
) : null}
|
||||
|
||||
{/* 类型切换/标签区域 - 主要用于type3 */}
|
||||
{type === 'type3' && tabsArea && (
|
||||
<>
|
||||
{tabsArea}
|
||||
</>
|
||||
)}
|
||||
{type === 'type3' && tabsArea && <>{tabsArea}</>}
|
||||
|
||||
{/* 移动端操作切换按钮 */}
|
||||
{isMobile && hasMobileHideableContent && (
|
||||
<>
|
||||
<div className="w-full mb-2">
|
||||
<div className='w-full mb-2'>
|
||||
<Button
|
||||
onClick={toggleMobileActions}
|
||||
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
|
||||
type="tertiary"
|
||||
size="small"
|
||||
type='tertiary'
|
||||
size='small'
|
||||
theme='outline'
|
||||
block
|
||||
>
|
||||
@@ -126,32 +117,24 @@ const CardPro = ({
|
||||
className={`flex flex-col gap-2 ${isMobile && !showMobileActions ? 'hidden' : ''}`}
|
||||
>
|
||||
{/* 操作按钮区域 - 用于type1和type3 */}
|
||||
{(type === 'type1' || type === 'type3') && actionsArea && (
|
||||
Array.isArray(actionsArea) ? (
|
||||
{(type === 'type1' || type === 'type3') &&
|
||||
actionsArea &&
|
||||
(Array.isArray(actionsArea) ? (
|
||||
actionsArea.map((area, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{idx !== 0 && <Divider />}
|
||||
<div className="w-full">
|
||||
{area}
|
||||
</div>
|
||||
<div className='w-full'>{area}</div>
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{actionsArea}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className='w-full'>{actionsArea}</div>
|
||||
))}
|
||||
|
||||
{/* 当同时存在操作区和搜索区时,插入分隔线 */}
|
||||
{(actionsArea && searchArea) && <Divider />}
|
||||
{actionsArea && searchArea && <Divider />}
|
||||
|
||||
{/* 搜索表单区域 - 所有类型都可能有 */}
|
||||
{searchArea && (
|
||||
<div className="w-full">
|
||||
{searchArea}
|
||||
</div>
|
||||
)}
|
||||
{searchArea && <div className='w-full'>{searchArea}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -214,4 +197,4 @@ CardPro.propTypes = {
|
||||
t: PropTypes.func,
|
||||
};
|
||||
|
||||
export default CardPro;
|
||||
export default CardPro;
|
||||
|
||||
@@ -19,7 +19,15 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Table, Card, Skeleton, Pagination, Empty, Button, Collapsible } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Table,
|
||||
Card,
|
||||
Skeleton,
|
||||
Pagination,
|
||||
Empty,
|
||||
Button,
|
||||
Collapsible,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
@@ -27,7 +35,7 @@ import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTi
|
||||
|
||||
/**
|
||||
* CardTable 响应式表格组件
|
||||
*
|
||||
*
|
||||
* 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。
|
||||
* 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。
|
||||
*/
|
||||
@@ -75,18 +83,22 @@ const CardTable = ({
|
||||
|
||||
const renderSkeletonCard = (key) => {
|
||||
const placeholder = (
|
||||
<div className="p-2">
|
||||
<div className='p-2'>
|
||||
{visibleCols.map((col, idx) => {
|
||||
if (!col.title) {
|
||||
return (
|
||||
<div key={idx} className="mt-2 flex justify-end">
|
||||
<div key={idx} className='mt-2 flex justify-end'>
|
||||
<Skeleton.Title active style={{ width: 100, height: 24 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex justify-between items-center py-1 border-b last:border-b-0 border-dashed" style={{ borderColor: 'var(--semi-color-border)' }}>
|
||||
<div
|
||||
key={idx}
|
||||
className='flex justify-between items-center py-1 border-b last:border-b-0 border-dashed'
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
<Skeleton.Title active style={{ width: 80, height: 14 }} />
|
||||
<Skeleton.Title
|
||||
active
|
||||
@@ -103,14 +115,14 @@ const CardTable = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={key} className="!rounded-2xl shadow-sm">
|
||||
<Card key={key} className='!rounded-2xl shadow-sm'>
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className='flex flex-col gap-2'>
|
||||
{[1, 2, 3].map((i) => renderSkeletonCard(i))}
|
||||
</div>
|
||||
);
|
||||
@@ -127,9 +139,12 @@ const CardTable = ({
|
||||
(!tableProps.rowExpandable || tableProps.rowExpandable(record));
|
||||
|
||||
return (
|
||||
<Card key={rowKeyVal} className="!rounded-2xl shadow-sm">
|
||||
<Card key={rowKeyVal} className='!rounded-2xl shadow-sm'>
|
||||
{columns.map((col, colIdx) => {
|
||||
if (tableProps?.visibleColumns && !tableProps.visibleColumns[col.key]) {
|
||||
if (
|
||||
tableProps?.visibleColumns &&
|
||||
!tableProps.visibleColumns[col.key]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -140,7 +155,7 @@ const CardTable = ({
|
||||
|
||||
if (!title) {
|
||||
return (
|
||||
<div key={col.key || colIdx} className="mt-2 flex justify-end">
|
||||
<div key={col.key || colIdx} className='mt-2 flex justify-end'>
|
||||
{cellContent}
|
||||
</div>
|
||||
);
|
||||
@@ -149,14 +164,16 @@ const CardTable = ({
|
||||
return (
|
||||
<div
|
||||
key={col.key || colIdx}
|
||||
className="flex justify-between items-start py-1 border-b last:border-b-0 border-dashed"
|
||||
className='flex justify-between items-start py-1 border-b last:border-b-0 border-dashed'
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
<span className="font-medium text-gray-600 mr-2 whitespace-nowrap select-none">
|
||||
<span className='font-medium text-gray-600 mr-2 whitespace-nowrap select-none'>
|
||||
{title}
|
||||
</span>
|
||||
<div className="flex-1 break-all flex justify-end items-center gap-1">
|
||||
{cellContent !== undefined && cellContent !== null ? cellContent : '-'}
|
||||
<div className='flex-1 break-all flex justify-end items-center gap-1'>
|
||||
{cellContent !== undefined && cellContent !== null
|
||||
? cellContent
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -177,7 +194,7 @@ const CardTable = ({
|
||||
{showDetails ? t('收起') : t('详情')}
|
||||
</Button>
|
||||
<Collapsible isOpen={showDetails} keepDOM>
|
||||
<div className="pt-2">
|
||||
<div className='pt-2'>
|
||||
{tableProps.expandedRowRender(record, index)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
@@ -190,19 +207,23 @@ const CardTable = ({
|
||||
if (isEmpty) {
|
||||
if (tableProps.empty) return tableProps.empty;
|
||||
return (
|
||||
<div className="flex justify-center p-4">
|
||||
<Empty description="No Data" />
|
||||
<div className='flex justify-center p-4'>
|
||||
<Empty description='No Data' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className='flex flex-col gap-2'>
|
||||
{dataSource.map((record, index) => (
|
||||
<MobileRowCard key={getRowKey(record, index)} record={record} index={index} />
|
||||
<MobileRowCard
|
||||
key={getRowKey(record, index)}
|
||||
record={record}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{!hidePagination && tableProps.pagination && dataSource.length > 0 && (
|
||||
<div className="mt-2 flex justify-center">
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<Pagination {...tableProps.pagination} />
|
||||
</div>
|
||||
)}
|
||||
@@ -218,4 +239,4 @@ CardTable.propTypes = {
|
||||
hidePagination: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default CardTable;
|
||||
export default CardTable;
|
||||
|
||||
@@ -30,9 +30,9 @@ import { copy, showSuccess } from '../../../helpers';
|
||||
*/
|
||||
const parseChannelKeys = (keyData, t) => {
|
||||
if (!keyData) return [];
|
||||
|
||||
|
||||
const trimmed = keyData.trim();
|
||||
|
||||
|
||||
// 检查是否是JSON数组格式(如Vertex AI)
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
@@ -40,9 +40,10 @@ const parseChannelKeys = (keyData, t) => {
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item, index) => ({
|
||||
id: index,
|
||||
content: typeof item === 'string' ? item : JSON.stringify(item, null, 2),
|
||||
content:
|
||||
typeof item === 'string' ? item : JSON.stringify(item, null, 2),
|
||||
type: typeof item === 'string' ? 'text' : 'json',
|
||||
label: `${t('密钥')} ${index + 1}`
|
||||
label: `${t('密钥')} ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -50,25 +51,27 @@ const parseChannelKeys = (keyData, t) => {
|
||||
console.warn('Failed to parse JSON keys:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 检查是否是多行密钥(按换行符分割)
|
||||
const lines = trimmed.split('\n').filter(line => line.trim());
|
||||
const lines = trimmed.split('\n').filter((line) => line.trim());
|
||||
if (lines.length > 1) {
|
||||
return lines.map((line, index) => ({
|
||||
id: index,
|
||||
content: line.trim(),
|
||||
type: 'text',
|
||||
label: `${t('密钥')} ${index + 1}`
|
||||
label: `${t('密钥')} ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
// 单个密钥
|
||||
return [{
|
||||
id: 0,
|
||||
content: trimmed,
|
||||
type: trimmed.startsWith('{') ? 'json' : 'text',
|
||||
label: t('密钥')
|
||||
}];
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
content: trimmed,
|
||||
type: trimmed.startsWith('{') ? 'json' : 'text',
|
||||
label: t('密钥'),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -85,7 +88,7 @@ const ChannelKeyDisplay = ({
|
||||
showSuccessIcon = true,
|
||||
successText,
|
||||
showWarning = true,
|
||||
warningText
|
||||
warningText,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -103,34 +106,42 @@ const ChannelKeyDisplay = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
{/* 成功状态 */}
|
||||
{showSuccessIcon && (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<svg
|
||||
className='w-5 h-5 text-green-600'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<Typography.Text strong className="text-green-700">
|
||||
<Typography.Text strong className='text-green-700'>
|
||||
{successText || t('验证成功')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 密钥内容 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Typography.Text strong>
|
||||
{isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')}
|
||||
</Typography.Text>
|
||||
{isMultipleKeys && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography.Text type="tertiary" size="small">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Typography.Text type='tertiary' size='small'>
|
||||
{t('共 {{count}} 个密钥', { count: parsedKeys.length })}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
theme="outline"
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleCopyAll}
|
||||
>
|
||||
{t('复制全部')}
|
||||
@@ -138,27 +149,40 @@ const ChannelKeyDisplay = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-80 overflow-auto">
|
||||
|
||||
<div className='space-y-3 max-h-80 overflow-auto'>
|
||||
{parsedKeys.map((keyItem) => (
|
||||
<Card key={keyItem.id} className="!rounded-lg !border !border-gray-200 dark:!border-gray-700">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.Text strong size="small" className="text-gray-700 dark:text-gray-300">
|
||||
<Card
|
||||
key={keyItem.id}
|
||||
className='!rounded-lg !border !border-gray-200 dark:!border-gray-700'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Typography.Text
|
||||
strong
|
||||
size='small'
|
||||
className='text-gray-700 dark:text-gray-300'
|
||||
>
|
||||
{keyItem.label}
|
||||
</Typography.Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
{keyItem.type === 'json' && (
|
||||
<Tag size="small" color="blue">{t('JSON')}</Tag>
|
||||
<Tag size='small' color='blue'>
|
||||
{t('JSON')}
|
||||
</Tag>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
theme="outline"
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
icon={
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
|
||||
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
|
||||
<svg
|
||||
className='w-3 h-3'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path d='M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z' />
|
||||
<path d='M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z' />
|
||||
</svg>
|
||||
}
|
||||
onClick={() => handleCopyKey(keyItem.content)}
|
||||
@@ -167,18 +191,22 @@ const ChannelKeyDisplay = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-40 overflow-auto">
|
||||
|
||||
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-40 overflow-auto'>
|
||||
<Typography.Text
|
||||
code
|
||||
className="text-xs font-mono break-all whitespace-pre-wrap text-gray-800 dark:text-gray-200"
|
||||
className='text-xs font-mono break-all whitespace-pre-wrap text-gray-800 dark:text-gray-200'
|
||||
>
|
||||
{keyItem.content}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
|
||||
{keyItem.type === 'json' && (
|
||||
<Typography.Text type="tertiary" size="small" className="block">
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='block'
|
||||
>
|
||||
{t('JSON格式密钥,请确保格式正确')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
@@ -186,14 +214,28 @@ const ChannelKeyDisplay = ({
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{isMultipleKeys && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-3">
|
||||
<Typography.Text type="tertiary" size="small" className="text-blue-700 dark:text-blue-300">
|
||||
<svg className="w-4 h-4 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-3'>
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='text-blue-700 dark:text-blue-300'
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4 inline mr-1'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
{t('检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。')}
|
||||
{t(
|
||||
'检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
@@ -201,17 +243,31 @@ const ChannelKeyDisplay = ({
|
||||
|
||||
{/* 安全警告 */}
|
||||
{showWarning && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<svg className="w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
<div className='bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4'>
|
||||
<div className='flex items-start'>
|
||||
<svg
|
||||
className='w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<Typography.Text strong className="text-yellow-800 dark:text-yellow-200">
|
||||
<Typography.Text
|
||||
strong
|
||||
className='text-yellow-800 dark:text-yellow-200'
|
||||
>
|
||||
{t('安全提醒')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="block text-yellow-700 dark:text-yellow-300 text-sm mt-1">
|
||||
{warningText || t('请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。')}
|
||||
<Typography.Text className='block text-yellow-700 dark:text-yellow-300 text-sm mt-1'>
|
||||
{warningText ||
|
||||
t(
|
||||
'请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,4 +277,4 @@ const ChannelKeyDisplay = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelKeyDisplay;
|
||||
export default ChannelKeyDisplay;
|
||||
|
||||
@@ -65,4 +65,4 @@ CompactModeToggle.propTypes = {
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default CompactModeToggle;
|
||||
export default CompactModeToggle;
|
||||
|
||||
@@ -36,11 +36,7 @@ import {
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconAlertTriangle,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -88,7 +84,7 @@ const JSONEditor = ({
|
||||
// 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
|
||||
const keyValueArrayToObject = useCallback((arr) => {
|
||||
const result = {};
|
||||
arr.forEach(item => {
|
||||
arr.forEach((item) => {
|
||||
if (item.key) {
|
||||
result[item.key] = item.value;
|
||||
}
|
||||
@@ -115,7 +111,8 @@ const JSONEditor = ({
|
||||
// 手动模式下的本地文本缓冲
|
||||
const [manualText, setManualText] = useState(() => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
if (value && typeof value === 'object')
|
||||
return JSON.stringify(value, null, 2);
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -140,7 +137,7 @@ const JSONEditor = ({
|
||||
const keyCount = {};
|
||||
const duplicates = new Set();
|
||||
|
||||
keyValuePairs.forEach(pair => {
|
||||
keyValuePairs.forEach((pair) => {
|
||||
if (pair.key) {
|
||||
keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
|
||||
if (keyCount[pair.key] > 1) {
|
||||
@@ -178,51 +175,65 @@ const JSONEditor = ({
|
||||
useEffect(() => {
|
||||
if (editMode !== 'manual') {
|
||||
if (typeof value === 'string') setManualText(value);
|
||||
else if (value && typeof value === 'object') setManualText(JSON.stringify(value, null, 2));
|
||||
else if (value && typeof value === 'object')
|
||||
setManualText(JSON.stringify(value, null, 2));
|
||||
else setManualText('');
|
||||
}
|
||||
}, [value, editMode]);
|
||||
|
||||
// 处理可视化编辑的数据变化
|
||||
const handleVisualChange = useCallback((newPairs) => {
|
||||
setKeyValuePairs(newPairs);
|
||||
const jsonObject = keyValueArrayToObject(newPairs);
|
||||
const jsonString = Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2);
|
||||
const handleVisualChange = useCallback(
|
||||
(newPairs) => {
|
||||
setKeyValuePairs(newPairs);
|
||||
const jsonObject = keyValueArrayToObject(newPairs);
|
||||
const jsonString =
|
||||
Object.keys(jsonObject).length === 0
|
||||
? ''
|
||||
: JSON.stringify(jsonObject, null, 2);
|
||||
|
||||
setJsonError('');
|
||||
setJsonError('');
|
||||
|
||||
// 通过formApi设置值
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, jsonString);
|
||||
}
|
||||
// 通过formApi设置值
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, jsonString);
|
||||
}
|
||||
|
||||
onChange?.(jsonString);
|
||||
}, [onChange, formApi, field, keyValueArrayToObject]);
|
||||
onChange?.(jsonString);
|
||||
},
|
||||
[onChange, formApi, field, keyValueArrayToObject],
|
||||
);
|
||||
|
||||
// 处理手动编辑的数据变化
|
||||
const handleManualChange = useCallback((newValue) => {
|
||||
setManualText(newValue);
|
||||
if (newValue && newValue.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(newValue);
|
||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||
const handleManualChange = useCallback(
|
||||
(newValue) => {
|
||||
setManualText(newValue);
|
||||
if (newValue && newValue.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(newValue);
|
||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||
setJsonError('');
|
||||
onChange?.(newValue);
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
}
|
||||
} else {
|
||||
setKeyValuePairs([]);
|
||||
setJsonError('');
|
||||
onChange?.(newValue);
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
onChange?.('');
|
||||
}
|
||||
} else {
|
||||
setKeyValuePairs([]);
|
||||
setJsonError('');
|
||||
onChange?.('');
|
||||
}
|
||||
}, [onChange, objectToKeyValueArray, keyValuePairs]);
|
||||
},
|
||||
[onChange, objectToKeyValueArray, keyValuePairs],
|
||||
);
|
||||
|
||||
// 切换编辑模式
|
||||
const toggleEditMode = useCallback(() => {
|
||||
if (editMode === 'visual') {
|
||||
const jsonObject = keyValueArrayToObject(keyValuePairs);
|
||||
setManualText(Object.keys(jsonObject).length === 0 ? '' : JSON.stringify(jsonObject, null, 2));
|
||||
setManualText(
|
||||
Object.keys(jsonObject).length === 0
|
||||
? ''
|
||||
: JSON.stringify(jsonObject, null, 2),
|
||||
);
|
||||
setEditMode('manual');
|
||||
} else {
|
||||
try {
|
||||
@@ -242,12 +253,19 @@ const JSONEditor = ({
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [editMode, value, manualText, keyValuePairs, keyValueArrayToObject, objectToKeyValueArray]);
|
||||
}, [
|
||||
editMode,
|
||||
value,
|
||||
manualText,
|
||||
keyValuePairs,
|
||||
keyValueArrayToObject,
|
||||
objectToKeyValueArray,
|
||||
]);
|
||||
|
||||
// 添加键值对
|
||||
const addKeyValue = useCallback(() => {
|
||||
const newPairs = [...keyValuePairs];
|
||||
const existingKeys = newPairs.map(p => p.key);
|
||||
const existingKeys = newPairs.map((p) => p.key);
|
||||
let counter = 1;
|
||||
let newKey = `field_${counter}`;
|
||||
while (existingKeys.includes(newKey)) {
|
||||
@@ -257,32 +275,41 @@ const JSONEditor = ({
|
||||
newPairs.push({
|
||||
id: generateUniqueId(),
|
||||
key: newKey,
|
||||
value: ''
|
||||
value: '',
|
||||
});
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
|
||||
// 删除键值对
|
||||
const removeKeyValue = useCallback((id) => {
|
||||
const newPairs = keyValuePairs.filter(pair => pair.id !== id);
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
const removeKeyValue = useCallback(
|
||||
(id) => {
|
||||
const newPairs = keyValuePairs.filter((pair) => pair.id !== id);
|
||||
handleVisualChange(newPairs);
|
||||
},
|
||||
[keyValuePairs, handleVisualChange],
|
||||
);
|
||||
|
||||
// 更新键名
|
||||
const updateKey = useCallback((id, newKey) => {
|
||||
const newPairs = keyValuePairs.map(pair =>
|
||||
pair.id === id ? { ...pair, key: newKey } : pair
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
const updateKey = useCallback(
|
||||
(id, newKey) => {
|
||||
const newPairs = keyValuePairs.map((pair) =>
|
||||
pair.id === id ? { ...pair, key: newKey } : pair,
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
},
|
||||
[keyValuePairs, handleVisualChange],
|
||||
);
|
||||
|
||||
// 更新值
|
||||
const updateValue = useCallback((id, newValue) => {
|
||||
const newPairs = keyValuePairs.map(pair =>
|
||||
pair.id === id ? { ...pair, value: newValue } : pair
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
const updateValue = useCallback(
|
||||
(id, newValue) => {
|
||||
const newPairs = keyValuePairs.map((pair) =>
|
||||
pair.id === id ? { ...pair, value: newValue } : pair,
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
},
|
||||
[keyValuePairs, handleVisualChange],
|
||||
);
|
||||
|
||||
// 填入模板
|
||||
const fillTemplate = useCallback(() => {
|
||||
@@ -298,7 +325,14 @@ const JSONEditor = ({
|
||||
onChange?.(templateString);
|
||||
setJsonError('');
|
||||
}
|
||||
}, [template, onChange, formApi, field, objectToKeyValueArray, keyValuePairs]);
|
||||
}, [
|
||||
template,
|
||||
onChange,
|
||||
formApi,
|
||||
field,
|
||||
objectToKeyValueArray,
|
||||
keyValuePairs,
|
||||
]);
|
||||
|
||||
// 渲染值输入控件(支持嵌套)
|
||||
const renderValueInput = (pairId, value) => {
|
||||
@@ -306,12 +340,12 @@ const JSONEditor = ({
|
||||
|
||||
if (valueType === 'boolean') {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className='flex items-center'>
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={(newValue) => updateValue(pairId, newValue)}
|
||||
/>
|
||||
<Text type="tertiary" className="ml-2">
|
||||
<Text type='tertiary' className='ml-2'>
|
||||
{value ? t('true') : t('false')}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -373,29 +407,29 @@ const JSONEditor = ({
|
||||
// 渲染键值对编辑器
|
||||
const renderKeyValueEditor = () => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className='space-y-1'>
|
||||
{/* 重复键警告 */}
|
||||
{duplicateKeys.size > 0 && (
|
||||
<Banner
|
||||
type="warning"
|
||||
type='warning'
|
||||
icon={<IconAlertTriangle />}
|
||||
description={
|
||||
<div>
|
||||
<Text strong>{t('存在重复的键名:')}</Text>
|
||||
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
||||
<br />
|
||||
<Text type="tertiary" size="small">
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
className="mb-3"
|
||||
className='mb-3'
|
||||
/>
|
||||
)}
|
||||
|
||||
{keyValuePairs.length === 0 && (
|
||||
<div className="text-center py-6 px-4">
|
||||
<Text type="tertiary" className="text-gray-500 text-sm">
|
||||
<div className='text-center py-6 px-4'>
|
||||
<Text type='tertiary' className='text-gray-500 text-sm'>
|
||||
{t('暂无数据,点击下方按钮添加键值对')}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -403,13 +437,14 @@ const JSONEditor = ({
|
||||
|
||||
{keyValuePairs.map((pair, index) => {
|
||||
const isDuplicate = duplicateKeys.has(pair.key);
|
||||
const isLastDuplicate = isDuplicate &&
|
||||
keyValuePairs.slice(index + 1).every(p => p.key !== pair.key);
|
||||
const isLastDuplicate =
|
||||
isDuplicate &&
|
||||
keyValuePairs.slice(index + 1).every((p) => p.key !== pair.key);
|
||||
|
||||
return (
|
||||
<Row key={pair.id} gutter={8} align="middle">
|
||||
<Row key={pair.id} gutter={8} align='middle'>
|
||||
<Col span={6}>
|
||||
<div className="relative">
|
||||
<div className='relative'>
|
||||
<Input
|
||||
placeholder={t('键名')}
|
||||
value={pair.key}
|
||||
@@ -425,24 +460,22 @@ const JSONEditor = ({
|
||||
}
|
||||
>
|
||||
<IconAlertTriangle
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
className='absolute right-2 top-1/2 transform -translate-y-1/2'
|
||||
style={{
|
||||
color: isLastDuplicate ? '#ff7d00' : '#faad14',
|
||||
fontSize: '14px'
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
{renderValueInput(pair.id, pair.value)}
|
||||
</Col>
|
||||
<Col span={16}>{renderValueInput(pair.id, pair.value)}</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
onClick={() => removeKeyValue(pair.id)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
@@ -451,11 +484,11 @@ const JSONEditor = ({
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-2 flex justify-center">
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
type="primary"
|
||||
theme="outline"
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={addKeyValue}
|
||||
>
|
||||
{t('添加键值对')}
|
||||
@@ -467,27 +500,27 @@ const JSONEditor = ({
|
||||
|
||||
// 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
|
||||
const renderRegionEditor = () => {
|
||||
const defaultPair = keyValuePairs.find(pair => pair.key === 'default');
|
||||
const modelPairs = keyValuePairs.filter(pair => pair.key !== 'default');
|
||||
const defaultPair = keyValuePairs.find((pair) => pair.key === 'default');
|
||||
const modelPairs = keyValuePairs.filter((pair) => pair.key !== 'default');
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
{/* 重复键警告 */}
|
||||
{duplicateKeys.size > 0 && (
|
||||
<Banner
|
||||
type="warning"
|
||||
type='warning'
|
||||
icon={<IconAlertTriangle />}
|
||||
description={
|
||||
<div>
|
||||
<Text strong>{t('存在重复的键名:')}</Text>
|
||||
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
||||
<br />
|
||||
<Text type="tertiary" size="small">
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
className="mb-3"
|
||||
className='mb-3'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -500,11 +533,14 @@ const JSONEditor = ({
|
||||
if (defaultPair) {
|
||||
updateValue(defaultPair.id, value);
|
||||
} else {
|
||||
const newPairs = [...keyValuePairs, {
|
||||
id: generateUniqueId(),
|
||||
key: 'default',
|
||||
value: value
|
||||
}];
|
||||
const newPairs = [
|
||||
...keyValuePairs,
|
||||
{
|
||||
id: generateUniqueId(),
|
||||
key: 'default',
|
||||
value: value,
|
||||
},
|
||||
];
|
||||
handleVisualChange(newPairs);
|
||||
}
|
||||
}}
|
||||
@@ -517,9 +553,9 @@ const JSONEditor = ({
|
||||
{modelPairs.map((pair) => {
|
||||
const isDuplicate = duplicateKeys.has(pair.key);
|
||||
return (
|
||||
<Row key={pair.id} gutter={8} align="middle" className="mb-2">
|
||||
<Row key={pair.id} gutter={8} align='middle' className='mb-2'>
|
||||
<Col span={10}>
|
||||
<div className="relative">
|
||||
<div className='relative'>
|
||||
<Input
|
||||
placeholder={t('模型名称')}
|
||||
value={pair.key}
|
||||
@@ -529,7 +565,7 @@ const JSONEditor = ({
|
||||
{isDuplicate && (
|
||||
<Tooltip content={t('重复的键名')}>
|
||||
<IconAlertTriangle
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
className='absolute right-2 top-1/2 transform -translate-y-1/2'
|
||||
style={{ color: '#faad14', fontSize: '14px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -546,8 +582,8 @@ const JSONEditor = ({
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type="danger"
|
||||
theme="borderless"
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
onClick={() => removeKeyValue(pair.id)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
@@ -556,12 +592,12 @@ const JSONEditor = ({
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-2 flex justify-center">
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={addKeyValue}
|
||||
type="primary"
|
||||
theme="outline"
|
||||
type='primary'
|
||||
theme='outline'
|
||||
>
|
||||
{t('添加模型区域')}
|
||||
</Button>
|
||||
@@ -590,9 +626,9 @@ const JSONEditor = ({
|
||||
<Form.Slot label={label}>
|
||||
<Card
|
||||
header={
|
||||
<div className="flex justify-between items-center">
|
||||
<div className='flex justify-between items-center'>
|
||||
<Tabs
|
||||
type="slash"
|
||||
type='slash'
|
||||
activeKey={editMode}
|
||||
onChange={(key) => {
|
||||
if (key === 'manual' && editMode === 'visual') {
|
||||
@@ -602,16 +638,12 @@ const JSONEditor = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabPane tab={t('可视化')} itemKey="visual" />
|
||||
<TabPane tab={t('手动编辑')} itemKey="manual" />
|
||||
<TabPane tab={t('可视化')} itemKey='visual' />
|
||||
<TabPane tab={t('手动编辑')} itemKey='manual' />
|
||||
</Tabs>
|
||||
|
||||
{template && templateLabel && (
|
||||
<Button
|
||||
type="tertiary"
|
||||
onClick={fillTemplate}
|
||||
size="small"
|
||||
>
|
||||
<Button type='tertiary' onClick={fillTemplate} size='small'>
|
||||
{templateLabel}
|
||||
</Button>
|
||||
)}
|
||||
@@ -619,14 +651,14 @@ const JSONEditor = ({
|
||||
}
|
||||
headerStyle={{ padding: '12px 16px' }}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
className="!rounded-2xl"
|
||||
className='!rounded-2xl'
|
||||
>
|
||||
{/* JSON错误提示 */}
|
||||
{hasJsonError && (
|
||||
<Banner
|
||||
type="danger"
|
||||
type='danger'
|
||||
description={`JSON 格式错误: ${jsonError}`}
|
||||
className="mb-3"
|
||||
className='mb-3'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -668,17 +700,15 @@ const JSONEditor = ({
|
||||
{/* 额外文本显示在卡片底部 */}
|
||||
{extraText && (
|
||||
<Divider margin='12px' align='center'>
|
||||
<Text type="tertiary" size="small">{extraText}</Text>
|
||||
<Text type='tertiary' size='small'>
|
||||
{extraText}
|
||||
</Text>
|
||||
</Divider>
|
||||
)}
|
||||
{extraFooter && (
|
||||
<div className="mt-1">
|
||||
{extraFooter}
|
||||
</div>
|
||||
)}
|
||||
{extraFooter && <div className='mt-1'>{extraFooter}</div>}
|
||||
</Card>
|
||||
</Form.Slot>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONEditor;
|
||||
export default JSONEditor;
|
||||
|
||||
@@ -21,13 +21,9 @@ import React from 'react';
|
||||
import { Spin } from '@douyinfe/semi-ui';
|
||||
|
||||
const Loading = ({ size = 'small' }) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center">
|
||||
<Spin
|
||||
size={size}
|
||||
spinning={true}
|
||||
/>
|
||||
<div className='fixed inset-0 w-screen h-screen flex items-center justify-center'>
|
||||
<Spin size={size} spinning={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,4 +57,4 @@ export const renderDescription = (text, maxWidth = 200) => {
|
||||
{text || '-'}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,197 +24,219 @@ import React, {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useImperativeHandle,
|
||||
forwardRef
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
|
||||
/**
|
||||
* ScrollableContainer 可滚动容器组件
|
||||
*
|
||||
*
|
||||
* 提供自动检测滚动状态和显示渐变指示器的功能
|
||||
* 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器
|
||||
*
|
||||
*
|
||||
*/
|
||||
const ScrollableContainer = forwardRef(({
|
||||
children,
|
||||
maxHeight = '24rem',
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
fadeIndicatorClassName = '',
|
||||
checkInterval = 100,
|
||||
scrollThreshold = 5,
|
||||
debounceDelay = 16, // ~60fps
|
||||
onScroll,
|
||||
onScrollStateChange,
|
||||
...props
|
||||
}, ref) => {
|
||||
const scrollRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const debounceTimerRef = useRef(null);
|
||||
const resizeObserverRef = useRef(null);
|
||||
const onScrollStateChangeRef = useRef(onScrollStateChange);
|
||||
const onScrollRef = useRef(onScroll);
|
||||
|
||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onScrollStateChangeRef.current = onScrollStateChange;
|
||||
}, [onScrollStateChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onScrollRef.current = onScroll;
|
||||
}, [onScroll]);
|
||||
|
||||
const debounce = useCallback((func, delay) => {
|
||||
return (...args) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
debounceTimerRef.current = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkScrollable = useCallback(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const element = scrollRef.current;
|
||||
const isScrollable = element.scrollHeight > element.clientHeight;
|
||||
const isAtBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold;
|
||||
const shouldShowHint = isScrollable && !isAtBottom;
|
||||
|
||||
setShowScrollHint(shouldShowHint);
|
||||
|
||||
if (onScrollStateChangeRef.current) {
|
||||
onScrollStateChangeRef.current({
|
||||
isScrollable,
|
||||
isAtBottom,
|
||||
showScrollHint: shouldShowHint,
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight
|
||||
});
|
||||
}
|
||||
}, [scrollThreshold]);
|
||||
|
||||
const debouncedCheckScrollable = useMemo(() =>
|
||||
debounce(checkScrollable, debounceDelay),
|
||||
[debounce, checkScrollable, debounceDelay]
|
||||
);
|
||||
|
||||
const handleScroll = useCallback((e) => {
|
||||
debouncedCheckScrollable();
|
||||
if (onScrollRef.current) {
|
||||
onScrollRef.current(e);
|
||||
}
|
||||
}, [debouncedCheckScrollable]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
checkScrollable: () => {
|
||||
checkScrollable();
|
||||
const ScrollableContainer = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
maxHeight = '24rem',
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
fadeIndicatorClassName = '',
|
||||
checkInterval = 100,
|
||||
scrollThreshold = 5,
|
||||
debounceDelay = 16, // ~60fps
|
||||
onScroll,
|
||||
onScrollStateChange,
|
||||
...props
|
||||
},
|
||||
scrollToTop: () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
scrollToBottom: () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
},
|
||||
getScrollInfo: () => {
|
||||
if (!scrollRef.current) return null;
|
||||
const element = scrollRef.current;
|
||||
return {
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
isScrollable: element.scrollHeight > element.clientHeight,
|
||||
isAtBottom: element.scrollTop + element.clientHeight >= element.scrollHeight - scrollThreshold
|
||||
ref,
|
||||
) => {
|
||||
const scrollRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const debounceTimerRef = useRef(null);
|
||||
const resizeObserverRef = useRef(null);
|
||||
const onScrollStateChangeRef = useRef(onScrollStateChange);
|
||||
const onScrollRef = useRef(onScroll);
|
||||
|
||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onScrollStateChangeRef.current = onScrollStateChange;
|
||||
}, [onScrollStateChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onScrollRef.current = onScroll;
|
||||
}, [onScroll]);
|
||||
|
||||
const debounce = useCallback((func, delay) => {
|
||||
return (...args) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
debounceTimerRef.current = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
}
|
||||
}), [checkScrollable, scrollThreshold]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
checkScrollable();
|
||||
}, checkInterval);
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkScrollable, checkInterval]);
|
||||
const checkScrollable = useCallback(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return;
|
||||
const element = scrollRef.current;
|
||||
const isScrollable = element.scrollHeight > element.clientHeight;
|
||||
const isAtBottom =
|
||||
element.scrollTop + element.clientHeight >=
|
||||
element.scrollHeight - scrollThreshold;
|
||||
const shouldShowHint = isScrollable && !isAtBottom;
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const observer = new MutationObserver(() => {
|
||||
debouncedCheckScrollable();
|
||||
setShowScrollHint(shouldShowHint);
|
||||
|
||||
if (onScrollStateChangeRef.current) {
|
||||
onScrollStateChangeRef.current({
|
||||
isScrollable,
|
||||
isAtBottom,
|
||||
showScrollHint: shouldShowHint,
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
});
|
||||
|
||||
observer.observe(scrollRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}, [scrollThreshold]);
|
||||
|
||||
resizeObserverRef.current = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const debouncedCheckScrollable = useMemo(
|
||||
() => debounce(checkScrollable, debounceDelay),
|
||||
[debounce, checkScrollable, debounceDelay],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
debouncedCheckScrollable();
|
||||
if (onScrollRef.current) {
|
||||
onScrollRef.current(e);
|
||||
}
|
||||
},
|
||||
[debouncedCheckScrollable],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
checkScrollable: () => {
|
||||
checkScrollable();
|
||||
},
|
||||
scrollToTop: () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
scrollToBottom: () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
},
|
||||
getScrollInfo: () => {
|
||||
if (!scrollRef.current) return null;
|
||||
const element = scrollRef.current;
|
||||
return {
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
isScrollable: element.scrollHeight > element.clientHeight,
|
||||
isAtBottom:
|
||||
element.scrollTop + element.clientHeight >=
|
||||
element.scrollHeight - scrollThreshold,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[checkScrollable, scrollThreshold],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
checkScrollable();
|
||||
}, checkInterval);
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkScrollable, checkInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const observer = new MutationObserver(() => {
|
||||
debouncedCheckScrollable();
|
||||
});
|
||||
|
||||
observer.observe(scrollRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserverRef.current.observe(scrollRef.current);
|
||||
resizeObserverRef.current = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
debouncedCheckScrollable();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [debouncedCheckScrollable]);
|
||||
resizeObserverRef.current.observe(scrollRef.current);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [debouncedCheckScrollable]);
|
||||
|
||||
const containerStyle = useMemo(() => ({
|
||||
maxHeight
|
||||
}), [maxHeight]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fadeIndicatorStyle = useMemo(() => ({
|
||||
opacity: showScrollHint ? 1 : 0
|
||||
}), [showScrollHint]);
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
maxHeight,
|
||||
}),
|
||||
[maxHeight],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`card-content-container ${className}`}
|
||||
{...props}
|
||||
>
|
||||
const fadeIndicatorStyle = useMemo(
|
||||
() => ({
|
||||
opacity: showScrollHint ? 1 : 0,
|
||||
}),
|
||||
[showScrollHint],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
|
||||
style={containerStyle}
|
||||
onScroll={handleScroll}
|
||||
ref={containerRef}
|
||||
className={`card-content-container ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
|
||||
style={containerStyle}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
|
||||
style={fadeIndicatorStyle}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
|
||||
style={fadeIndicatorStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ScrollableContainer.displayName = 'ScrollableContainer';
|
||||
|
||||
export default ScrollableContainer;
|
||||
export default ScrollableContainer;
|
||||
|
||||
@@ -20,7 +20,17 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
||||
import { useContainerWidth } from '../../../hooks/common/useContainerWidth';
|
||||
import { Divider, Button, Tag, Row, Col, Collapsible, Checkbox, Skeleton, Tooltip } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Divider,
|
||||
Button,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Collapsible,
|
||||
Checkbox,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
|
||||
/**
|
||||
@@ -47,7 +57,7 @@ const SelectableButtonGroup = ({
|
||||
collapsible = true,
|
||||
collapseHeight = 200,
|
||||
withCheckbox = false,
|
||||
loading = false
|
||||
loading = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [skeletonCount] = useState(12);
|
||||
@@ -64,15 +74,13 @@ const SelectableButtonGroup = ({
|
||||
}, [text, containerWidth]);
|
||||
|
||||
const textElement = (
|
||||
<span ref={textRef} className="sbg-ellipsis">
|
||||
<span ref={textRef} className='sbg-ellipsis'>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
|
||||
return isOverflowing ? (
|
||||
<Tooltip content={text}>
|
||||
{textElement}
|
||||
</Tooltip>
|
||||
<Tooltip content={text}>{textElement}</Tooltip>
|
||||
) : (
|
||||
textElement
|
||||
);
|
||||
@@ -80,10 +88,10 @@ const SelectableButtonGroup = ({
|
||||
|
||||
// 基于容器宽度计算响应式列数和标签显示策略
|
||||
const getResponsiveConfig = () => {
|
||||
if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄:1列+标签
|
||||
if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏:2列+标签
|
||||
if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等:3列不加标签
|
||||
return { columns: 3, showTags: true }; // 最宽:3列+标签
|
||||
if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄:1列+标签
|
||||
if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏:2列+标签
|
||||
if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等:3列不加标签
|
||||
return { columns: 3, showTags: true }; // 最宽:3列+标签
|
||||
};
|
||||
|
||||
const { columns: perRow, showTags: shouldShowTags } = getResponsiveConfig();
|
||||
@@ -102,9 +110,9 @@ const SelectableButtonGroup = ({
|
||||
const maskStyle = isOpen
|
||||
? {}
|
||||
: {
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
|
||||
};
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
@@ -127,25 +135,23 @@ const SelectableButtonGroup = ({
|
||||
};
|
||||
|
||||
const renderSkeletonButtons = () => {
|
||||
|
||||
const placeholder = (
|
||||
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<Col
|
||||
span={getColSpan()}
|
||||
key={index}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 'var(--semi-border-radius-medium)',
|
||||
padding: '0 12px',
|
||||
gap: '6px'
|
||||
}}>
|
||||
<Col span={getColSpan()} key={index}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 'var(--semi-border-radius-medium)',
|
||||
padding: '0 12px',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{withCheckbox && (
|
||||
<Skeleton.Title active style={{ width: 14, height: 14 }} />
|
||||
)}
|
||||
@@ -153,7 +159,7 @@ const SelectableButtonGroup = ({
|
||||
active
|
||||
style={{
|
||||
width: `${60 + (index % 3) * 20}px`,
|
||||
height: 14
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -167,26 +173,29 @@ const SelectableButtonGroup = ({
|
||||
);
|
||||
};
|
||||
|
||||
const contentElement = showSkeleton ? renderSkeletonButtons() : (
|
||||
const contentElement = showSkeleton ? (
|
||||
renderSkeletonButtons()
|
||||
) : (
|
||||
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
|
||||
{items.map((item) => {
|
||||
const isDisabled = item.disabled || (typeof item.tagCount === 'number' && item.tagCount === 0);
|
||||
const isDisabled =
|
||||
item.disabled ||
|
||||
(typeof item.tagCount === 'number' && item.tagCount === 0);
|
||||
const isActive = Array.isArray(activeValue)
|
||||
? activeValue.includes(item.value)
|
||||
: activeValue === item.value;
|
||||
|
||||
if (withCheckbox) {
|
||||
return (
|
||||
<Col
|
||||
span={getColSpan()}
|
||||
key={item.value}
|
||||
>
|
||||
<Col span={getColSpan()} key={item.value}>
|
||||
<Button
|
||||
onClick={() => { /* disabled */ }}
|
||||
onClick={() => {
|
||||
/* disabled */
|
||||
}}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
disabled={isDisabled}
|
||||
className="sbg-button"
|
||||
className='sbg-button'
|
||||
icon={
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
@@ -197,11 +206,18 @@ const SelectableButtonGroup = ({
|
||||
}
|
||||
style={{ width: '100%', cursor: 'default' }}
|
||||
>
|
||||
<div className="sbg-content">
|
||||
{item.icon && (<span className="sbg-icon">{item.icon}</span>)}
|
||||
<div className='sbg-content'>
|
||||
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
|
||||
<ConditionalTooltipText text={item.label} />
|
||||
{item.tagCount !== undefined && shouldShowTags && (
|
||||
<Tag className="sbg-tag" color='white' shape="circle" size="small">{item.tagCount}</Tag>
|
||||
<Tag
|
||||
className='sbg-tag'
|
||||
color='white'
|
||||
shape='circle'
|
||||
size='small'
|
||||
>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
@@ -210,23 +226,27 @@ const SelectableButtonGroup = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Col
|
||||
span={getColSpan()}
|
||||
key={item.value}
|
||||
>
|
||||
<Col span={getColSpan()} key={item.value}>
|
||||
<Button
|
||||
onClick={() => onChange(item.value)}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
disabled={isDisabled}
|
||||
className="sbg-button"
|
||||
className='sbg-button'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div className="sbg-content">
|
||||
{item.icon && (<span className="sbg-icon">{item.icon}</span>)}
|
||||
<div className='sbg-content'>
|
||||
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
|
||||
<ConditionalTooltipText text={item.label} />
|
||||
{item.tagCount !== undefined && shouldShowTags && (
|
||||
<Tag className="sbg-tag" color='white' shape="circle" size="small">{item.tagCount}</Tag>
|
||||
<Tag
|
||||
className='sbg-tag'
|
||||
color='white'
|
||||
shape='circle'
|
||||
size='small'
|
||||
>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
@@ -237,9 +257,12 @@ const SelectableButtonGroup = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}`} ref={containerRef}>
|
||||
<div
|
||||
className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
{title && (
|
||||
<Divider margin="12px" align="left">
|
||||
<Divider margin='12px' align='left'>
|
||||
{showSkeleton ? (
|
||||
<Skeleton.Title active style={{ width: 80, height: 14 }} />
|
||||
) : (
|
||||
@@ -249,23 +272,30 @@ const SelectableButtonGroup = ({
|
||||
)}
|
||||
{needCollapse && !showSkeleton ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Collapsible isOpen={isOpen} collapseHeight={collapseHeight} style={{ ...maskStyle }}>
|
||||
<Collapsible
|
||||
isOpen={isOpen}
|
||||
collapseHeight={collapseHeight}
|
||||
style={{ ...maskStyle }}
|
||||
>
|
||||
{contentElement}
|
||||
</Collapsible>
|
||||
{isOpen ? null : (
|
||||
<div onClick={toggle} style={{ ...linkStyle }}>
|
||||
<IconChevronDown size="small" />
|
||||
<IconChevronDown size='small' />
|
||||
<span>{t('展开更多')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && (
|
||||
<div onClick={toggle} style={{
|
||||
...linkStyle,
|
||||
position: 'static',
|
||||
marginTop: 8,
|
||||
bottom: 'auto'
|
||||
}}>
|
||||
<IconChevronUp size="small" />
|
||||
<div
|
||||
onClick={toggle}
|
||||
style={{
|
||||
...linkStyle,
|
||||
position: 'static',
|
||||
marginTop: 8,
|
||||
bottom: 'auto',
|
||||
}}
|
||||
>
|
||||
<IconChevronUp size='small' />
|
||||
<span>{t('收起')}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -277,4 +307,4 @@ const SelectableButtonGroup = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectableButtonGroup;
|
||||
export default SelectableButtonGroup;
|
||||
|
||||
Reference in New Issue
Block a user