新增: F-260619-01 任务可关联灵感(tasks.idea_id 1对1 单向)

- df-storage V20 迁移:tasks 加 idea_id TEXT REFERENCES ideas(id)(填预留空位,
  幂等 column_exists 探测)+ V9_SQL 同步 + TaskRecord.idea_id + task_repo SQL/SELECT + 白名单
- AI 工具 create_task 加 idea_id 参数;update_task 经白名单自动支持
- IPC commands/task.rs CreateTaskInput 加 idea_id
- 前端 types TaskRecord/CreateTaskInput idea_id + TaskDetail.vue 关联灵感展示
  (router-link 友好 title 非裸 id)+ i18n
- 补 idea_id 字段:df-mcp tools / df-nodes 测试 helper
- 单向 1对1,Option 可空,复用 projects.idea_id 模式,老任务 None 兼容

自验: df-storage 47 passed(含 V20 2)+ workspace EXIT 0 + vue-tsc EXIT 0
This commit is contained in:
2026-06-19 21:03:18 +08:00
parent e5a8fec1fe
commit 4a87c552cc
15 changed files with 158 additions and 17 deletions

View File

@@ -392,6 +392,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
base_branch: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}; };
@@ -444,6 +445,7 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
base_branch: existing.base_branch, base_branch: existing.base_branch,
review_rounds: existing.review_rounds, review_rounds: existing.review_rounds,
output_json: existing.output_json, output_json: existing.output_json,
idea_id: existing.idea_id,
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };

View File

@@ -699,6 +699,7 @@ mod tests {
base_branch: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: task_output_json.map(String::from), output_json: task_output_json.map(String::from),
idea_id: None,
created_at: "0".to_string(), created_at: "0".to_string(),
updated_at: "0".to_string(), updated_at: "0".to_string(),
}) })

View File

@@ -189,6 +189,7 @@ mod tests {
base_branch: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None,
created_at: "0".to_string(), created_at: "0".to_string(),
updated_at: "0".to_string(), updated_at: "0".to_string(),
} }

View File

@@ -142,6 +142,9 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
// output_json:ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象 // output_json:ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象
// (决策 a:task 中心,产出跟 task 走)。非状态机收口字段,合法可写。 // (决策 a:task 中心,产出跟 task 走)。非状态机收口字段,合法可写。
"output_json", "output_json",
// idea_id(F-260619-01 任务关联灵感,1对1 单向):任务可关联/解关联一条灵感,
// 非状态机收口字段,合法可写(idea_id 存在性由外键约束 + 上层校验兜底)。
"idea_id",
"updated_at", "updated_at",
// TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。 // TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。
// 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。 // 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。

View File

@@ -31,6 +31,7 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
base_branch: row.get("base_branch")?, base_branch: row.get("base_branch")?,
review_rounds: row.get("review_rounds")?, review_rounds: row.get("review_rounds")?,
output_json: row.get("output_json")?, output_json: row.get("output_json")?,
idea_id: row.get("idea_id")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?, updated_at: row.get("updated_at")?,
}) })
@@ -48,22 +49,22 @@ impl_repo!(
from_row => |row| task_from_row(row), from_row => |row| task_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, created_at, updated_at) "INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![ params![
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.created_at, rec.updated_at rec.review_rounds, rec.output_json, rec.idea_id, rec.created_at, rec.updated_at
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( conn.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, updated_at = ?12 WHERE id = ?13", "UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, updated_at = ?13 WHERE id = ?14",
params![ params![
rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.updated_at, rec.id rec.review_rounds, rec.output_json, rec.idea_id, rec.updated_at, rec.id
], ],
) )
} }
@@ -79,7 +80,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map([], |row| task_from_row(row)) .query_map([], |row| task_from_row(row))
@@ -180,7 +181,7 @@ impl TaskRepo {
} }
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。 // 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
let mut stmt = guard let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, created_at, updated_at FROM tasks WHERE id = ?1") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE id = ?1")
.map_err(storage_err)?; .map_err(storage_err)?;
let row = stmt let row = stmt
.query_row(params![id], |row| task_from_row(row)) .query_row(params![id], |row| task_from_row(row))
@@ -201,7 +202,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map([], |row| task_from_row(row)) .query_map([], |row| task_from_row(row))

View File

