style-dry: - 新增 src/styles/components.css: .info-item*/.panel/.panel-header/.tech-tag/.card-tags 全局 - main.ts 注册 import - 删重复: ProjectDetail/TaskDetail/TaskOutputCard/ProjectCard/Projects(scoped删重复, 全局对子组件生效) - 未使用清理: Projects .card-progress dead(1处保守核对) tsjs-dry: - aiShared.ts: resolveAiLang(合并useAiSend.resolveLang + useAiContext.resolveLanguage) - stores/knowledge.ts: kindText/kindLabel/kindIcon/knowledgeStatusLabel/knowledgeConfidenceLabel(5公共) - 删重复: useAiSend/useAiContext(resolveLang) + Knowledge/KnowledgeDetail(4+5 helper) + ActiveProjectsPanel(formatLastActivity→formatRelativeZh复用) strategy: DRY收口, 公共沉淀(utils/store/shared); relativeTime非纯重复保留; .btn/.empty-hint局部override WATCH未动
713 lines
26 KiB
Vue
713 lines
26 KiB
Vue
<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.sourceIdea') }}</span>
|
||
<router-link v-if="currentProject.idea_id" :to="`/ideas/${currentProject.idea_id}`" class="idea-link">
|
||
{{ $t('projectDetail.viewIdea') }}
|
||
</router-link>
|
||
<span v-else class="no-idea">{{ $t('projectDetail.ideaDeleted') }}</span>
|
||
</div>
|
||
|
||
<!-- 代码目录(绑定 + 重定位) -->
|
||
<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>
|
||
</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 { 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)
|
||
|
||
// ── 任务 ──
|
||
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 单例)
|
||
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; }
|
||
</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>
|