feat: 人工接管任务(takeover)+ 任务详情展示附件

B-③ takeover(⑧ ★ POST /api/tasks/:id/takeover):

后端:
- POST /api/tasks/:id/takeover:准备(或复用 result.worktree)任务 worktree,
  返回 {taskId, worktree, branch, console}——console 为用户在自己终端起交互式
  claude 的命令(daemon 无 TTY,不在进程内起交互会话);在途 run 时拒绝

前端:
- api.takeover;TakeoverModal(展示命令 + 一键复制 + 分支提示)
- TaskDetail 加「⌨ 人工接管」按钮(onTakeover 经 TaskSection→TaskRow 线程下来)
- 顺带:TaskDetail 展示任务附件(📎 文件名,读 task._raw.attachments)

验证:typecheck 干净;206 测试通过;前端 build + dev 渲染 0 错误。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-24 17:06:35 +08:00
parent a688b6977e
commit 638f2f64f0
4 changed files with 72 additions and 8 deletions
+1
View File
@@ -31,6 +31,7 @@ export const api = {
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),
takeover: (taskId) => http('POST', `/api/tasks/${taskId}/takeover`),
// 附件上传走 multipart(不能用 JSON helpercontent-type 由浏览器带 boundary
uploadAttachments: async (taskId, files) => {
const fd = new FormData();
+32 -1
View File
@@ -103,6 +103,29 @@ function StreamModal({ agent, onClose }) {
);
}
// 接管模态:展示 daemon 准备好的 worktree + 在自己终端起交互式 claude 的命令
function TakeoverModal({ info, onClose }) {
const [copied, setCopied] = React.useState(false);
const copy = () => { try { navigator.clipboard.writeText(info.console); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* 非安全上下文 */ } };
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>
<div style={{ position: 'relative', width: 'min(620px,92vw)', background: 'var(--panel)', border: '1px solid var(--cyan-dim)', borderRadius: 'var(--radius-lg,10px)', padding: 18, boxShadow: '0 0 0 1px var(--line-soft), 0 24px 64px rgba(0,0,0,.6)' }}>
<div style={{ fontWeight: 700, fontSize: 14, color: 'var(--cyan)', marginBottom: 12 }}> 人工接管 · {info.title}</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 8 }}>worktree 已就绪在你自己的终端运行下面命令起交互式 Claude Code 手动接手</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
<code style={{ flex: 1, background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--cyan-dim)', borderRadius: 4, padding: '10px 12px', fontSize: 12.5, color: 'var(--ink)', wordBreak: 'break-all' }}>{info.console}</code>
<button onClick={copy} style={{ flex: 'none', fontFamily: 'var(--mono)', fontSize: 12, cursor: 'pointer', background: 'transparent', color: copied ? 'var(--green)' : 'var(--cyan)', border: '1px solid var(--cyan-dim)', borderRadius: 4, padding: '0 12px' }}>{copied ? '✓ 已复制' : '复制'}</button>
</div>
<div style={{ fontSize: 11, color: 'var(--faint)', marginTop: 10 }}>分支 {info.branch} · 完成后用 git 提交,可经审核区合并</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<button onClick={onClose} style={{ fontFamily: 'var(--mono)', fontSize: 13, background: 'transparent', color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 4, padding: '5px 14px', cursor: 'pointer' }}>关闭</button>
</div>
</div>
</div>
);
}
export function App() {
const I18N = window.MAESTRO_I18N;
const [lang, setLang] = React.useState(() => {
@@ -125,6 +148,7 @@ export function App() {
const [showConfig, setShowConfig] = React.useState(false);
const [archiveItem, setArchiveItem] = React.useState(null);
const [streamAgent, setStreamAgent] = React.useState(null);
const [takeoverInfo, setTakeoverInfo] = React.useState(null);
const [toasts, setToasts] = React.useState([]);
const [theme, setTheme] = React.useState(() => localStorage.getItem('maestro-kit-theme') || 'dark');
React.useEffect(() => {
@@ -281,6 +305,12 @@ export function App() {
setShowConfig(false); toast('ok', t.toastSaved); loadGlobal();
} catch (e) { toast('warn', '保存失败:' + e.message); }
};
const takeover = async (task) => {
try {
const info = await api.takeover(task.id);
setTakeoverInfo({ ...info, title: task.title });
} catch (e) { toast('warn', '接管失败:' + e.message); }
};
const [showNewProject, setShowNewProject] = React.useState(false);
const createProject = async (body) => {
try {
@@ -312,7 +342,7 @@ export function App() {
<window.MaestroKitAgentSection agents={agents} quota={quota} t={t} onOpenStream={setStreamAgent} />
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
{tasks.length ? (
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} t={t} />
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} onTakeover={takeover} 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>
@@ -325,6 +355,7 @@ export function App() {
{archiveItem ? <window.MaestroKitArchiveModal item={archiveItem} t={t} onClose={() => setArchiveItem(null)} /> : null}
{showNewProject ? <NewProjectModal t={t} lang={lang} onCreate={createProject} onClose={() => setShowNewProject(false)} /> : null}
{streamAgent ? <StreamModal agent={streamAgent} onClose={() => setStreamAgent(null)} /> : null}
{takeoverInfo ? <TakeoverModal info={takeoverInfo} onClose={() => setTakeoverInfo(null)} /> : 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>