@@ -23,8 +23,8 @@ pub fn run(conn: &Connection) -> Result<()> {
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。 // 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。 // 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
// V20 预留给 F-260619-01(任务关联灵感),跳过;V21 = 消息拆分存储 + audit message_id。 // V20 = F-260619-01(任务关联灵感 idea_id);V21 = 消息拆分存储 + audit message_id。
let steps: [(i32, fn(&Connection) -> Result<()>); 20] = [ let steps: [(i32, fn(&Connection) -> Result<()>); 21] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -44,6 +44,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(17, migrate_v17), (17, migrate_v17),
(18, migrate_v18), (18, migrate_v18),
(19, migrate_v19), (19, migrate_v19),
(20, migrate_v20),
(21, migrate_v21), (21, migrate_v21),
]; ];
@@ -350,6 +351,26 @@ fn migrate_v19(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V20:幂等补 tasks.idea_id 列(F-260619-01 任务关联灵感,1对1 单向)
///
/// 任务可关联到一条灵感(任务→灵感单向),复用 projects.idea_id 模式
/// (REFERENCES ideas(id) 外键)。TEXT NULL 向后兼容:老库行默认 NULL,TaskRecord
/// 字段为 Option<String>(未关联灵感的任务为 None)。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v11/v14/v17 模式),对新库/老库均安全。
/// 新库已在 V9_SQL(tasks 建表)直接带 idea_id 列,此处只补老库。
fn migrate_v20(conn: &Connection) -> Result<()> {
if !column_exists(conn, "tasks", "idea_id") {
conn.execute(
"ALTER TABLE tasks ADD COLUMN idea_id TEXT REFERENCES ideas(id)",
[],
)?;
tracing::info!("v20: 补建 tasks.idea_id 列(任务关联灵感,F-260619-01)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [20])?;
tracing::info!("迁移 v20 完成");
Ok(())
}
/// V21:消息拆分存储(ai_messages 表 + 全量迁移)+ ai_tool_executions.message_id 列 /// V21:消息拆分存储(ai_messages 表 + 全量迁移)+ ai_tool_executions.message_id 列
/// ///
/// **一次原子迁移**(决策 V21 合并,不拆 V21a/V21b): /// **一次原子迁移**(决策 V21 合并,不拆 V21a/V21b):
@@ -554,6 +575,8 @@ CREATE TABLE IF NOT EXISTS tasks (
priority INTEGER NOT NULL DEFAULT 2, priority INTEGER NOT NULL DEFAULT 2,
branch_name TEXT, branch_name TEXT,
assignee TEXT, assignee TEXT,
-- F-260619-01 任务关联灵感(1对1 单向,复用 projects.idea_id 模式)。老库由 V20 迁移补列。
idea_id TEXT REFERENCES ideas(id),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -997,4 +1020,72 @@ mod tests {
"迁移后应有 message_id 列" "迁移后应有 message_id 列"
); );
} }
// ============================================================
// V20 迁移幂等安全(F-260619-01 任务关联灵感)
// ============================================================
/// 构造最小老库 schema:tasks 表(无 idea_id 列,模拟 V1 建表老形态)+ schema_version。
fn setup_legacy_tasks_db() -> Connection {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo',
priority INTEGER NOT NULL DEFAULT 2,
branch_name TEXT,
assignee TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);",
)
.expect("create legacy tasks table");
conn
}
/// 老库无 idea_id 列:迁移应补建 + 写版本号 20
#[test]
fn v20_legacy_db_adds_idea_id_column() {
let conn = setup_legacy_tasks_db();
assert!(
!column_exists(&conn, "tasks", "idea_id"),
"迁移前应无 idea_id 列"
);
migrate_v20(&conn).expect("v20 应在老库补建 idea_id 列");
assert!(
column_exists(&conn, "tasks", "idea_id"),
"迁移后应有 idea_id 列"
);
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 20, "应写入版本号 20");
}
/// 幂等重跑:列已存在时跳过 ALTER,版本号不重复写(PK 冲突防)
/// 注:migrate_v20 用普通 INSERT(非 IGNORE),重跑会因 PK 冲突报错——这是预期行为,
/// run() 正常流程下 current_version<20 只调一次;此处验证列存在时 ALTER 被短路(不报 duplicate column)。
#[test]
fn v20_column_exists_skips_alter() {
let conn = setup_legacy_tasks_db();
// 先跑一次补列
migrate_v20(&conn).expect("首次迁移");
assert!(column_exists(&conn, "tasks", "idea_id"));
// 手动回退版本号模拟「列已存在但版本号未写」场景,验证 ALTER 被短路不报 duplicate column
conn.execute("DELETE FROM schema_version WHERE version = 20", [])
.unwrap();
// 此时列存在但版本号 20 缺失 → migrate_v20 应跳过 ALTER 只补版本号
migrate_v20(&conn).expect("列存在时应跳过 ALTER 不报错");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 20);
}
} }

