🎨 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:
t0ng7u
2025-08-30 21:15:10 +08:00
parent 41cf516ec5
commit 0d57b1acd4
274 changed files with 11025 additions and 7659 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,4 +65,4 @@ CompactModeToggle.propTypes = {
className: PropTypes.string,
};
export default CompactModeToggle;
export default CompactModeToggle;

View File

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

View File

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

View File

@@ -57,4 +57,4 @@ export const renderDescription = (text, maxWidth = 200) => {
{text || '-'}
</Text>
);
};
};

View File

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

View File

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