feat(app+ds): 阶段2 接前端写操作——任务创建/配置保存/新建项目
让原型里的视觉 mock 表单变为真正可写(附加式、向后兼容原型 index.html):
design/ui_kits/console(surface 功能化,DS 组件本就支持 onChange):
- NewTaskPanel:表单受控(title/complexity/priority/parentId),onCreate 出参;
父任务下拉扁平化含子任务
- TaskSection:暴露 onCreate prop(无则回退原 toast)
- ConfigPanel:受控(concurrency/autonomy/logo/verifyCmd/model),onSave 出参
app/:
- api.js:加 createProject
- app.jsx:createTask→POST /tasks、saveConfig→PATCH /projects、createProject→
POST /projects;新增 NewProjectModal(design 无现成 surface,用 DS 组件就地拼装,
zh/en 标签自含);接 Sidebar 新建项目入口
验证:dev + 生产构建双通过;Playwright 实测新建项目模态打开、受控输入捕获值
(["demo-app",".../demo-app","main"])、0 console 错误。写端点契约逐字段比对
server.ts(PATCH camelCase / POST tasks {title,complexity(auto),priority,parentId,deps})。
活写测试从略:maestro 为 auto-approved,建真实任务会触发 agent。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ export const api = {
|
||||
http('POST', `/api/tasks/${taskId}/decide`, { action, reason, merge }),
|
||||
createTask: (pid, body) => http('POST', `/api/projects/${pid}/tasks`, body),
|
||||
patchProject: (pid, body) => http('PATCH', `/api/projects/${pid}`, body),
|
||||
createProject: (body) => http('POST', '/api/projects', body),
|
||||
};
|
||||
|
||||
// WS 单向订阅:连上后服务端推送 events 表记录。断线自动重连(指数退避,封顶 10s)。
|
||||
|
||||
+62
-3
@@ -11,6 +11,43 @@ const { Toast } = window.MaestroDesignSystem_a6a290;
|
||||
|
||||
const USER = { name: 'local', handle: '@local', plan: 'Claude Code', initial: 'L', hue: 200 };
|
||||
|
||||
// 新建项目模态(design 无现成 surface,用 DS 组件就地拼装)。标签 zh/en 自含。
|
||||
function NewProjectModal({ t, lang, onCreate, onClose }) {
|
||||
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
|
||||
const L = lang === 'zh'
|
||||
? { title: '新建项目', name: '项目名', repo: '仓库路径', branch: '默认分支', mode: '工作模式', namePh: 'my-app', repoPh: '/Users/you/code/my-app', create: '创建' }
|
||||
: { title: 'New project', name: 'Name', repo: 'Repo path', branch: 'Default branch', mode: 'Mode', namePh: 'my-app', repoPh: '/Users/you/code/my-app', create: 'Create' };
|
||||
const [name, setName] = React.useState('');
|
||||
const [repoPath, setRepoPath] = React.useState('');
|
||||
const [defaultBranch, setDefaultBranch] = React.useState('main');
|
||||
const [autonomy, setAutonomy] = React.useState('manual');
|
||||
const submit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !repoPath.trim()) return;
|
||||
onCreate({ name: name.trim(), repoPath: repoPath.trim(), defaultBranch: defaultBranch.trim() || 'main', autonomy });
|
||||
};
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 1100, display: 'grid', placeItems: 'center' }}>
|
||||
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
|
||||
<form onSubmit={submit} style={{ position: 'relative', width: 'min(520px, 92vw)', background: 'var(--panel)', border: '1px solid var(--green-dim)', borderRadius: 'var(--radius-lg,10px)', padding: 18, boxShadow: '0 0 0 1px var(--line-soft), 0 24px 64px rgba(0,0,0,.6)', animation: 'maestro-rise .18s ease both' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, letterSpacing: '.06em', marginBottom: 14, color: 'var(--green)' }}>▍ {L.title}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Input label={L.name} required placeholder={L.namePh} value={name} onChange={setName} style={{ width: '100%' }} />
|
||||
<Input label={L.repo} required placeholder={L.repoPh} value={repoPath} onChange={setRepoPath} style={{ width: '100%' }} />
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Input label={L.branch} value={defaultBranch} onChange={setDefaultBranch} width={140} />
|
||||
<Select label={L.mode} value={autonomy} onChange={setAutonomy} options={Object.entries(t.autonomy).map(([value, label]) => ({ value, label }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 18, paddingTop: 12, borderTop: '1px solid var(--line-soft)' }}>
|
||||
<Button onClick={onClose}>{t.cancel}</Button>
|
||||
<Button variant="solid" type="submit">{L.create}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const I18N = window.MAESTRO_I18N;
|
||||
const [lang, setLang] = React.useState(() => {
|
||||
@@ -166,6 +203,27 @@ export function App() {
|
||||
try { await api.syncProject(currentId); toast('ok', t.toastSynced); loadProject(currentId); loadGlobal(); }
|
||||
catch (e) { toast('warn', '同步失败:' + e.message); }
|
||||
};
|
||||
const createTask = async (payload) => {
|
||||
try {
|
||||
await api.createTask(currentId, payload);
|
||||
toast('ok', t.toastCreated);
|
||||
loadProject(currentId); loadGlobal();
|
||||
} catch (e) { toast('warn', '创建失败:' + e.message); }
|
||||
};
|
||||
const saveConfig = async (patch) => {
|
||||
try {
|
||||
await api.patchProject(currentId, patch);
|
||||
setShowConfig(false); toast('ok', t.toastSaved); loadGlobal();
|
||||
} catch (e) { toast('warn', '保存失败:' + e.message); }
|
||||
};
|
||||
const [showNewProject, setShowNewProject] = React.useState(false);
|
||||
const createProject = async (body) => {
|
||||
try {
|
||||
const p = await api.createProject(body);
|
||||
setShowNewProject(false); toast('ok', '项目已创建 · ' + body.name);
|
||||
await loadGlobal(); setCurrentId(p.id);
|
||||
} catch (e) { toast('warn', '创建项目失败:' + e.message); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'grid', gridTemplateColumns: (collapsed ? '52px' : sideW + 'px') + ' minmax(0,1fr) ' + (evCollapsed ? '52px' : evW + 'px'), height: '100vh' }}>
|
||||
@@ -174,7 +232,7 @@ export function App() {
|
||||
<window.MaestroKitSidebar projects={projects} currentId={currentId} t={t}
|
||||
global={global} user={USER} summary={agentSummary} onGlobalConfig={() => toast('warn', t.toastModal)}
|
||||
collapsed={collapsed} onToggleCollapse={toggleCollapse}
|
||||
onSelect={setCurrentId} onNewProject={() => toast('warn', t.toastModal)} />
|
||||
onSelect={setCurrentId} onNewProject={() => setShowNewProject(true)} />
|
||||
<main style={{ overflowY: 'auto', padding: '0 22px 60px' }}>
|
||||
{project ? <window.MaestroKitTopbar project={project} t={t} theme={theme}
|
||||
onToggleTheme={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||
@@ -183,13 +241,13 @@ export function App() {
|
||||
onSync={onSync}
|
||||
onToggleConfig={() => setShowConfig(!showConfig)} /> : null}
|
||||
{showConfig && project ? <window.MaestroKitConfigPanel project={project} t={t}
|
||||
onSave={() => { setShowConfig(false); toast('ok', t.toastSaved); }}
|
||||
onSave={saveConfig}
|
||||
onSync={onSync}
|
||||
onClose={() => setShowConfig(false)} /> : null}
|
||||
<window.MaestroKitAgentSection agents={agents} quota={quota} t={t} />
|
||||
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
|
||||
{tasks.length ? (
|
||||
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} t={t} />
|
||||
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} t={t} />
|
||||
) : (
|
||||
<div style={{ padding: '46px 0', textAlign: 'center', color: 'var(--faint)', border: '1px dashed var(--line)', borderRadius: 6, marginTop: 18 }}>
|
||||
<b style={{ display: 'block', fontSize: 15, color: 'var(--muted)', marginBottom: 6, letterSpacing: '.2em' }}>{t.empty}</b>
|
||||
@@ -200,6 +258,7 @@ export function App() {
|
||||
</main>
|
||||
<window.MaestroKitEventPanel events={events} collapsed={evCollapsed} onToggleCollapse={toggleEvents} t={t} />
|
||||
{archiveItem ? <window.MaestroKitArchiveModal item={archiveItem} t={t} onClose={() => setArchiveItem(null)} /> : null}
|
||||
{showNewProject ? <NewProjectModal t={t} lang={lang} onCreate={createProject} onClose={() => setShowNewProject(false)} /> : null}
|
||||
<div style={{ position: 'fixed', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 1200, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
||||
{toasts.map((x) => <Toast key={x.id} kind={x.kind}>{x.text}</Toast>)}
|
||||
</div>
|
||||
|
||||
@@ -201,22 +201,35 @@ function TaskDetail({ task, t, byId, onJump }) {
|
||||
|
||||
function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
|
||||
const { Button, Input, Select, ComplexitySeg } = window.MaestroDesignSystem_a6a290;
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [complexity, setComplexity] = React.useState('auto');
|
||||
const [priority, setPriority] = React.useState('1');
|
||||
const [parentId, setParentId] = React.useState('');
|
||||
const submit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
onCreate({ title: title.trim(), complexity, priority: Number(priority), parentId: parentId || null });
|
||||
};
|
||||
// 扁平化任务树供父任务下拉(含子任务)
|
||||
const flat = [];
|
||||
const walk = (list, prefix) => (list || []).forEach((tk) => { flat.push({ value: tk.id, label: prefix + tk.title }); walk(tk.children, prefix + '— '); });
|
||||
walk(tasks, '');
|
||||
return (
|
||||
<form style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: 14, marginBottom: 14 }}
|
||||
onSubmit={(e) => { e.preventDefault(); onCreate(); }}>
|
||||
onSubmit={submit}>
|
||||
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<span style={{ flex: 1, minWidth: 320, display: 'flex' }}><Input label={t.titleLabel} required placeholder={t.titlePlaceholder} style={{ width: '100%' }} /></span>
|
||||
<span style={{ flex: 1, minWidth: 320, display: 'flex' }}><Input label={t.titleLabel} required placeholder={t.titlePlaceholder} value={title} onChange={setTitle} style={{ width: '100%' }} /></span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: 12 }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
|
||||
<span>{t.complexity} <span style={{ color: 'var(--red)' }}>*</span></span>
|
||||
<ComplexitySeg defaultValue="auto" includeAuto autoLabel={t.cplxAuto} />
|
||||
<ComplexitySeg defaultValue="auto" includeAuto autoLabel={t.cplxAuto} onChange={setComplexity} />
|
||||
</label>
|
||||
<Select label={t.priority} defaultValue="1" options={[
|
||||
<Select label={t.priority} defaultValue="1" onChange={setPriority} options={[
|
||||
{ value: '0', label: t.p0 }, { value: '1', label: t.p1 }, { value: '2', label: t.p2 },
|
||||
]} />
|
||||
<span style={{ flex: 1, minWidth: 240, display: 'flex' }}>
|
||||
<Select label={t.parentTask} style={{ width: '100%' }} options={[{ value: '', label: t.topLevel }, ...tasks.map((tk) => ({ value: tk.id, label: tk.title }))]} />
|
||||
<Select label={t.parentTask} style={{ width: '100%' }} onChange={setParentId} options={[{ value: '', label: t.topLevel }, ...flat]} />
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--line-soft)' }}>
|
||||
@@ -227,7 +240,7 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TaskSection({ tasks, onToast, t }) {
|
||||
function TaskSection({ tasks, onToast, onCreate, t }) {
|
||||
const { Button, SectionHead } = window.MaestroDesignSystem_a6a290;
|
||||
const [expandedId, setExpandedId] = React.useState(null);
|
||||
const [openIds, setOpenIds] = React.useState(() => new Set(['t1']));
|
||||
@@ -300,7 +313,7 @@ function TaskSection({ tasks, onToast, t }) {
|
||||
<section>
|
||||
<SectionHead title={t.taskSection} sticky action={<Button variant="ghost" size="xs" onClick={() => setShowNew(!showNew)}>{t.newTask}</Button>} />
|
||||
{showNew ? <NewTaskPanel tasks={tasks} t={t} onCancel={() => setShowNew(false)}
|
||||
onCreate={() => { setShowNew(false); onToast('ok', t.toastCreated); }} /> : null}
|
||||
onCreate={(payload) => { setShowNew(false); onCreate ? onCreate(payload) : onToast('ok', t.toastCreated); }} /> : null}
|
||||
<FilterBar t={t} kw={kw} setKw={setKw} cplxSet={cplxSet} toggleCplx={toggleCplx}
|
||||
statusSet={statusSet} toggleGroup={toggleGroup} matchCount={matchCount} filtering={filtering} onClear={clearFilters} />
|
||||
<div>
|
||||
|
||||
@@ -109,21 +109,30 @@ function Topbar({ project, counts, onSync, onToggleConfig, t, theme, onToggleThe
|
||||
|
||||
function ConfigPanel({ project, onSave, onClose, onSync, t }) {
|
||||
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
|
||||
const [concurrency, setConcurrency] = React.useState(String(project.concurrency));
|
||||
const [autonomy, setAutonomy] = React.useState(project.autonomy);
|
||||
const [logo, setLogo] = React.useState(project.logo || '');
|
||||
const [verifyCmd, setVerifyCmd] = React.useState(project.verifyCmd || '');
|
||||
const [model, setModel] = React.useState(project.model || '');
|
||||
const save = () => onSave({
|
||||
concurrency: Number(concurrency), autonomy,
|
||||
logo: logo || null, verifyCmd: verifyCmd || null, model: model || null,
|
||||
});
|
||||
return (
|
||||
<section style={{ position: 'relative', zIndex: 30, background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: '12px 14px', marginTop: 12, animation: 'maestro-rise .18s ease both' }}>
|
||||
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<Input label={t.maxConcurrency} type="number" defaultValue={String(project.concurrency)} width={76} />
|
||||
<Select label={t.workMode} defaultValue={project.autonomy} options={Object.entries(t.autonomy).map(([value, label]) => ({ value, label }))} />
|
||||
<Input label={t.maxConcurrency} type="number" value={concurrency} onChange={setConcurrency} width={76} />
|
||||
<Select label={t.workMode} value={autonomy} onChange={setAutonomy} options={Object.entries(t.autonomy).map(([value, label]) => ({ value, label }))} />
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', letterSpacing: '.06em', marginLeft: 'auto', paddingBottom: 7 }}>
|
||||
{t.current}:{project.concurrency} · {project.autonomy}
|
||||
</span>
|
||||
<Button variant="solid" onClick={onSave}>{t.save}</Button>
|
||||
<Button variant="solid" onClick={save}>{t.save}</Button>
|
||||
<Button onClick={onClose}>{t.collapse}</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: 12 }}>
|
||||
<span style={{ flex: 2, minWidth: 220, display: 'flex' }}><Input label={t.projLogo} placeholder={t.logoPh} style={{ width: '100%' }} /></span>
|
||||
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.verifyCmd} placeholder={t.verifyPh} style={{ width: '100%' }} /></span>
|
||||
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.model} placeholder={t.modelPh} style={{ width: '100%' }} /></span>
|
||||
<span style={{ flex: 2, minWidth: 220, display: 'flex' }}><Input label={t.projLogo} placeholder={t.logoPh} value={logo} onChange={setLogo} style={{ width: '100%' }} /></span>
|
||||
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.verifyCmd} placeholder={t.verifyPh} value={verifyCmd} onChange={setVerifyCmd} style={{ width: '100%' }} /></span>
|
||||
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.model} placeholder={t.modelPh} value={model} onChange={setModel} style={{ width: '100%' }} /></span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10, paddingTop: 10, borderTop: '1px dashed var(--line-soft)' }}>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--faint)', letterSpacing: '.06em' }}>{t.lastSync}</span>
|
||||
|
||||
Reference in New Issue
Block a user