merge: 同步上游 dev 最新内容

This commit is contained in:
DawnARC
2026-05-06 00:53:11 +08:00
125 changed files with 3069 additions and 1271 deletions

View File

@@ -76,7 +76,6 @@ function DynamicConfigSection({
basePath,
hooks,
level,
mergedChildren = [],
nestedSchema,
onChange,
sectionDescription,
@@ -87,11 +86,6 @@ function DynamicConfigSection({
basePath: string
hooks: FieldHookRegistry
level: number
mergedChildren?: Array<{
key: string
schema: ConfigSchema
values: Record<string, unknown>
}>
nestedSchema: ConfigSchema
onChange: (field: string, value: unknown) => void
sectionDescription?: string
@@ -100,9 +94,7 @@ function DynamicConfigSection({
values: Record<string, unknown>
}) {
const [advancedVisible, setAdvancedVisible] = React.useState(false)
const hasAdvanced =
hasTopLevelAdvancedFields(nestedSchema) ||
mergedChildren.some((child) => hasTopLevelAdvancedFields(child.schema))
const hasAdvanced = hasTopLevelAdvancedFields(nestedSchema)
return (
<Card>
@@ -135,37 +127,6 @@ function DynamicConfigSection({
level={level}
advancedVisible={hasAdvanced ? advancedVisible : undefined}
/>
{mergedChildren.map((child) => {
const childTitle = resolveSectionTitle(child.schema)
const childDescription = resolveSectionDescription(child.schema, childTitle)
const parentPath = basePath.includes('.')
? basePath.replace(/\.[^.]+$/, '')
: ''
const childPath = buildFieldPath(parentPath, child.key)
return (
<div key={child.key} className="mt-5 border-t border-border/50 pt-4">
<div className="mb-3 space-y-1">
<div className="flex items-center gap-2">
<SectionIcon iconName={child.schema.uiIcon} />
<h3 className="text-sm font-medium">{childTitle}</h3>
</div>
{childDescription && (
<p className="text-xs text-muted-foreground">{childDescription}</p>
)}
</div>
<DynamicConfigForm
schema={child.schema}
values={child.values}
onChange={(field, value) => onChange(`${child.key}.${field}`, value)}
basePath={childPath}
hooks={hooks}
level={level}
advancedVisible={hasAdvanced ? advancedVisible : undefined}
/>
</div>
)
})}
</CardContent>
</Card>
)
@@ -197,17 +158,6 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
() => new Map(schema.fields.map((field) => [field.name, field])),
[schema.fields],
)
const mergedChildKeys = React.useMemo(() => {
const keys = new Set<string>()
for (const nestedSchema of Object.values(schema.nested ?? {})) {
for (const childKey of nestedSchema.uiMergeChildren ?? []) {
if (schema.nested?.[childKey]) {
keys.add(childKey)
}
}
}
return keys
}, [schema.nested])
const renderField = (field: FieldSchema) => {
const fieldPath = buildFieldPath(basePath, field.name)
@@ -225,6 +175,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
schema={field}
parentValues={values}
/>
)
}
@@ -235,6 +186,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
schema={field}
parentValues={values}
>
<DynamicField
schema={field}
@@ -265,12 +217,50 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
? [...normalFields, ...advancedFields]
: normalFields
const groupFieldsByRow = (fields: FieldSchema[]) => {
const rows: FieldSchema[][] = []
let currentRow: FieldSchema[] = []
let currentRowKey: string | undefined
for (const field of fields) {
const rowKey = field['x-row']
if (rowKey && rowKey === currentRowKey) {
currentRow.push(field)
continue
}
if (currentRow.length > 0) {
rows.push(currentRow)
}
currentRow = [field]
currentRowKey = rowKey
}
if (currentRow.length > 0) {
rows.push(currentRow)
}
return rows
}
const renderFieldList = (fields: FieldSchema[]) => (
<>
{fields.map((field, index) => (
<React.Fragment key={field.name}>
{groupFieldsByRow(fields).map((row, index) => (
<React.Fragment key={row.map((field) => field.name).join('|')}>
{index > 0 && <Separator className="my-2 bg-border/50" />}
<div className="py-1">{renderField(field)}</div>
{row.length > 1 ? (
<div
className="grid gap-4 py-1 md:grid-cols-[repeat(var(--field-row-count),minmax(0,1fr))]"
style={{ '--field-row-count': row.length } as React.CSSProperties}
>
{row.map((field) => (
<div key={field.name}>{renderField(field)}</div>
))}
</div>
) : (
<div className="py-1">{renderField(row[0])}</div>
)}
</React.Fragment>
))}
</>
@@ -294,7 +284,6 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
{schema.nested &&
Object.entries(schema.nested)
.filter(([key]) => !mergedChildKeys.has(key))
.map(([key, nestedSchema]) => {
const nestedField = fieldMap.get(key)
const nestedFieldPath = buildFieldPath(basePath, key)
@@ -313,6 +302,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
onChange={(v) => onChange(key, v)}
schema={nestedField ?? nestedSchema}
nestedSchema={nestedSchema}
parentValues={values}
/>
</div>
)
@@ -326,6 +316,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
onChange={(v) => onChange(key, v)}
schema={nestedField ?? nestedSchema}
nestedSchema={nestedSchema}
parentValues={values}
>
<DynamicConfigForm
schema={nestedSchema}
@@ -342,34 +333,11 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
const sectionTitle = resolveSectionTitle(nestedSchema)
const sectionDescription = resolveSectionDescription(nestedSchema, sectionTitle)
const mergedChildren = (nestedSchema.uiMergeChildren ?? [])
.map((childKey) => {
const childSchema = schema.nested?.[childKey]
if (!childSchema) {
return null
}
return {
key: childKey,
schema: childSchema,
values: (values[childKey] as Record<string, unknown>) || {},
}
})
.filter(
(
child,
): child is {
key: string
schema: ConfigSchema
values: Record<string, unknown>
} => Boolean(child),
)
if (level === 0) {
return (
<DynamicConfigSection
key={key}
mergedChildren={mergedChildren}
nestedSchema={nestedSchema}
values={(values[key] as Record<string, unknown>) || {}}
onChange={onChange}

View File

@@ -8,6 +8,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { FieldSchema } from "@/types/config-schema"
@@ -31,6 +37,27 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
value,
onChange,
}) => {
const isNumericField = schema.type === 'integer' || schema.type === 'number'
const parseNumericValue = (rawValue: unknown, fallbackValue: unknown = 0) => {
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
return rawValue
}
if (typeof rawValue === 'string') {
const parsedValue = parseFloat(rawValue)
if (Number.isFinite(parsedValue)) {
return schema.type === 'integer' ? Math.trunc(parsedValue) : parsedValue
}
}
if (fallbackValue !== rawValue) {
return parseNumericValue(fallbackValue, 0)
}
return 0
}
const renderPrimitiveArrayEditor = () => {
const itemType = schema.items?.type ?? 'string'
const arrayValue = Array.isArray(value)
@@ -94,6 +121,10 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
return <IconComponent className="h-4 w-4" />
}
const optionDescriptions = schema['x-option-descriptions'] ?? {}
const hasOptionDescriptions = Object.keys(optionDescriptions).length > 0
const inlineDescription = hasOptionDescriptions ? '' : schema.description
const renderFieldHeader = () => (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Label
@@ -108,9 +139,9 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
<span className="break-all">{schema.label}</span>
{schema.required && <span className="text-destructive">*</span>}
</Label>
{schema.description && (
{inlineDescription && (
<span className="text-[13px] leading-6 text-muted-foreground whitespace-pre-line">
{schema.description}
{inlineDescription}
</span>
)}
</div>
@@ -122,10 +153,14 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
const renderInputComponent = () => {
const widget = schema['x-widget']
const type = schema.type
const resolvedWidget =
isNumericField && (widget === 'input' || widget === 'number' || !widget)
? 'number'
: widget
// x-widget 优先
if (widget) {
switch (widget) {
if (resolvedWidget) {
switch (resolvedWidget) {
case 'slider':
return renderSlider()
case 'input':
@@ -214,7 +249,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
* 渲染 Slider 组件(用于 number 类型 + x-widget: slider
*/
const renderSlider = () => {
const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0)
const numValue = parseNumericValue(value, schema.default)
const min = schema.minValue ?? 0
const max = schema.maxValue ?? 100
const step = schema.step ?? 1
@@ -241,7 +276,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
* 渲染 Input[type="number"] 组件(用于 number/integer 类型)
*/
const renderNumberInput = () => {
const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0)
const numValue = parseNumericValue(value, schema.default)
const min = schema.minValue
const max = schema.maxValue
const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1)
@@ -250,7 +285,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
<Input
type="number"
value={numValue}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
onChange={(e) => {
const nextValue = schema.type === 'integer'
? parseInt(e.target.value, 10)
: parseFloat(e.target.value)
onChange(Number.isFinite(nextValue) ? nextValue : 0)
}}
min={min}
max={max}
step={step}
@@ -262,7 +302,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
* 渲染 Input[type="text"] 组件(用于 string 类型)
*/
const renderTextInput = (type: 'password' | 'text' = 'text') => {
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
const strValue =
typeof value === 'string'
? value
: value === null || value === undefined
? String(schema.default ?? '')
: String(value)
return (
<Input
type={type}
@@ -277,11 +322,19 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
*/
const renderTextarea = () => {
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
const minHeight = typeof schema['x-textarea-min-height'] === 'number'
? schema['x-textarea-min-height']
: undefined
const rows = typeof schema['x-textarea-rows'] === 'number'
? schema['x-textarea-rows']
: 4
return (
<Textarea
value={strValue}
onChange={(e) => onChange(e.target.value)}
rows={4}
rows={rows}
minHeight={minHeight}
/>
)
}
@@ -307,11 +360,39 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
<SelectValue placeholder={`Select ${schema.label}`} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
{hasOptionDescriptions ? (
<TooltipProvider delayDuration={150}>
{options.map((option) => {
const description = optionDescriptions[option]
return description ? (
<Tooltip key={option}>
<TooltipTrigger asChild>
<SelectItem value={option} title={description}>
{option}
</SelectItem>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
className="max-w-72 bg-background text-foreground border shadow-lg"
>
{description}
</TooltipContent>
</Tooltip>
) : (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
)
})}
</TooltipProvider>
) : (
options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))
)}
</SelectContent>
</Select>
)

View File

@@ -100,6 +100,42 @@ describe('DynamicField', () => {
expect(screen.getByText('Custom field requires Hook')).toBeInTheDocument()
})
it('renders number Input when x-widget is input but type is integer', () => {
const schema: FieldSchema = {
name: 'test_integer_input_widget',
type: 'integer',
label: 'Test Integer Input Widget',
description: 'A numeric field rendered as input',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={2} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(2)
})
it('parses string values for numeric input widgets', () => {
const schema: FieldSchema = {
name: 'test_string_number_input_widget',
type: 'integer',
label: 'Test String Number Input Widget',
description: 'A numeric field with legacy string value',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="2" onChange={onChange} />)
expect(screen.getByRole('spinbutton')).toHaveValue(2)
})
})
describe('type fallback', () => {
@@ -305,6 +341,27 @@ describe('DynamicField', () => {
await user.type(input, '123')
expect(onChange).toHaveBeenCalled()
})
it('triggers numeric onChange for input widget with integer type', async () => {
const schema: FieldSchema = {
name: 'test_integer_input_widget_change',
type: 'integer',
label: 'Test Integer Input Widget Change',
description: 'A numeric field rendered as input',
required: false,
'x-widget': 'input',
default: 0,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '5')
expect(onChange).toHaveBeenLastCalledWith(5)
})
})
describe('visual features', () => {
@@ -377,6 +434,25 @@ describe('DynamicField', () => {
expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText('25')).toBeInTheDocument()
})
it('parses string values for slider widgets', () => {
const schema: FieldSchema = {
name: 'test_slider_string_value',
type: 'number',
label: 'Test Slider String Value',
description: 'A slider with legacy string value',
required: false,
'x-widget': 'slider',
minValue: 0,
maxValue: 10,
default: 0,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="2.5" onChange={onChange} />)
expect(screen.getByText('2.5')).toBeInTheDocument()
})
})
describe('select features', () => {

View File

@@ -80,6 +80,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
// 快速审核模式状态
const [quickFilterType, setQuickFilterType] = useState<'unchecked' | 'passed' | 'rejected' | 'all'>('unchecked')
const [quickExpressions, setQuickExpressions] = useState<Expression[]>([])
const quickExpressionsRef = useRef<Expression[]>([])
const [quickCurrentIndex, setQuickCurrentIndex] = useState(0)
const [quickLoading, setQuickLoading] = useState(false)
const [quickTotal, setQuickTotal] = useState(0)
@@ -92,6 +93,10 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
const cardRef = useRef<HTMLDivElement>(null)
const dragStartRef = useRef<{ x: number; y: number } | null>(null)
const isDraggingRef = useRef(false)
useEffect(() => {
quickExpressionsRef.current = quickExpressions
}, [quickExpressions])
const [loading, setLoading] = useState(false)
const [statsLoading, setStatsLoading] = useState(false)
const [total, setTotal] = useState(0)
@@ -180,9 +185,13 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
setQuickLoading(true)
const pageToLoad = append ? quickPage + 1 : quickPage
const result = await getReviewList({
page: pageToLoad,
page: quickFilterType === 'unchecked' ? 1 : pageToLoad,
page_size: 20,
filter_type: quickFilterType,
order: quickFilterType === 'unchecked' ? 'random' : 'latest',
exclude_ids: quickFilterType === 'unchecked' && append
? quickExpressionsRef.current.map((expr) => expr.id)
: undefined,
})
if (result.success) {
@@ -552,8 +561,8 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
}
// 获取聊天名称
const getChatName = (chatId: string): string => {
return chatNameMap.get(chatId) || chatId
const getChatName = (expression: Expression): string => {
return expression.chat_name || chatNameMap.get(expression.chat_id) || expression.chat_id
}
// 单条审核
@@ -1104,8 +1113,8 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro
<div className="flex flex-wrap items-center gap-1 sm:gap-2 text-xs text-muted-foreground">
<span>#{expr.id}</span>
<span>·</span>
<span title={getChatName(expr.chat_id)} className="truncate max-w-24 sm:max-w-32">
{getChatName(expr.chat_id)}
<span title={getChatName(expr)} className="truncate max-w-24 sm:max-w-32">
{getChatName(expr)}
</span>
<span>·</span>
<span>{formatTime(expr.create_date)}</span>
@@ -1585,8 +1594,8 @@ if (isCurrent) {
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<User className="h-3 w-3" />
</div>
<span title={getChatName(expr.chat_id)} className="truncate max-w-[120px] font-medium">
{getChatName(expr.chat_id)}
<span title={getChatName(expr)} className="truncate max-w-[120px] font-medium">
{getChatName(expr)}
</span>
</div>
<span className="font-mono">{formatTime(expr.create_date)}</span>
@@ -1638,8 +1647,8 @@ if (isCurrent) {
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<User className="h-3 w-3" />
</div>
<span title={getChatName(expr.chat_id)} className="truncate max-w-[120px] font-medium">
{getChatName(expr.chat_id)}
<span title={getChatName(expr)} className="truncate max-w-[120px] font-medium">
{getChatName(expr)}
</span>
</div>
<span className="font-mono">{formatTime(expr.create_date)}</span>