重构: df-ai-core trait下沉拆crate+导入历史项目批量扫描

This commit is contained in:
2026-06-16 20:19:55 +08:00
parent d00b30f0ba
commit 2069f79198
19 changed files with 1518 additions and 311 deletions

View File

@@ -3,6 +3,7 @@
<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>
@@ -69,6 +70,68 @@
</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)">
@@ -110,11 +173,14 @@
<!-- 确认弹层删除/彻底删除替代原生 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, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { open } from '@tauri-apps/plugin-dialog'
@@ -126,6 +192,7 @@ import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } fr
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()
@@ -234,6 +301,122 @@ async function handlePurge(project: ProjectRecord) {
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>
@@ -437,4 +620,35 @@ onMounted(() => {
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>