@@ -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 : 1 fr ;
}
}
/* ===== 导入历史项目 ===== */
. import - box { width : 720 px ; max - height : 80 vh ; display : flex ; flex - direction : column ; }
. import - status { font - size : 13 px ; color : var ( -- df - text - dim ) ; padding : 16 px 0 ; text - align : center ; }
. import - table - wrap { flex : 1 ; overflow : auto ; border : 0.5 px solid var ( -- df - border ) ; border - radius : var ( -- df - radius - sm ) ; margin - bottom : 8 px ; }
. import - table { width : 100 % ; border - collapse : collapse ; font - size : 12 px ; }
. import - table thead th {
position : sticky ; top : 0 ; background : var ( -- df - bg ) ;
text - align : left ; padding : 8 px 10 px ; font - weight : 500 ;
color : var ( -- df - text - dim ) ; border - bottom : 0.5 px solid var ( -- df - border ) ;
}
. import - table tbody td { padding : 8 px 10 px ; border - bottom : 0.5 px 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 : 32 px ; text - align : center ; }
. import - table . col - path { max - width : 260 px ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; font - family : var ( -- df - mono , monospace ) ; font - size : 11 px ; }
. row - disabled { opacity : 0.5 ; }
. imp - name { font - weight : 500 ; color : var ( -- df - text ) ; }
. mono - tag { margin - left : 6 px ; font - size : 10 px ; padding : 1 px 6 px ; border - radius : var ( -- df - radius - xs ) ; background : rgba ( 255 , 217 , 61 , 0.15 ) ; color : var ( -- df - warning ) ; border : 0.5 px solid var ( -- df - border ) ; }
. bound - mark { color : var ( -- df - success ) ; font - weight : 600 ; }
. selection - count { margin - right : auto ; font - size : 12 px ; color : var ( -- df - text - dim ) ; }
. import - box . modal - actions { align - items : center ; }
/* ===== Toast ===== */
. toast {
position : fixed ; bottom : 24 px ; left : 50 % ; transform : translateX ( - 50 % ) ;
padding : 10 px 18 px ; border - radius : var ( -- df - radius - sm ) ;
font - size : 13 px ; z - index : 200 ; max - width : 80 vw ;
box - shadow : 0 4 px 12 px rgba ( 0 , 0 , 0 , 0.2 ) ;
}
. toast - info { background : var ( -- df - accent ) ; color : # fff ; }
. toast - error { background : var ( -- df - danger ) ; color : # fff ; }
< / style >