883 lines
37 KiB
Vue
883 lines
37 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { useTabStore } from '@/pinia/tab'
|
|
import { useKvStore } from '@/pinia/kv'
|
|
import { useUiPrefsStore } from '@/pinia/uiPrefs'
|
|
import {
|
|
Calculator,
|
|
Check,
|
|
ChevronDown,
|
|
Languages,
|
|
X
|
|
} from 'lucide-vue-next'
|
|
import { getIndustryDisplayName, industryTypeList } from '@/sql'
|
|
import { initializeProjectFactorStates, initializeProjectScaleState } from '@/lib/projectWorkspace'
|
|
import {
|
|
SelectContent,
|
|
SelectIcon,
|
|
SelectItem,
|
|
SelectItemIndicator,
|
|
SelectItemText,
|
|
SelectPortal,
|
|
SelectRoot,
|
|
SelectTrigger,
|
|
SelectViewport
|
|
} from 'reka-ui'
|
|
import {
|
|
buildProjectUrl,
|
|
DEFAULT_PROJECT_ID,
|
|
FORCE_HOME_QUERY_KEY,
|
|
NEW_PROJECT_QUERY_KEY,
|
|
OPEN_PROJECT_DIALOG_QUERY_KEY,
|
|
PROJECT_TAB_ID,
|
|
QUICK_PROJECT_ID,
|
|
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
|
QUICK_CONTRACT_FALLBACK_NAME,
|
|
QUICK_CONTRACT_ID,
|
|
QUICK_CONTRACT_META_KEY,
|
|
QUICK_MAJOR_FACTOR_KEY,
|
|
QUICK_PROJECT_INFO_KEY,
|
|
readCurrentProjectId,
|
|
writeProjectIdToUrl,
|
|
setPendingHomeImportFile,
|
|
writeWorkspaceMode
|
|
} from '@/lib/workspace'
|
|
import { createProject, listProjects, upsertProject } from '@/lib/projectRegistry'
|
|
import { createProjectKvAdapter } from '@/lib/projectKvStore'
|
|
import { collectActiveProjectSessionLocks } from '@/lib/projectSessionLock'
|
|
|
|
interface QuickProjectInfoState {
|
|
projectIndustry?: string
|
|
projectName?: string
|
|
preparedBy?: string
|
|
reviewedBy?: string
|
|
preparedCompany?: string
|
|
preparedDate?: string
|
|
}
|
|
|
|
interface QuickContractMetaState {
|
|
id?: string
|
|
name?: string
|
|
updatedAt?: string
|
|
}
|
|
|
|
interface ProjectInfoState {
|
|
projectIndustry?: string
|
|
projectName?: string
|
|
preparedBy?: string
|
|
reviewedBy?: string
|
|
preparedCompany?: string
|
|
preparedDate?: string
|
|
}
|
|
|
|
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
|
const PROJECT_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
|
|
const PROJECT_MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
|
|
const PROJECT_SCALE_KEY = 'xm-info-v3'
|
|
const getActiveProjectId = () => readCurrentProjectId()
|
|
|
|
const tabStore = useTabStore()
|
|
const kvStore = useKvStore()
|
|
const uiPrefsStore = useUiPrefsStore()
|
|
const { t, locale } = useI18n()
|
|
const projectDialogOpen = ref(false)
|
|
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
|
const projectSubmitting = ref(false)
|
|
const quickIndustry = ref(String(industryTypeList[0]?.id || ''))
|
|
const quickContractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
|
|
const quickSubmitting = ref(false)
|
|
const homeImportInputRef = ref<HTMLInputElement | null>(null)
|
|
const homeImportConfirmOpen = ref(false)
|
|
const pendingHomeImportFile = ref<File | null>(null)
|
|
const pendingHomeImportFileName = ref('')
|
|
const existingProjectDialogOpen = ref(false)
|
|
const existingProjects = ref<Array<{ id: string; name: string; updatedAt: string }>>([])
|
|
const existingProjectLoading = ref(false)
|
|
const hasExistingProjects = ref(false)
|
|
const openedProjectIds = ref<string[]>([])
|
|
let existingProjectPollTimer: ReturnType<typeof setInterval> | null = null
|
|
const projectIndustryLabel = computed(() => {
|
|
const target = String(projectIndustry.value || '').trim()
|
|
if (!target) return ''
|
|
return getIndustryDisplayName(target, locale.value) || ''
|
|
})
|
|
const heroBrandName = computed(() => t('home.cards.heroBrand'))
|
|
const heroBrandMessages = computed(() => [
|
|
t('home.cards.heroBrandMessage1'),
|
|
t('home.cards.heroBrandMessage2'),
|
|
t('home.cards.heroBrandMessage3'),
|
|
t('home.cards.heroBrandMessage4'),
|
|
t('home.cards.heroBrandMessage5')
|
|
].filter(Boolean))
|
|
const heroBrandMessageIndex = ref(0)
|
|
const activeHeroBrandMessage = computed(() => {
|
|
const options = heroBrandMessages.value
|
|
if (!options.length) return ''
|
|
return options[((heroBrandMessageIndex.value % options.length) + options.length) % options.length]
|
|
})
|
|
const localeBadge = computed(() => (locale.value === 'en-US' ? 'EN' : '中'))
|
|
const toggleLocale = () => {
|
|
const next = locale.value === 'en-US' ? 'zh-CN' : 'en-US'
|
|
uiPrefsStore.setLocale(next as 'zh-CN' | 'en-US')
|
|
}
|
|
const resolveProjectRegistryName = (projectIdRaw: string) => {
|
|
const projectId = String(projectIdRaw || '').trim()
|
|
if (projectId !== DEFAULT_PROJECT_ID) return undefined
|
|
return t('xmInfo.defaultProjectName')
|
|
}
|
|
|
|
const writeCleanProjectUrl = (projectIdRaw: string) => {
|
|
try {
|
|
const href = buildProjectUrl(projectIdRaw, { forceHome: false, newProject: false })
|
|
window.history.replaceState({}, '', href)
|
|
} catch {
|
|
writeProjectIdToUrl(projectIdRaw)
|
|
}
|
|
}
|
|
|
|
const navigateToWorkspace = (projectIdRaw: string, mode: 'project' | 'quick') => {
|
|
const projectId = String(projectIdRaw || '').trim()
|
|
if (!projectId) return false
|
|
writeWorkspaceMode(mode)
|
|
const currentProjectId = getActiveProjectId()
|
|
const shouldReloadApp = mode === 'project' && currentProjectId !== projectId
|
|
if (shouldReloadApp) {
|
|
window.location.href = buildProjectUrl(projectId, { forceHome: false, newProject: false })
|
|
return false
|
|
}
|
|
writeCleanProjectUrl(projectId)
|
|
return true
|
|
}
|
|
|
|
const getTodayDateString = () => {
|
|
const now = new Date()
|
|
const year = String(now.getFullYear())
|
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
const day = String(now.getDate()).padStart(2, '0')
|
|
return `${year}-${month}-${day}`
|
|
}
|
|
|
|
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 enterProjectCalc = () => {
|
|
const projectId = getActiveProjectId()
|
|
upsertProject(projectId, resolveProjectRegistryName(projectId))
|
|
if (!navigateToWorkspace(projectId, 'project')) return
|
|
tabStore.enterWorkspace({
|
|
id: PROJECT_TAB_ID,
|
|
title: t('home.projectCalcTab'),
|
|
componentName: 'ProjectCalcView'
|
|
})
|
|
tabStore.hasCompletedSetup = true
|
|
}
|
|
|
|
const loadProjectDefaults = async () => {
|
|
const savedInfo = await kvStore.getItem<ProjectInfoState>(PROJECT_INFO_KEY)
|
|
projectIndustry.value =
|
|
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
|
|
? savedInfo.projectIndustry.trim()
|
|
: String(industryTypeList[0]?.id || '')
|
|
}
|
|
|
|
const openProjectCalc = async () => {
|
|
|
|
projectDialogOpen.value = true
|
|
}
|
|
|
|
const syncExistingProjectOpenedState = (projectIds: string[]) => {
|
|
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(projectIds))
|
|
}
|
|
|
|
const isExistingProjectOpened = (projectIdRaw: string) => {
|
|
const projectId = String(projectIdRaw || '').trim()
|
|
return projectId ? openedProjectIds.value.includes(projectId) : false
|
|
}
|
|
|
|
const refreshExistingProjects = async (options?: { showLoading?: boolean }) => {
|
|
if (options?.showLoading !== false) {
|
|
existingProjectLoading.value = true
|
|
}
|
|
try {
|
|
const projects = listProjects()
|
|
.filter(item => item.id !== QUICK_PROJECT_ID)
|
|
.sort((a, b) => {
|
|
const left = new Date(a.updatedAt).getTime()
|
|
const right = new Date(b.updatedAt).getTime()
|
|
return (Number.isFinite(right) ? right : 0) - (Number.isFinite(left) ? left : 0)
|
|
})
|
|
existingProjects.value = projects.map(project => ({
|
|
id: project.id,
|
|
name: project.name,
|
|
updatedAt: project.updatedAt
|
|
}))
|
|
hasExistingProjects.value = projects.length > 0
|
|
syncExistingProjectOpenedState(projects.map(project => project.id))
|
|
} finally {
|
|
if (options?.showLoading !== false) {
|
|
existingProjectLoading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
const stopExistingProjectPolling = () => {
|
|
if (existingProjectPollTimer == null) return
|
|
clearInterval(existingProjectPollTimer)
|
|
existingProjectPollTimer = null
|
|
}
|
|
|
|
const startExistingProjectPolling = () => {
|
|
stopExistingProjectPolling()
|
|
existingProjectPollTimer = setInterval(() => {
|
|
if (!existingProjectDialogOpen.value) return
|
|
void refreshExistingProjects({ showLoading: false })
|
|
}, 3000)
|
|
}
|
|
|
|
const openExistingProjectDialog = async () => {
|
|
existingProjectDialogOpen.value = true
|
|
await refreshExistingProjects()
|
|
startExistingProjectPolling()
|
|
}
|
|
|
|
const closeExistingProjectDialog = () => {
|
|
existingProjectDialogOpen.value = false
|
|
stopExistingProjectPolling()
|
|
}
|
|
|
|
const enterExistingProject = (projectIdRaw: string) => {
|
|
const projectId = String(projectIdRaw || '').trim()
|
|
if (!projectId || isExistingProjectOpened(projectId)) return
|
|
upsertProject(projectId, resolveProjectRegistryName(projectId))
|
|
if (!navigateToWorkspace(projectId, 'project')) return
|
|
tabStore.enterWorkspace({
|
|
id: PROJECT_TAB_ID,
|
|
title: t('home.projectCalcTab'),
|
|
componentName: 'ProjectCalcView'
|
|
})
|
|
tabStore.hasCompletedSetup = true
|
|
closeExistingProjectDialog()
|
|
}
|
|
|
|
const closeProjectCalcDialog = () => {
|
|
projectDialogOpen.value = false
|
|
}
|
|
|
|
const confirmProjectCalc = async () => {
|
|
const industry = projectIndustry.value.trim()
|
|
if (!industry) return
|
|
|
|
projectSubmitting.value = true
|
|
try {
|
|
const project = createProject(t('xmInfo.defaultProjectName'))
|
|
const kvAdapter = createProjectKvAdapter(project.id)
|
|
await kvAdapter.setItem<ProjectInfoState>(PROJECT_INFO_KEY, {
|
|
projectIndustry: industry,
|
|
projectName: t('xmInfo.defaultProjectName'),
|
|
preparedBy: '',
|
|
reviewedBy: '',
|
|
preparedCompany: '',
|
|
preparedDate: getTodayDateString()
|
|
})
|
|
await initializeProjectFactorStates(
|
|
kvAdapter,
|
|
industry,
|
|
PROJECT_CONSULT_CATEGORY_FACTOR_KEY,
|
|
PROJECT_MAJOR_FACTOR_KEY
|
|
)
|
|
await initializeProjectScaleState(kvAdapter, industry, PROJECT_SCALE_KEY)
|
|
writeWorkspaceMode('project')
|
|
window.location.href = buildProjectUrl(project.id, { forceHome: false, newProject: false })
|
|
} finally {
|
|
projectSubmitting.value = false
|
|
projectDialogOpen.value = false
|
|
}
|
|
}
|
|
|
|
const loadQuickDefaults = async () => {
|
|
const [savedInfo, savedMeta] = await Promise.all([
|
|
kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY),
|
|
kvStore.getItem<QuickContractMetaState>(QUICK_CONTRACT_META_KEY)
|
|
])
|
|
|
|
quickIndustry.value =
|
|
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
|
|
? savedInfo.projectIndustry.trim()
|
|
: String(industryTypeList[0]?.id || '')
|
|
quickContractName.value =
|
|
typeof savedMeta?.name === 'string' && savedMeta.name.trim()
|
|
? savedMeta.name.trim()
|
|
: QUICK_CONTRACT_FALLBACK_NAME
|
|
}
|
|
|
|
const enterQuickCalc = (contractName: string) => {
|
|
if (!navigateToWorkspace(QUICK_PROJECT_ID, 'quick')) return
|
|
tabStore.enterWorkspace({
|
|
id: `contract-${QUICK_CONTRACT_ID}`,
|
|
title: t('home.quickCalcTab'),
|
|
componentName: 'QuickCalcWorkbenchView',
|
|
props: {
|
|
contractId: QUICK_CONTRACT_ID,
|
|
contractName,
|
|
projectInfoKey: QUICK_PROJECT_INFO_KEY,
|
|
projectScaleKey: null,
|
|
projectConsultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
|
projectMajorFactorKey: QUICK_MAJOR_FACTOR_KEY
|
|
}
|
|
})
|
|
tabStore.hasCompletedSetup = true
|
|
}
|
|
|
|
const openQuickCalc = async () => {
|
|
await loadQuickDefaults()
|
|
const contractName = quickContractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME
|
|
const industry = quickIndustry.value.trim()
|
|
|
|
quickSubmitting.value = true
|
|
try {
|
|
const currentInfo = await kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY)
|
|
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
|
|
...currentInfo,
|
|
projectIndustry: industry,
|
|
projectName: t('quickCalc.projectName')
|
|
})
|
|
await kvStore.setItem(QUICK_CONTRACT_META_KEY, {
|
|
id: QUICK_CONTRACT_ID,
|
|
name: contractName,
|
|
updatedAt: new Date().toISOString()
|
|
})
|
|
if (industry) {
|
|
await initializeProjectFactorStates(
|
|
kvStore,
|
|
industry,
|
|
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
|
QUICK_MAJOR_FACTOR_KEY
|
|
)
|
|
}
|
|
enterQuickCalc(contractName)
|
|
} finally {
|
|
quickSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
const handleHomeImportChange = (event: Event) => {
|
|
const input = event.target as HTMLInputElement
|
|
const file = input.files?.[0]
|
|
if (!file) return
|
|
pendingHomeImportFile.value = file
|
|
pendingHomeImportFileName.value = file.name
|
|
homeImportConfirmOpen.value = true
|
|
input.value = ''
|
|
}
|
|
|
|
const openHomeImport = () => {
|
|
homeImportInputRef.value?.click()
|
|
}
|
|
|
|
const cancelHomeImportConfirm = () => {
|
|
homeImportConfirmOpen.value = false
|
|
pendingHomeImportFile.value = null
|
|
pendingHomeImportFileName.value = ''
|
|
}
|
|
|
|
const confirmHomeImport = async () => {
|
|
const file = pendingHomeImportFile.value
|
|
if (!file) return
|
|
await setPendingHomeImportFile(file, { skipWorkspaceConfirm: true })
|
|
const targetProject = createProject(t('xmInfo.defaultProjectName'))
|
|
writeWorkspaceMode('project')
|
|
window.location.href = buildProjectUrl(targetProject.id, { forceHome: false, newProject: false })
|
|
cancelHomeImportConfirm()
|
|
}
|
|
|
|
const handleHomeWindowFocus = () => {
|
|
if (!existingProjectDialogOpen.value) return
|
|
void refreshExistingProjects({ showLoading: false })
|
|
}
|
|
|
|
const handleHomeVisibilityChange = () => {
|
|
if (document.visibilityState !== 'visible') return
|
|
handleHomeWindowFocus()
|
|
}
|
|
|
|
let heroBrandMessageTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
const stopHeroBrandMessageRotation = () => {
|
|
if (heroBrandMessageTimer == null) return
|
|
clearInterval(heroBrandMessageTimer)
|
|
heroBrandMessageTimer = null
|
|
}
|
|
|
|
const startHeroBrandMessageRotation = () => {
|
|
stopHeroBrandMessageRotation()
|
|
if (heroBrandMessages.value.length <= 1) return
|
|
heroBrandMessageTimer = setInterval(() => {
|
|
const total = heroBrandMessages.value.length
|
|
if (total <= 1) return
|
|
heroBrandMessageIndex.value = (heroBrandMessageIndex.value + 1) % total
|
|
}, 2400)
|
|
}
|
|
|
|
onMounted(() => {
|
|
void refreshExistingProjects()
|
|
void loadProjectDefaults()
|
|
void loadQuickDefaults()
|
|
startHeroBrandMessageRotation()
|
|
window.addEventListener('focus', handleHomeWindowFocus)
|
|
document.addEventListener('visibilitychange', handleHomeVisibilityChange)
|
|
try {
|
|
const url = new URL(window.location.href)
|
|
const isNewProject = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
|
|
if (isNewProject) {
|
|
const projectId = getActiveProjectId()
|
|
upsertProject(projectId, resolveProjectRegistryName(projectId))
|
|
const openProjectDialog = url.searchParams.get(OPEN_PROJECT_DIALOG_QUERY_KEY) !== '0'
|
|
if (openProjectDialog) {
|
|
void openProjectCalc()
|
|
}
|
|
url.searchParams.delete(NEW_PROJECT_QUERY_KEY)
|
|
url.searchParams.delete(OPEN_PROJECT_DIALOG_QUERY_KEY)
|
|
url.searchParams.delete(FORCE_HOME_QUERY_KEY)
|
|
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`)
|
|
}
|
|
} catch {
|
|
// ignore url parsing errors
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
stopExistingProjectPolling()
|
|
stopHeroBrandMessageRotation()
|
|
window.removeEventListener('focus', handleHomeWindowFocus)
|
|
document.removeEventListener('visibilitychange', handleHomeVisibilityChange)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<input ref="homeImportInputRef" type="file" accept=".zw" class="sr-only" @change="handleHomeImportChange" />
|
|
<div class="home-entry relative flex min-h-full items-center justify-center px-4 py-8 lg:py-10">
|
|
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,rgba(59,130,246,0.06),transparent_70%)]" />
|
|
<div class="relative w-full max-w-[1240px]">
|
|
<div class="absolute right-0 top-0 z-10">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
class="h-8 cursor-pointer gap-1.5 rounded-full border-slate-200/80 bg-white/85 px-3 text-xs text-slate-600 shadow-sm backdrop-blur transition hover:bg-white"
|
|
@click="toggleLocale"
|
|
>
|
|
<Languages class="h-3.5 w-3.5" />
|
|
<span>{{ localeBadge }}</span>
|
|
</Button>
|
|
</div>
|
|
<div class="home-title text-center">
|
|
<h1 class="text-2xl font-semibold tracking-tight text-slate-900 lg:text-3xl">{{ t('home.title') }}</h1>
|
|
<p class="mt-1.5 text-sm text-slate-500">{{ t('home.subtitle') }}</p>
|
|
</div>
|
|
<div class="mt-5 grid items-stretch gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<div
|
|
class="home-hero home-card-base home-entry-item home-entry-item--1 relative overflow-hidden rounded-2xl bg-[#dc2626] p-7 text-white shadow-[0_24px_60px_rgba(153,27,27,0.35)]"
|
|
>
|
|
<div class="pointer-events-none absolute -right-20 -top-16 h-56 w-56 rounded-full bg-white/12 blur-2xl" />
|
|
<div class="pointer-events-none absolute -left-10 -bottom-10 h-40 w-40 rounded-full bg-white/8 blur-3xl" />
|
|
<div class="home-hero-meteor home-hero-meteor--1" />
|
|
<div class="home-hero-meteor home-hero-meteor--2" />
|
|
<div class="home-hero-meteor home-hero-meteor--3" />
|
|
<div class="home-hero-meteor home-hero-meteor--4" />
|
|
<div class="home-hero-meteor home-hero-meteor--5" />
|
|
<div class="home-hero-meteor home-hero-meteor--6" />
|
|
<div class="home-hero-meteor home-hero-meteor--7" />
|
|
<div class="home-hero-meteor home-hero-meteor--8" />
|
|
<div class="home-hero-meteor home-hero-meteor--9" />
|
|
<div class="home-hero-meteor home-hero-meteor--10" />
|
|
<div class="relative inline-flex h-11 w-11 items-center justify-center rounded-xl bg-white/15 ring-1 ring-white/35">
|
|
<Calculator class="h-5 w-5" />
|
|
</div>
|
|
<h2 class="relative mt-8 text-2xl font-semibold leading-tight tracking-tight lg:text-3xl">{{ t('home.cards.heroTitle') }}</h2>
|
|
<p class="relative mt-2 text-sm text-red-200/90">{{ t('home.cards.heroSubTitle') }}</p>
|
|
<div class="relative mt-4 inline-flex max-w-full items-center gap-3 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs text-white/90 backdrop-blur-sm">
|
|
<span class="shrink-0 font-semibold tracking-[0.28em] text-white">{{ heroBrandName }}</span>
|
|
<span class="h-3.5 w-px shrink-0 bg-white/25" />
|
|
<Transition name="hero-brand-message" mode="out-in">
|
|
<span :key="`${locale}-${heroBrandMessageIndex}`" class="truncate text-white/90">{{ activeHeroBrandMessage }}</span>
|
|
</Transition>
|
|
</div>
|
|
<div class="relative mt-6 h-px bg-white/20" />
|
|
<p class="relative mt-4 text-xs leading-5 text-red-200/60">{{ t('home.cards.heroDesc') }}</p>
|
|
</div>
|
|
|
|
<Card
|
|
role="button"
|
|
tabindex="0"
|
|
class="home-card home-card-base home-entry-item home-entry-item--2 group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
|
@click="openProjectCalc"
|
|
@keydown.enter.prevent="openProjectCalc"
|
|
@keydown.space.prevent="openProjectCalc"
|
|
>
|
|
<CardHeader class="p-0">
|
|
<div
|
|
class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-blue-100 bg-blue-50/80 text-blue-600 shadow-sm transition-transform duration-200 group-hover:scale-105"
|
|
>
|
|
<svg viewBox="0 0 1024 1024" class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
<path
|
|
fill="currentColor"
|
|
d="M938.666667 874.666667c0 11.733333-9.6 21.333333-21.333334 21.333333H106.666667c-11.733333 0-21.333333-9.6-21.333334-21.333333s9.6-21.333333 21.333334-21.333334h42.666666V490.666667c0-11.733333 9.6-21.333333 21.333334-21.333334h170.666666c11.733333 0 21.333333 9.6 21.333334 21.333334v362.666666h42.666666V320c0-11.733333 9.6-21.333333 21.333334-21.333333h170.666666c11.733333 0 21.333333 9.6 21.333334 21.333333v533.333333h42.666666V149.333333c0-11.733333 9.6-21.333333 21.333334-21.333333h170.666666c11.733333 0 21.333333 9.6 21.333334 21.333333v704h42.666666c11.733333 0 21.333333 9.6 21.333334 21.333334z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<CardTitle class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.projectBudget') }}</CardTitle>
|
|
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
|
|
{{ t('home.cards.projectBudgetDesc') }}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<div class="mt-4 flex items-center justify-between gap-2">
|
|
<button
|
|
v-if="hasExistingProjects"
|
|
type="button"
|
|
class="cursor-pointer rounded-md border border-slate-200 px-2.5 py-1 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700"
|
|
@click.stop="openExistingProjectDialog"
|
|
>
|
|
{{ t('home.cards.pickExisting') }}
|
|
</button>
|
|
<div class="flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
|
|
<span>{{ t('home.cards.enter') }}</span>
|
|
<svg class="ml-1 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card
|
|
role="button"
|
|
tabindex="0"
|
|
class="home-card home-card-base home-entry-item home-entry-item--3 group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
|
@click="openQuickCalc"
|
|
@keydown.enter.prevent="openQuickCalc"
|
|
@keydown.space.prevent="openQuickCalc"
|
|
>
|
|
<CardHeader class="p-0">
|
|
<div
|
|
class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-amber-100 bg-amber-50/80 text-amber-600 shadow-sm transition-transform duration-200 group-hover:scale-105"
|
|
>
|
|
<svg viewBox="0 0 800 800" class="h-10 w-10" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
<path
|
|
fill="currentColor"
|
|
d="M5245 5891c-11-5-47-38-80-73-33-36-269-281-525-544-256-263-514-530-575-592-90-93-113-123-130-170-20-54-79-170-200-392-70-129-81-164-61-194 22-35 59-40 114-15 26 11 119 47 207 79 88 33 185 69 215 81 30 12 83 31 118 44 58 20 82 40 300 246 130 123 291 276 357 339 66 63 165 158 219 210 55 52 105 100 111 106 57 58 205 194 212 194 4 0 7-520 5-1155l-2-1155-553 0c-343 0-576-4-613-11-86-15-175-46-227-79-25-15-48-26-51-23-3 4-6 154-6 335l0 328-80 0-80 0 0-336 0-336-32 22c-44 29-106 56-176 77-50 15-130 17-647 22l-590 6 0 1165 0 1165 440 0c467 0 528-4 702-50 105-28 200-69 261-114 41-31 42-32 42-91l0-60 80 0 80 0 1 33c3 69 8 82 40 110 19 16 63 43 99 59 36 16 71 33 79 37 13 7 56 169 47 178-6 6-170-49-221-74-27-14-66-38-86-54l-35-28-32 25c-51 40-181 101-274 128-188 54-249 59-840 63l-548 4 0-1330 0-1331 619 0c669 0 715-3 826-54 63-29 138-94 155-136 12-30 13-30 91-30l78 0 17 35c20 44 70 87 137 122 114 58 98 56 807 62l655 6 3 1313c2 1296 2 1314 22 1339 34 43 21 153-29 252-35 67-147 173-224 211-70 34-179 50-222 31z m171-190c72-42 154-148 154-200 0-12-47-64-125-138-69-65-129-119-133-121-4-1-60 51-125 116l-117 118 121 127c136 144 141 146 225 98z m-341-468l110-114-65-62c-36-33-110-104-165-157-385-367-609-575-618-575-7 0-54 44-106 97l-94 97 335 343c184 189 366 376 403 416 38 39 73 71 79 71 6-1 61-53 121-116z m-905-994c0-7-193-79-211-79-5 0 14 46 42 101l51 102 59-59c32-32 59-61 59-65z"
|
|
transform="translate(0,800) scale(0.1,-0.1)"
|
|
/>
|
|
<path
|
|
fill="currentColor"
|
|
d="M1897 4983c-9-8-9-2599-1-2630l6-23 787 0c432 0 791-4 797-8 6-4 18-21 27-38 24-46 102-113 170-144 169-79 482-78 640 1 96 49 141 90 193 177 6 9 97 11 822 11l782 1 0 1330 0 1330-85 0-85 0 0-1250 0-1250-773 0-772 0-18-51c-24-66-84-131-146-158-131-56-369-51-491 11-69 35-121 96-138 162l-8 31-775 5-774 5-2 1150c-2 633-3 1194-3 1248l0 97-73 0c-41 0-77-3-80-7z"
|
|
transform="translate(0,800) scale(0.1,-0.1)"
|
|
/>
|
|
<path
|
|
fill="currentColor"
|
|
d="M3391 3872c-261-2-347-5-353-15-4-6-8-38-8-69 0-47 4-59 19-68 13-6 214-10 584-10 474 0 566 2 576 14 7 9 11 40 9 78l-3 63-240 5c-132 3-395 4-584 2z"
|
|
transform="translate(0,800) scale(0.1,-0.1)"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<CardTitle class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.quickCalc') }}</CardTitle>
|
|
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
|
|
{{ t('home.cards.quickCalcDesc') }}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
|
|
<span>{{ t('home.cards.enter') }}</span>
|
|
<svg class="ml-1 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card
|
|
role="button"
|
|
tabindex="0"
|
|
class="home-card home-card-base home-entry-item home-entry-item--4 group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
|
@click="openHomeImport"
|
|
@keydown.enter.prevent="openHomeImport"
|
|
@keydown.space.prevent="openHomeImport"
|
|
>
|
|
<CardHeader class="p-0">
|
|
<div
|
|
class="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-emerald-100 bg-emerald-50/80 text-emerald-600 shadow-sm transition-transform duration-200 group-hover:scale-105"
|
|
>
|
|
<svg viewBox="0 0 1024 1024" class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
<path
|
|
fill="currentColor"
|
|
d="M154.579478 1001.73913v-332.844521h89.043479V912.695652H912.695652V369.530435h-234.896695V111.304348H243.890087v349.184h-89.043478V22.26087h585.683478l261.431652 263.924869V1001.73913z m612.173913-721.252173h104.314435l-104.314435-105.293914z m-416.857043 411.469913l79.026087-79.026087H22.26087v-89.043479h406.661565L349.94087 444.861217l41.138087-41.22713 123.592347 123.592348 41.227131 41.182608-41.227131 41.138087-123.592347 123.592348z m123.013565-123.013566l0.489739-0.534261-0.489739-0.489739z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<CardTitle class="mt-4 text-base font-semibold text-slate-900">{{ t('home.cards.importData') }}</CardTitle>
|
|
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
|
|
{{ t('home.cards.importDataDesc') }}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
|
|
<span>{{ t('home.cards.pickFile') }}</span>
|
|
<svg class="ml-1 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
<div class="home-slogan-row mt-5 flex items-center justify-center">
|
|
<div class="flex w-full flex-wrap items-center justify-center gap-x-4 gap-y-1 px-4 py-2 text-center">
|
|
<span class="text-lg font-semibold tracking-[0.18em] text-slate-900 sm:text-xl">{{ t('home.cards.heroFooterTitle') }}</span>
|
|
<span class="text-slate-300">|</span>
|
|
<span class="text-sm tracking-[0.08em] text-slate-500 sm:text-base">{{ t('home.cards.heroFooterSubTitle') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="existingProjectDialogOpen"
|
|
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
|
@click.self="closeExistingProjectDialog"
|
|
>
|
|
<div class="w-full max-w-lg rounded-xl border bg-background shadow-2xl">
|
|
<div class="flex items-center justify-between border-b px-5 py-4">
|
|
<div>
|
|
<h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.chooseExistingProject') }}</h3>
|
|
<p class="mt-1 text-sm text-muted-foreground">{{ t('home.dialog.chooseExistingProjectDesc') }}</p>
|
|
</div>
|
|
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeExistingProjectDialog">
|
|
<X class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="max-h-80 space-y-2 overflow-auto px-5 py-4">
|
|
<div
|
|
v-if="!existingProjectLoading && existingProjects.length === 0"
|
|
class="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-5 text-center text-sm text-slate-500"
|
|
>
|
|
{{ t('home.dialog.noProjectYet') }}
|
|
</div>
|
|
<button
|
|
v-for="project in existingProjects"
|
|
:key="project.id"
|
|
type="button"
|
|
:disabled="isExistingProjectOpened(project.id)"
|
|
class="flex w-full items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-left transition"
|
|
:class="isExistingProjectOpened(project.id)
|
|
? 'cursor-not-allowed border-slate-200 bg-slate-100/80 opacity-70'
|
|
: 'cursor-pointer hover:border-slate-300 hover:bg-slate-50'"
|
|
@click="enterExistingProject(project.id)"
|
|
>
|
|
<div class="min-w-0">
|
|
<div class="truncate text-sm font-medium text-slate-800">
|
|
{{ project.name }}
|
|
<span v-if="isExistingProjectOpened(project.id)" class="ml-1 text-xs text-slate-500">
|
|
{{ t('tab.toolbar.opened') }}
|
|
</span>
|
|
</div>
|
|
<div class="mt-0.5 text-xs text-slate-500">{{ project.id }}</div>
|
|
</div>
|
|
<div class="shrink-0 pl-2 text-xs text-slate-500">
|
|
{{ t('tab.toolbar.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-end gap-2 border-t px-5 py-4">
|
|
<Button variant="outline" @click="closeExistingProjectDialog">{{ t('common.cancel') }}</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="projectDialogOpen"
|
|
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
|
@click.self="closeProjectCalcDialog"
|
|
>
|
|
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
|
|
<div class="flex items-center justify-between border-b px-5 py-4">
|
|
<div>
|
|
<h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.newProject') }}</h3>
|
|
<p class="mt-1 text-sm text-muted-foreground">{{ t('home.dialog.chooseIndustryDesc') }}</p>
|
|
</div>
|
|
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeProjectCalcDialog">
|
|
<X class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="space-y-4 px-5 py-4">
|
|
<label class="block space-y-2">
|
|
<span class="text-sm font-medium text-foreground">{{ t('home.dialog.industry') }}</span>
|
|
<SelectRoot v-model="projectIndustry">
|
|
<SelectTrigger
|
|
class="inline-flex h-11 w-full items-center justify-between rounded-xl border border-border/70 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.92))] px-4 text-sm text-foreground shadow-sm outline-none transition hover:border-border focus-visible:ring-2 focus-visible:ring-slate-300"
|
|
>
|
|
<span :class="projectIndustryLabel ? 'text-foreground' : 'text-muted-foreground'">
|
|
{{ projectIndustryLabel || t('home.dialog.selectIndustry') }}
|
|
</span>
|
|
<SelectIcon as-child>
|
|
<ChevronDown class="h-4 w-4 text-muted-foreground" />
|
|
</SelectIcon>
|
|
</SelectTrigger>
|
|
<SelectPortal>
|
|
<SelectContent
|
|
:side-offset="6"
|
|
position="popper"
|
|
class="z-[120] w-[var(--reka-select-trigger-width)] overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
|
|
>
|
|
<SelectViewport class="p-1">
|
|
<SelectItem
|
|
v-for="item in industryTypeList"
|
|
:key="`project-${item.id}`"
|
|
:value="String(item.id)"
|
|
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
|
|
>
|
|
<SelectItemText>{{ getIndustryDisplayName(item.id, locale) }}</SelectItemText>
|
|
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
|
|
<Check class="h-4 w-4" />
|
|
</SelectItemIndicator>
|
|
</SelectItem>
|
|
</SelectViewport>
|
|
</SelectContent>
|
|
</SelectPortal>
|
|
</SelectRoot>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-end gap-2 border-t px-5 py-4">
|
|
<Button variant="outline" @click="closeProjectCalcDialog">{{ t('common.cancel') }}</Button>
|
|
<Button :disabled="projectSubmitting || !projectIndustry" @click="confirmProjectCalc">
|
|
{{ projectSubmitting ? t('home.dialog.entering') : t('home.dialog.enterProjectCalc') }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="homeImportConfirmOpen"
|
|
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
|
@click.self="cancelHomeImportConfirm"
|
|
>
|
|
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
|
|
<div class=" px-5 py-4">
|
|
<h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.confirmImport') }}</h3>
|
|
<p class="mt-1 text-sm text-muted-foreground">
|
|
{{ t('home.dialog.confirmImportDesc', { file: pendingHomeImportFileName || t('home.cards.pickFile') }) }}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center justify-end gap-2 px-5 py-4">
|
|
<Button variant="outline" @click="cancelHomeImportConfirm">{{ t('common.cancel') }}</Button>
|
|
<Button variant="destructive" @click="confirmHomeImport">{{ t('home.dialog.confirmImportAction') }}</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</template>
|
|
|
|
<style scoped>
|
|
.home-hero {
|
|
animation: none;
|
|
}
|
|
.home-card-base {
|
|
min-height: 248px;
|
|
}
|
|
.home-title {
|
|
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both;
|
|
}
|
|
.home-entry-item {
|
|
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
}
|
|
.home-slogan-row {
|
|
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.6s both;
|
|
}
|
|
.home-entry-item--1 { animation-delay: 0.2s; }
|
|
.home-entry-item--2 { animation-delay: 0.3s; }
|
|
.home-entry-item--3 { animation-delay: 0.4s; }
|
|
.home-entry-item--4 { animation-delay: 0.5s; }
|
|
|
|
@keyframes hero-in {
|
|
from { opacity: 0; transform: translateX(-20px) scale(0.97); }
|
|
to { opacity: 1; transform: translateX(0) scale(1); }
|
|
}
|
|
.home-hero-meteor {
|
|
pointer-events: none;
|
|
position: absolute;
|
|
width: 120px;
|
|
height: 1px;
|
|
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0));
|
|
transform: rotate(-28deg);
|
|
opacity: 0;
|
|
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
|
|
animation: hero-meteor 3.8s linear infinite;
|
|
}
|
|
.home-hero-meteor--1 {
|
|
top: 16%;
|
|
right: -30%;
|
|
animation-delay: 0s;
|
|
}
|
|
.home-hero-meteor--2 {
|
|
top: 38%;
|
|
right: -40%;
|
|
animation-delay: 1.2s;
|
|
}
|
|
.home-hero-meteor--3 {
|
|
top: 62%;
|
|
right: -35%;
|
|
animation-delay: 2.2s;
|
|
}
|
|
.home-hero-meteor--4 {
|
|
top: 24%;
|
|
right: -45%;
|
|
animation-delay: 0.6s;
|
|
}
|
|
.home-hero-meteor--5 {
|
|
top: 50%;
|
|
right: -28%;
|
|
animation-delay: 1.7s;
|
|
}
|
|
.home-hero-meteor--6 {
|
|
top: 74%;
|
|
right: -42%;
|
|
animation-delay: 2.8s;
|
|
}
|
|
.home-hero-meteor--7 {
|
|
top: 10%;
|
|
right: -48%;
|
|
animation-delay: 0.35s;
|
|
}
|
|
.home-hero-meteor--8 {
|
|
top: 31%;
|
|
right: -26%;
|
|
animation-delay: 1.05s;
|
|
}
|
|
.home-hero-meteor--9 {
|
|
top: 56%;
|
|
right: -50%;
|
|
animation-delay: 2.45s;
|
|
}
|
|
.home-hero-meteor--10 {
|
|
top: 82%;
|
|
right: -30%;
|
|
animation-delay: 3.15s;
|
|
}
|
|
@keyframes hero-meteor {
|
|
0% { transform: translate3d(0, 0, 0) rotate(-28deg); opacity: 0; }
|
|
8% { opacity: 0.9; }
|
|
34% { opacity: 0.9; }
|
|
42% { opacity: 0; }
|
|
100% { transform: translate3d(-340px, 220px, 0) rotate(-28deg); opacity: 0; }
|
|
}
|
|
@keyframes fade-up {
|
|
from { opacity: 0; transform: translateY(16px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.hero-brand-message-enter-active,
|
|
.hero-brand-message-leave-active {
|
|
transition: opacity 0.22s ease, transform 0.22s ease;
|
|
}
|
|
.hero-brand-message-enter-from,
|
|
.hero-brand-message-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(6px);
|
|
}
|
|
</style>
|