重构: df-ai-core trait下沉拆crate+导入历史项目批量扫描
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user