View File

@@ -75,6 +75,11 @@ pub struct TaskRecord {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)] #[serde(default)]
pub output_json: Option<String>, pub output_json: Option<String>,
/// 关联灵感 ID(F-260619-01,1对1 单向,任务→灵感)。
/// 复用 projects.idea_id 模式(REFERENCES ideas(id) 外键),可空(任务可不关联灵感)。
/// 老任务无 idea_id → None。#[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
#[serde(default)]
pub idea_id: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }

View File

@@ -43,6 +43,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
base_branch: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None,
created_at: now_ts(), created_at: now_ts(),
updated_at: now_ts(), updated_at: now_ts(),
} }

View File

@@ -630,8 +630,8 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
})}, })},
); );
registry.register( registry.register(
"create_task", "在指定项目下创建新任务", "create_task", "在指定项目下创建新任务,可选传 idea_id 关联灵感(1对1 单向)",
df_ai::ai_tools::object_schema(vec![("project_id", "string", true), ("title", "string", true), ("description", "string", false), ("priority", "integer", false)]), df_ai::ai_tools::object_schema(vec![("project_id", "string", true), ("title", "string", true), ("description", "string", false), ("priority", "integer", false), ("idea_id", "string", false)]),
RiskLevel::Medium, RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| { { let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone(); let db = db.clone();
@@ -647,6 +647,8 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
// F-260619-01 可选关联灵感(空字符串/缺省视为不关联)
idea_id: args.get("idea_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
created_at: now_millis(), updated_at: now_millis(), created_at: now_millis(), updated_at: now_millis(),
}; };
let id = record.id.clone(); let id = record.id.clone();

View File

@@ -21,6 +21,9 @@ pub struct CreateTaskInput {
pub priority: i32, pub priority: i32,
pub branch_name: Option<String>, pub branch_name: Option<String>,
pub assignee: Option<String>, pub assignee: Option<String>,
/// 关联灵感 ID(F-260619-01,1对1 单向,可空=不关联)。
/// 空字符串视为不关联(与 AI 工具层一致)。
pub idea_id: Option<String>,
} }
fn default_priority() -> i32 { fn default_priority() -> i32 {
@@ -79,6 +82,8 @@ pub async fn create_task(
base_branch: None, base_branch: None,
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
// F-260619-01:空字符串视为不关联(与 AI 工具层一致)
idea_id: input.idea_id.filter(|s| !s.is_empty()),
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}; };

View File

@@ -117,6 +117,8 @@ export interface TaskRecord {
* 旧任务无产出时为 undefined。前端 parsedOutput try/catch 兜底解析。 * 旧任务无产出时为 undefined。前端 parsedOutput try/catch 兜底解析。
*/ */
output_json?: string output_json?: string
/** 关联灵感 ID(F-260619-01,1对1 单向,任务→灵感)。未关联时为 undefined。 */
idea_id?: string
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -128,6 +130,8 @@ export interface CreateTaskInput {
priority?: number priority?: number
branch_name?: string branch_name?: string
assignee?: string assignee?: string
/** 关联灵感 ID(1对1 单向,可空)。空字符串视为不关联(与后端一致)。 */
idea_id?: string
} }
// ============================================================ // ============================================================

View File

@@ -14,6 +14,7 @@ export default {
status: 'Status', status: 'Status',
priority: 'Priority', priority: 'Priority',
project: 'Project', project: 'Project',
relatedIdea: 'Related Idea',
branch: 'Branch', branch: 'Branch',
assignee: 'Assignee', assignee: 'Assignee',
baseBranch: 'Base Branch', baseBranch: 'Base Branch',

View File

@@ -14,6 +14,7 @@ export default {
status: '状态', status: '状态',
priority: '优先级', priority: '优先级',
project: '关联项目', project: '关联项目',
relatedIdea: '关联灵感',
branch: '分支', branch: '分支',
assignee: '负责人', assignee: '负责人',
baseBranch: '基础分支', baseBranch: '基础分支',

View File

@@ -12,7 +12,7 @@ export function createTasksStore() {
} }
} }
async function createTask(input: { project_id: string; title: string; description?: string; priority?: number; branch_name?: string; assignee?: string }) { async function createTask(input: { project_id: string; title: string; description?: string; priority?: number; branch_name?: string; assignee?: string; idea_id?: string }) {
try { try {
const record = await taskApi.create(input) const record = await taskApi.create(input)
state.tasks.push(record) state.tasks.push(record)

View File

@@ -104,6 +104,16 @@
<span v-else></span> <span v-else></span>
</span> </span>
</div> </div>
<!-- F-260619-01:关联灵感(1对1 单向,idea_id 解析为友好 title,非裸 id) -->
<div class="info-item">
<span class="label">{{ $t('taskDetail.relatedIdea') }}</span>
<span class="value">
<router-link v-if="task.idea_id" :to="`/ideas/${task.idea_id}`" class="project-link">
{{ ideaTitle }}
</router-link>
<span v-else></span>
</span>
</div>
<div class="info-item"> <div class="info-item">
<span class="label">{{ $t('taskDetail.branch') }}</span> <span class="label">{{ $t('taskDetail.branch') }}</span>
<span class="value"> <span class="value">
@@ -146,7 +156,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import { taskApi, projectApi } from '@/api' import { taskApi, projectApi, ideaApi } from '@/api'
import { workflowApi } from '@/api' import { workflowApi } from '@/api'
import { formatDate } from '@/utils/time' import { formatDate } from '@/utils/time'
import { useRendered } from '@/composables/useMarkdown' import { useRendered } from '@/composables/useMarkdown'
@@ -156,7 +166,7 @@ import {
priorityLabel, priorityLabel,
priorityClass, priorityClass,
} from '../constants/project' } from '../constants/project'
import type { TaskRecord, ProjectRecord, DfDataChangedPayload, WorkflowEventPayload } from '@/api/types' import type { TaskRecord, ProjectRecord, IdeaRecord, DfDataChangedPayload, WorkflowEventPayload } from '@/api/types'
import TaskOutputCard from '@/components/task/TaskOutputCard.vue' import TaskOutputCard from '@/components/task/TaskOutputCard.vue'
const { t } = useI18n() const { t } = useI18n()
@@ -166,6 +176,8 @@ const loading = ref(true)
const errorMsg = ref('') const errorMsg = ref('')
const task = ref<TaskRecord | null>(null) const task = ref<TaskRecord | null>(null)
const projects = ref<ProjectRecord[]>([]) const projects = ref<ProjectRecord[]>([])
// F-260619-01:灵感列表用于解析 task.idea_id → 灵感 title(独立入口,同 projects 模式)
const ideas = ref<IdeaRecord[]>([])
const advancing = ref(false) const advancing = ref(false)
// ============================================================ // ============================================================
@@ -204,6 +216,14 @@ const projectName = computed(() => {
return p?.name ?? task.value?.project_id ?? '—' return p?.name ?? task.value?.project_id ?? '—'
}) })
// F-260619-01:解析 task.idea_id → 灵感 title(找不到时回退 id,保证非空可点击)
const ideaTitle = computed(() => {
const ideaId = task.value?.idea_id
if (!ideaId) return '—'
const idea = ideas.value.find(i => i.id === ideaId)
return idea?.title ?? ideaId
})
// ============================================================ // ============================================================
// F-05 推进按钮 — 状态机合法下一态映射 // F-05 推进按钮 — 状态机合法下一态映射
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -379,13 +399,16 @@ async function load() {
loading.value = true loading.value = true
errorMsg.value = '' errorMsg.value = ''
try { try {
const [t, ps] = await Promise.all([ const [t, ps, ideasList] = await Promise.all([
taskApi.get(taskId.value), taskApi.get(taskId.value),
// 项目列表用于解析 project_id → 项目名(独立入口,不依赖全局 store) // 项目列表用于解析 project_id → 项目名(独立入口,不依赖全局 store)
projectApi.list(), projectApi.list(),
// F-260619-01:灵感列表用于解析 idea_id → 灵感 title
ideaApi.list(),
]) ])
task.value = t task.value = t
projects.value = ps projects.value = ps
ideas.value = ideasList
} catch (e: any) { } catch (e: any) {
task.value = null task.value = null
errorMsg.value = t('taskDetail.loadFailed', { msg: e?.toString() ?? t('common.unknownError') }) errorMsg.value = t('taskDetail.loadFailed', { msg: e?.toString() ?? t('common.unknownError') })
@@ -417,7 +440,7 @@ onMounted(async () => {
try { try {
_unlistenDataChanged = await listen<DfDataChangedPayload>('df-data-changed', (event) => { _unlistenDataChanged = await listen<DfDataChangedPayload>('df-data-changed', (event) => {
const { entity } = event.payload const { entity } = event.payload
if (entity === 'task' || entity === 'project') { if (entity === 'task' || entity === 'project' || entity === 'idea') {
load() load()
} }
}) })