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:
@@ -2147,3 +2147,20 @@ export function subscribeSessionEvents(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Design Skill API ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listDesignStyles(): Promise<string[]> {
|
||||||
|
const result = await portalFetch<{ designs: string[] }>('/admin-api/skills/designs');
|
||||||
|
return result.designs ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invokeDesignSkill(
|
||||||
|
design: string,
|
||||||
|
task: string,
|
||||||
|
): Promise<{ invocation_id: number; output: string }> {
|
||||||
|
return portalFetch('/admin-api/skills/awesome-design-md/invoke', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ design, task }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { openAvatarPicker } from '../utils/userAvatar';
|
|||||||
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
|
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
|
||||||
import { AvatarPicker } from './AvatarPicker';
|
import { AvatarPicker } from './AvatarPicker';
|
||||||
import { ChatSkillPicker } from './ChatSkillPicker';
|
import { ChatSkillPicker } from './ChatSkillPicker';
|
||||||
|
import { DesignSkillPanel } from './DesignSkillPanel';
|
||||||
import { MessageList } from './MessageList';
|
import { MessageList } from './MessageList';
|
||||||
import { PageSaveDialog } from './PageSaveDialog';
|
import { PageSaveDialog } from './PageSaveDialog';
|
||||||
import { VoiceInputButton } from './VoiceInputButton';
|
import { VoiceInputButton } from './VoiceInputButton';
|
||||||
@@ -67,6 +68,7 @@ export function ChatPanel({
|
|||||||
}) {
|
}) {
|
||||||
const online = useNetworkStatus();
|
const online = useNetworkStatus();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
const [designPanelOpen, setDesignPanelOpen] = useState(false);
|
||||||
const [voiceRecording, setVoiceRecording] = useState(false);
|
const [voiceRecording, setVoiceRecording] = useState(false);
|
||||||
const [pageSource, setPageSource] = useState<Message | null>(null);
|
const [pageSource, setPageSource] = useState<Message | null>(null);
|
||||||
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
|
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
|
||||||
@@ -455,6 +457,25 @@ export function ChatPanel({
|
|||||||
onPrefill={setInput}
|
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' ? (
|
{chatState === 'streaming' ? (
|
||||||
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+183
@@ -7867,3 +7867,186 @@ body,
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Design Skill Panel ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.design-skill-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.design-skill-close:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-hint {
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-loading {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-chip {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.design-skill-chip:hover {
|
||||||
|
border-color: var(--color-border-strong, #3a4a60);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
.design-skill-chip.selected {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-bg-active);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-task {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border-input);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 10px 12px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.design-skill-task:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-border-input-focus);
|
||||||
|
}
|
||||||
|
.design-skill-task:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-error {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-skill-cancel {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.design-skill-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.design-skill-cancel:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.design-skill-submit {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 6px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.design-skill-submit:hover:not(:disabled) { opacity: 0.88; }
|
||||||
|
.design-skill-submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|||||||
Reference in New Issue
Block a user