新增: Phase2 阶段收尾(Sprint 1-20)

重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录
功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore
修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸
文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View File

@@ -1,27 +1,70 @@
<template>
<div class="projects">
<header class="page-header">
<h1>📂 项目列表</h1>
<h1>{{ $t('projects.title') }}</h1>
<div class="header-actions">
<button class="btn btn-primary" @click="showCreateModal = true">+ 新建项目</button>
<button class="btn btn-ghost" @click="openTrash">{{ $t('projects.trash') }}</button>
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
</div>
</header>
<!-- 新建项目模态框 -->
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
<div class="modal-box">
<h3 class="modal-title">新建项目</h3>
<h3 class="modal-title">{{ $t('projects.createTitle') }}</h3>
<div class="modal-field">
<label>项目名称</label>
<input v-model="newName" placeholder="输入项目名称" @keyup.enter="submitCreate" />
<label>{{ $t('projects.nameLabel') }}</label>
<input v-model="newName" :placeholder="$t('projects.namePlaceholder')" @keyup.enter="submitCreate" />
</div>
<div class="modal-field">
<label>项目描述</label>
<textarea v-model="newDesc" placeholder="简要描述项目目标" rows="3"></textarea>
<label>{{ $t('projects.descLabel') }}</label>
<textarea v-model="newDesc" :placeholder="$t('projects.descPlaceholder')" rows="3"></textarea>
</div>
<div class="modal-field">
<label>{{ $t('projects.codeDirLabel') }}可选绑定后自动识别技术栈</label>
<div class="dir-row">
<input v-model="newPath" :placeholder="$t('projects.codeDirPlaceholder')" readonly />
<button class="btn btn-ghost btn-sm" type="button" @click="pickDir">{{ $t('projects.selectDir') }}</button>
<button v-if="newPath" class="btn btn-ghost btn-sm" type="button" @click="clearDir">{{ $t('projects.clearDir') }}</button>
</div>
<div v-if="pathWarning" class="path-warning"> {{ pathWarning }}</div>
<div v-if="newPath" class="scan-row">
<button class="btn btn-ghost btn-sm" type="button" @click="aiScan" :disabled="scanning">
{{ scanning ? 'AI 扫描中…' : '🤖 AI 扫描填信息' }}
</button>
<span v-if="scanProjectType" class="tech-tag">{{ scanProjectType }}</span>
</div>
<div v-if="scanError" class="path-warning"> {{ scanError }}</div>
<div v-if="detectedStack.length" class="card-tags" style="margin-top: 8px">
<span class="tech-tag" v-for="t in detectedStack" :key="t">{{ t }}</span>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showCreateModal = false">取消</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="!newName.trim()">确认创建</button>
<button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="!newName.trim()">{{ $t('projects.confirmCreate') }}</button>
</div>
</div>
</div>
<!-- 回收站模态框 -->
<div v-if="showTrashModal" class="modal-overlay" @click.self="showTrashModal = false">
<div class="modal-box" style="width: 520px">
<h3 class="modal-title">{{ $t('projects.trashTitle') }}</h3>
<div v-if="store.deletedProjects.length === 0" class="empty-state" style="padding: 30px 0">{{ $t('projects.trashEmpty') }}</div>
<div v-else class="trash-list">
<div class="trash-item" v-for="p in store.deletedProjects" :key="p.id">
<div class="trash-info">
<span class="trash-name">{{ p.name }}</span>
<span class="trash-date">{{ formatDate(p.updated_at) }} {{ $t('projects.movedIn') }}</span>
</div>
<div class="trash-actions">
<button class="btn btn-ghost btn-sm" @click="handleRestore(p.id)">{{ $t('projects.restore') }}</button>
<button class="btn btn-danger btn-sm" @click="handlePurge(p)">{{ $t('projects.purge') }}</button>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showTrashModal = false">{{ $t('common.close') }}</button>
</div>
</div>
</div>
@@ -33,7 +76,10 @@
<div class="card-title-row">
<h2 class="card-name">{{ project.name }}</h2>
</div>
<span class="stage-badge" :class="stageClass(project.status)">{{ statusLabel(project.status) }}</span>
<div class="card-top-actions">
<span class="stage-badge" :class="stageClass(project.status)">{{ $t(statusLabel(project.status)) }}</span>
<button class="card-del-btn" :title="$t('projects.deleteTitle')" @click.stop="handleDelete(project)">🗑</button>
</div>
</div>
<p class="card-desc">{{ project.description }}</p>
@@ -49,12 +95,17 @@
<span>{{ formatDate(project.updated_at) }}</span>
</div>
</div>
<!-- 技术栈标签 -->
<div class="card-tags" v-if="parseStack(project.stack).length">
<span class="tech-tag" v-for="t in parseStack(project.stack)" :key="t">{{ t }}</span>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!store.loading && store.projects.length === 0" class="empty-state">
暂无项目点击右上角创建第一个项目
{{ $t('projects.empty') }}
</div>
</div>
</template>
@@ -62,50 +113,123 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { open } from '@tauri-apps/plugin-dialog'
import { useProjectStore } from '../stores/project'
import { projectApi } from '../api'
import { formatDate } from '../utils/time'
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '../constants/project'
import type { ProjectRecord } from '../api/types'
const router = useRouter()
const store = useProjectStore()
const { t } = useI18n()
// ── 状态映射 ──
const statusLabels: Record<string, string> = {
planning: '📐 规划中',
in_progress: '💻 进行中',
paused: '⏸ 已暂停',
completed: '✅ 已完成',
cancelled: '❌ 已取消',
}
const statusStageClass: Record<string, string> = {
planning: 'stage-design',
in_progress: 'stage-coding',
paused: 'stage-testing',
completed: 'stage-release',
cancelled: 'stage-design',
}
function statusLabel(status: string) {
return statusLabels[status] ?? status
}
function stageClass(status: string) {
return statusStageClass[status] ?? 'stage-design'
}
// 状态映射统一走 ../constants/project(与 ProjectDetail/Tasks/Dashboard 一致)
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date
// ── 新建项目 ──
const showCreateModal = ref(false)
const newName = ref('')
const newDesc = ref('')
const newPath = ref('')
const detectedStack = ref<string[]>([])
const pathWarning = ref('')
const scanning = ref(false)
const scanError = ref('')
const scanProjectType = ref('')
// 解析技术栈 JSON 数组字符串(防崩)
function parseStack(stack: string | null): string[] {
if (!stack) return []
try {
const arr = JSON.parse(stack)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
// 选择目录 → 探测技术栈 + 查重
async function pickDir() {
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
newPath.value = selected as string
detectedStack.value = []
pathWarning.value = ''
// 探测技术栈(失败不阻塞,后端创建时再探)
try { detectedStack.value = await projectApi.scanStack(selected as string) } catch { /* ignore */ }
// 查重(已被其他项目绑定则警告,禁止创建)
try {
const conflict = await projectApi.checkBinding(selected as string)
if (conflict) pathWarning.value = `该目录已被项目「${conflict.name}」绑定`
} catch { /* ignore */ }
} catch (e) {
console.error('选择目录失败:', e)
}
}
function clearDir() {
newPath.value = ''
detectedStack.value = []
pathWarning.value = ''
scanning.value = false
scanError.value = ''
scanProjectType.value = ''
}
// AI 扫描目录 → 自动填描述/技术栈/项目类型(LLM 失败降级规则探测)
async function aiScan() {
if (!newPath.value) return
scanning.value = true
scanError.value = ''
scanProjectType.value = ''
try {
const result = await projectApi.scanWithAi(newPath.value)
detectedStack.value = result.stack
if (result.description) newDesc.value = result.description
scanProjectType.value = result.project_type ?? ''
if (!result.description) scanError.value = 'AI 未能生成描述,可手动填写'
} catch (e: any) {
scanError.value = e?.toString() ?? 'AI 扫描失败'
} finally {
scanning.value = false
}
}
async function submitCreate() {
if (!newName.value.trim()) return
await store.createProject(newName.value.trim(), newDesc.value.trim())
if (pathWarning.value) return // 目录冲突,禁止创建
const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined
const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack)
if (!r) return // 失败已 toast,保持弹窗不关
showCreateModal.value = false
newName.value = ''
newDesc.value = ''
clearDir()
}
// ── 删除 / 回收站 ──
const showTrashModal = ref(false)
async function handleDelete(project: ProjectRecord) {
if (!confirm(t('projects.confirmDelete', { name: project.name }))) return
await store.deleteProject(project.id)
}
async function openTrash() {
await store.loadDeletedProjects()
showTrashModal.value = true
}
async function handleRestore(id: string) {
await store.restoreProject(id)
}
async function handlePurge(project: ProjectRecord) {
if (!confirm(t('projects.confirmPurge', { name: project.name }))) return
await store.purgeProject(project.id)
}
// ── 加载 ──
@@ -137,6 +261,9 @@ onMounted(() => {
}
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.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); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* ===== 卡片网格 ===== */
.project-grid {
@@ -241,6 +368,12 @@ onMounted(() => {
border: 0.5px solid var(--df-border);
}
/* ===== 目录选择行 ===== */
.dir-row { display: flex; gap: 8px; align-items: center; }
.dir-row input { flex: 1; cursor: pointer; }
.path-warning { margin-top: 6px; font-size: 12px; color: var(--df-danger); }
.scan-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
/* ===== 模态框 ===== */
.modal-overlay {
position: fixed; inset: 0;
@@ -279,6 +412,26 @@ onMounted(() => {
font-size: 14px; color: var(--df-text-dim);
}
/* ===== 卡片删除按钮 + 回收站 ===== */
.card-top-actions { display: flex; align-items: center; gap: 8px; }
.card-del-btn {
background: transparent; border: none; cursor: pointer;
font-size: 14px; padding: 2px 4px; opacity: 0;
transition: opacity 0.15s;
}
.project-card:hover .card-del-btn { opacity: 0.55; }
.card-del-btn:hover { opacity: 1 !important; }
.trash-list { display: flex; flex-direction: column; max-height: 400px; overflow-y: auto; }
.trash-item {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 0.5px solid var(--df-border);
}
.trash-item:last-child { border-bottom: none; }
.trash-info { display: flex; flex-direction: column; gap: 2px; }
.trash-name { font-size: 14px; color: var(--df-text); font-weight: 500; }
.trash-date { font-size: 11px; color: var(--df-text-dim); }
.trash-actions { display: flex; gap: 8px; }
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.projects { padding: 16px; }