Files
DevFlow/src/views/Projects.vue

655 lines
24 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="projects">
<header class="page-header">
<h1>{{ $t('projects.title') }}</h1>
<div class="header-actions">
<button class="btn btn-ghost" @click="openImportModal">{{ $t('projects.importHistory') }}</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">{{ $t('projects.createTitle') }}</h3>
<div class="modal-field">
<label>{{ $t('projects.nameLabel') }}</label>
<input v-model="newName" :placeholder="$t('projects.namePlaceholder')" @keyup.enter="submitCreate" />
</div>
<div class="modal-field">
<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') }}{{ $t('projects.codeDirOptionalHint') }}</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 ? $t('projects.aiScanning') : $t('projects.aiScanFill') }}
</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">{{ $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>
<!-- 导入历史项目模态框(F-260614-06 scan 第二步) -->
<div v-if="showImportModal" class="modal-overlay" @click.self="closeImportModal">
<div class="modal-box import-box">
<h3 class="modal-title">{{ $t('projects.importTitle') }}</h3>
<!-- 选根目录 + 扫描 -->
<div class="modal-field">
<div class="dir-row">
<input v-model="importRootPath" :placeholder="$t('projects.importSelectRoot')" readonly />
<button class="btn btn-ghost btn-sm" type="button" @click="pickImportRoot">{{ $t('projects.selectDir') }}</button>
</div>
</div>
<!-- 扫描结果表格(只读预览 + 勾选) -->
<div v-if="importScanning" class="import-status">{{ $t('projects.importScanning') }}</div>
<div v-else-if="importError" class="path-warning"> {{ importError }}</div>
<div v-else-if="scannedItems.length === 0 && importRootPath" class="import-status">{{ $t('projects.importEmpty') }}</div>
<div v-if="scannedItems.length" class="import-table-wrap">
<table class="import-table">
<thead>
<tr>
<th class="col-check">
<input type="checkbox" :checked="allImportSelected" :indeterminate.prop="someImportSelected" @change="toggleSelectAll(($event.target as HTMLInputElement).checked)" />
</th>
<th>{{ $t('projects.importColName') }}</th>
<th>{{ $t('projects.importColStack') }}</th>
<th>{{ $t('projects.importColPath') }}</th>
<th>{{ $t('projects.importColBound') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in scannedItems" :key="item.path" :class="{ 'row-disabled': item.already_bound }">
<td class="col-check">
<input type="checkbox" :disabled="item.already_bound" :checked="isImportSelected(item.path)" @change="toggleSelect(item.path, ($event.target as HTMLInputElement).checked)" />
</td>
<td>
<span class="imp-name">{{ item.name }}</span>
<span v-if="item.is_monorepo" class="mono-tag">{{ $t('projects.importMonoHint') }}</span>
</td>
<td>
<span class="tech-tag" v-for="s in item.stack" :key="s">{{ s }}</span>
</td>
<td class="col-path" :title="item.path">{{ item.path }}</td>
<td>
<span v-if="item.already_bound" class="bound-mark"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-actions">
<span class="selection-count" v-if="scannedItems.length">{{ $t('projects.importSelected', { n: selectedImportPaths.size }) }}</span>
<button class="btn btn-ghost" @click="closeImportModal">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="runImport" :disabled="importRunning || selectedImportPaths.size === 0">
{{ importRunning ? $t('projects.importRunning') : $t('projects.importRun') }}
</button>
</div>
</div>
</div>
<!-- 项目卡片网格 -->
<div class="project-grid">
<div class="project-card" v-for="project in store.projects" :key="project.id" @click="router.push('/projects/' + project.id)">
<div class="card-top">
<div class="card-title-row">
<h2 class="card-name">{{ project.name }}</h2>
</div>
<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>
<!-- 底部信息 -->
<div class="card-footer">
<div class="footer-stat">
<span class="stat-icon">📅</span>
<span>{{ formatDate(project.created_at) }}</span>
</div>
<div class="footer-stat">
<span class="stat-icon">🕐</span>
<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>
<!-- 确认弹层删除/彻底删除替代原生 window.confirm -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
<!-- 批量导入结果 toast -->
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, 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 { parseStack } from '@/utils/project'
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '../constants/project'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useConfirm } from '@/composables/useConfirm'
import type { ProjectRecord } from '@/api/types'
import type { ScannedProjectItem } from '@/api/project'
const router = useRouter()
const store = useProjectStore()
const { t } = useI18n()
// 状态映射统一走 ../constants/project(与 ProjectDetail/Tasks/Dashboard 一致)
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ── 新建项目 ──
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('')
// 选择目录 → 探测技术栈 + 查重
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 = t('projects.dirConflict', { name: 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 = t('projects.aiScanNoDesc')
} catch (e: any) {
scanError.value = e?.toString() ?? t('projects.aiScanFailed')
} finally {
scanning.value = false
}
}
async function submitCreate() {
if (!newName.value.trim()) return
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 (!await confirmDialog(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 (!await confirmDialog(t('projects.confirmPurge', { name: project.name }))) return
await store.purgeProject(project.id)
}
// ── 加载 ──
onMounted(() => {
store.loadProjects()
})
// ── 导入历史项目(F-260614-06 scan 第二步) ──
const showImportModal = ref(false)
const importRootPath = ref('')
const scannedItems = ref<ScannedProjectItem[]>([])
const importScanning = ref(false)
const importError = ref('')
const importRunning = ref(false)
const selectedImportPaths = ref<Set<string>>(new Set())
const toast = ref({ visible: false, msg: '', type: 'info' as 'info' | 'error' })
let _toastTimer: ReturnType<typeof setTimeout> | null = null
function showToast(msg: string, type: 'info' | 'error' = 'info') {
toast.value = { visible: true, msg, type }
if (_toastTimer) clearTimeout(_toastTimer)
_toastTimer = setTimeout(() => { toast.value.visible = false }, 4000)
}
const allImportSelected = computed(() =>
scannedItems.value.length > 0
&& scannedItems.value.filter(i => !i.already_bound).every(i => selectedImportPaths.value.has(i.path))
)
const someImportSelected = computed(() =>
selectedImportPaths.value.size > 0 && !allImportSelected.value
)
function isImportSelected(path: string) {
return selectedImportPaths.value.has(path)
}
function toggleSelect(path: string, checked: boolean) {
const next = new Set(selectedImportPaths.value)
if (checked) next.add(path)
else next.delete(path)
selectedImportPaths.value = next
}
function toggleSelectAll(checked: boolean) {
if (checked) {
selectedImportPaths.value = new Set(
scannedItems.value.filter(i => !i.already_bound).map(i => i.path)
)
} else {
selectedImportPaths.value = new Set()
}
}
function openImportModal() {
showImportModal.value = true
importError.value = ''
scannedItems.value = []
selectedImportPaths.value = new Set()
}
function closeImportModal() {
showImportModal.value = false
importRootPath.value = ''
scannedItems.value = []
selectedImportPaths.value = new Set()
importError.value = ''
importScanning.value = false
}
async function pickImportRoot() {
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
importRootPath.value = selected as string
await runScan()
} catch (e) {
console.error('选择根目录失败:', e)
}
}
async function runScan() {
if (!importRootPath.value) return
importScanning.value = true
importError.value = ''
scannedItems.value = []
selectedImportPaths.value = new Set()
try {
scannedItems.value = await projectApi.scanDirectoryForProjects(importRootPath.value)
} catch (e: any) {
importError.value = e?.toString() ?? t('projects.importScanFailed')
} finally {
importScanning.value = false
}
}
async function runImport() {
if (selectedImportPaths.value.size === 0) {
showToast(t('projects.importNoSelection'), 'error')
return
}
importRunning.value = true
try {
const items = Array.from(selectedImportPaths.value).map(p => {
const found = scannedItems.value.find(i => i.path === p)
return { path: p, name: found?.name }
})
const result = await projectApi.importProjectsBatch(items)
// 刷新列表(批量入库后)
await store.loadProjects()
showToast(t('projects.importDone', { imported: result.imported, skipped: result.skipped }), result.imported > 0 ? 'info' : 'error')
if (result.imported > 0) {
closeImportModal()
} else {
// 全失败:保留弹窗 + 结果,让用户看失败项;重新扫描刷新已绑定态
await runScan()
}
} catch (e: any) {
showToast(e?.toString() ?? t('projects.importFailed'), 'error')
} finally {
importRunning.value = false
}
}
</script>
<style scoped>
.projects { padding: 16px 20px 20px; }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-page);
}
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
.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-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 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--df-gap-grid);
}
.project-card {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
transition: all 0.15s;
cursor: pointer;
}
.project-card:hover {
border-color: var(--df-accent);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.card-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.card-icon { font-size: 22px; }
.card-name { font-size: 17px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stage-badge {
font-size: 12px; font-weight: 500;
padding: 3px 10px;
border-radius: var(--df-radius-sm);
}
.stage-design { background: rgba(100,181,246,0.15); color: var(--df-info); }
.stage-coding { background: rgba(108,99,255,0.15); color: var(--df-accent); }
.stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); }
.stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); }
.card-desc {
font-size: 13px;
color: var(--df-text-secondary);
line-height: 1.5;
margin-bottom: 16px;
}
/* ===== 进度条 ===== */
.card-progress { margin-bottom: 16px; }
.progress-label-row {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.progress-stage { font-size: 12px; color: var(--df-text-dim); }
.progress-pct { font-size: 12px; font-weight: 500; color: var(--df-text); }
.progress-bar {
height: 6px;
background: var(--df-border);
border-radius: var(--df-radius-sm);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--df-accent);
border-radius: var(--df-radius-sm);
transition: width 0.3s;
}
/* ===== 底部信息 ===== */
.card-footer {
display: flex;
gap: var(--df-gap-grid);
margin-bottom: 14px;
}
.footer-stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--df-text-dim);
}
.stat-icon { font-size: 13px; }
/* ===== 技术栈标签 ===== */
.card-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.tech-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: var(--df-radius-xs);
background: rgba(108, 99, 255, 0.08);
color: var(--df-text-secondary);
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;
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-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:disabled { opacity: 0.4; cursor: not-allowed; }
/* ===== 空状态 ===== */
.empty-state {
text-align: center; padding: 60px 20px;
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; }
.project-grid {
grid-template-columns: 1fr;
}
}
/* ===== 导入历史项目 ===== */
.import-box { width: 720px; max-height: 80vh; display: flex; flex-direction: column; }
.import-status { font-size: 13px; color: var(--df-text-dim); padding: 16px 0; text-align: center; }
.import-table-wrap { flex: 1; overflow: auto; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); margin-bottom: 8px; }
.import-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.import-table thead th {
position: sticky; top: 0; background: var(--df-bg);
text-align: left; padding: 8px 10px; font-weight: 500;
color: var(--df-text-dim); border-bottom: 0.5px solid var(--df-border);
}
.import-table tbody td { padding: 8px 10px; border-bottom: 0.5px solid var(--df-border); vertical-align: top; color: var(--df-text-secondary); }
.import-table tbody tr:last-child td { border-bottom: none; }
.import-table .col-check { width: 32px; text-align: center; }
.import-table .col-path { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--df-mono, monospace); font-size: 11px; }
.row-disabled { opacity: 0.5; }
.imp-name { font-weight: 500; color: var(--df-text); }
.mono-tag { margin-left: 6px; font-size: 10px; padding: 1px 6px; border-radius: var(--df-radius-xs); background: rgba(255,217,61,0.15); color: var(--df-warning); border: 0.5px solid var(--df-border); }
.bound-mark { color: var(--df-success); font-weight: 600; }
.selection-count { margin-right: auto; font-size: 12px; color: var(--df-text-dim); }
.import-box .modal-actions { align-items: center; }
/* ===== Toast ===== */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 10px 18px; border-radius: var(--df-radius-sm);
font-size: 13px; z-index: 200; max-width: 80vw;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.toast-info { background: var(--df-accent); color: #fff; }
.toast-error { background: var(--df-danger); color: #fff; }
</style>