Files
DevFlow/src/views/ProjectDetail.vue
绝尘 bd6a41fe6e 新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
2026-06-21 20:51:26 +08:00

889 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="project-detail">
<!-- 页面头部 -->
<header class="page-header">
<div class="header-left">
<router-link to="/projects" class="back-link">{{ $t('projectDetail.backToList') }}</router-link>
<h1>{{ currentProject?.name ?? '...' }}</h1>
<span class="stage-badge" :class="'stage-' + stageKey">{{ $t(statusLabel) }}</span>
</div>
<div class="header-actions">
<button class="btn btn-ghost" @click="handleSync">{{ $t('projectDetail.sync') }}</button>
<button class="btn btn-ghost" type="button" @click="handleImportDir">{{ $t('projectDetail.importDir') }}</button>
<button class="btn btn-danger" @click="handleDelete">{{ $t('projectDetail.delete') }}</button>
<button class="btn btn-primary" @click="showNewTaskModal = true">{{ $t('projectDetail.newTask') }}</button>
</div>
</header>
<!-- 新建任务模态框 -->
<div v-if="showNewTaskModal" class="modal-overlay" @click.self="showNewTaskModal = false">
<div class="modal-box">
<h3 class="modal-title">{{ $t('projectDetail.newTaskTitle') }}</h3>
<div class="modal-field">
<label>{{ $t('projectDetail.taskTitleLabel') }}</label>
<input v-model="newTaskTitle" :placeholder="$t('projectDetail.taskTitlePlaceholder')" @keyup.enter="submitNewTask" />
</div>
<div class="modal-field">
<label>{{ $t('projectDetail.descLabel') }}</label>
<textarea v-model="newTaskDesc" :placeholder="$t('projectDetail.descPlaceholder')" rows="3"></textarea>
</div>
<div class="modal-field">
<label>{{ $t('projectDetail.branchLabel') }}</label>
<input v-model="newTaskBranch" placeholder="feature/xxx" />
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showNewTaskModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitNewTask" :disabled="submitting || !newTaskTitle.trim()">{{ $t('projectDetail.confirmCreate') }}</button>
</div>
</div>
</div>
<!-- 阶段进度条 -->
<div class="stage-pipeline">
<div
v-for="(stage, idx) in stages"
:key="stage.key"
class="stage-step"
:class="{
'stage-active': idx === currentStageIndex,
'stage-done': idx < currentStageIndex,
}"
>
<div class="stage-dot">
<span v-if="idx < currentStageIndex"></span>
<span v-else>{{ idx + 1 }}</span>
</div>
<div class="stage-label">{{ $t('projectDetail.' + stage.labelKey) }}</div>
<div v-if="idx < stages.length - 1" class="stage-connector" :class="{ 'connector-done': idx < currentStageIndex }"></div>
</div>
</div>
<!-- 主体两栏 -->
<div class="detail-grid">
<!-- 左栏项目信息 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.infoTitle') }}</h2>
</div>
<div class="project-info" v-if="currentProject">
<!-- 代码目录(绑定 + 重定位) -->
<div class="info-item">
<span class="label">{{ $t('projectDetail.codeDirLabel') }}</span>
<div v-if="currentProject.path" class="path-row">
<span class="path-text" :title="currentProject.path">{{ currentProject.path }}</span>
<button class="btn btn-ghost btn-sm" type="button" @click="relocateDir">{{ $t('projectDetail.relocateDir') }}</button>
</div>
<div v-else class="path-row">
<span class="no-idea">{{ $t('projectDetail.noDirBound') }}</span>
<button class="btn btn-ghost btn-sm" type="button" @click="relocateDir">{{ $t('projectDetail.bindDir') }}</button>
</div>
</div>
<!-- 目录状态(仅绑定时显示) -->
<div class="info-item" v-if="currentProject.path">
<span class="label">{{ $t('projectDetail.dirStatusLabel') }}</span>
<span :class="pathExists === false ? 'path-missing' : 'path-ok'">
{{ pathExists === null ? $t('projectDetail.dirChecking') : pathExists ? $t('projectDetail.dirExists') : $t('projectDetail.dirMissing') }}
</span>
</div>
<!-- 技术栈 -->
<div class="info-item" v-if="parseStack(currentProject.stack).length">
<span class="label">{{ $t('projectDetail.techStackLabel') }}</span>
<div class="info-tags">
<span class="tech-tag" v-for="t in parseStack(currentProject.stack)" :key="t">{{ t }}</span>
</div>
</div>
<div class="info-item">
<span class="label">{{ $t('projectDetail.createdAt') }}</span>
<span>{{ formatDate(currentProject.created_at) }}</span>
</div>
<div class="info-item">
<span class="label">{{ $t('projectDetail.updatedAt') }}</span>
<span>{{ formatDate(currentProject.updated_at) }}</span>
</div>
<div class="info-item info-block">
<span class="label">{{ $t('projectDetail.description') }}</span>
<!-- B-260615-25:项目描述 Markdown 渲染,复用 useMarkdown composable( B-24 TaskDetail) -->
<span
v-if="currentProject.description"
class="value description ai-md"
v-html="renderedDesc"
></span>
<span v-else></span>
</div>
<div class="info-item">
<span class="label">{{ $t('projectDetail.projectStatus') }}</span>
<span class="status-badge" :class="currentProject.status">{{ $t(statusLabel) }}</span>
</div>
<!-- 来源灵感卡片(晋升携带评估结论回溯): idea_id 有值时显示 -->
<div v-if="currentProject.idea_id" class="source-idea-card">
<div class="source-idea-header">
<span class="source-idea-title">{{ $t('ideas.sourceIdea') }}</span>
<router-link
v-if="sourceIdea"
class="idea-link"
:to="`/ideas/${currentProject.idea_id}`"
>
{{ sourceIdea.title }}
</router-link>
<router-link
v-else
class="idea-link idea-link-dim"
:to="`/ideas/${currentProject.idea_id}`"
>
#{{ currentProject.idea_id }}
</router-link>
</div>
<!-- sourceIdea 未找到(灵感被删/ load):提示跳转 -->
<p v-if="!sourceIdea" class="source-idea-hint">{{ $t('projectDetail.ideaDeleted') }}</p>
<!-- 已评估:显示评估结论回溯 -->
<template v-else-if="sourceAiAnalysis">
<div class="source-idea-body">
<span
class="assessment-badge"
:class="assessmentClass(sourceAiAnalysis.recommendation)"
>
{{ assessmentLabel(sourceAiAnalysis.recommendation) }}
</span>
<span class="source-final-score">
{{ $t('ideas.finalScore', { score: sourceAiAnalysis.final_score.toFixed(1) }) }}
</span>
</div>
<p v-if="sourceAiAnalysis.summary" class="source-summary">{{ sourceAiAnalysis.summary }}</p>
<!-- 多维评分条(借鉴 IdeaDetail parseScores 渲染) -->
<div v-if="sourceScores.length" class="source-scores">
<div class="radar-row" v-for="dim in sourceScores" :key="dim.name">
<span class="radar-label">{{ dim.name }}</span>
<div class="radar-bar-track">
<div
class="radar-bar-fill"
:class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"
:style="{ width: dim.score + '%' }"
></div>
</div>
<span class="radar-value">{{ dim.score }}</span>
</div>
</div>
</template>
<!-- 灵感未评估:鼓励去评估 -->
<p v-else class="source-idea-hint">
<router-link class="idea-link" :to="`/ideas/${currentProject.idea_id}`">
{{ $t('ideas.sourceIdeaNotEvaluated') }}
</router-link>
</p>
</div>
</div>
</section>
<!-- 左栏任务列表 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
<span class="task-count">{{ $t('projectDetail.taskCount', { n: projectTasks.length }) }}</span>
</div>
<div class="task-list">
<div class="task-card" v-for="task in projectTasks" :key="task.id">
<div class="task-top">
<span class="task-title">{{ task.title }}</span>
<span class="task-status" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
</div>
<div class="task-branch" v-if="task.branch_name">
<span class="branch-icon"></span>
<span class="branch-name">{{ task.branch_name }}</span>
</div>
<div class="task-meta">
<span>{{ task.assignee ?? '—' }}</span>
<span>{{ formatDate(task.created_at) }}</span>
<span style="opacity: 0.7">{{ formatDate(task.updated_at) }}</span>
</div>
</div>
</div>
<div v-if="projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
</section>
<!-- 右栏 -->
<div class="right-column">
<!-- 工作流执行日志 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
</div>
<div class="log-list" ref="logListRef">
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
<span class="log-time">{{ evt.time }}</span>
<span class="log-level">{{ evt.level }}</span>
<span class="log-msg">{{ evt.message }}</span>
</div>
<div v-if="formattedEvents.length === 0" class="empty-hint">{{ $t('projectDetail.emptyWorkflowLog') }}</div>
</div>
</section>
</div>
</div>
<!-- 人工审批对话框(抽离至 components/project/ApprovalDialog.vue)-->
<ApprovalDialog
:visible="showApprovalDialog"
:store="store"
@cancel="handleCancelApproval"
@later="showApprovalDialog = false"
/>
<!-- 确认弹层删除/重定位确认替代原生 window.confirm/alert -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import { open } from '@tauri-apps/plugin-dialog'
import { useProjectStore } from '@/stores/project'
import { projectApi } from '@/api'
import { formatDate } from '@/utils/time'
import { parseStack } from '@/utils/project'
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n } from '@/utils/ideaEval'
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
import { useConfirm } from '@/composables/useConfirm'
import { useRendered } from '@/composables/useMarkdown'
const route = useRoute()
const router = useRouter()
const store = useProjectStore()
const { t, locale } = useI18n()
const logListRef = ref<HTMLElement | null>(null)
const showApprovalDialog = ref(false)
let unlisten: (() => void) | null = null
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ── 阶段定义(labelKey 对应 i18n projectDetail.stage*) ──
const stages = [
{ key: 'idea', labelKey: 'stageIdea' },
{ key: 'requirement', labelKey: 'stageRequirement' },
{ key: 'coding', labelKey: 'stageCoding' },
{ key: 'testing', labelKey: 'stageTesting' },
{ key: 'release', labelKey: 'stageRelease' },
]
// ── 当前项目 ──
// 状态文案/阶段进度统一走 ../constants/project(与 Projects/Tasks/Dashboard 一致,
// 根治此前 stageMap 含不存在的 testing 状态、三套映射互相矛盾)
const projectId = computed(() => route.params.id as string)
const currentProject = computed(() =>
store.projects.find(p => p.id === projectId.value)
)
// B-260615-25:项目描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
const { rendered: renderedDesc, ensureLoaded } = useRendered(
() => currentProject.value?.description ?? '',
)
const stageKey = computed(() => currentProject.value?.status ?? 'planning')
const statusLabel = computed(() => projectStatusLabel(stageKey.value))
const currentStageIndex = computed(() => projectStageInfo(stageKey.value).stepIndex)
// ── 来源灵感(晋升携带评估结论回溯)──
// ProjectRecord.idea_id 存灵感 id(promote_idea 写入)。
// sourceIdea: 从 store.ideas 反查;store 未 load 时由 onMounted 守卫补 load。
const sourceIdea = computed(() =>
store.ideas.find(i => i.id === currentProject.value?.idea_id) ?? null,
)
// ai_analysis / scores 是 JSON 字符串(对齐 IdeaDetail 现有解析模式,try/catch 容错)
interface SourceAnalysis {
recommendation: string
final_score: number
summary: string
}
const sourceAiAnalysis = computed<SourceAnalysis | null>(() => {
const idea = sourceIdea.value
if (!idea?.ai_analysis) return null
try {
const parsed = JSON.parse(idea.ai_analysis) as Record<string, unknown>
if (typeof parsed !== 'object' || parsed === null) return null
const recommendation = typeof parsed.recommendation === 'string' ? parsed.recommendation : ''
const final_score = typeof parsed.final_score === 'number' ? parsed.final_score : 0
// summary 优先顶层,回退 analyst.summary(对齐 IdeaDetail historySummary 模式)
const summary = typeof parsed.summary === 'string' ? parsed.summary
: (typeof parsed.analyst === 'object' && parsed.analyst !== null
&& typeof (parsed.analyst as Record<string, unknown>).summary === 'string'
? ((parsed.analyst as Record<string, unknown>).summary as string)
: '')
if (!recommendation && !final_score && !summary) return null
return { recommendation, final_score, summary }
} catch {
return null
}
})
// 项1 DRY:ScoreDimension/parseScores/assessmentClass/assessmentLabel 抽到 @/utils/ideaEval,
// 与 IdeaDetail 共用。sourceScores 仅保留 computed(读 sourceIdea.scores → 复用纯函数解析)。
const sourceScores = computed(() => parseScoresJson(sourceIdea.value?.scores))
// assessmentClass 直接复用 ideaEval(模板直调);assessmentLabel 需注入 t,留薄包装消除重复。
const assessmentLabel = (recommendation: string): string =>
assessmentLabelI18n(t, recommendation)
// ── 任务 ──
const projectTasks = computed(() =>
store.tasks.filter(t => t.project_id === projectId.value)
)
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
// ── 工作流 ──
// demoDag + runDemoWorkflow 已下线R-PD-2"script" 节点不再注册到 NodeRegistry
// 前端构造含 script 节点的 DagDef 会在后端 build_dag 失败报错。
// 工作流执行日志面板保留,实时事件展示能力不依赖 demoDag。
// 待真实工作流需求落地(独立 BuildNode + 审批链)时重建演示入口。
// ── 实时日志格式化 ──
interface LogEntry {
time: string
level: string
message: string
}
const formattedEvents = computed<LogEntry[]>(() => {
return store.liveEvents.map((evt) => {
const ev = evt.event
const type = ev.type ?? 'unknown'
const level = type.includes('error') || type.includes('fail') ? 'error'
: type.includes('warn') ? 'warn' : 'info'
// 用事件入数组时固化的时间戳(FR-C1),非 computed 重算的当前时刻
const time = new Date(evt._ts).toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
const message = ev.label
? `[${ev.label}] ${type}: ${ev.output ?? ev.code ?? ev.message ?? JSON.stringify(ev)}`
: `${type}: ${ev.output ?? ev.code ?? ev.message ?? JSON.stringify(ev)}`
return { time, level, message }
})
})
// ── 新建任务 ──
const showNewTaskModal = ref(false)
const newTaskTitle = ref('')
const newTaskDesc = ref('')
const newTaskBranch = ref('')
// 异步操作禁用态(IPC 期间防双击重复提交):submitNewTask 创建任务期间禁用确认按钮
const submitting = ref(false)
async function submitNewTask() {
if (!newTaskTitle.value.trim()) return
submitting.value = true
try {
const r = await store.createTask({
project_id: projectId.value,
title: newTaskTitle.value.trim(),
description: newTaskDesc.value.trim(),
branch_name: newTaskBranch.value.trim() || undefined,
})
if (!r) return // 失败已 toast,保持弹窗不关
showNewTaskModal.value = false
newTaskTitle.value = ''
newTaskDesc.value = ''
newTaskBranch.value = ''
} finally {
submitting.value = false
}
}
// ── 同步 ──
async function handleSync() {
await Promise.all([store.loadProjects(), store.loadTasks()])
await checkPath()
}
// ── 代码目录绑定 ──
const pathExists = ref<boolean | null>(null)
// 检查绑定目录是否存在(详情页「目录是否还在」)
async function checkPath() {
const p = currentProject.value
if (!p?.path) { pathExists.value = null; return }
try {
pathExists.value = await projectApi.checkPathExists(p.path)
} catch {
pathExists.value = null
}
}
// 重定位/绑定目录(选目录 → 查重 → 确认 → 后端重探测 stack)
async function relocateDir() {
const p = currentProject.value
if (!p) return
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
const dir = selected as string
// 查重(排除自身)
try {
const conflict = await projectApi.checkBinding(dir, p.id)
if (conflict) {
Message.warning(t('projectDetail.dirConflict', { name: conflict.name }))
return
}
} catch { /* ignore */ }
const confirmMsg = p.path
? t('projectDetail.relocateConfirmRelocate', { dir })
: t('projectDetail.relocateConfirmBind', { dir })
if (!await confirmDialog(confirmMsg)) return
await store.relocateProjectPath(p.id, dir)
await checkPath()
} catch (e: any) {
console.error('重定位失败:', e)
Message.error(t('projectDetail.relocateFailed', { msg: e?.toString() ?? t('common.unknownError') }))
}
}
// 导入历史项目(选已存在目录 → 后端创建实体+绑定+探测栈+读 README 首段一步完成)
async function handleImportDir() {
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
const dir = selected as string
if (!await confirmDialog(t('projectDetail.importConfirm', { dir }))) return
const record = await store.importProject({ path: dir })
if (!record) {
// store 已 toast 错误
if (store.error) Message.error(store.error)
return
}
Message.success(t('projectDetail.importSuccess', { name: record.name }))
// 跳转到新导入项目的详情页
router.push(`/projects/${record.id}`)
} catch (e: any) {
console.error('导入失败:', e)
Message.error(t('projectDetail.importFailed', { msg: e?.toString() ?? t('common.unknownError') }))
}
}
// ── 审批处理 ──
// 审批对话框 UI + 单/多选决策逻辑抽离至 components/project/ApprovalDialog.vue
// handleApproval/handleApprovalMulti/isMultipleSelect/multiDecisions 已迁移;
// submitting(UX-260618-17)在子组件自治,父级 submitting 仅用于 submitNewTask
// ── 取消审批(调 cancel_workflow_node IPC 置节点 Cancelled──
async function handleCancelApproval() {
await store.cancelHumanApproval()
showApprovalDialog.value = false
}
// ── 删除项目(软删 → 回收站,可恢复)──
async function handleDelete() {
const p = currentProject.value
if (!p) return
if (!await confirmDialog(t('projectDetail.confirmDelete', { name: p.name }))) return
await store.deleteProject(p.id)
router.push('/projects')
}
// 日志自动滚到底部(让 logListRef 不再是死 ref)
watch(formattedEvents, () => {
if (logListRef.value) logListRef.value.scrollTop = logListRef.value.scrollHeight
})
// 监听审批请求(pendingApproval → 开弹窗;多选清空已在 ApprovalDialog 内 watch)
watch(() => store.pendingApproval, (newApproval) => {
if (newApproval) {
showApprovalDialog.value = true
}
}, { immediate: true })
// ── 工具函数 ──
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date
// ── 生命周期 ──
onMounted(async () => {
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
await store.loadProjects()
await store.loadTasks() // 全量加载,详情页用 computed 过滤本项目(避免覆盖全局 tasks 单例)
// 来源灵感回溯:项目有 idea_id 但 store.ideas 为空(本页未 load 过)时补拉,
// 否则 sourceIdea computed 永远找不到对应灵感记录
await store.loadIdeas()
unlisten = await store.startEventListener()
await checkPath()
})
// 目录变更(重定位后)重新检测存在性
watch(() => currentProject.value?.path, () => { checkPath() })
onUnmounted(() => {
if (unlisten) {
unlisten()
unlisten = null
}
})
</script>
<style scoped>
/* ... 现有样式 ... */
/* ===== 项目信息样式 ===== */
.project-info {
display: flex;
flex-direction: column;
gap: var(--df-gap-grid);
}
/* B-260615-31:字段同行布局(label 固定宽 + 值占余),描述字段 info-block 保持块状)
基础 .info-item/.label/.value 已收敛至全局 components.css(DRY 收口 B-260619),
此处仅保留本组件特有覆盖。 */
.info-item a.idea-link {
color: var(--df-accent);
text-decoration: none;
font-weight: 500;
}
.info-item a.idea-link:hover {
text-decoration: underline;
}
.info-item .no-idea {
color: var(--df-text-dim);
font-style: italic;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--df-radius-lg);
font-size: 11px;
font-weight: 500;
background: rgba(108,99,255,0.1);
color: var(--df-accent);
}
/* ===== 代码目录 / 技术栈 ===== */
.path-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.path-text {
font-size: 12px; font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--df-text-secondary); word-break: break-all;
}
.path-ok { color: var(--df-success); font-size: 12px; }
.path-missing { color: var(--df-danger); font-size: 12px; }
.info-tags { display: flex; flex-wrap: wrap; gap: 6px; }
/* .info-tags .tech-tag 与全局 .tech-tag(components.css)逐字一致,移除局部冗余,
标签样式由全局提供。 */
/* ===== 项目描述 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css) ===== */
.description.ai-md { font-size: 14px; color: var(--df-text); line-height: 1.6; }
/* ===== 来源灵感卡片(晋升携带评估结论回溯)===== */
.source-idea-card {
margin-top: 8px;
padding: 12px 14px;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-left: 3px solid var(--df-accent);
border-radius: var(--df-radius);
}
.source-idea-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.source-idea-title { font-size: 13px; font-weight: 500; color: var(--df-text); }
.info-item a.idea-link.idea-link-dim,
.source-idea-card a.idea-link-dim { color: var(--df-text-dim); }
.source-idea-card .source-idea-hint {
font-size: 12px;
color: var(--df-text-dim);
margin: 4px 0 0;
}
.source-idea-body {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin: 6px 0;
}
.source-final-score {
font-size: 13px;
font-weight: 500;
color: var(--df-text);
}
.source-summary {
font-size: 12px;
color: var(--df-text-secondary);
line-height: 1.5;
margin: 6px 0;
}
/* assessment badge(复用 IdeaDetail 同名类的样式定义) */
.assessment-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
}
.assessment-badge.immediate { background: var(--df-success-bg); color: var(--df-success); }
.assessment-badge.soon { background: var(--df-info-bg); color: var(--df-info); }
.assessment-badge.conditional { background: var(--df-warning-bg); color: var(--df-warning); }
.assessment-badge.revised { background: rgba(255, 217, 61, 0.2); color: var(--df-warning); }
.assessment-badge.defer { background: var(--df-danger-bg); color: var(--df-danger); }
.assessment-badge.cancel { background: var(--df-danger-bg); color: var(--df-danger); }
/* 多维评分条(复用 IdeaDetail radar-* 样式定义) */
.source-scores {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.radar-row { display: flex; align-items: center; gap: 10px; }
.radar-label { font-size: 12px; color: var(--df-text-secondary); min-width: 64px; text-align: right; }
.radar-bar-track {
flex: 1; height: 8px; background: var(--df-border);
border-radius: var(--df-radius-xs); overflow: hidden;
}
.radar-bar-fill { height: 100%; border-radius: var(--df-radius-xs); transition: width 0.4s; }
.fill-high { background: var(--df-success); }
.fill-mid { background: var(--df-warning); }
.fill-low { background: var(--df-danger); }
.radar-value { font-size: 12px; font-weight: 500; min-width: 28px; text-align: right; color: var(--df-text); }
</style>
<style scoped>
.project-detail { padding: 16px 20px 20px; }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-page);
}
.header-left { display: flex; align-items: center; gap: 12px; }
.back-link { font-size: 13px; color: var(--df-accent); text-decoration: none; }
.back-link:hover { text-decoration: underline; }
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
.stage-badge {
font-size: 12px;
padding: 3px 10px;
border-radius: var(--df-radius-lg);
font-weight: 500;
}
.stage-idea { background: rgba(100,181,246,0.15); color: var(--df-info); }
.stage-requirement { background: rgba(255,217,61,0.15); color: var(--df-warning); }
.stage-coding { background: rgba(108,99,255,0.15); color: var(--df-accent); }
.stage-testing { background: rgba(100,255,218,0.15); color: var(--df-success); }
.stage-release { background: rgba(255,107,107,0.15); color: #ff9800; }
.header-actions { display: flex; gap: 10px; }
/* ===== 按钮 ===== */
.btn {
padding: 8px 16px;
border: none;
border-radius: var(--df-radius-sm);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
.btn-sm { padding: 4px 12px; font-size: 12px; }
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid rgba(240,101,101,0.4); }
.btn-danger:hover { background: rgba(240,101,101,0.12); }
/* ===== 阶段进度条 ===== */
.stage-pipeline {
display: flex;
align-items: flex-start;
gap: 0;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
margin-bottom: var(--df-gap-page);
}
.stage-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
}
.stage-dot {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 500;
background: var(--df-border);
color: var(--df-text-dim);
transition: all 0.2s;
position: relative;
z-index: 1;
}
.stage-active .stage-dot {
background: var(--df-accent);
color: #fff;
}
.stage-done .stage-dot {
background: var(--df-success);
color: var(--df-bg);
}
.stage-label {
margin-top: 8px;
font-size: 12px;
color: var(--df-text-dim);
white-space: nowrap;
}
.stage-active .stage-label { color: var(--df-accent); font-weight: 500; }
.stage-done .stage-label { color: var(--df-success); }
.stage-connector {
position: absolute;
top: 16px;
left: calc(50% + 16px);
width: calc(100% - 32px);
height: 2px;
background: var(--df-border);
}
.connector-done { background: var(--df-success); }
/* ===== 两栏布局 ===== */
.detail-grid {
display: grid;
grid-template-columns: 1fr 380px;
gap: var(--df-gap-grid);
}
.right-column { display: flex; flex-direction: column; gap: var(--df-gap-grid); }
/* ===== 面板 ===== */
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619),
此处仅保留本组件特有 .task-count。 */
.task-count { font-size: 12px; color: var(--df-text-dim); }
/* ===== 任务卡片 ===== */
.task-list { display: flex; flex-direction: column; }
.task-card {
padding: 14px 0;
border-bottom: 0.5px solid var(--df-border);
}
.task-card:last-child { border-bottom: none; }
.task-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.task-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.task-status { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); }
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
.status-progress { background: rgba(108,99,255,0.15); color: var(--df-accent); }
.status-review { background: rgba(255,217,61,0.15); color: var(--df-warning); }
.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.task-branch {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
}
.branch-icon { color: var(--df-accent); font-size: 13px; }
.branch-name {
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--df-text-secondary);
background: rgba(108,99,255,0.1);
padding: 1px 6px;
border-radius: var(--df-radius-sm);
}
.task-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--df-text-dim);
}
/* ===== 日志 ===== */
.log-list {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
max-height: 280px;
overflow-y: auto;
}
.log-item {
display: flex;
gap: 8px;
padding: 5px 0;
border-bottom: 0.5px solid rgba(42,42,74,0.5);
}
.log-item:last-child { border-bottom: none; }
.log-time { color: var(--df-text-dim); min-width: 64px; }
.log-level {
min-width: 42px;
font-weight: 500;
text-transform: uppercase;
font-size: 10px;
padding: 1px 4px;
border-radius: var(--df-radius-sm);
text-align: center;
}
.log-info .log-level { color: var(--df-info); }
.log-warn .log-level { color: var(--df-warning); }
.log-error .log-level { color: var(--df-danger); }
.log-msg { color: var(--df-text-secondary); flex: 1; }
/* ===== 项目信息(左栏 .project-info/.info-item,右栏重复面板已删)===== */
/* ===== 模态框 ===== */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 100;
}
.modal-box {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: 24px;
width: 420px;
max-width: 90vw;
}
.modal-title { font-size: 18px; font-weight: 500; color: var(--df-text); margin-bottom: 16px; }
.modal-field { margin-bottom: 14px; }
.modal-field label { display: block; font-size: 12px; color: var(--df-text-dim); margin-bottom: 4px; }
.modal-field input, .modal-field textarea {
width: 100%; padding: 8px 10px; border-radius: var(--df-radius-sm);
border: 0.5px solid var(--df-border); background: var(--df-bg);
color: var(--df-text); font-size: 13px; font-family: inherit;
box-sizing: border-box;
}
.modal-field input:focus, .modal-field textarea:focus {
outline: none; border-color: var(--df-accent);
}
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.empty-hint {
text-align: center; padding: 24px 12px;
font-size: 13px; color: var(--df-text-dim);
}
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.project-detail { padding: 16px; }
.detail-grid {
grid-template-columns: 1fr;
}
.stage-pipeline { overflow-x: auto; }
}
</style>