Files
DevFlow/src/views/Projects.vue
绝尘 c3b47b5771 修复: 前端UI审查问题(项目状态badge+confirm替换+加载态+CSS修正)
- ProjectDetail: 加载态/不存在态 + stage badge CSS 类名修正(7个实际状态)

- Tasks: confirm()替换为useConfirm + 死代码清理 + priority CSS去重

- Knowledge: 列表加载态

- Projects: CSS变量名拼写修正(--df-mono→--df-font-mono)

- EmptyState: opacity从容器移到图标(对比度修复)

- Ideas: 5处内联style替换为modal-field class

- ProjectDetail: 删除注释stages死代码
2026-07-02 12:12:29 +08:00

600 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>
<!-- 错误条:消费 store.error(加载/创建/删除失败对用户可见,对齐 Knowledge error-banner) -->
<div v-if="store.error" class="error-banner">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="store.clearError()"></button>
</div>
<!-- 新建项目模态框 -->
<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="submitting || !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>
<!-- 项目卡片网格(单卡片渲染抽至 components/project/ProjectCard.vue,删除走 emit 回父处理) -->
<div class="project-grid">
<ProjectCard v-for="project in pagedProjects" :key="project.id" :project="project" @delete="handleDelete" />
</div>
<!-- 分页器(F-260621-02 P3):pageSize=0 不分页(全量,等价旧行为);用户选 N 条触发客户端切片 -->
<Paginator
v-model:page="page"
v-model:pageSize="pageSize"
:total="store.projects.length"
/>
<!-- 空状态 -->
<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, onUnmounted } from 'vue'
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 ConfirmDialog from '@/components/ConfirmDialog.vue'
import ProjectCard from '@/components/project/ProjectCard.vue'
import Paginator from '@/components/Paginator.vue'
import { useConfirm } from '@/composables/useConfirm'
import type { ProjectRecord } from '@/api/types'
import type { ScannedProjectItem } from '@/api/project'
const store = useProjectStore()
const { t } = useI18n()
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片
const page = ref(1)
const pageSize = ref(0)
// 分页视图:pageSize=0 → 全量;否则取当前页窗口
const pagedProjects = computed(() => {
if (pageSize.value <= 0) return store.projects
const start = (page.value - 1) * pageSize.value
return store.projects.slice(start, start + pageSize.value)
})
// 状态映射统一走 ../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('')
// 创建提交中(防双击重复提交,对齐 ProviderPanel.saving 模式)
const submitting = ref(false)
// 选择目录 → 探测技术栈 + 查重
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 // 目录冲突,禁止创建
if (submitting.value) return // 防双击重复提交
submitting.value = true
try {
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()
} finally {
submitting.value = false
}
}
// ── 删除 / 回收站 ──
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()
})
// 卸载清 toast timer:防组件已离开视图后 4s 内 timer 触发向已销毁组件写状态。
// 对齐 Settings.vue 的 _toastTimer 清理模式(Knowledge.vue searchTimer 同模式)。
onUnmounted(() => {
if (_toastTimer) {
clearTimeout(_toastTimer)
_toastTimer = null
}
})
// ── 导入历史项目(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);
}
/* ===== 进度条 ===== */
.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;
}
/* ===== 技术栈标签:基础样式已收敛至全局 components.css(DRY 收口 B-260619),
创建/导入模态与本页 ProjectCard 渲染的标签均走全局 .tech-tag/.card-tags。 ===== */
/* ===== 目录选择行 ===== */
.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);
}
/* ===== 错误条(消费 store.error,对齐 Knowledge error-banner) ===== */
.error-banner {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 8px 12px; margin-bottom: var(--df-gap-page);
background: rgba(255,107,107,0.12); border: 0.5px solid var(--df-danger);
border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px;
}
.error-text { word-break: break-word; }
.error-dismiss {
flex-shrink: 0; background: transparent; border: none; color: var(--df-danger);
font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
}
.error-dismiss:hover { opacity: 0.7; }
/* ===== 回收站(卡片删除按钮样式随 ProjectCard.vue 迁出) ===== */
.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-font-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>