feat(web): add prompt editor with split-pane and Markdown editing
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { Plus } from 'lucide-react';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useAccounts } from '@/hooks/useAccounts';
|
import { useAccounts } from '@/hooks/useAccounts';
|
||||||
import { AccountForm } from './AccountForm';
|
import { AccountForm } from './AccountForm';
|
||||||
|
import { PromptEditor } from '@/components/prompts/PromptEditor';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import type { Account } from '@/types';
|
import type { Account } from '@/types';
|
||||||
@@ -10,6 +11,7 @@ export function AccountList() {
|
|||||||
const { accounts, create, update, remove } = useAccounts();
|
const { accounts, create, update, remove } = useAccounts();
|
||||||
const [editing, setEditing] = useState<Account | null>(null);
|
const [editing, setEditing] = useState<Account | null>(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [subTab, setSubTab] = useState<'info' | 'prompts'>('info');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
@@ -23,7 +25,7 @@ export function AccountList() {
|
|||||||
{accounts.map((a) => (
|
{accounts.map((a) => (
|
||||||
<button
|
<button
|
||||||
key={a.id}
|
key={a.id}
|
||||||
onClick={() => setEditing(a)}
|
onClick={() => { setEditing(a); setSubTab('info'); }}
|
||||||
className="w-full text-left px-3 py-2 rounded-md text-sm hover:bg-zinc-800/50 text-zinc-400 hover:text-zinc-200 transition-colors"
|
className="w-full text-left px-3 py-2 rounded-md text-sm hover:bg-zinc-800/50 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="font-medium truncate">{a.name}</div>
|
<div className="font-medium truncate">{a.name}</div>
|
||||||
@@ -32,8 +34,25 @@ export function AccountList() {
|
|||||||
))}
|
))}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="flex-1 p-6">
|
<div className="flex-1 flex flex-col">
|
||||||
{(editing || creating) ? (
|
<div className="flex gap-0 border-b border-zinc-800 px-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setSubTab('info')}
|
||||||
|
className={`px-3 py-2 text-sm ${subTab === 'info' ? 'border-b-2 border-white text-white' : 'text-zinc-500'}`}
|
||||||
|
>
|
||||||
|
账户设置
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSubTab('prompts')}
|
||||||
|
className={`px-3 py-2 text-sm ${subTab === 'prompts' ? 'border-b-2 border-white text-white' : 'text-zinc-500'}`}
|
||||||
|
>
|
||||||
|
提示词模板
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{subTab === 'info' ? (
|
||||||
|
(editing || creating) ? (
|
||||||
|
<div className="p-6">
|
||||||
<AccountForm
|
<AccountForm
|
||||||
account={creating ? undefined : editing!}
|
account={creating ? undefined : editing!}
|
||||||
onSave={(data) => {
|
onSave={(data) => {
|
||||||
@@ -50,12 +69,17 @@ export function AccountList() {
|
|||||||
} : undefined}
|
} : undefined}
|
||||||
onCancel={() => { setEditing(null); setCreating(false); }}
|
onCancel={() => { setEditing(null); setCreating(false); }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full text-zinc-600 text-sm">
|
<div className="flex items-center justify-center h-full text-zinc-600 text-sm">
|
||||||
选择一个账户或创建新账户
|
选择一个账户或创建新账户
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<PromptEditor />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
84
web/client/src/components/prompts/PromptEditor.tsx
Normal file
84
web/client/src/components/prompts/PromptEditor.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAccounts } from '@/hooks/useAccounts';
|
||||||
|
import { usePrompts } from '@/hooks/usePrompts';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const PROMPT_TYPES = [
|
||||||
|
{ type: 'storyboard', label: '分镜' },
|
||||||
|
{ type: 'image', label: '图片提示词' },
|
||||||
|
{ type: 'video', label: '视频提示词' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function PromptEditor() {
|
||||||
|
const { accounts } = useAccounts();
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<string>('');
|
||||||
|
const [selectedType, setSelectedType] = useState<string>('storyboard');
|
||||||
|
const { content, path, loading, load, save, setContent } = usePrompts();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accounts.length > 0 && !selectedAccount) {
|
||||||
|
setSelectedAccount(accounts[0].id);
|
||||||
|
}
|
||||||
|
}, [accounts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedAccount) {
|
||||||
|
load(selectedAccount, selectedType);
|
||||||
|
}
|
||||||
|
}, [selectedAccount, selectedType]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div className="w-48 border-r border-zinc-800 p-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">账户</label>
|
||||||
|
<select
|
||||||
|
value={selectedAccount}
|
||||||
|
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||||
|
className="mt-1 w-full h-9 rounded-md border border-zinc-800 bg-zinc-900 px-2 text-sm"
|
||||||
|
>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">模板</label>
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{PROMPT_TYPES.map(({ type, label }) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setSelectedType(type)}
|
||||||
|
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
|
||||||
|
selectedType === type ? 'bg-zinc-800 text-white' : 'text-zinc-500 hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
|
||||||
|
<span className="text-xs text-zinc-500 font-mono">{path}</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => save(selectedAccount, selectedType, content)}
|
||||||
|
disabled={!selectedAccount || loading}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
className="flex-1 w-full bg-zinc-950 text-zinc-200 font-mono text-sm p-4 resize-none outline-none"
|
||||||
|
placeholder="加载中..."
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
web/client/src/hooks/usePrompts.ts
Normal file
25
web/client/src/hooks/usePrompts.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export function usePrompts() {
|
||||||
|
const [content, setContent] = useState<string>('');
|
||||||
|
const [path, setPath] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async (accountId: string, type: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.getPrompt(accountId, type);
|
||||||
|
setContent(result.content);
|
||||||
|
setPath(result.path);
|
||||||
|
} catch { setContent(''); setPath(''); }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = useCallback(async (accountId: string, type: string, newContent: string) => {
|
||||||
|
await api.savePrompt(accountId, type, newContent);
|
||||||
|
setContent(newContent);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { content, path, loading, load, save, setContent };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user