重构: 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

@@ -13,6 +13,39 @@ export interface ImportProjectInput {
stack?: string
}
/** 扫描发现的候选项目(规则发现,无 LLM)。前端预览表格只读展示。 */
export interface ScannedProjectItem {
path: string
name: string
stack: string[]
is_monorepo: boolean
/** 该目录是否已被某项目绑定(防重复,前端标记禁选) */
already_bound: boolean
}
/** 批量导入单条入参 */
export interface ImportBatchItemInput {
path: string
/** 项目名(可选,空=用目录名) */
name?: string
}
/** 批量导入单条结果 */
export interface ImportBatchItemResult {
path: string
/** 成功:导入的项目名;失败:null */
name: string | null
/** 失败原因(成功为 null) */
error: string | null
}
/** 批量导入结果(前端 toast 汇总) */
export interface ImportBatchResult {
imported: number
skipped: number
items: ImportBatchItemResult[]
}
export const projectApi = {
list(): Promise<ProjectRecord[]> {
return invoke('list_projects')
@@ -75,4 +108,20 @@ export const projectApi = {
scanWithAi(path: string): Promise<AiScanResult> {
return invoke('scan_project_with_ai', { path })
},
/**
* 扫描根目录发现候选项目(规则发现,快、不跑 LLM)。
* 返回含 monorepo 子项目展开结果,前端预览表格勾选后调 importProjectsBatch。
*/
scanDirectoryForProjects(rootPath: string): Promise<ScannedProjectItem[]> {
return invoke('scan_directory_for_projects', { rootPath })
},
/**
* 批量导入历史项目 — 对勾选项并发 LLM 抽 description + 入库绑定。
* 每项独立非原子,逐项结果回传。LLM 失败 description 留空(不喂噪音)。
*/
importProjectsBatch(items: ImportBatchItemInput[]): Promise<ImportBatchResult> {
return invoke('import_projects_batch', { items })
},
}

View File

@@ -36,6 +36,26 @@ export default {
aiScanNoDesc: 'AI could not generate a description, please fill manually',
aiScanFailed: 'AI scan failed',
dirConflict: 'This directory is already bound to project "{name}"',
// Import historical projects (F-260614-06 scan step 2)
importHistory: '📥 Import Projects',
importTitle: 'Import Historical Projects',
importSelectRoot: 'Select root directory to scan',
importScanning: 'Scanning…',
importRescan: 'Rescan',
importEmpty: 'No candidate projects found in this directory (need Cargo.toml/package.json/go.mod etc.)',
importColName: 'Name',
importColPath: 'Path',
importColStack: 'Stack',
importColBound: 'Bound',
importMonoHint: 'monorepo',
importSelectAll: 'Select all',
importSelected: '{n} selected',
importRun: 'Import selected',
importRunning: 'Importing…',
importDone: 'Import complete: {imported} succeeded, {skipped} skipped',
importNoSelection: 'Please select projects to import first',
importScanFailed: 'Failed to scan directory',
importFailed: 'Batch import failed',
// Status labels (PROJECT_STATUS_LABELS values in constants/project.ts use these keys)
status: {
planning: '📐 Planning',

View File

@@ -22,6 +22,26 @@ export default {
aiScanNoDesc: 'AI 未能生成描述,可手动填写',
aiScanFailed: 'AI 扫描失败',
dirConflict: '该目录已被项目「{name}」绑定',
// 导入历史项目(F-260614-06 scan 第二步)
importHistory: '📥 导入历史项目',
importTitle: '导入历史项目',
importSelectRoot: '选择根目录扫描',
importScanning: '扫描中…',
importRescan: '重新扫描',
importEmpty: '该目录下未发现候选项目(需要有 Cargo.toml/package.json/go.mod 等标志文件)',
importColName: '项目名',
importColPath: '路径',
importColStack: '技术栈',
importColBound: '已绑定',
importMonoHint: 'monorepo',
importSelectAll: '全选',
importSelected: '已选 {n} 项',
importRun: '导入选中项',
importRunning: '导入中…',
importDone: '导入完成:成功 {imported} 项,跳过 {skipped} 项',
importNoSelection: '请先勾选要导入的项目',
importScanFailed: '扫描目录失败',
importFailed: '批量导入失败',
// 回收站模态框
trashTitle: '🗑 回收站',
trashEmpty: '回收站为空',

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>