feat: P0e 模型选择器升级 — 卡片式下拉

- ModelSelector 组件:标签/提供商/描述/选中状态
- 替换 sandbox page + workshop page 原生 select
- 模型数据增加 provider/desc 字段
This commit is contained in:
yuzhiran-dev
2026-05-18 10:39:32 +08:00
parent 0fdad71c57
commit f2a6bb93f9
4 changed files with 58 additions and 13 deletions
+2 -7
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context'; import { useAuth } from '@/lib/auth-context';
import { apiFetch, getToken } from '@/lib/auth'; import { apiFetch, getToken } from '@/lib/auth';
import { AVAILABLE_MODELS, DEFAULT_MODEL } from '@/lib/models'; import { AVAILABLE_MODELS, DEFAULT_MODEL } from '@/lib/models';
import { ModelSelector } from '@/components/ui/model-selector';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1'; const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
@@ -119,13 +120,7 @@ export default function PromptWorkshopPage() {
<div className="bg-card rounded-2xl border border-border p-6"> <div className="bg-card rounded-2xl border border-border p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-foreground"></h3> <h3 className="font-semibold text-foreground"></h3>
<select <ModelSelector value={model} onChange={setModel} className="w-48" />
value={model}
onChange={e => setModel(e.target.value)}
className="px-3 py-1.5 border border-border rounded-lg text-sm bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
{AVAILABLE_MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div> </div>
<textarea <textarea
+2 -4
View File
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useAuth } from '@/lib/auth-context'; import { useAuth } from '@/lib/auth-context';
import { getToken, apiFetch } from '@/lib/auth'; import { getToken, apiFetch } from '@/lib/auth';
import { AVAILABLE_MODELS, DEFAULT_MODEL } from '@/lib/models'; import { AVAILABLE_MODELS, DEFAULT_MODEL } from '@/lib/models';
import { ModelSelector } from '@/components/ui/model-selector';
import { useT } from '@/i18n'; import { useT } from '@/i18n';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1'; const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
@@ -254,10 +255,7 @@ export default function SandboxPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<select value={model} onChange={e => setModel(e.target.value)} <ModelSelector value={model} onChange={setModel} className="w-48" />
className="px-3 py-1.5 border border-border rounded-lg text-sm bg-background focus:outline-none focus:border-brand-400">
{AVAILABLE_MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
{!isLoggedIn && ( {!isLoggedIn && (
<Link href="/auth" <Link href="/auth"
className="px-4 py-1.5 text-sm font-medium text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50"> className="px-4 py-1.5 text-sm font-medium text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50">
@@ -0,0 +1,50 @@
'use client';
import { useState } from 'react';
import { AVAILABLE_MODELS, type ModelOption } from '@/lib/models';
interface ModelSelectorProps {
value: string;
onChange: (id: string) => void;
className?: string;
}
export function ModelSelector({ value, onChange, className = '' }: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const selected = AVAILABLE_MODELS.find(m => m.id === value) || AVAILABLE_MODELS[0];
return (
<div className={`relative ${className}`}>
<button onClick={() => setOpen(!open)}
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-background hover:bg-accent transition-colors text-sm w-full text-left">
<span className="w-2 h-2 rounded-full bg-brand-600 shrink-0" />
<span className="text-foreground font-medium">{selected.label}</span>
<svg className={`w-3.5 h-3.5 ml-auto text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-20 w-64 bg-card border border-border rounded-xl shadow-lg overflow-hidden">
{AVAILABLE_MODELS.map(m => (
<button key={m.id} onClick={() => { onChange(m.id); setOpen(false); }}
className={`w-full text-left px-4 py-3 hover:bg-accent transition-colors flex items-center gap-3 ${m.id === value ? 'bg-accent' : ''}`}>
<span className={`w-2 h-2 rounded-full shrink-0 ${m.id === value ? 'bg-brand-600' : 'bg-muted-foreground/30'}`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">{m.label}</div>
<div className="text-xs text-muted-foreground">{m.provider} · {m.desc}</div>
</div>
{m.id === value && (
<svg className="w-4 h-4 text-brand-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</div>
</>
)}
</div>
);
}
+4 -2
View File
@@ -1,11 +1,13 @@
export interface ModelOption { export interface ModelOption {
id: string; id: string;
label: string; label: string;
provider: string;
desc: string;
} }
export const AVAILABLE_MODELS: ModelOption[] = [ export const AVAILABLE_MODELS: ModelOption[] = [
{ id: 'general', label: '通用模式' }, { id: 'general', label: '通用模式', provider: 'OpenAI 兼容', desc: '日常问答,综合能力均衡' },
{ id: 'opencode-go', label: 'DeepSeek V4 Flash' }, { id: 'opencode-go', label: 'DeepSeek V4 Flash', provider: 'OpenCode Go', desc: '高速推理,代码生成强' },
]; ];
export const DEFAULT_MODEL = 'general'; export const DEFAULT_MODEL = 'general';