feat: DesignSkillPanel — awesome-design-md 前台 UI 集成

- 新增 DesignSkillPanel 组件:54 品牌风格选择网格 + 任务描述输入
- ChatPanel 工具栏增加 design 按钮,点击弹出面板
- 生成结果回填到输入框,用户可编辑后发送
- 新增 API:listDesignStyles / invokeDesignSkill(走 /admin-api/skills/)
- .env.local: H5_DEV_ADMIN=http://127.0.0.1:8085(对接本地 memindadm)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
john
2026-06-26 19:44:21 +08:00
parent 8502775042
commit 8627188ee7
4 changed files with 403 additions and 0 deletions
+32
View File
@@ -4,6 +4,7 @@ import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
import { AvatarPicker } from './AvatarPicker';
import { ChatSkillPicker } from './ChatSkillPicker';
import { DesignSkillPanel } from './DesignSkillPanel';
import { MessageList } from './MessageList';
import { PageSaveDialog } from './PageSaveDialog';
import { VoiceInputButton } from './VoiceInputButton';
@@ -67,6 +68,7 @@ export function ChatPanel({
}) {
const online = useNetworkStatus();
const [input, setInput] = useState('');
const [designPanelOpen, setDesignPanelOpen] = useState(false);
const [voiceRecording, setVoiceRecording] = useState(false);
const [pageSource, setPageSource] = useState<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
@@ -455,6 +457,25 @@ export function ChatPanel({
onPrefill={setInput}
/>
)}
<button
type="button"
className="chat-skill-trigger"
title="Design Style Generator"
disabled={voiceDisabled}
onClick={() => setDesignPanelOpen(true)}
>
<DesignBtnIcon className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">design</span>
</button>
{designPanelOpen && (
<DesignSkillPanel
onClose={() => setDesignPanelOpen(false)}
onResult={(md) => {
setInput(md);
setDesignPanelOpen(false);
}}
/>
)}
{chatState === 'streaming' ? (
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
@@ -479,3 +500,14 @@ export function ChatPanel({
</>
);
}
function DesignBtnIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.48 2 12c0 5.52 4.48 10 10 10 1.1 0 2-.9 2-2 0-.52-.2-1-.52-1.36-.3-.35-.48-.83-.48-1.36 0-1.1.9-2 2-2h2.36c3.1 0 5.64-2.54 5.64-5.64C22 6.02 17.52 2 12 2zM6.5 13C5.67 13 5 12.33 5 11.5S5.67 10 6.5 10 8 10.67 8 11.5 7.33 13 6.5 13zm3-4C8.67 9 8 8.33 8 7.5S8.67 6 9.5 6s1.5.67 1.5 1.5S10.33 9 9.5 9zm5 0c-.83 0-1.5-.67-1.5-1.5S13.67 6 14.5 6s1.5.67 1.5 1.5S15.33 9 14.5 9zm3 4c-.83 0-1.5-.67-1.5-1.5S16.67 10 17.5 10s1.5.67 1.5 1.5S18.33 13 17.5 13z"
/>
</svg>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useRef, useState } from 'react';
import { invokeDesignSkill, listDesignStyles } from '../api/client';
type Props = {
onClose: () => void;
onResult: (markdown: string) => void;
};
const FEATURED: Record<string, string> = {
notion: 'Notion',
stripe: 'Stripe',
apple: 'Apple',
vercel: 'Vercel',
airbnb: 'Airbnb',
'linear.app': 'Linear',
figma: 'Figma',
spotify: 'Spotify',
cursor: 'Cursor',
supabase: 'Supabase',
};
function formatBrandLabel(slug: string) {
return FEATURED[slug] ?? slug.replace(/[-_.]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
export function DesignSkillPanel({ onClose, onResult }: Props) {
const [designs, setDesigns] = useState<string[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [task, setTask] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fetching, setFetching] = useState(true);
const panelRef = useRef<HTMLDivElement>(null);
const taskRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
listDesignStyles()
.then((list) => {
const featured = Object.keys(FEATURED).filter((f) => list.includes(f));
const rest = list.filter((d) => !FEATURED[d]).sort();
setDesigns([...featured, ...rest]);
})
.catch(() => setDesigns([]))
.finally(() => setFetching(false));
}, []);
useEffect(() => {
if (selected) taskRef.current?.focus();
}, [selected]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handler);
document.addEventListener('keydown', keyHandler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('keydown', keyHandler);
};
}, [onClose]);
const handleSubmit = async () => {
if (!selected || !task.trim()) return;
setLoading(true);
setError(null);
try {
const res = await invokeDesignSkill(selected, task.trim());
onResult(res.output ?? '');
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '生成失败,请稍后重试');
} finally {
setLoading(false);
}
};
return (
<div className="design-skill-overlay">
<div
className="design-skill-panel"
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="Design Style Generator"
>
<div className="design-skill-header">
<span className="design-skill-title">
<PaletteIcon />
Design Style Generator
</span>
<button type="button" className="design-skill-close" onClick={onClose} aria-label="关闭">
</button>
</div>
<div className="design-skill-body">
<p className="design-skill-section-label"></p>
{fetching ? (
<div className="design-skill-loading"></div>
) : (
<div className="design-skill-grid">
{designs.map((d) => (
<button
key={d}
type="button"
className={`design-skill-chip${selected === d ? ' selected' : ''}`}
onClick={() => setSelected(selected === d ? null : d)}
>
{formatBrandLabel(d)}
</button>
))}
</div>
)}
{selected && (
<>
<p className="design-skill-section-label" style={{ marginTop: 16 }}>
UI
<span className="design-skill-hint"> {formatBrandLabel(selected)} </span>
</p>
<textarea
ref={taskRef}
className="design-skill-task"
placeholder={`例:生成一个 ${formatBrandLabel(selected)} 风格的用户卡片,含头像、姓名和操作按钮`}
value={task}
rows={4}
onChange={(e) => setTask(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void handleSubmit();
}}
disabled={loading}
/>
{error && <p className="design-skill-error">{error}</p>}
</>
)}
</div>
<div className="design-skill-footer">
<button
type="button"
className="design-skill-cancel"
onClick={onClose}
disabled={loading}
>
</button>
<button
type="button"
className="design-skill-submit"
disabled={!selected || !task.trim() || loading}
onClick={() => void handleSubmit()}
>
{loading ? '生成中…' : '生成'}
</button>
</div>
</div>
</div>
);
}
function PaletteIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2C6.48 2 2 6.48 2 12c0 5.52 4.48 10 10 10 1.1 0 2-.9 2-2 0-.52-.2-1-.52-1.36-.3-.35-.48-.83-.48-1.36 0-1.1.9-2 2-2h2.36c3.1 0 5.64-2.54 5.64-5.64C22 6.02 17.52 2 12 2zM6.5 13C5.67 13 5 12.33 5 11.5S5.67 10 6.5 10 8 10.67 8 11.5 7.33 13 6.5 13zm3-4C8.67 9 8 8.33 8 7.5S8.67 6 9.5 6s1.5.67 1.5 1.5S10.33 9 9.5 9zm5 0c-.83 0-1.5-.67-1.5-1.5S13.67 6 14.5 6s1.5.67 1.5 1.5S15.33 9 14.5 9zm3 4c-.83 0-1.5-.67-1.5-1.5S16.67 10 17.5 10s1.5.67 1.5 1.5S18.33 13 17.5 13z"/>
</svg>
);
}