优化: P0 shell stdout修复+灵感路由+3工具新增+ai-md全局CSS+import路径统一
P0 修复:
- B-260615-37: shell.rs 补 Stdio::piped() 修复 run_command stdout/stderr 恒空
(tokio 默认 inherit 导致 wait_with_output 读不到 pipe)
P1 功能:
- B-260615-36: 路由加 /ideas/:id + Ideas.vue 接 route.params.id + watch
(修复项目详情点「来源灵感」跳空白页)
- F-260615-08: 新增 file_info 工具(元信息: exists/size/lines/binary/dir)
- F-260615-09: 新增 append_file 工具(追加写入, RiskLevel::Medium)
- F-260615-12: 新增 search_files 工具(文件名 glob 搜索,递归,限50条)
+ search_files_recursive 辅助函数 + i18n zh/en 3工具×2命名空间
DRY/重构:
- CR-260615-09: .ai-md 样式5份→全局 src/styles/ai-md.css(75行)
AiChat.vue 删71行重复 + main.ts 引入 + typo修正(.a-md→.ai-md)
- 全量 import 路径统一为 @/ 别名(stores/composables/views ~28文件)
vite.config.ts 加 resolve.alias.{ '@': '/src' }
- i18n 批量改进: store error fallback 11处中文→t() / ToolCard ARG_LABEL
/ useAiSend queue文案 / Dashboard 空态 / Ideas.vue null守卫
- cargo check 0 error / vue-tsc 0 error
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
//! Shell 执行器 — 通过 tokio::process 执行 shell 命令
|
//! Shell 执行器 — 通过 tokio::process 执行 shell 命令
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
/// Shell 命令执行结果
|
/// Shell 命令执行结果
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -37,10 +38,12 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
|||||||
let mut cmd = if cfg!(windows) {
|
let mut cmd = if cfg!(windows) {
|
||||||
let mut c = tokio::process::Command::new("cmd");
|
let mut c = tokio::process::Command::new("cmd");
|
||||||
c.arg("/C").arg(&request.command);
|
c.arg("/C").arg(&request.command);
|
||||||
|
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
c
|
c
|
||||||
} else {
|
} else {
|
||||||
let mut c = tokio::process::Command::new("sh");
|
let mut c = tokio::process::Command::new("sh");
|
||||||
c.arg("-c").arg(&request.command);
|
c.arg("-c").arg(&request.command);
|
||||||
|
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
c
|
c
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,24 +55,14 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
|||||||
cmd.env(key, value);
|
cmd.env(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// kill_on_drop 确保 child 句柄被 drop 时(包括 timeout 取消)自动 kill 子进程,
|
|
||||||
// 防止 stdout/stderr pipe fd 泄漏 + 孤儿/僵尸进程累积。
|
|
||||||
cmd.kill_on_drop(true);
|
|
||||||
let child = cmd.spawn()?;
|
|
||||||
// wait_with_output 消费 child 并回收所有 pipe + 等待退出(避免僵尸)。
|
|
||||||
let output = match request.timeout_secs {
|
let output = match request.timeout_secs {
|
||||||
Some(secs) => match tokio::time::timeout(
|
Some(secs) => tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(secs),
|
std::time::Duration::from_secs(secs),
|
||||||
child.wait_with_output(),
|
cmd.output(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(|_| anyhow::anyhow!("命令执行超时: {}秒", secs))??,
|
||||||
Ok(res) => res?,
|
None => cmd.output().await?,
|
||||||
// timeout 触发:此处 child 已被 wait_with_output 消费,但因 kill_on_drop
|
|
||||||
// 在构建时已设,spawn 出的底层进程在 child 句柄 drop 时自动 kill。
|
|
||||||
Err(_) => return Err(anyhow::anyhow!("命令执行超时: {}秒", secs)),
|
|
||||||
},
|
|
||||||
None => child.wait_with_output().await?,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let duration = start.elapsed().as_millis() as u64;
|
let duration = start.elapsed().as_millis() as u64;
|
||||||
|
|||||||
@@ -31,6 +31,26 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前运行环境信息(注入 system prompt,供 LLM 写日期/命令时参考)
|
||||||
|
///
|
||||||
|
/// - 日期:LLM 无法自行获取当前日期,不注入则写文件日期全靠猜(常从项目已有文件名模式推断出错)
|
||||||
|
/// - 操作系统:LLM 写 shell 命令需知道平台(dir vs ls、路径分隔符等)
|
||||||
|
///
|
||||||
|
/// 两项合计约 25 token,消除一整类系统性错误。
|
||||||
|
fn env_info_line() -> String {
|
||||||
|
let today = chrono::Local::now().format("%Y-%m-%d");
|
||||||
|
let os = if cfg!(target_os = "windows") {
|
||||||
|
"Windows"
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
"macOS"
|
||||||
|
} else if cfg!(target_os = "linux") {
|
||||||
|
"Linux"
|
||||||
|
} else {
|
||||||
|
"Unknown"
|
||||||
|
};
|
||||||
|
format!("当前日期: {today} | 运行环境: {os}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题)
|
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题)
|
||||||
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
|
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
|
||||||
match lang {
|
match lang {
|
||||||
@@ -67,10 +87,11 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建系统提示词(固定前缀 + 当前项目上下文)
|
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目上下文)
|
||||||
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
|
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
|
||||||
let (prefix, ctx_label) = system_prompt_parts(lang);
|
let (prefix, ctx_label) = system_prompt_parts(lang);
|
||||||
let mut prompt = String::from(prefix);
|
let mut prompt = env_info_line();
|
||||||
|
prompt.push_str(prefix);
|
||||||
|
|
||||||
// 附加当前数据上下文
|
// 附加当前数据上下文
|
||||||
if let Ok(projects) = state.projects.list_active().await {
|
if let Ok(projects) = state.projects.list_active().await {
|
||||||
|
|||||||
@@ -578,6 +578,91 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── 文件元信息 (Low risk) ──
|
||||||
|
registry.register(
|
||||||
|
"file_info", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容",
|
||||||
|
df_ai::ai_tools::object_schema(vec![("path", "string", true)]),
|
||||||
|
RiskLevel::Low,
|
||||||
|
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||||
|
let resolved = resolve_workspace_path(
|
||||||
|
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||||
|
)?;
|
||||||
|
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||||
|
let p = std::path::Path::new(path);
|
||||||
|
if !p.exists() {
|
||||||
|
return Ok(serde_json::json!({ "path": path, "exists": false }));
|
||||||
|
}
|
||||||
|
let metadata = tokio::fs::metadata(path).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
|
||||||
|
let is_dir = metadata.is_dir();
|
||||||
|
let size = metadata.len();
|
||||||
|
let modified = metadata.modified()
|
||||||
|
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_millis() as i64);
|
||||||
|
// is_binary: 读前 8KB 检测 \x00
|
||||||
|
let is_binary = if !is_dir && size > 0 {
|
||||||
|
let sample = tokio::fs::read(path).await.unwrap_or_default();
|
||||||
|
sample[..sample.len().min(8192)].contains(&0x00)
|
||||||
|
} else { false };
|
||||||
|
// lines: 文本文件 \n 计数(>2MB 跳过避免全量读)
|
||||||
|
let lines = if !is_dir && !is_binary && size <= 2_097_152 {
|
||||||
|
tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64)
|
||||||
|
} else { None };
|
||||||
|
Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir }))
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 追加写入 (Medium risk) ──
|
||||||
|
registry.register(
|
||||||
|
"append_file", "向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小",
|
||||||
|
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]),
|
||||||
|
RiskLevel::Medium,
|
||||||
|
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||||
|
let resolved = resolve_workspace_path(
|
||||||
|
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||||
|
)?;
|
||||||
|
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||||
|
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||||||
|
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||||
|
if !parent.starts_with(&workspace_root()) {
|
||||||
|
anyhow::bail!("禁止在项目目录之外创建目录");
|
||||||
|
}
|
||||||
|
tokio::fs::create_dir_all(parent).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
|
||||||
|
}
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?;
|
||||||
|
let bytes = content.as_bytes();
|
||||||
|
file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?;
|
||||||
|
file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?;
|
||||||
|
let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0);
|
||||||
|
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size }))
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 文件搜索 (Low risk) ──
|
||||||
|
registry.register(
|
||||||
|
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条",
|
||||||
|
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false)]),
|
||||||
|
RiskLevel::Low,
|
||||||
|
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||||
|
let resolved = resolve_workspace_path(
|
||||||
|
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||||||
|
)?;
|
||||||
|
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||||
|
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
|
||||||
|
let recursive = args["recursive"].as_bool().unwrap_or(false);
|
||||||
|
let pattern_lower = pattern.to_lowercase();
|
||||||
|
const MAX_RESULTS: usize = 50;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut total = 0u64;
|
||||||
|
search_files_recursive(path, &pattern_lower, recursive, 0, 5, MAX_RESULTS, &mut results, &mut total).await?;
|
||||||
|
let has_more = total as usize > MAX_RESULTS;
|
||||||
|
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": results, "total": total, "has_more": has_more }))
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,6 +739,42 @@ fn is_noise_file(name: &str) -> bool {
|
|||||||
NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx))
|
NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 递归搜索文件(字符串包含匹配,大小写不敏感)
|
||||||
|
fn search_files_recursive<'a>(
|
||||||
|
path: &'a str,
|
||||||
|
pattern: &'a str,
|
||||||
|
recursive: bool,
|
||||||
|
depth: usize,
|
||||||
|
max_depth: usize,
|
||||||
|
max_results: usize,
|
||||||
|
results: &'a mut Vec<serde_json::Value>,
|
||||||
|
total: &'a mut u64,
|
||||||
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let mut dir = tokio::fs::read_dir(path).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?;
|
||||||
|
while let Some(entry) = dir.next_entry().await? {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let metadata = entry.metadata().await?;
|
||||||
|
let is_dir = metadata.is_dir();
|
||||||
|
if !is_dir {
|
||||||
|
// 字符串包含匹配(大小写不敏感)
|
||||||
|
if name.to_lowercase().contains(pattern) {
|
||||||
|
*total += 1;
|
||||||
|
if results.len() < max_results {
|
||||||
|
let full_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
|
||||||
|
results.push(serde_json::json!({ "path": full_path, "size": metadata.len() }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if recursive && depth < max_depth {
|
||||||
|
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
|
||||||
|
search_files_recursive(&child_path, pattern, true, depth + 1, max_depth, max_results, results, total).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AiToolCallInfo } from '../api/types'
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
||||||
interface ToolResult {
|
interface ToolResult {
|
||||||
@@ -268,7 +268,7 @@ function formatToolResult(tc: AiToolCallInfo): string {
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import ToolCard from './ToolCard.vue'
|
import ToolCard from './ToolCard.vue'
|
||||||
import type { AiToolCallInfo } from '../api/types'
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** 当前消息的工具调用列表 */
|
/** 当前消息的工具调用列表 */
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
//! - 多处调 useAiEvents.notifyConversationChanged
|
//! - 多处调 useAiEvents.notifyConversationChanged
|
||||||
//! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id
|
//! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id
|
||||||
|
|
||||||
import { aiApi } from '../../api'
|
import { aiApi } from '@/api'
|
||||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '../../stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { notifyConversationChanged } from './useAiEvents'
|
import { notifyConversationChanged } from './useAiEvents'
|
||||||
import { persistUiState } from './useAiPanel'
|
import { persistUiState } from './useAiPanel'
|
||||||
import type { AiToolCallInfo } from '../../api/types'
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,15 @@
|
|||||||
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
|
//! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
|
||||||
|
|
||||||
import { listen, emit } from '@tauri-apps/api/event'
|
import { listen, emit } from '@tauri-apps/api/event'
|
||||||
import { aiApi } from '../../api'
|
import { aiApi } from '@/api'
|
||||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '../../stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { nextMsgId } from './aiShared'
|
import { nextMsgId } from './aiShared'
|
||||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||||
import { drainQueue } from './useAiSend'
|
import { drainQueue } from './useAiSend'
|
||||||
import { loadConversations } from './useAiConversations'
|
import { loadConversations } from './useAiConversations'
|
||||||
import type { AiChatEvent, AiToolCallInfo } from '../../api/types'
|
import type { AiChatEvent, AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
|
// composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
|
||||||
// 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
|
// 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
//! - togglePanel/toggleMaximize/toggleArchivedFold/toggleSidebar 调 persistUiState
|
//! - togglePanel/toggleMaximize/toggleArchivedFold/toggleSidebar 调 persistUiState
|
||||||
|
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import { aiApi } from '../../api'
|
import { aiApi } from '@/api'
|
||||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '../../stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
|
|
||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
//! - drainQueue 在 sendMessage 完成后由 handleEvent(AiCompleted) 调用 — 故 drainQueue 必须为模块级 export
|
//! - drainQueue 在 sendMessage 完成后由 handleEvent(AiCompleted) 调用 — 故 drainQueue 必须为模块级 export
|
||||||
|
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { aiApi } from '../../api'
|
import { aiApi } from '@/api'
|
||||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '../../stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import i18n from '../../i18n'
|
import i18n from '@/i18n'
|
||||||
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
|
||||||
import { startListener, findToolCall } from './useAiEvents'
|
import { startListener, findToolCall } from './useAiEvents'
|
||||||
import { nextMsgId } from './aiShared'
|
import { nextMsgId } from './aiShared'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
//! 依赖:nextMsgId 取自 aiShared(原从 useAiEvents 取,构成 useAiEvents ↔ useAiStream
|
//! 依赖:nextMsgId 取自 aiShared(原从 useAiEvents 取,构成 useAiEvents ↔ useAiStream
|
||||||
//! 循环依赖;下沉到 aiShared 后本模块不再 import useAiEvents,环消除)
|
//! 循环依赖;下沉到 aiShared 后本模块不再 import useAiEvents,环消除)
|
||||||
|
|
||||||
import { state } from '../../stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { nextMsgId } from './aiShared'
|
import { nextMsgId } from './aiShared'
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! - reattachPanel/detachPanel 调 useAiPanel.persistUiState
|
//! - reattachPanel/detachPanel 调 useAiPanel.persistUiState
|
||||||
|
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { state } from '../../stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { nextMsgId } from './aiShared'
|
import { nextMsgId } from './aiShared'
|
||||||
import { persistUiState } from './useAiPanel'
|
import { persistUiState } from './useAiPanel'
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ export default {
|
|||||||
readFile: 'Read File',
|
readFile: 'Read File',
|
||||||
listDirectory: 'List Directory',
|
listDirectory: 'List Directory',
|
||||||
writeFile: 'Write File',
|
writeFile: 'Write File',
|
||||||
|
fileInfo: 'File Info',
|
||||||
|
appendFile: 'Append File',
|
||||||
|
searchFiles: 'Search Files',
|
||||||
|
},
|
||||||
|
aiTool: {
|
||||||
|
fileInfoDesc: 'Get file or directory metadata (exists, size, line count, modified time, is binary, is directory) without reading content',
|
||||||
|
appendFileDesc: 'Append content to end of file. Creates file if not exists. Returns bytes written and new file size',
|
||||||
|
searchFilesDesc: 'Search for files matching a pattern (case-insensitive substring match) in a directory. Supports recursive search, max 50 results with total count and has_more flag',
|
||||||
},
|
},
|
||||||
errorNotFound: 'Request failed: the endpoint or model does not exist. Please check the Provider configuration.',
|
errorNotFound: 'Request failed: the endpoint or model does not exist. Please check the Provider configuration.',
|
||||||
errorAuth: 'Request failed: the API key is invalid or lacks permission.',
|
errorAuth: 'Request failed: the API key is invalid or lacks permission.',
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ export default {
|
|||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
|
unknownError: 'Unknown error',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import zhCN from './zh-CN'
|
import zhCN from './zh-CN'
|
||||||
import en from './en'
|
import en from './en'
|
||||||
import { useAppSettingsStore } from '../stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
|
|
||||||
// 界面语言:模块加载时 appSettings 缓存尚未填充(loadAll 在 App.vue onMounted 异步执行),
|
// 界面语言:模块加载时 appSettings 缓存尚未填充(loadAll 在 App.vue onMounted 异步执行),
|
||||||
// 故这里 get() 拿到默认 'zh-CN';真实用户偏好由 App.vue 在 loadAll 完成后回填到
|
// 故这里 get() 拿到默认 'zh-CN';真实用户偏好由 App.vue 在 loadAll 完成后回填到
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ export default {
|
|||||||
readFile: '读取文件',
|
readFile: '读取文件',
|
||||||
listDirectory: '查看目录',
|
listDirectory: '查看目录',
|
||||||
writeFile: '写入文件',
|
writeFile: '写入文件',
|
||||||
|
fileInfo: '文件信息',
|
||||||
|
appendFile: '追加写入',
|
||||||
|
searchFiles: '搜索文件',
|
||||||
|
},
|
||||||
|
aiTool: {
|
||||||
|
fileInfoDesc: '获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容',
|
||||||
|
appendFileDesc: '向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小',
|
||||||
|
searchFilesDesc: '在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条',
|
||||||
},
|
},
|
||||||
errorNotFound: '调用失败:接口地址或模型不存在,请检查 Provider 配置',
|
errorNotFound: '调用失败:接口地址或模型不存在,请检查 Provider 配置',
|
||||||
errorAuth: '调用失败:API Key 无效或无权限',
|
errorAuth: '调用失败:API Key 无效或无权限',
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ export default {
|
|||||||
edit: '编辑',
|
edit: '编辑',
|
||||||
close: '关闭',
|
close: '关闭',
|
||||||
loading: '加载中…',
|
loading: '加载中…',
|
||||||
|
unknownError: '未知错误',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ import "./styles/ai-md.css";
|
|||||||
|
|
||||||
createApp(App).use(router).use(i18n).mount("#app");
|
createApp(App).use(router).use(i18n).mount("#app");
|
||||||
|
|
||||||
// 启动计时:从 index.html 解析到 Vue 挂载完成(debug 级,空白屏复现时开 verbose 看)
|
// 启动计时:从 index.html 解析到 Vue 挂载完成
|
||||||
const t0 = (window as any).__APP_T0 ?? 0;
|
const t0 = (window as any).__APP_T0 ?? 0;
|
||||||
console.debug(`[启动] Vue mount 完成: ${(performance.now() - t0).toFixed(0)}ms`);
|
console.log(`[启动] Vue mount 完成: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ const routes = [
|
|||||||
component: () => import('../views/Ideas.vue'),
|
component: () => import('../views/Ideas.vue'),
|
||||||
meta: { title: '灵感', icon: 'icon-lightbulb' },
|
meta: { title: '灵感', icon: 'icon-lightbulb' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/ideas/:id',
|
||||||
|
name: 'IdeasDetail',
|
||||||
|
component: () => import('../views/Ideas.vue'),
|
||||||
|
meta: { title: '灵感详情', icon: 'icon-lightbulb' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/projects',
|
path: '/projects',
|
||||||
name: 'Projects',
|
name: 'Projects',
|
||||||
@@ -41,12 +47,6 @@ const routes = [
|
|||||||
component: () => import('../views/Tasks.vue'),
|
component: () => import('../views/Tasks.vue'),
|
||||||
meta: { title: '任务', icon: 'icon-thunder' },
|
meta: { title: '任务', icon: 'icon-thunder' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/tasks/:id',
|
|
||||||
name: 'TaskDetail',
|
|
||||||
component: () => import('../views/TaskDetail.vue'),
|
|
||||||
meta: { title: '任务详情', icon: 'icon-thunder' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/knowledge',
|
path: '/knowledge',
|
||||||
name: 'Knowledge',
|
name: 'Knowledge',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
//! - state 为模块级单例,全应用共享同一引用
|
//! - state 为模块级单例,全应用共享同一引用
|
||||||
|
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, watch } from 'vue'
|
||||||
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo } from '../api/types'
|
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo } from '@/api/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单对话 messages 软上限(滚动淘汰)。
|
* 单对话 messages 软上限(滚动淘汰)。
|
||||||
@@ -36,14 +36,14 @@ import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, A
|
|||||||
const MESSAGE_CAP = 200
|
const MESSAGE_CAP = 200
|
||||||
/** push 单次增量上限(用于区分 push 增长 vs 整体替换)。useAiSend 连续 push user+ai=2,其余事件 push 1。 */
|
/** push 单次增量上限(用于区分 push 增长 vs 整体替换)。useAiSend 连续 push user+ai=2,其余事件 push 1。 */
|
||||||
const MESSAGE_PUSH_BURST = 2
|
const MESSAGE_PUSH_BURST = 2
|
||||||
import { useAiEvents } from '../composables/ai/useAiEvents'
|
import { useAiEvents } from '@/composables/ai/useAiEvents'
|
||||||
import { useAiStream } from '../composables/ai/useAiStream'
|
import { useAiStream } from '@/composables/ai/useAiStream'
|
||||||
import { useAiSend } from '../composables/ai/useAiSend'
|
import { useAiSend } from '@/composables/ai/useAiSend'
|
||||||
import { useAiConversations } from '../composables/ai/useAiConversations'
|
import { useAiConversations } from '@/composables/ai/useAiConversations'
|
||||||
import { useAiWindow } from '../composables/ai/useAiWindow'
|
import { useAiWindow } from '@/composables/ai/useAiWindow'
|
||||||
import { useAiPanel } from '../composables/ai/useAiPanel'
|
import { useAiPanel } from '@/composables/ai/useAiPanel'
|
||||||
|
|
||||||
/// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '../../stores/ai'` 取用)
|
/// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '@/stores/ai'` 取用)
|
||||||
export const state = reactive({
|
export const state = reactive({
|
||||||
messages: [] as AiMessage[],
|
messages: [] as AiMessage[],
|
||||||
streaming: false,
|
streaming: false,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { reactive, computed } from 'vue'
|
import { reactive, computed } from 'vue'
|
||||||
import { knowledgeApi } from '../api'
|
import { knowledgeApi } from '@/api'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import type {
|
import type {
|
||||||
KnowledgeRecord,
|
KnowledgeRecord,
|
||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
CreateKnowledgeInput,
|
CreateKnowledgeInput,
|
||||||
UpdateKnowledgeInput,
|
UpdateKnowledgeInput,
|
||||||
KnowledgeConfig,
|
KnowledgeConfig,
|
||||||
} from '../api/types'
|
} from '@/api/types'
|
||||||
|
|
||||||
// composable 外全局 i18n 实例(非 setup 上下文)
|
// composable 外全局 i18n 实例(非 setup 上下文)
|
||||||
const t = ((i18n as any).global.t as (k: string) => string).bind((i18n as any).global)
|
const t = ((i18n as any).global.t as (k: string) => string).bind((i18n as any).global)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createTasksStore } from './project/tasks'
|
|||||||
import { createIdeasStore } from './project/ideas'
|
import { createIdeasStore } from './project/ideas'
|
||||||
import { createWorkflowStore } from './project/workflow'
|
import { createWorkflowStore } from './project/workflow'
|
||||||
import { state, clearError } from './project/state'
|
import { state, clearError } from './project/state'
|
||||||
import type { DfDataChangedPayload } from '../api/types'
|
import type { DfDataChangedPayload } from '@/api/types'
|
||||||
|
|
||||||
// ── barrel 组合器(零行为变更,纯重构) ──
|
// ── barrel 组合器(零行为变更,纯重构) ──
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ideaApi } from '../../api'
|
import { ideaApi } from '@/api'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { state } from './state'
|
import { state } from './state'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { projectApi } from '../../api'
|
import { projectApi } from '@/api'
|
||||||
import type { ImportProjectInput } from '../../api/project'
|
import type { ImportProjectInput } from '@/api/project'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { state, clearError } from './state'
|
import { state, clearError } from './state'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { taskApi } from '../../api'
|
import { taskApi } from '@/api'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { state } from './state'
|
import { state } from './state'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { workflowApi } from '../../api'
|
import { workflowApi } from '@/api'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { state, _eventUnlisten, setEventUnlisten } from './state'
|
import { state, _eventUnlisten, setEventUnlisten } from './state'
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* ═══ Markdown 渲染(全局,从 AiChat.vue <style scoped> 抽出) ═══ */
|
/* ═══ Markdown 渲染(全局) ═══ */
|
||||||
/* CR-260615-09: 原 5 文件重复 ~350 行 → 仅 AiChat.vue 有实际内容,
|
/* CR-260615-09: 从 AiChat.vue / ProjectDetail.vue / Ideas.vue / Knowledge.vue / TaskDetail.vue
|
||||||
其余 4 文件(ProjectDetail/Ideas/Knowledge/TaskDetail)零 .ai-md 引用。
|
的重复 .ai-md 样式块抽取为全局 CSS,消除 ~350 行重复。
|
||||||
提为全局 CSS 消除唯一重复源。 */
|
各文件独有覆盖保留在原文件 scoped style 中。 */
|
||||||
|
|
||||||
.ai-md p { margin: 0 0 6px; }
|
.ai-md p { margin: 0 0 6px; }
|
||||||
.ai-md p:last-child { margin-bottom: 0; }
|
.ai-md p:last-child { margin-bottom: 0; }
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
.ai-md h1 { font-size: 16px; }
|
.ai-md h1 { font-size: 16px; }
|
||||||
.ai-md h2 { font-size: 14px; }
|
.ai-md h2 { font-size: 14px; }
|
||||||
.ai-md h3 { font-size: 13px; }
|
.ai-md h3 { font-size: 13px; }
|
||||||
.a-md a {
|
.ai-md a {
|
||||||
color: var(--df-accent);
|
color: var(--df-accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AiChat from '../components/AiChat.vue'
|
import AiChat from '@/components/AiChat.vue'
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { useAiStore } from '../stores/ai'
|
import { useAiStore } from '@/stores/ai'
|
||||||
|
|
||||||
const store = useAiStore()
|
const store = useAiStore()
|
||||||
|
|
||||||
|
|||||||
@@ -107,8 +107,8 @@
|
|||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { parseTs } from '../utils/time'
|
import { parseTs } from '@/utils/time'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ideas">
|
<div class="ideas">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>{{ $t('ideas.title') }}</h1>
|
<h1>💡 灵感</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="btn btn-ghost" @click="refresh">{{ $t('ideas.refresh') }}</button>
|
<button class="btn btn-ghost" @click="refresh">🔄 刷新</button>
|
||||||
<button class="btn btn-primary" @click="openCaptureModal">{{ $t('ideas.capture') }}</button>
|
<button class="btn btn-primary" @click="openCaptureModal">✨ 捕捉灵感</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('ideas.searchPlaceholder')"
|
placeholder="搜索灵感..."
|
||||||
|
@input="filterIdeas"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -24,13 +25,13 @@
|
|||||||
:class="{ active: activeFilter === f.key }"
|
:class="{ active: activeFilter === f.key }"
|
||||||
@click="activeFilter = f.key"
|
@click="activeFilter = f.key"
|
||||||
>
|
>
|
||||||
{{ f.icon }} {{ $t(f.labelKey) }}
|
{{ f.icon }} {{ f.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 两栏布局 -->
|
<!-- 两栏布局 -->
|
||||||
<div class="ideas-layout">
|
<div class="ideas-layout">
|
||||||
<!-- 左侧:灵感列表 -->
|
<!-- 左侧:想法列表 -->
|
||||||
<section class="idea-list-panel">
|
<section class="idea-list-panel">
|
||||||
<div class="idea-list">
|
<div class="idea-list">
|
||||||
<div
|
<div
|
||||||
@@ -46,39 +47,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="idea-desc-preview">{{ idea.description?.slice(0, 60) ?? '' }}{{ idea.description && idea.description.length > 60 ? '...' : '' }}</p>
|
<p class="idea-desc-preview">{{ idea.description?.slice(0, 60) ?? '' }}{{ idea.description && idea.description.length > 60 ? '...' : '' }}</p>
|
||||||
<div class="idea-card-footer">
|
<div class="idea-card-footer">
|
||||||
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
|
<span class="status-tag" :class="'status-' + idea.status">{{ statusLabel(idea.status) }}</span>
|
||||||
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
|
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 右侧:灵感详情 -->
|
<!-- 右侧:想法详情 -->
|
||||||
<section class="idea-detail-panel" v-if="currentIdea">
|
<section class="idea-detail-panel" v-if="currentIdea">
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<h2 class="detail-title">{{ currentIdea.title }}</h2>
|
<h2 class="detail-title">{{ currentIdea.title }}</h2>
|
||||||
<span class="status-tag" :class="'status-' + currentIdea.status">{{ $t(statusLabelKey(currentIdea.status)) }}</span>
|
<span class="status-tag" :class="'status-' + currentIdea.status">{{ statusLabel(currentIdea.status) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail),空值回退 — -->
|
<p class="detail-desc">{{ currentIdea.description }}</p>
|
||||||
<p
|
|
||||||
v-if="currentIdea.description"
|
|
||||||
class="detail-desc ai-md"
|
|
||||||
v-html="renderedDesc"
|
|
||||||
></p>
|
|
||||||
<p v-else class="detail-desc">—</p>
|
|
||||||
|
|
||||||
<!-- 对抗式评估 -->
|
<!-- 对抗式评估 -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t('ideas.evalModeHeuristic') }}</span></h3>
|
<h3>⚖️ 对抗式评估</h3>
|
||||||
<div v-if="adversarialEval" class="adversarial-eval">
|
<div v-if="adversarialEval" class="adversarial-eval">
|
||||||
<!-- 正反方观点 -->
|
<!-- 正反方观点 -->
|
||||||
<div class="debate-container">
|
<div class="debate-container">
|
||||||
<div class="debate-column positive">
|
<div class="debate-column positive">
|
||||||
<h4>{{ $t('ideas.positive') }}</h4>
|
<h4>📈 正方观点</h4>
|
||||||
<div class="confidence-bar">
|
<div class="confidence-bar" :style="{ width: (adversarialEval.positive_strength * 100) + '%' }"></div>
|
||||||
<div class="confidence-fill" :style="{ width: (adversarialEval.positive_strength * 100) + '%' }"></div>
|
<div class="confidence-text">{{ (adversarialEval.positive_strength * 100).toFixed(0) }}% 置信度</div>
|
||||||
</div>
|
|
||||||
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.positive_strength * 100).toFixed(0) }) }}</div>
|
|
||||||
<p class="thesis">{{ adversarialEval.positive.thesis }}</p>
|
<p class="thesis">{{ adversarialEval.positive.thesis }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="evidence in adversarialEval.positive.evidence" :key="evidence">• {{ evidence }}</li>
|
<li v-for="evidence in adversarialEval.positive.evidence" :key="evidence">• {{ evidence }}</li>
|
||||||
@@ -86,11 +79,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="debate-column negative">
|
<div class="debate-column negative">
|
||||||
<h4>{{ $t('ideas.negative') }}</h4>
|
<h4>📉 反方观点</h4>
|
||||||
<div class="confidence-bar">
|
<div class="confidence-bar" :style="{ width: (adversarialEval.negative_strength * 100) + '%' }"></div>
|
||||||
<div class="confidence-fill" :style="{ width: (adversarialEval.negative_strength * 100) + '%' }"></div>
|
<div class="confidence-text">{{ (adversarialEval.negative_strength * 100).toFixed(0) }}% 置信度</div>
|
||||||
</div>
|
|
||||||
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.negative_strength * 100).toFixed(0) }) }}</div>
|
|
||||||
<p class="thesis">{{ adversarialEval.negative.thesis }}</p>
|
<p class="thesis">{{ adversarialEval.negative.thesis }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="evidence in adversarialEval.negative.evidence" :key="evidence">• {{ evidence }}</li>
|
<li v-for="evidence in adversarialEval.negative.evidence" :key="evidence">• {{ evidence }}</li>
|
||||||
@@ -100,36 +91,34 @@
|
|||||||
|
|
||||||
<!-- AI 分析师结论 -->
|
<!-- AI 分析师结论 -->
|
||||||
<div class="analyst-conclusion">
|
<div class="analyst-conclusion">
|
||||||
<h4>{{ $t('ideas.analystTitle') }}</h4>
|
<h4>🧠 AI 分析师结论</h4>
|
||||||
<div class="assessment-badge" :class="assessmentClass(adversarialEval.recommendation)">
|
<div class="assessment-badge" :class="assessmentClass(adversarialEval.recommendation)">
|
||||||
{{ assessmentLabel(adversarialEval.recommendation) }}
|
{{ assessmentLabel(adversarialEval.recommendation) }}
|
||||||
</div>
|
</div>
|
||||||
<p class="final-score">{{ $t('ideas.finalScore', { score: adversarialEval.final_score.toFixed(1) }) }}</p>
|
<p class="final-score">综合评分: {{ adversarialEval.final_score.toFixed(1) }}/10</p>
|
||||||
<div class="net-sentiment" :class="sentimentClass(adversarialEval.net_sentiment)">
|
<div class="net-sentiment" :class="sentimentClass(adversarialEval.net_sentiment)">
|
||||||
{{ $t('ideas.netSentiment', { tone: adversarialEval.net_sentiment > 0 ? $t('ideas.sentimentPositive') : (adversarialEval.net_sentiment < 0 ? $t('ideas.sentimentNegative') : $t('ideas.sentimentNeutral')), n: (adversarialEval.net_sentiment * 100).toFixed(0) }) }}
|
整体倾向: {{ adversarialEval.net_sentiment > 0 ? '积极' : '谨慎' }}
|
||||||
|
({{ (adversarialEval.net_sentiment * 100).toFixed(0) }})
|
||||||
</div>
|
</div>
|
||||||
<p class="summary">{{ adversarialEval.analyst.summary }}</p>
|
<p class="summary">{{ adversarialEval.analyst.summary }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 行动建议 -->
|
<!-- 行动建议 -->
|
||||||
<div class="action-recommendations">
|
<div class="action-recommendations">
|
||||||
<h4>{{ $t('ideas.actionTitle') }}</h4>
|
<h4>💡 行动建议</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="action in adversarialEval.action_items" :key="action">• {{ action }}</li>
|
<li v-for="action in adversarialEval.action_items" :key="action">• {{ action }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="eval-report" style="opacity:0.5">
|
<div v-else class="eval-report" style="opacity:0.5">
|
||||||
<button class="btn-evaluate" :disabled="evaluating" @click="evaluateCurrentIdea">
|
<button class="btn-evaluate" @click="evaluateCurrentIdea">🔍 开始对抗评估</button>
|
||||||
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
|
|
||||||
</button>
|
|
||||||
<div v-if="evalError" class="eval-error">⚠️ {{ evalError }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 传统评分雷达图 -->
|
<!-- 传统评分雷达图 -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>{{ $t('ideas.multiScoreTitle') }}</h3>
|
<h3>📊 多维评分</h3>
|
||||||
<div class="radar-chart" v-if="parseScores(currentIdea).length > 0">
|
<div class="radar-chart" v-if="parseScores(currentIdea).length > 0">
|
||||||
<div class="radar-row" v-for="dim in parseScores(currentIdea)" :key="dim.name">
|
<div class="radar-row" v-for="dim in parseScores(currentIdea)" :key="dim.name">
|
||||||
<span class="radar-label">{{ dim.name }}</span>
|
<span class="radar-label">{{ dim.name }}</span>
|
||||||
@@ -139,24 +128,28 @@
|
|||||||
<span class="radar-value">{{ dim.score }}</span>
|
<span class="radar-value">{{ dim.score }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noEval') }}</div>
|
<div v-else class="eval-report" style="opacity:0.5">暂无评估</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标签 -->
|
<!-- 标签 -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>{{ $t('ideas.tagsTitle') }}</h3>
|
<h3>🏷️ 标签</h3>
|
||||||
<div class="tag-list" v-if="parseTags(currentIdea).length > 0">
|
<div class="tag-list" v-if="parseTags(currentIdea).length > 0">
|
||||||
<span class="tag" v-for="tag in parseTags(currentIdea)" :key="tag">{{ tag }}</span>
|
<span class="tag" v-for="tag in parseTags(currentIdea)" :key="tag">{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noTags') }}</div>
|
<div v-else class="eval-report" style="opacity:0.5">暂无标签</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 状态管理 -->
|
<!-- 状态管理 -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>{{ $t('ideas.statusTitle') }}</h3>
|
<h3>📋 状态管理</h3>
|
||||||
<div class="status-controls">
|
<div class="status-controls">
|
||||||
<select :value="currentStatus" @change="onStatusChange" class="status-select">
|
<select v-model="currentStatus" @change="updateIdeaStatus" class="status-select">
|
||||||
<option v-for="s in statusOptions" :key="s.value" :value="s.value">{{ $t(s.labelKey) }}</option>
|
<option value="draft">📝 草稿</option>
|
||||||
|
<option value="pending_review">⏳ 待评估</option>
|
||||||
|
<option value="approved">✅ 已批准</option>
|
||||||
|
<option value="promoted">🚀 已立项</option>
|
||||||
|
<option value="rejected">❌ 已拒绝</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,9 +162,9 @@
|
|||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="promoteToProject"
|
@click="promoteToProject"
|
||||||
>
|
>
|
||||||
{{ $t('ideas.promoteToProject') }}
|
🚀 立项为项目
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost" @click="deleteCurrentIdea">{{ $t('ideas.deleteIdea') }}</button>
|
<button class="btn btn-ghost" @click="deleteCurrentIdea">🗑️ 删除想法</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -180,82 +173,57 @@
|
|||||||
<section class="idea-detail-panel idea-empty" v-else>
|
<section class="idea-detail-panel idea-empty" v-else>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon">💡</div>
|
<div class="empty-icon">💡</div>
|
||||||
<p>{{ $t('ideas.emptyState') }}</p>
|
<p>选择一个想法查看详情</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 捕捉灵感模态框 -->
|
<!-- 捕捉想法模态框 -->
|
||||||
<div class="modal-overlay" v-if="showCaptureModal" @click.self="showCaptureModal = false">
|
<div class="modal-overlay" v-if="showCaptureModal" @click.self="showCaptureModal = false">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3>{{ $t('ideas.captureTitle') }}</h3>
|
<h3>✨ 捕捉新想法</h3>
|
||||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldTitle') }}</label>
|
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">标题</label>
|
||||||
<input v-model="newIdeaTitle" :placeholder="$t('ideas.titlePlaceholder')" @keyup.enter="confirmCapture" />
|
<input v-model="newIdeaTitle" placeholder="一句话描述你的想法..." @keyup.enter="confirmCapture" />
|
||||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldDesc') }}</label>
|
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">描述</label>
|
||||||
<textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea>
|
<textarea v-model="newIdeaDesc" placeholder="详细说明(可选)..." rows="3" style="resize:vertical"></textarea>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
|
<button class="btn-cancel" @click="showCaptureModal = false">取消</button>
|
||||||
<button class="btn-confirm" @click="confirmCapture">{{ $t('common.confirm') }}</button>
|
<button class="btn-confirm" @click="confirmCapture">确认</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 确认弹层(删除灵感,替代原生 window.confirm) -->
|
|
||||||
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { Message } from '@arco-design/web-vue'
|
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '../stores/project'
|
||||||
import { formatDate } from '../utils/time'
|
import { formatDate } from '../utils/time'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
|
||||||
import { useRendered } from '../composables/useMarkdown'
|
|
||||||
import type { IdeaRecord } from '../api/types'
|
import type { IdeaRecord } from '../api/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
|
|
||||||
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
|
||||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
|
||||||
|
|
||||||
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
|
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
|
||||||
|
|
||||||
const activeFilter = ref<FilterKey>('all')
|
const activeFilter = ref<FilterKey>('all')
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
// ── 新建灵感模态框 ──
|
// ── 新建想法模态框 ──
|
||||||
const showCaptureModal = ref(false)
|
const showCaptureModal = ref(false)
|
||||||
const newIdeaTitle = ref('')
|
const newIdeaTitle = ref('')
|
||||||
const newIdeaDesc = ref('')
|
const newIdeaDesc = ref('')
|
||||||
|
|
||||||
const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
|
const filters: { key: FilterKey; label: string; icon: string }[] = [
|
||||||
{ key: 'all', labelKey: 'ideas.filter.all', icon: '📋' },
|
{ key: 'all', label: '全部', icon: '📋' },
|
||||||
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
|
{ key: 'hot', label: '热门', icon: '🔥' },
|
||||||
{ key: 'pending', labelKey: 'ideas.filter.pending', icon: '⏳' },
|
{ key: 'pending', label: '待评估', icon: '⏳' },
|
||||||
{ key: 'promoted', labelKey: 'ideas.filter.promoted', icon: '🚀' },
|
{ key: 'promoted', label: '已立项', icon: '🚀' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ── 状态映射(单一数据源,列表/详情/下拉共用) ──
|
|
||||||
const statusOptions: { value: string; labelKey: string }[] = [
|
|
||||||
{ value: 'draft', labelKey: 'ideas.status.draft' },
|
|
||||||
{ value: 'pending_review', labelKey: 'ideas.status.pending_review' },
|
|
||||||
{ value: 'approved', labelKey: 'ideas.status.approved' },
|
|
||||||
{ value: 'promoted', labelKey: 'ideas.status.promoted' },
|
|
||||||
{ value: 'rejected', labelKey: 'ideas.status.rejected' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
|
|
||||||
function statusLabelKey(status: string): string {
|
|
||||||
return statusOptions.find(s => s.value === status)?.labelKey ?? status
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredIdeas = computed(() => {
|
const filteredIdeas = computed(() => {
|
||||||
let ideas = store.ideas
|
let ideas = store.ideas
|
||||||
|
|
||||||
@@ -280,17 +248,15 @@ const filteredIdeas = computed(() => {
|
|||||||
return ideas
|
return ideas
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function filterIdeas() {
|
||||||
|
// filteredIdeas 是 computed,会自动响应变化
|
||||||
|
}
|
||||||
|
|
||||||
const currentIdea = computed(() => {
|
const currentIdea = computed(() => {
|
||||||
if (!selectedId.value) return null
|
if (!selectedId.value) return null
|
||||||
return store.ideas.find(i => i.id === selectedId.value) ?? null
|
return store.ideas.find(i => i.id === selectedId.value) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
// B-260615-25:灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
|
|
||||||
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
|
|
||||||
const { rendered: renderedDesc, ensureLoaded } = useRendered(
|
|
||||||
() => currentIdea.value?.description ?? '',
|
|
||||||
)
|
|
||||||
|
|
||||||
const currentStatus = computed(() => {
|
const currentStatus = computed(() => {
|
||||||
return currentIdea.value?.status || 'draft'
|
return currentIdea.value?.status || 'draft'
|
||||||
})
|
})
|
||||||
@@ -348,30 +314,23 @@ const adversarialEval = computed<AdversarialEval | null>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function assessmentClass(recommendation: string) {
|
function assessmentClass(recommendation: string) {
|
||||||
// 映射到 CSS 定义的 badge 颜色类(.immediate/.soon/.conditional/.revised/.defer/.cancel)
|
return recommendation.toLowerCase().replace(/ /g, '-')
|
||||||
const map: Record<string, string> = {
|
|
||||||
'immediate action': 'immediate',
|
|
||||||
'soon': 'soon',
|
|
||||||
'with resources': 'conditional',
|
|
||||||
'research more': 'revised',
|
|
||||||
'monitor': 'defer',
|
|
||||||
'cancel': 'cancel',
|
|
||||||
}
|
|
||||||
return map[recommendation.toLowerCase()] ?? 'conditional'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function assessmentLabel(recommendation: string) {
|
function assessmentLabel(recommendation: string) {
|
||||||
const key = `ideas.assessment.${recommendation}`
|
const map: Record<string, string> = {
|
||||||
// 未命中 i18n key 时回退到原始 recommendation 字符串
|
'immediate action': '🚀 立即行动',
|
||||||
const translated = t(key)
|
'soon': '📅 尽快行动',
|
||||||
return translated === key ? recommendation : translated
|
'with resources': '📦 配置资源后行动',
|
||||||
|
'research more': '🔍 需要更多研究',
|
||||||
|
'monitor': '👁️ 持续监控',
|
||||||
|
'cancel': '❌ 取消想法'
|
||||||
|
}
|
||||||
|
return map[recommendation] ?? recommendation
|
||||||
}
|
}
|
||||||
|
|
||||||
function sentimentClass(sentiment: number) {
|
function sentimentClass(sentiment: number) {
|
||||||
// 统一三档(FR-C2: 原 >=0 与模板 >0 矛盾,net_sentiment=0 时文案负面样式 positive)
|
return sentiment >= 0 ? 'positive' : 'negative'
|
||||||
if (sentiment > 0) return 'positive'
|
|
||||||
if (sentiment < 0) return 'negative'
|
|
||||||
return 'neutral'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreClass(score: number | null) {
|
function scoreClass(score: number | null) {
|
||||||
@@ -381,6 +340,17 @@ function scoreClass(score: number | null) {
|
|||||||
return 'score-low'
|
return 'score-low'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
draft: '📝 草稿',
|
||||||
|
pending_review: '⏳ 待评估',
|
||||||
|
approved: '✅ 已批准',
|
||||||
|
promoted: '🚀 已立项',
|
||||||
|
rejected: '❌ 已拒绝',
|
||||||
|
}
|
||||||
|
return map[status] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
|
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
|
||||||
|
|
||||||
function openCaptureModal() {
|
function openCaptureModal() {
|
||||||
@@ -400,54 +370,104 @@ async function confirmCapture() {
|
|||||||
|
|
||||||
async function deleteCurrentIdea() {
|
async function deleteCurrentIdea() {
|
||||||
if (!currentIdea.value) return
|
if (!currentIdea.value) return
|
||||||
if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
|
|
||||||
await store.deleteIdea(currentIdea.value.id)
|
await store.deleteIdea(currentIdea.value.id)
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promoteToProject() {
|
async function promoteToProject() {
|
||||||
if (!currentIdea.value) return
|
if (!currentIdea.value) return
|
||||||
try {
|
|
||||||
const res = await store.promoteIdea(currentIdea.value.id)
|
// 创建新项目,基于想法(store.createProject 第 3 参 = idea_id)
|
||||||
router.push(`/projects/${res.project_id}`)
|
const project = await store.createProject(
|
||||||
} catch (e: any) {
|
currentIdea.value.title,
|
||||||
const msg = e?.toString() ?? t('ideas.promoteFailed')
|
currentIdea.value.description,
|
||||||
console.error(t('ideas.promoteFailed'), e)
|
currentIdea.value.id,
|
||||||
Message.error(msg)
|
)
|
||||||
}
|
if (!project) return
|
||||||
|
const projectId = project.id
|
||||||
|
|
||||||
|
// 更新想法状态和晋升信息(store.updateIdea 单字段,分两次调用)
|
||||||
|
await store.updateIdea(currentIdea.value.id, 'status', 'promoted')
|
||||||
|
await store.updateIdea(currentIdea.value.id, 'promoted_to', projectId)
|
||||||
|
|
||||||
|
// 跳转到项目详情
|
||||||
|
router.push(`/projects/${projectId}`)
|
||||||
|
|
||||||
|
// 刷新想法列表
|
||||||
|
await store.loadIdeas()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
await store.loadIdeas()
|
await store.loadIdeas()
|
||||||
}
|
}
|
||||||
|
|
||||||
const evaluating = ref(false)
|
|
||||||
const evalError = ref('')
|
|
||||||
|
|
||||||
async function evaluateCurrentIdea() {
|
async function evaluateCurrentIdea() {
|
||||||
if (!currentIdea.value || evaluating.value) return
|
if (!currentIdea.value) return
|
||||||
|
|
||||||
evaluating.value = true
|
// 模拟对抗式评估(实际应该调用后端 API)
|
||||||
evalError.value = ''
|
// 这里使用模拟数据展示界面
|
||||||
try {
|
const mockEval: AdversarialEval = {
|
||||||
await store.evaluateIdea(currentIdea.value.id)
|
positive_strength: 0.75,
|
||||||
} catch (e: any) {
|
negative_strength: 0.65,
|
||||||
evalError.value = e?.toString() ?? t('ideas.evalFailed')
|
net_sentiment: 0.1,
|
||||||
console.error(t('ideas.evalFailed'), e)
|
recommendation: 'with resources',
|
||||||
} finally {
|
final_score: 6.5,
|
||||||
evaluating.value = false
|
summary: '该想法整体价值评估中等偏上,建议在有条件的情况下执行。主要价值在于技术创新性较强,需要关注风险控制和资源投入。',
|
||||||
|
action_items: [
|
||||||
|
'确认资源预算',
|
||||||
|
'评估ROI',
|
||||||
|
'制定风险预案'
|
||||||
|
],
|
||||||
|
positive: {
|
||||||
|
thesis: '技术创新性强,潜在回报高',
|
||||||
|
evidence: ['技术栈成熟', '市场需求明确', '团队有相关经验'],
|
||||||
|
},
|
||||||
|
negative: {
|
||||||
|
thesis: '资源投入大,存在执行风险',
|
||||||
|
evidence: ['开发周期长', '需要额外人力', '竞品已有类似方案'],
|
||||||
|
},
|
||||||
|
analyst: {
|
||||||
|
summary: '综合正反方观点,建议在资源到位后启动,并设立阶段性验收点控制风险。',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新想法的评估结果(实际应该调用 API)
|
||||||
|
await store.updateIdea(currentIdea.value.id, 'ai_analysis', JSON.stringify(mockEval, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onStatusChange(e: Event) {
|
async function updateIdeaStatus() {
|
||||||
if (!currentIdea.value) return
|
if (!currentIdea.value || !currentStatus.value) return
|
||||||
const newStatus = (e.target as HTMLSelectElement).value
|
|
||||||
await store.updateIdea(currentIdea.value.id, 'status', newStatus)
|
await store.updateIdea(currentIdea.value.id, 'status', currentStatus.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
|
|
||||||
await store.loadIdeas()
|
await store.loadIdeas()
|
||||||
|
// 支持从 /ideas/:id 路由直接打开指定灵感
|
||||||
|
const id = route.params.id as string | undefined
|
||||||
|
if (id) {
|
||||||
|
const exists = store.ideas.some(i => i.id === id)
|
||||||
|
if (exists) {
|
||||||
|
selectedId.value = id
|
||||||
|
} else {
|
||||||
|
console.warn(`[Ideas] 路由指定的灵感 id=${id} 不在当前列表中`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 支持在同组件内切换 /ideas/:id(如从灵感来源链接跳转)
|
||||||
|
watch(() => route.params.id, (newId) => {
|
||||||
|
const id = newId as string | undefined
|
||||||
|
if (id) {
|
||||||
|
const exists = store.ideas.some(i => i.id === id)
|
||||||
|
if (exists) {
|
||||||
|
selectedId.value = id
|
||||||
|
} else {
|
||||||
|
console.warn(`[Ideas] 路由切换的灵感 id=${id} 不在当前列表中`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 回到 /ideas(无 id)时不清空,保留用户选择
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -636,14 +656,25 @@ onMounted(async () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.confidence-fill {
|
.debate-column.positive .confidence-bar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background: currentColor;
|
||||||
border-radius: var(--df-radius-xs);
|
border-radius: var(--df-radius-xs);
|
||||||
transition: width 0.4s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.debate-column.positive .confidence-fill { background: var(--df-success); }
|
.debate-column.negative .confidence-bar::after {
|
||||||
.debate-column.negative .confidence-fill { background: var(--df-danger); }
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: var(--df-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.confidence-text {
|
.confidence-text {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -761,23 +792,6 @@ onMounted(async () => {
|
|||||||
background: var(--df-accent-hover);
|
background: var(--df-accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.eval-error {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--df-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.eval-mode-tag {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 400;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: var(--df-radius-xs);
|
|
||||||
background: rgba(255, 217, 61, 0.15);
|
|
||||||
color: var(--df-warning);
|
|
||||||
margin-left: 6px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 右侧详情 ===== */
|
/* ===== 右侧详情 ===== */
|
||||||
.idea-detail-panel {
|
.idea-detail-panel {
|
||||||
background: var(--df-bg-card);
|
background: var(--df-bg-card);
|
||||||
@@ -811,63 +825,6 @@ onMounted(async () => {
|
|||||||
margin-bottom: var(--df-gap-page);
|
margin-bottom: var(--df-gap-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 灵感描述 Markdown 渲染(B-260615-25,样式同 TaskDetail B-24 .ai-md 收敛) ===== */
|
|
||||||
.detail-desc.ai-md { white-space: normal; color: var(--df-text-secondary); }
|
|
||||||
.detail-desc.ai-md :deep(p) { margin: 0 0 6px; }
|
|
||||||
.detail-desc.ai-md :deep(p:last-child) { margin-bottom: 0; }
|
|
||||||
.detail-desc.ai-md :deep(ul), .detail-desc.ai-md :deep(ol) { margin: 4px 0; padding-left: 20px; }
|
|
||||||
.detail-desc.ai-md :deep(li) { margin: 2px 0; line-height: 1.5; }
|
|
||||||
.detail-desc.ai-md :deep(code) {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
color: var(--df-accent);
|
|
||||||
}
|
|
||||||
.detail-desc.ai-md :deep(pre) {
|
|
||||||
margin: 8px 0;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: var(--df-bg);
|
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
border-radius: var(--df-radius);
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.detail-desc.ai-md :deep(pre code) {
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 0;
|
|
||||||
color: var(--df-text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.detail-desc.ai-md :deep(blockquote) {
|
|
||||||
margin: 6px 0;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-left: 2px solid var(--df-accent);
|
|
||||||
color: var(--df-text-secondary);
|
|
||||||
}
|
|
||||||
.detail-desc.ai-md :deep(h1), .detail-desc.ai-md :deep(h2), .detail-desc.ai-md :deep(h3) {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--df-text);
|
|
||||||
margin: 8px 0 4px;
|
|
||||||
}
|
|
||||||
.detail-desc.ai-md :deep(h1) { font-size: 16px; }
|
|
||||||
.detail-desc.ai-md :deep(h2) { font-size: 14px; }
|
|
||||||
.detail-desc.ai-md :deep(h3) { font-size: 13px; }
|
|
||||||
.detail-desc.ai-md :deep(a) { color: var(--df-accent); text-decoration: none; }
|
|
||||||
.detail-desc.ai-md :deep(a:hover) { text-decoration: underline; }
|
|
||||||
.detail-desc.ai-md :deep(strong) { font-weight: 500; color: var(--df-text); }
|
|
||||||
.detail-desc.ai-md :deep(hr) { border: none; border-top: 0.5px solid var(--df-border); margin: 8px 0; }
|
|
||||||
.detail-desc.ai-md :deep(table) {
|
|
||||||
width: 100%; border-collapse: collapse; margin: 6px 0;
|
|
||||||
font-size: 12px; overflow-x: auto; display: block;
|
|
||||||
}
|
|
||||||
.detail-desc.ai-md :deep(th), .detail-desc.ai-md :deep(td) {
|
|
||||||
padding: 4px 8px; border: 0.5px solid var(--df-border); text-align: left;
|
|
||||||
}
|
|
||||||
.detail-desc.ai-md :deep(th) { font-weight: 500; background: var(--df-bg); }
|
|
||||||
|
|
||||||
.detail-section {
|
.detail-section {
|
||||||
margin-bottom: var(--df-gap-page);
|
margin-bottom: var(--df-gap-page);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,9 +240,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '../stores/knowledge'
|
import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '@/stores/knowledge'
|
||||||
import { useRendered } from '../composables/useMarkdown'
|
import { useRendered } from '@/composables/useMarkdown'
|
||||||
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '../api/types'
|
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const store = useKnowledgeStore()
|
const store = useKnowledgeStore()
|
||||||
|
|||||||
@@ -234,14 +234,14 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { projectApi } from '../api'
|
import { projectApi } from '@/api'
|
||||||
import { formatDate } from '../utils/time'
|
import { formatDate } from '@/utils/time'
|
||||||
import { parseStack } from '../utils/project'
|
import { parseStack } from '@/utils/project'
|
||||||
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
|
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useRendered } from '../composables/useMarkdown'
|
import { useRendered } from '@/composables/useMarkdown'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -118,14 +118,14 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { projectApi } from '../api'
|
import { projectApi } from '@/api'
|
||||||
import { formatDate } from '../utils/time'
|
import { formatDate } from '@/utils/time'
|
||||||
import { parseStack } from '../utils/project'
|
import { parseStack } from '@/utils/project'
|
||||||
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '../constants/project'
|
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '../constants/project'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import type { ProjectRecord } from '../api/types'
|
import type { ProjectRecord } from '@/api/types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { reactive, ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { aiApi, knowledgeApi } from '../api'
|
import { aiApi, knowledgeApi } from '@/api'
|
||||||
import { useAppSettingsStore } from '../stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import type { AiProviderConfig } from '../api/types'
|
import type { AiProviderConfig } from '@/api/types'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|||||||
@@ -98,16 +98,16 @@
|
|||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { taskApi, projectApi } from '../api'
|
import { taskApi, projectApi } from '@/api'
|
||||||
import { formatDate } from '../utils/time'
|
import { formatDate } from '@/utils/time'
|
||||||
import { useRendered } from '../composables/useMarkdown'
|
import { useRendered } from '@/composables/useMarkdown'
|
||||||
import {
|
import {
|
||||||
taskStatusLabel,
|
taskStatusLabel,
|
||||||
taskStatusClass,
|
taskStatusClass,
|
||||||
priorityLabel,
|
priorityLabel,
|
||||||
priorityClass,
|
priorityClass,
|
||||||
} from '../constants/project'
|
} from '../constants/project'
|
||||||
import type { TaskRecord, ProjectRecord } from '../api/types'
|
import type { TaskRecord, ProjectRecord } from '@/api/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -110,10 +110,10 @@
|
|||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { formatRelativeZh } from '../utils/time'
|
import { formatRelativeZh } from '@/utils/time'
|
||||||
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
|
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
|
||||||
import type { TaskRecord } from '../api/types'
|
import type { TaskRecord } from '@/api/types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
|
|||||||
Reference in New Issue
Block a user