321 lines
11 KiB
Vue
321 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useTabStore } from '@/pinia/tab'
|
|
import HomeEntryView from '@/features/workbench/components/HomeEntryView.vue'
|
|
import Tab from '@/layout/tab.vue'
|
|
import { waitForHydration } from '@/pinia/Plugin/indexdb'
|
|
import localforage from 'localforage'
|
|
import {
|
|
buildProjectUrl,
|
|
DEFAULT_PROJECT_ID,
|
|
ensureProjectIdInUrl,
|
|
FORCE_HOME_QUERY_KEY,
|
|
getProjectDbName,
|
|
NEW_PROJECT_QUERY_KEY,
|
|
PROJECT_TAB_ID,
|
|
QUICK_PROJECT_ID
|
|
} from '@/lib/workspace'
|
|
import { collectActiveProjectSessionLocks, initProjectSessionLock } from '@/lib/projectSessionLock'
|
|
import { listenProjectDeleted, listenResetAll } from '@/lib/projectEvents'
|
|
import { createProject, listProjects, type ProjectMeta } from '@/lib/projectRegistry'
|
|
|
|
const tabStore = useTabStore()
|
|
const { t } = useI18n()
|
|
const isReady = ref(false)
|
|
const lockConflict = ref(false)
|
|
const currentProjectId = ref('')
|
|
const currentProjectName = ref('')
|
|
const conflictProjectList = ref<ProjectMeta[]>([])
|
|
const openedProjectIds = ref<string[]>([])
|
|
const closeCountdown = ref(10)
|
|
const isNewProjectRequest = ref(false)
|
|
const isForceHomeRequest = ref(false)
|
|
let closeCountdownTimer: ReturnType<typeof setInterval> | null = null
|
|
let releaseLock: (() => void) | null = null
|
|
let stopProjectDeletedListener: (() => void) | null = null
|
|
let stopResetAllListener: (() => void) | null = null
|
|
let isHandlingDeletedProject = false
|
|
let isHandlingGlobalReset = false
|
|
|
|
const showHomeEntry = computed(() => !tabStore.hasCompletedSetup)
|
|
|
|
const handleImportComplete = () => {
|
|
tabStore.hasCompletedSetup = true
|
|
}
|
|
|
|
const initCurrentProjectLock = () => {
|
|
if (releaseLock) return
|
|
const projectId = String(currentProjectId.value || '').trim()
|
|
if (!projectId || projectId === QUICK_PROJECT_ID) {
|
|
lockConflict.value = false
|
|
return
|
|
}
|
|
const lock = initProjectSessionLock({
|
|
projectId,
|
|
onConflict: (next) => {
|
|
lockConflict.value = next
|
|
if (next) {
|
|
refreshConflictProjectList()
|
|
startCloseCountdown()
|
|
} else {
|
|
clearCloseCountdown()
|
|
}
|
|
}
|
|
})
|
|
releaseLock = lock.release
|
|
}
|
|
|
|
const refreshConflictProjectList = () => {
|
|
void (async () => {
|
|
const projects = listProjects()
|
|
const enriched = await Promise.all(
|
|
projects.map(async (project) => {
|
|
try {
|
|
const kvStoreInstance = localforage.createInstance({
|
|
name: getProjectDbName(project.id),
|
|
storeName: 'pinia-kv'
|
|
})
|
|
const kvState = await kvStoreInstance.getItem<any>('pinia-kv')
|
|
const entries = kvState?.entries && typeof kvState.entries === 'object' ? kvState.entries : null
|
|
const projectInfo = entries?.['xm-base-info-v1']
|
|
const projectName =
|
|
projectInfo && typeof projectInfo === 'object' && typeof projectInfo.projectName === 'string'
|
|
? projectInfo.projectName.trim()
|
|
: ''
|
|
return {
|
|
...project,
|
|
name: projectName || project.name
|
|
}
|
|
} catch {
|
|
return project
|
|
}
|
|
})
|
|
)
|
|
conflictProjectList.value = enriched
|
|
const hit = enriched.find(item => item.id === currentProjectId.value)
|
|
currentProjectName.value = hit?.name || currentProjectId.value
|
|
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(enriched.map(item => item.id)))
|
|
})()
|
|
}
|
|
|
|
const clearCloseCountdown = () => {
|
|
if (!closeCountdownTimer) return
|
|
clearInterval(closeCountdownTimer)
|
|
closeCountdownTimer = null
|
|
}
|
|
|
|
const startCloseCountdown = () => {
|
|
clearCloseCountdown()
|
|
closeCountdown.value = 10
|
|
closeCountdownTimer = setInterval(() => {
|
|
closeCountdown.value -= 1
|
|
if (closeCountdown.value <= 0) {
|
|
clearCloseCountdown()
|
|
try {
|
|
window.close()
|
|
} catch {
|
|
// 部分浏览器会阻止关闭,保留阻断页。
|
|
}
|
|
}
|
|
}, 1000)
|
|
}
|
|
|
|
const isConflictProjectOpen = (projectId: string) => openedProjectIds.value.includes(projectId)
|
|
|
|
const openProjectInNewTab = (projectId: string, options?: { newProject?: boolean }) => {
|
|
if (isConflictProjectOpen(projectId)) return
|
|
const href = buildProjectUrl(projectId, options)
|
|
window.open(href, '_blank', 'noopener')
|
|
}
|
|
|
|
const createProjectAndOpen = () => {
|
|
const project = createProject(t('xmInfo.defaultProjectName'))
|
|
refreshConflictProjectList()
|
|
openProjectInNewTab(project.id, { newProject: true })
|
|
}
|
|
|
|
const syncRouteRequestFlags = () => {
|
|
try {
|
|
const url = new URL(window.location.href)
|
|
isNewProjectRequest.value = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
|
|
isForceHomeRequest.value = url.searchParams.get(FORCE_HOME_QUERY_KEY) === '1'
|
|
} catch {
|
|
isNewProjectRequest.value = false
|
|
isForceHomeRequest.value = false
|
|
}
|
|
}
|
|
|
|
const formatProjectEditedTime = (value: string) => {
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return '-'
|
|
const pad = (num: number) => String(num).padStart(2, '0')
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
}
|
|
|
|
const handleReleaseProjectLock = () => {
|
|
if (!releaseLock) return
|
|
releaseLock()
|
|
releaseLock = null
|
|
lockConflict.value = false
|
|
}
|
|
|
|
const handleProjectDeleted = (deletedProjectId: string) => {
|
|
if (String(deletedProjectId || '').trim() !== currentProjectId.value) return
|
|
if (isHandlingDeletedProject) return
|
|
isHandlingDeletedProject = true
|
|
handleReleaseProjectLock()
|
|
tabStore.resetTabs()
|
|
tabStore.hasCompletedSetup = false
|
|
const href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true })
|
|
try {
|
|
window.close()
|
|
} catch {
|
|
// ignore and fallback to redirect
|
|
}
|
|
window.setTimeout(() => {
|
|
window.location.href = href
|
|
}, 120)
|
|
}
|
|
|
|
const handleResetAll = () => {
|
|
if (isHandlingGlobalReset) return
|
|
isHandlingGlobalReset = true
|
|
handleReleaseProjectLock()
|
|
tabStore.resetTabs()
|
|
tabStore.hasCompletedSetup = false
|
|
const href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true })
|
|
try {
|
|
window.close()
|
|
} catch {
|
|
// ignore and fallback to redirect
|
|
}
|
|
window.setTimeout(() => {
|
|
window.location.href = href
|
|
}, 120)
|
|
}
|
|
|
|
onMounted(() => {
|
|
currentProjectId.value = ensureProjectIdInUrl()
|
|
syncRouteRequestFlags()
|
|
refreshConflictProjectList()
|
|
if (!isForceHomeRequest.value && currentProjectId.value !== QUICK_PROJECT_ID && currentProjectId.value !== DEFAULT_PROJECT_ID) {
|
|
initCurrentProjectLock()
|
|
}
|
|
|
|
window.addEventListener('home-import-selected', handleImportComplete)
|
|
window.addEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
|
|
stopProjectDeletedListener = listenProjectDeleted(handleProjectDeleted)
|
|
stopResetAllListener = listenResetAll(handleResetAll)
|
|
waitForHydration('tabs').then(() => {
|
|
if (isForceHomeRequest.value) {
|
|
tabStore.resetTabs()
|
|
tabStore.hasCompletedSetup = false
|
|
}
|
|
if (!tabStore.hasCompletedSetup && !isNewProjectRequest.value && !isForceHomeRequest.value) {
|
|
const hasProjects = listProjects().length > 0
|
|
if (hasProjects && currentProjectId.value !== DEFAULT_PROJECT_ID) {
|
|
if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
|
|
tabStore.hasCompletedSetup = true
|
|
} else {
|
|
tabStore.enterWorkspace({
|
|
id: PROJECT_TAB_ID,
|
|
title: t('home.cards.projectBudget'),
|
|
componentName: 'ProjectCalcView'
|
|
})
|
|
tabStore.hasCompletedSetup = true
|
|
}
|
|
}
|
|
}
|
|
if (tabStore.hasCompletedSetup && Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
|
|
const activeId = typeof tabStore.activeTabId === 'string' ? tabStore.activeTabId : ''
|
|
const hasActive = Boolean(activeId) && tabStore.tabs.some(tab => tab.id === activeId)
|
|
if (!hasActive) {
|
|
tabStore.activeTabId = tabStore.tabs[0]?.id
|
|
}
|
|
}
|
|
if (!releaseLock) {
|
|
lockConflict.value = false
|
|
clearCloseCountdown()
|
|
}
|
|
isReady.value = true
|
|
})
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
clearCloseCountdown()
|
|
window.removeEventListener('home-import-selected', handleImportComplete)
|
|
window.removeEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
|
|
if (stopProjectDeletedListener) {
|
|
stopProjectDeletedListener()
|
|
stopProjectDeletedListener = null
|
|
}
|
|
if (stopResetAllListener) {
|
|
stopResetAllListener()
|
|
stopResetAllListener = null
|
|
}
|
|
if (releaseLock) {
|
|
releaseLock()
|
|
releaseLock = null
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<template v-if="isReady">
|
|
<div
|
|
v-if="lockConflict"
|
|
class="flex min-h-screen items-center justify-center bg-slate-50 px-4"
|
|
>
|
|
<div class="w-full max-w-lg rounded-xl border border-red-200 bg-white p-6 shadow-sm">
|
|
<h2 class="text-lg font-semibold text-slate-900">{{ t('app.projectConflict.title') }}</h2>
|
|
<p class="mt-2 text-sm leading-6 text-slate-600">
|
|
{{ t('app.projectConflict.desc', { name: currentProjectName }) }}
|
|
</p>
|
|
<p class="mt-2 text-xs text-slate-500">
|
|
{{ t('app.projectConflict.countdown', { seconds: closeCountdown }) }}
|
|
</p>
|
|
<div class="mt-4 max-h-52 space-y-2 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-2">
|
|
<button
|
|
v-for="project in conflictProjectList"
|
|
:key="project.id"
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-md border border-transparent bg-white px-3 py-2 text-left text-sm transition"
|
|
:class="isConflictProjectOpen(project.id) ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-slate-200 hover:bg-slate-100'"
|
|
:disabled="isConflictProjectOpen(project.id)"
|
|
@click="openProjectInNewTab(project.id)"
|
|
>
|
|
<span class="font-medium text-slate-700">
|
|
{{ project.name }}
|
|
<span v-if="isConflictProjectOpen(project.id)" class="ml-1 text-xs text-slate-500">{{ t('app.projectConflict.opened') }}</span>
|
|
</span>
|
|
<span class="text-xs text-slate-500">{{ t('app.projectConflict.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}</span>
|
|
</button>
|
|
</div>
|
|
<div class="mt-4 flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="cursor-pointer rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-100"
|
|
@click="createProjectAndOpen"
|
|
>
|
|
{{ t('app.projectConflict.createAndOpen') }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
|
|
:class="isConflictProjectOpen('default') ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-slate-100'"
|
|
:disabled="isConflictProjectOpen('default')"
|
|
@click="openProjectInNewTab('default')"
|
|
>
|
|
{{ t('app.projectConflict.openDefault') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template v-else>
|
|
<HomeEntryView v-if="showHomeEntry" />
|
|
<Tab v-else />
|
|
</template>
|
|
</template>
|
|
</template>
|