feat(ui): unify provider settings styling and improve channel config UX (#395)

This commit is contained in:
DigHuang
2026-03-10 19:04:23 +08:00
committed by GitHub
Unverified
parent 807f6b8adf
commit 45d7ff61c3
3 changed files with 70 additions and 39 deletions

View File

@@ -589,10 +589,7 @@ export function ChannelConfigModal({
<Separator className="bg-black/10 dark:bg-white/10" /> <Separator className="bg-black/10 dark:bg-white/10" />
<div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-3 pt-2"> <div className="flex flex-col sm:flex-row sm:justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => setSelectedType(null)} className={outlineButtonClasses}>
{t('dialog.back')}
</Button>
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
{meta?.connectionType === 'token' && ( {meta?.connectionType === 'token' && (
<Button <Button

View File

@@ -52,6 +52,9 @@ import { useSettingsStore } from '@/stores/settings';
import { hostApiFetch } from '@/lib/host-api'; import { hostApiFetch } from '@/lib/host-api';
import { subscribeHostEvent } from '@/lib/host-events'; import { subscribeHostEvent } from '@/lib/host-events';
const inputClasses = 'h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-[#151514] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const labelClasses = 'text-[14px] text-foreground/80 font-bold';
function normalizeFallbackProviderIds(ids?: string[]): string[] { function normalizeFallbackProviderIds(ids?: string[]): string[] {
return Array.from(new Set((ids ?? []).filter(Boolean))); return Array.from(new Set((ids ?? []).filter(Boolean)));
} }
@@ -403,18 +406,25 @@ function ProviderCard({
} }
}; };
const currentInputClasses = isDefault
? "h-[40px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm"
: inputClasses;
const currentLabelClasses = isDefault ? "text-[13px] text-muted-foreground" : labelClasses;
const currentSectionLabelClasses = isDefault ? "text-[14px] font-bold text-foreground/80" : labelClasses;
return ( return (
<div <div
className={cn( className={cn(
"group flex flex-col p-4 rounded-2xl transition-all relative overflow-hidden", "group flex flex-col p-4 rounded-2xl transition-all relative overflow-hidden",
isDefault isDefault
? "bg-white dark:bg-[#1a1a19] border border-black/10 dark:border-white/10 shadow-sm" ? "bg-white dark:bg-accent border border-black/10 dark:border-white/10 shadow-sm"
: "bg-transparent border border-black/10 dark:border-white/10" : "bg-transparent border border-black/10 dark:border-white/10"
)} )}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className={cn("h-[42px] w-[42px] shrink-0 flex items-center justify-center text-foreground border border-black/5 dark:border-white/10 rounded-full shadow-sm group-hover:scale-105 transition-transform", isDefault ? "bg-black/5 dark:bg-white/5" : "bg-white dark:bg-[#1a1a19]")}> <div className={cn("h-[42px] w-[42px] shrink-0 flex items-center justify-center text-foreground border border-black/5 dark:border-white/10 rounded-full shadow-sm group-hover:scale-105 transition-transform", isDefault ? "bg-black/5 dark:bg-white/5" : "bg-white dark:bg-accent")}>
{getProviderIconUrl(account.vendorId) ? ( {getProviderIconUrl(account.vendorId) ? (
<img src={getProviderIconUrl(account.vendorId)} alt={typeInfo?.name || account.vendorId} className={cn('h-5 w-5', shouldInvertInDark(account.vendorId) && 'dark:invert')} /> <img src={getProviderIconUrl(account.vendorId)} alt={typeInfo?.name || account.vendorId} className={cn('h-5 w-5', shouldInvertInDark(account.vendorId) && 'dark:invert')} />
) : ( ) : (
@@ -505,32 +515,32 @@ function ProviderCard({
<div className="space-y-6 mt-4 pt-4 border-t border-black/5 dark:border-white/5"> <div className="space-y-6 mt-4 pt-4 border-t border-black/5 dark:border-white/5">
{canEditModelConfig && ( {canEditModelConfig && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-[14px] font-bold text-foreground/80">{t('aiProviders.sections.model')}</p> <p className={currentSectionLabelClasses}>{t('aiProviders.sections.model')}</p>
{typeInfo?.showBaseUrl && ( {typeInfo?.showBaseUrl && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-[13px] text-muted-foreground">{t('aiProviders.dialog.baseUrl')}</Label> <Label className={currentLabelClasses}>{t('aiProviders.dialog.baseUrl')}</Label>
<Input <Input
value={baseUrl} value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)} onChange={(e) => setBaseUrl(e.target.value)}
placeholder={apiProtocol === 'anthropic-messages' ? "https://api.example.com/anthropic" : "https://api.example.com/v1"} placeholder={apiProtocol === 'anthropic-messages' ? "https://api.example.com/anthropic" : "https://api.example.com/v1"}
className="h-[40px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm" className={currentInputClasses}
/> />
</div> </div>
)} )}
{showModelIdField && ( {showModelIdField && (
<div className="space-y-1.5 pt-2"> <div className="space-y-1.5 pt-2">
<Label className="text-[13px] text-muted-foreground">{t('aiProviders.dialog.modelId')}</Label> <Label className={currentLabelClasses}>{t('aiProviders.dialog.modelId')}</Label>
<Input <Input
value={modelId} value={modelId}
onChange={(e) => setModelId(e.target.value)} onChange={(e) => setModelId(e.target.value)}
placeholder={typeInfo?.modelIdPlaceholder || 'provider/model-id'} placeholder={typeInfo?.modelIdPlaceholder || 'provider/model-id'}
className="h-[40px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm" className={currentInputClasses}
/> />
</div> </div>
)} )}
{account.vendorId === 'custom' && ( {account.vendorId === 'custom' && (
<div className="space-y-1.5 pt-2"> <div className="space-y-1.5 pt-2">
<Label className="text-[13px] text-muted-foreground">{t('aiProviders.dialog.protocol', 'Protocol')}</Label> <Label className={currentLabelClasses}>{t('aiProviders.dialog.protocol', 'Protocol')}</Label>
<div className="flex gap-2 text-[13px]"> <div className="flex gap-2 text-[13px]">
<button <button
type="button" type="button"
@@ -562,23 +572,25 @@ function ProviderCard({
{showFallback && ( {showFallback && (
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-[13px] text-muted-foreground">{t('aiProviders.dialog.fallbackModelIds')}</Label> <Label className={currentLabelClasses}>{t('aiProviders.dialog.fallbackModelIds')}</Label>
<textarea <textarea
value={fallbackModelsText} value={fallbackModelsText}
onChange={(e) => setFallbackModelsText(e.target.value)} onChange={(e) => setFallbackModelsText(e.target.value)}
placeholder={t('aiProviders.dialog.fallbackModelIdsPlaceholder')} placeholder={t('aiProviders.dialog.fallbackModelIdsPlaceholder')}
className="min-h-24 w-full rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-[#1a1a19] px-3 py-2 text-[13px] font-mono outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm" className={isDefault
? "min-h-24 w-full rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-[#1a1a19] px-3 py-2 text-[13px] font-mono outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm"
: "min-h-24 w-full rounded-xl border border-black/10 dark:border-white/10 bg-[#eeece3] dark:bg-[#151514] px-3 py-2 text-[13px] font-mono outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40"}
/> />
<p className="text-[12px] text-muted-foreground"> <p className="text-[12px] text-muted-foreground">
{t('aiProviders.dialog.fallbackModelIdsHelp')} {t('aiProviders.dialog.fallbackModelIdsHelp')}
</p> </p>
</div> </div>
<div className="space-y-2 pt-1"> <div className="space-y-2 pt-1">
<Label className="text-[13px] text-muted-foreground">{t('aiProviders.dialog.fallbackProviders')}</Label> <Label className={currentLabelClasses}>{t('aiProviders.dialog.fallbackProviders')}</Label>
{fallbackOptions.length === 0 ? ( {fallbackOptions.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t('aiProviders.dialog.noFallbackOptions')}</p> <p className="text-[13px] text-muted-foreground">{t('aiProviders.dialog.noFallbackOptions')}</p>
) : ( ) : (
<div className="space-y-2 rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-[#1a1a19] p-3 shadow-sm"> <div className={cn("space-y-2 rounded-xl border border-black/10 dark:border-white/10 p-3 shadow-sm", isDefault ? "bg-white dark:bg-[#1a1a19]" : "bg-[#eeece3] dark:bg-[#151514]")}>
{fallbackOptions.map((candidate) => ( {fallbackOptions.map((candidate) => (
<label key={candidate.account.id} className="flex items-center gap-3 text-[13px] cursor-pointer group/label"> <label key={candidate.account.id} className="flex items-center gap-3 text-[13px] cursor-pointer group/label">
<input <input
@@ -602,7 +614,7 @@ function ProviderCard({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label className="text-[14px] font-bold text-foreground/80">{t('aiProviders.dialog.apiKey')}</Label> <Label className={currentSectionLabelClasses}>{t('aiProviders.dialog.apiKey')}</Label>
<p className="text-[12px] text-muted-foreground"> <p className="text-[12px] text-muted-foreground">
{hasConfiguredCredentials(account, status) {hasConfiguredCredentials(account, status)
? t('aiProviders.dialog.apiKeyConfigured') ? t('aiProviders.dialog.apiKeyConfigured')
@@ -630,7 +642,7 @@ function ProviderCard({
</div> </div>
)} )}
<div className="space-y-1.5 pt-1"> <div className="space-y-1.5 pt-1">
<Label className="text-[13px] text-muted-foreground">{t('aiProviders.dialog.replaceApiKey')}</Label> <Label className={currentLabelClasses}>{t('aiProviders.dialog.replaceApiKey')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
@@ -638,7 +650,7 @@ function ProviderCard({
placeholder={typeInfo?.requiresApiKey ? typeInfo?.placeholder : (typeInfo?.id === 'ollama' ? t('aiProviders.notRequired') : t('aiProviders.card.editKey'))} placeholder={typeInfo?.requiresApiKey ? typeInfo?.placeholder : (typeInfo?.id === 'ollama' ? t('aiProviders.notRequired') : t('aiProviders.card.editKey'))}
value={newKey} value={newKey}
onChange={(e) => setNewKey(e.target.value)} onChange={(e) => setNewKey(e.target.value)}
className="pr-10 h-[40px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm" className={cn(currentInputClasses, 'pr-10')}
/> />
<button <button
type="button" type="button"
@@ -651,7 +663,12 @@ function ProviderCard({
<Button <Button
variant="outline" variant="outline"
onClick={handleSaveEdits} onClick={handleSaveEdits}
className="h-[40px] rounded-xl px-4 bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/10" className={cn(
"rounded-xl px-4 border-black/10 dark:border-white/10",
isDefault
? "h-[40px] bg-white dark:bg-[#1a1a19] hover:bg-black/5 dark:hover:bg-white/10"
: "h-[44px] bg-[#eeece3] dark:bg-[#151514] hover:bg-black/5 dark:hover:bg-white/10 shadow-sm"
)}
disabled={ disabled={
validating validating
|| saving || saving
@@ -671,7 +688,16 @@ function ProviderCard({
<Check className="h-4 w-4 text-green-500" /> <Check className="h-4 w-4 text-green-500" />
)} )}
</Button> </Button>
<Button variant="ghost" onClick={onCancelEdit} className="h-[40px] w-[40px] p-0 rounded-xl hover:bg-black/5 dark:hover:bg-white/10"> <Button
variant="ghost"
onClick={onCancelEdit}
className={cn(
"p-0 rounded-xl",
isDefault
? "h-[40px] w-[40px] hover:bg-black/5 dark:hover:bg-white/10"
: "h-[44px] w-[44px] bg-[#eeece3] dark:bg-[#151514] border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/10 shadow-sm text-muted-foreground hover:text-foreground"
)}
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -1000,21 +1026,21 @@ function AddProviderDialog({
</div> </div>
</div> </div>
<div className="space-y-4 bg-[#eeece3] dark:bg-[#151514] p-5 rounded-2xl border border-black/5 dark:border-white/5 shadow-sm"> <div className="space-y-6 bg-transparent p-0">
<div className="space-y-2"> <div className="space-y-2.5">
<Label htmlFor="name" className="text-[14px] font-bold text-foreground/80">{t('aiProviders.dialog.displayName')}</Label> <Label htmlFor="name" className={labelClasses}>{t('aiProviders.dialog.displayName')}</Label>
<Input <Input
id="name" id="name"
placeholder={typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name} placeholder={typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="h-[44px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm" className={inputClasses}
/> />
</div> </div>
{/* Auth mode toggle for providers supporting both */} {/* Auth mode toggle for providers supporting both */}
{isOAuth && supportsApiKey && ( {isOAuth && supportsApiKey && (
<div className="flex rounded-xl border border-black/10 dark:border-white/10 overflow-hidden text-[13px] font-medium shadow-sm bg-white dark:bg-[#1a1a19] p-1 gap-1"> <div className="flex rounded-xl border border-black/10 dark:border-white/10 overflow-hidden text-[13px] font-medium shadow-sm bg-[#eeece3] dark:bg-[#151514] p-1 gap-1">
<button <button
onClick={() => setAuthMode('oauth')} onClick={() => setAuthMode('oauth')}
className={cn( className={cn(
@@ -1038,9 +1064,9 @@ function AddProviderDialog({
{/* API Key input — shown for non-OAuth providers or when apikey mode is selected */} {/* API Key input — shown for non-OAuth providers or when apikey mode is selected */}
{(!isOAuth || (supportsApiKey && authMode === 'apikey')) && ( {(!isOAuth || (supportsApiKey && authMode === 'apikey')) && (
<div className="space-y-2"> <div className="space-y-2.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="apiKey" className="text-[14px] font-bold text-foreground/80">{t('aiProviders.dialog.apiKey')}</Label> <Label htmlFor="apiKey" className={labelClasses}>{t('aiProviders.dialog.apiKey')}</Label>
{typeInfo?.apiKeyUrl && ( {typeInfo?.apiKeyUrl && (
<a <a
href={typeInfo.apiKeyUrl} href={typeInfo.apiKeyUrl}
@@ -1063,7 +1089,7 @@ function AddProviderDialog({
setApiKey(e.target.value); setApiKey(e.target.value);
setValidationError(null); setValidationError(null);
}} }}
className="pr-10 h-[44px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm" className={inputClasses}
/> />
<button <button
type="button" type="button"
@@ -1083,21 +1109,21 @@ function AddProviderDialog({
)} )}
{typeInfo?.showBaseUrl && ( {typeInfo?.showBaseUrl && (
<div className="space-y-2"> <div className="space-y-2.5">
<Label htmlFor="baseUrl" className="text-[14px] font-bold text-foreground/80">{t('aiProviders.dialog.baseUrl')}</Label> <Label htmlFor="baseUrl" className={labelClasses}>{t('aiProviders.dialog.baseUrl')}</Label>
<Input <Input
id="baseUrl" id="baseUrl"
placeholder={apiProtocol === 'anthropic-messages' ? "https://api.example.com/anthropic" : "https://api.example.com/v1"} placeholder={apiProtocol === 'anthropic-messages' ? "https://api.example.com/anthropic" : "https://api.example.com/v1"}
value={baseUrl} value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)} onChange={(e) => setBaseUrl(e.target.value)}
className="h-[44px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm" className={inputClasses}
/> />
</div> </div>
)} )}
{showModelIdField && ( {showModelIdField && (
<div className="space-y-2"> <div className="space-y-2.5">
<Label htmlFor="modelId" className="text-[14px] font-bold text-foreground/80">{t('aiProviders.dialog.modelId')}</Label> <Label htmlFor="modelId" className={labelClasses}>{t('aiProviders.dialog.modelId')}</Label>
<Input <Input
id="modelId" id="modelId"
placeholder={typeInfo?.modelIdPlaceholder || 'provider/model-id'} placeholder={typeInfo?.modelIdPlaceholder || 'provider/model-id'}
@@ -1106,13 +1132,13 @@ function AddProviderDialog({
setModelId(e.target.value); setModelId(e.target.value);
setValidationError(null); setValidationError(null);
}} }}
className="h-[44px] rounded-xl font-mono text-[13px] bg-white dark:bg-[#1a1a19] border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm" className={inputClasses}
/> />
</div> </div>
)} )}
{selectedType === 'custom' && ( {selectedType === 'custom' && (
<div className="space-y-2"> <div className="space-y-2.5">
<Label className="text-[14px] font-bold text-foreground/80">{t('aiProviders.dialog.protocol', 'Protocol')}</Label> <Label className={labelClasses}>{t('aiProviders.dialog.protocol', 'Protocol')}</Label>
<div className="flex gap-2 text-[13px]"> <div className="flex gap-2 text-[13px]">
<button <button
type="button" type="button"

View File

@@ -150,6 +150,10 @@ export function Channels() {
<ChannelCard <ChannelCard
key={channel.id} key={channel.id}
channel={channel} channel={channel}
onClick={() => {
setSelectedChannelType(channel.type);
setShowAddDialog(true);
}}
onDelete={() => setChannelToDelete({ id: channel.id })} onDelete={() => setChannelToDelete({ id: channel.id })}
/> />
))} ))}
@@ -264,15 +268,19 @@ function ChannelLogo({ type }: { type: ChannelType }) {
interface ChannelCardProps { interface ChannelCardProps {
channel: Channel; channel: Channel;
onClick: () => void;
onDelete: () => void; onDelete: () => void;
} }
function ChannelCard({ channel, onDelete }: ChannelCardProps) { function ChannelCard({ channel, onClick, onDelete }: ChannelCardProps) {
const { t } = useTranslation('channels'); const { t } = useTranslation('channels');
const meta = CHANNEL_META[channel.type]; const meta = CHANNEL_META[channel.type];
return ( return (
<div className="group flex items-start gap-4 p-4 rounded-2xl transition-all text-left border relative overflow-hidden bg-transparent border-transparent hover:bg-black/5 dark:hover:bg-white/5"> <div
onClick={onClick}
className="group flex items-start gap-4 p-4 rounded-2xl transition-all text-left border relative overflow-hidden bg-transparent border-transparent hover:bg-black/5 dark:hover:bg-white/5 cursor-pointer"
>
<div className="h-[46px] w-[46px] shrink-0 flex items-center justify-center text-foreground bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 rounded-full shadow-sm mb-3"> <div className="h-[46px] w-[46px] shrink-0 flex items-center justify-center text-foreground bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 rounded-full shadow-sm mb-3">
<ChannelLogo type={channel.type} /> <ChannelLogo type={channel.type} />
</div> </div>