feat: 任务附件上传(图片/文件随任务提交)
B-① 附件上传(⑧ ★ POST /api/tasks/:id/attachments):
后端:
- 加 @fastify/multipart;schema/db.ts tasks.attachments 列(幂等迁移)
- types.Attachment{name,type,path} + Task.attachments;mappers 映射
- store.addAttachments(追加元数据 + 广播 task.updated)
- POST /api/tasks/:id/attachments:multipart files[](单文件≤25MB/单次≤10),
落盘 <data>/tasks/<id>/attachments/,文件名消毒,返回全部附件
前端:
- api.uploadAttachments(FormData multipart)
- 新建任务表单加文件选择 + 已选列表;创建任务后自动上传附件
验证:typecheck 干净;206 测试通过;前端 build 通过。
(端到端需重启 daemon 加载新端点——与后续 SSE/takeover 一并重启。)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ export function openDb(file: string): Database.Database {
|
||||
ensureColumn(db, 'runs', 'cost_usd', 'cost_usd REAL'); // 该 run 折算成本 USD(按模型×token)
|
||||
ensureColumn(db, 'projects', 'budget_usd', 'budget_usd REAL'); // 项目当期预算上限 USD(null=不限)
|
||||
ensureColumn(db, 'projects', 'budget_period', "budget_period TEXT NOT NULL DEFAULT 'month'"); // 预算周期 day|month
|
||||
ensureColumn(db, 'tasks', 'attachments', 'attachments TEXT'); // 附件 JSON [{name,type,path}](图片/文件随任务提交)
|
||||
const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8');
|
||||
db.exec(schema);
|
||||
return db;
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface TaskRow {
|
||||
next_eligible_at: string | null;
|
||||
created_at: string; updated_at: string;
|
||||
source_ref: string | null;
|
||||
attachments: string | null;
|
||||
last_run_error?: string | null; // pendingApprovals 扩展字段(subquery)
|
||||
}
|
||||
export interface ApprovalRow {
|
||||
@@ -81,6 +82,7 @@ export function rowToTask(r: TaskRow, approvals: ApprovalRecord[] = []): Task {
|
||||
assignee: r.assignee as Task['assignee'], retryBaseline: r.retry_baseline ?? 0,
|
||||
nextEligibleAt: r.next_eligible_at ?? null,
|
||||
lastRunError: r.last_run_error ?? null,
|
||||
attachments: r.attachments ? (JSON.parse(r.attachments) as Task['attachments']) : undefined,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
next_eligible_at TEXT, -- 持久化退避:早于此时间不被领取(null=即刻可领)
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
source_ref TEXT -- 旧 todo 来源标识(todo:17 / todo:17/1A),项目内唯一
|
||||
source_ref TEXT, -- 旧 todo 来源标识(todo:17 / todo:17/1A),项目内唯一
|
||||
attachments TEXT -- 附件 JSON [{name,type,path}](图片/文件随任务提交)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
|
||||
|
||||
+13
-2
@@ -4,7 +4,7 @@ import {
|
||||
rowToProject, rowToTask, rowToApproval, rowToRun, rowToEvent,
|
||||
type ProjectRow, type TaskRow, type ApprovalRow, type RunRow, type EventRow,
|
||||
} from './mappers.js';
|
||||
import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType } from '../model/types.js';
|
||||
import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, EventType, Attachment } from '../model/types.js';
|
||||
import { DEFAULT_MAX_DEPTH, HARD_MAX_DEPTH } from '../model/types.js';
|
||||
import type { Complexity } from '../model/complexity.js';
|
||||
import { rankByScore } from '../model/scoring.js';
|
||||
@@ -277,7 +277,7 @@ export class Store {
|
||||
title: input.title, complexity: input.complexity, status, priority: input.priority ?? 1,
|
||||
deps: JSON.stringify(input.deps ?? []), plan: null, spec: null, operations: null,
|
||||
result: null, assignee: null, retry_baseline: 0, next_eligible_at: null,
|
||||
created_at: now(), updated_at: now(), source_ref: null,
|
||||
created_at: now(), updated_at: now(), source_ref: null, attachments: null,
|
||||
};
|
||||
this.db.prepare(
|
||||
`INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,retry_baseline,created_at,updated_at,source_ref)
|
||||
@@ -287,6 +287,17 @@ export class Store {
|
||||
return rowToTask(row);
|
||||
}
|
||||
|
||||
/** 追加任务附件(文件已落盘,这里只记元数据)。返回更新后的全部附件。 */
|
||||
addAttachments(taskId: string, items: Attachment[]): Attachment[] {
|
||||
const t = this.getTaskRow(taskId);
|
||||
if (!t) throw new StoreError(`任务不存在: ${taskId}`);
|
||||
const cur: Attachment[] = t.attachments ? JSON.parse(t.attachments) as Attachment[] : [];
|
||||
const next = [...cur, ...items];
|
||||
this.db.prepare(`UPDATE tasks SET attachments = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(next), now(), taskId);
|
||||
this.emit(t.project_id, taskId, 'task.updated', { field: 'attachments', count: next.length });
|
||||
return next;
|
||||
}
|
||||
|
||||
private getTaskRow(taskId: string): TaskRow | undefined {
|
||||
return this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId) as TaskRow | undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user